From 088294216423f53674e20b27e4e5471a1547847b Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Thu, 6 Aug 2026 20:56:47 -0400 Subject: [PATCH] General Chat now hands a Varaverk question up to the Assistant instead of telling the operator to do it --- Plugin/unraid/Tools/ai_chat_worker.php | 84 +++++++++++++++++++++----- 1 file changed, 69 insertions(+), 15 deletions(-) diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 9677891..aefaa47 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -73,7 +73,8 @@ // JOB FILE STATES // {"status":"retrieving"} // {"status":"generating","sources":[…]} -// {"status":"done","answer":…,"thinking":…,"sources":[…],"timing":{…}} +// {"status":"done","answer":…,"thinking":…,"sources":[…],"timing":{…}, +// "profile":the profile that answered,"escalated":true if chat handed it up} // {"status":"error","error":…} // ═══════════════════════════════════════════════════════════════════════════════════════════════ @@ -92,10 +93,32 @@ if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); $profile = in_array($profile, ['varaverk', 'chat', 'code', 'troubleshoot'], true) ? $profile : 'varaverk'; -// Every "is this profile allowed X" question in this file goes through here. Written as a closure -// over $profile so no call site can accidentally ask about a different one, which is the shape the -// old scattered comparisons kept taking. -$can = fn(string $cap): bool => vv_ai_profile_can($profile, $cap); +// General Chat cannot answer a question about this installation — that is the whole point of it, +// and it is why it holds no capabilities. But refusing is not the same as being unable to help, +// and "the Varaverk Assistant profile can answer this" is a sentence the operator then has to act +// on: switch profile, retype the question, lose the thread. The question is already here and the +// profile that can take it is one line away. +// +// One direction only, and never the reverse. Escalating adds evidence and tightens the contract, +// so a wrong escalation costs tokens and an over-serious answer. Routing the other way — deciding +// a Varaverk question is small talk — removes the documentation and produces confident invention +// about someone's own server, which is the failure this whole design exists to prevent. There is +// no code path that moves a question down the ladder except the retrieval fallback below, which +// returns it to where it started rather than choosing a profile for it. +// +// The trigger is the same detector the deterministic backstop uses, so the two cannot disagree. +$escalated = false; +if ($profile === 'chat' && vv_ai_chat_needs_varaverk($question)) { + $profile = 'varaverk'; + $escalated = true; + wlog('handoff chat -> varaverk: ' . mb_substr($question, 0, 80)); +} + +// Every "is this profile allowed X" question in this file goes through here. Bound by reference +// rather than by value: the profile can still change after this point — the handoff above and the +// fallback below both move it — and a capability check that answered for the profile in force at +// definition time would be silently wrong for the rest of the run. +$can = function (string $cap) use (&$profile): bool { return vv_ai_profile_can($profile, $cap); }; function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); @@ -134,11 +157,22 @@ if ($can('retrieve')) { exit(1); } if (!$r['results']) { - jw($jobFile, ['status' => 'error', - 'error' => 'No relevant documentation found. Try rephrasing, use the readme filter ' - . 'for questions about what something is, or switch to General Chat if this ' - . 'is not a Varaverk question.']); - exit(0); + // A question that arrived here by handoff must not inherit this error. The operator asked + // in General Chat: something in the phrasing looked Varaverk-shaped, the index turned out + // to hold nothing on it, and a hard failure would be a worse answer than the polite + // deferral they would have got had the handoff never happened. Put it back where it came + // from — the only downward move in the file, and it chooses nothing, it just undoes. + if ($escalated) { + $profile = 'chat'; + $escalated = false; + wlog('handoff reverted -> chat: nothing in the index for it'); + } else { + jw($jobFile, ['status' => 'error', + 'error' => 'No relevant documentation found. Try rephrasing, use the readme filter ' + . 'for questions about what something is, or switch to General Chat if this ' + . 'is not a Varaverk question.']); + exit(0); + } } $tRetrieve = microtime(true) - $t0; @@ -406,6 +440,21 @@ if ($profile === 'chat') { . "fill the gap from general knowledge. Prefer the user's own terminology.\n\n"); } +// The operator chose General Chat and is getting the Assistant. That has to be said in the answer +// itself: they picked a profile, the profile button in the tab still shows the one they picked, +// and an answer that quietly arrives under a different contract — with citations and a refusal +// rule they did not ask for — reads as the assistant ignoring them. One line, at the top. +if ($escalated) { + $system .= "HOW THIS QUESTION REACHED YOU\n" + . "The operator asked in General Chat, which is not shown Varaverk's documentation. " + . "Their message named something specific to this installation, so it was handed to " + . "you, and you do have the material. Open with one short line saying so — something " + . "like \"General Chat can't see your docs, so I've picked this up as the Varaverk " + . "Assistant\" — then answer the question normally. One line: do not apologise for " + . "the switch, do not explain how the profiles work, and do not suggest they switch " + . "profile themselves. It has already happened.\n\n"; +} + // A run-outcome question arrives with the run attached, and the assistant's standing contract — // answer only from the retrieved passages — is wrong for it: the run record and the log are not // in the index and never will be. Without this the honest reading of its own rules is to fall @@ -638,12 +687,17 @@ $tokS = $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null; // been closed and nobody ever reads the result. Best-effort by contract — it cannot throw. vv_ai_token_record($profile, 'webgui', (int)($d['prompt_eval_count'] ?? 0), $evalCount, $tokS); +// profile is the one that actually answered, not the one that was asked for — they differ on a +// handoff. Reported so the page can show which contract produced the answer rather than the +// button the operator last pressed. jw($jobFile, [ - 'status' => 'done', - 'answer' => $answer, - 'thinking' => $thinking, - 'sources' => $sources, - 'warnings' => array_values(array_unique($warnings)), + 'status' => 'done', + 'answer' => $answer, + 'thinking' => $thinking, + 'sources' => $sources, + 'profile' => $profile, + 'escalated' => $escalated, + 'warnings' => array_values(array_unique($warnings)), 'timing' => [ 'retrieve_ms' => (int)round($tRetrieve * 1000), 'generate_ms' => (int)round((microtime(true) - $t1) * 1000),