diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index c3fda33..630e374 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -446,6 +446,55 @@ if ($profile === 'chat') { . "knowledge freely — Linux, scripting, hardware, whatever comes up. The restriction " . "is only about the specifics of THIS installation.\n\n"; +} elseif ($profile === 'repair') { + // The only profile that can change a setting, so the prompt's job is mostly to stop it + // believing that it decides anything. It does not: the resolver picks the key, the probe + // picks the value, and the operator picks whether to write. What the model contributes is + // the explanation and the conversation — the parts where being wrong costs a sentence. + // + // Prior context first, because the failure this profile is most prone to is confidently + // re-deriving something already settled: proposing a fix that was tried and did not work, + // or reading a term the operator has already corrected once. + require_once dirname(__DIR__) . '/include/ai_repair.php'; + $priorContext = vv_ai_repair_context(); + if ($priorContext !== '') $attached['prior'] = 'phrasebook + closed findings'; + + $system = "You are helping the operator of a Varaverk server deal with a finding — something " + . "on this machine that is misconfigured or is reporting a problem.\n\n" + + . "WHAT YOU DO NOT DECIDE\n" + . "You do not choose which setting is involved: that was resolved from the conf " + . "before you were asked. You do not choose what value to write: a probe either got " + . "an answer from a candidate or it did not, and only a value that answered can be " + . "written. You do not decide whether to apply anything — the operator does.\n\n" + . "So never say you have changed, set, fixed or updated anything. If a change was " + . "applied you will have been told so; otherwise it has not happened yet.\n\n" + + . "TOGGLES ARE NEVER YOURS TO FLIP\n" + . "Whether something should be switched on is a decision about what the operator " + . "wants, not a fact you can discover. Offer it, explain the consequence, and wait.\n\n" + + . "WHEN THE VALUE CANNOT BE WORKED OUT HERE\n" + . "Some things cannot be derived from this machine at all — an API key most of all. " + . "Say so plainly and walk the operator through getting it, step by step, naming the " + . "screen and the field. Then ask them to paste it. That is a normal outcome and not " + . "a failure; pretending to have found it is the failure.\n\n" + + . "HOW TO ANSWER\n" + . "Lead with what is wrong and what it stops working. Quote the evidence on the " + . "finding. Then give the operator their choices in plain terms: apply the proposed " + . "value, acknowledge it as intended, or leave it. Keep it short — they are reading " + . "this to make one decision.\n\n" + . "Say plainly when you do not know. A wrong cause sends someone to fix the wrong " + . "thing, which is worse than saying the finding does not explain itself.\n\n" + + . ($priorContext !== '' + ? "WHAT HAS ALREADY HAPPENED HERE\n" + . "Use this before reasoning from scratch. If a term below was corrected, use the " + . "corrected meaning without being told again. If a similar finding was closed " + . "before, say what ended it last time.\n\n" . $priorContext . "\n\n" + : ""); + } elseif ($profile === 'troubleshoot') { // Different inputs and a different refusal rule from the assistant, which is what earns it a // profile of its own. The assistant's contract is "answer only from the passages, refuse if diff --git a/Plugin/unraid/include/ai_profiles.php b/Plugin/unraid/include/ai_profiles.php index 52c8c78..c6b6560 100644 --- a/Plugin/unraid/include/ai_profiles.php +++ b/Plugin/unraid/include/ai_profiles.php @@ -109,6 +109,26 @@ const VV_AI_PROFILES_DEF = [ 'ui' => true, 'caps' => ['retrieve', 'health', 'run_evidence', 'scoped_log', 'incidents', 'conf_lookup', 'file_bugs'], ], + // The only profile that may change a setting, and the only one not offered as a button. + // + // It is entered by opening a finding, the way troubleshoot was originally entered by opening + // a log — a repair conversation with nothing to repair is a contract with no subject. That is + // not the only reason though: capabilities are granted per profile, so making this selectable + // would put conf_write one click away from any question at all. The narrow grant is the point. + // + // Reads two records of what has happened before. The phrasebook holds what the operator calls + // things and, more usefully, what they have had to correct; closed findings hold what actually + // fixed a fault last time. Between them the profile starts a conversation already knowing the + // operator's vocabulary and this installation's history, instead of relearning both each time. + 'repair' => [ + 'label' => 'Repair', + 'short' => 'Repair', + 'hint' => 'Works through a finding with you, and can apply a fix you approve.', + 'turns' => 3, + 'ui' => false, + 'caps' => ['retrieve', 'health', 'run_evidence', 'scoped_log', 'incidents', 'conf_lookup', + 'conf_write', 'probe', 'file_findings', 'phrasebook', 'past_fixes'], + ], ]; // Documentation only — nothing branches on it. Kept beside the table because a capability name @@ -123,6 +143,11 @@ const VV_AI_CAP_MEANING = [ 'conf_lookup' => 'deterministic "where does this conf key actually live" lookup', 'file_bugs' => 'may file a bug report against Varaverk itself', 'code_scan' => 'destructive-operation scan of generated shell', + 'conf_write' => 'may change a setting — through the guarded write path, never directly', + 'probe' => 'may test a candidate address before anything is written to conf', + 'file_findings'=> 'may record, acknowledge and close findings about this installation', + 'phrasebook' => 'what the operator calls things, and what they have corrected before', + 'past_fixes' => 'findings already closed — what fixed this last time', ]; function vv_ai_profiles(): array { return VV_AI_PROFILES_DEF; } diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index 6217c63..d95d0d5 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -915,6 +915,66 @@ function vv_ai_phrase_lookup(string $term, int $minSeen = 3): ?array { return vv_ai_phrase_aliases($minSeen)[strtolower(trim($term))] ?? null; } +// ── What the repair profile is given before it answers ─────────────────────────────────────── +// Two records of what has already happened, assembled as plain text for the prompt. +// +// The phrasebook half is the operator's vocabulary — and the corrections matter more than the +// settled terms, because a term that has been corrected is one the assistant has already got +// wrong once and would otherwise get wrong again. +// +// The closed-findings half is this installation's history: the same fault, and what actually +// ended it. "Emby stopped answering last month and the address had changed" is worth more at the +// start of a repair conversation than any amount of reasoning from first principles. +// +// Bounded hard. This goes into a 16k context that retrieval and a log tail are also competing +// for, and an unbounded history would crowd out the evidence for the fault actually being +// discussed. +function vv_ai_repair_context(int $maxAliases = 20, int $maxFixes = 8): string { + $out = []; + + $aliases = vv_ai_phrase_aliases(); + if ($aliases) { + $lines = []; + foreach (array_slice($aliases, 0, $maxAliases, true) as $term => $a) { + $lines[] = ' "' . $term . '" means ' . $a['target']; + } + $out[] = "What the operator calls things:\n" . implode("\n", $lines); + } + + $unsettled = vv_ai_phrase_unsettled(); + if ($unsettled) { + $lines = []; + foreach (array_slice($unsettled, 0, $maxAliases, true) as $term => $c) { + $last = end($c); + $lines[] = ' "' . $term . '" was read as ' . ($last['took_it_as'] ?: '?') + . ' and meant ' . ($last['meant'] ?: '?'); + } + $out[] = "Corrected before — do not repeat these:\n" . implode("\n", $lines); + } + + $closed = vv_ai_findings_list(['fixed', 'resolved']); + if ($closed) { + $lines = []; + foreach (array_slice($closed, 0, $maxFixes) as $f) { + $lines[] = ' ' . ($f['subject'] ?? '?') . ' / ' . ($f['ref'] ?? $f['conf_key'] ?? '?') + . ' — ' . ($f['state'] ?? '?') + . (!empty($f['note']) ? ': ' . mb_substr((string)$f['note'], 0, 140) : ''); + } + $out[] = "Already dealt with on this host:\n" . implode("\n", $lines); + } + + $spellings = vv_ai_spellings(); + if ($spellings) { + $lines = []; + foreach (array_slice($spellings, 0, $maxAliases, true) as $typo => $meant) { + $lines[] = ' "' . $typo . '" = "' . $meant . '"'; + } + $out[] = "The operator types quickly; known shorthand:\n" . implode("\n", $lines); + } + + return implode("\n\n", $out); +} + // ── Resolving what the operator named ──────────────────────────────────────────────────────── // "turn off the zfs scrub", "change the emby api key" — a phrase in, an exact target out, or an // honest refusal with the candidates that were considered.