From 714d97dd491c6df99e07c7c3f15f1d280af2680b Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Wed, 5 Aug 2026 20:26:56 -0400 Subject: [PATCH] Add an incident journal the troubleshooter reads back, and thinking for diagnosis --- Plugin/unraid/Tools/ai_chat_worker.php | 14 +++++ Plugin/unraid/api/ai.php | 13 ++++- Plugin/unraid/include/ai.php | 79 ++++++++++++++++++++++++++ Plugin/unraid/pages/scheduler.php | 55 +++++++++++++++++- 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index bcf5cc1..f962730 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -189,6 +189,20 @@ if ($profile === 'troubleshoot' && $scope !== '') { } } +// What has gone wrong with this same thing before, and what actually fixed it. Operator-written, +// so it outranks anything the model would infer from the log — it is the only input here that +// records a confirmed outcome rather than a reading of evidence. +if ($scope !== '') { + $past = vv_ai_incidents_for($scope, 4); + if ($past) { + $diagBlock .= "PREVIOUSLY ON THIS, WRITTEN BY THE OPERATOR AFTER IT WAS RESOLVED\n" + . "Confirmed outcomes, not guesses. If the current symptom matches one of " + . "these, say so and lead with it. If it clearly does not, ignore them " + . "rather than forcing a fit.\n\n" + . implode("\n", $past) . "\n\n"; + } +} + // Where a named conf key really lives, resolved before the model sees the question. Deterministic // so the answer cannot be a guess: the operator may be certain a setting is in master.conf when // it is in the host conf, and the useful reply names the file and line rather than not finding it. diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index c65dfa8..441578b 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -187,6 +187,17 @@ if ($action === 'clear') { exit; } +// ── incident_add ────────────────────────────────────────────────────────────── +// Appends one operator-written "this was the fix" note against a scope. POST only, and the +// scope is whitelisted the same way ask's is — it is written to a file that later rides in a +// prompt, so it gets the same treatment as anything else that reaches the model. +if ($action === 'incident_add') { + if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } + echo json_encode(vv_ai_incident_add( + trim($_POST['scope'] ?? ''), trim($_POST['symptom'] ?? ''), trim($_POST['fix'] ?? ''))); + exit; +} + // ── ask ─────────────────────────────────────────────────────────────────────── if ($action === 'ask') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } @@ -220,7 +231,7 @@ if ($action === 'ask') { // richer than a file name is an instruction-injection surface for no benefit — a scope is // only ever a name from this page's own view state. $scope = trim($_POST['scope'] ?? ''); - if ($scope !== '' && !preg_match('#^[A-Za-z0-9 ._/-]{1,80}$#', $scope)) $scope = ''; + if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = ''; // The retrieval filter only means anything to the profile that retrieves. $kind = $profile === 'varaverk' ? trim($_POST['kind'] ?? '') : ''; diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index a56671b..ac1bbae 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -781,6 +781,85 @@ function vv_ai_find_conf_key(string $key): array { return ['ok' => false]; } +// One definition of a valid scope, used by every caller. A scope names something in this page's +// own view state — a conf file, a script, a log. It reaches the model as text and, for the +// troubleshooting profile, composes a path under LOG_DIR, so `..` is refused outright rather +// than left for the containment check downstream to catch. That check stays: this is the first +// gate, not the only one. +function vv_ai_scope_ok(string $scope): bool { + if (!preg_match('#^[A-Za-z0-9 ._/-]{1,80}$#', $scope)) return false; + if (str_contains($scope, '..')) return false; + return true; +} + +// ── Incident journal ───────────────────────────────────────────────────────────────────────── +// "We have seen this before, and here is what it was." Appended as you work through logs, and +// fed back the next time the same script is being diagnosed. +// +// Deliberately NOT part of the AI index. The index reads git-tracked files only, and this lives +// under data/ which is gitignored — which is correct twice over: incident notes are about this +// installation and should not be pushed, and retrieval by similarity is the wrong lookup here. +// The right question is "what has gone wrong with THIS script before", which is an exact match +// on the scope, not a vector search. Same reasoning as the standing memory file. +// +// Markdown rather than a delimited .db because the useful part is prose — a fix is a sentence, +// not a field — and it stays hand-editable when a note turns out to be wrong. +function vv_ai_incidents_path(): string { + return DATA_DIR . '/ai_incidents.md'; +} + +// One entry, appended. The symptom is captured from what was being asked; the fix is written by +// the operator. That split matters: the model's diagnosis is a hypothesis, and writing a +// hypothesis into institutional memory as fact is how a wrong answer outlives the incident. +function vv_ai_incident_add(string $scope, string $symptom, string $fix): array { + $scope = trim($scope); + $symptom = trim($symptom); + $fix = trim($fix); + if ($scope === '' || $fix === '') return ['ok' => false, 'error' => 'scope and fix are required']; + if (!vv_ai_scope_ok($scope)) return ['ok' => false, 'error' => 'bad scope']; + + $entry = "\n## " . date('Y-m-d') . ' · ' . $scope . "\n" + . ($symptom !== '' ? '**Symptom:** ' . mb_substr($symptom, 0, 400) . "\n" : '') + . '**Fix:** ' . mb_substr($fix, 0, 1200) . "\n"; + + $p = vv_ai_incidents_path(); + if (!is_dir(dirname($p)) && !@mkdir(dirname($p), 0755, true)) { + return ['ok' => false, 'error' => 'cannot create data dir']; + } + if (!file_exists($p)) { + @file_put_contents($p, "# Incident journal\n\nWhat went wrong, and what actually fixed it." + . " Written by the operator from the Scheduler assistant; fed back when the same" + . " script is diagnosed again.\n"); + } + if (@file_put_contents($p, $entry, FILE_APPEND | LOCK_EX) === false) { + return ['ok' => false, 'error' => 'cannot write journal']; + } + return ['ok' => true]; +} + +// Past entries for one scope, newest first. Capped hard: this rides in the prompt alongside a +// 120-line log and retrieved passages, and the context budget is already the tight thing. +function vv_ai_incidents_for(string $scope, int $max = 4): array { + $p = vv_ai_incidents_path(); + if ($scope === '' || !is_file($p)) return []; + + $blocks = preg_split('/^## /m', (string)@file_get_contents($p)); + $want = strtolower(trim($scope)); + $base = strtolower(basename(trim($scope))); + $hits = []; + foreach ($blocks as $b) { + $b = trim($b); + if ($b === '' || !str_contains($b, "\n")) continue; + [$head] = explode("\n", $b, 2); + $head = strtolower($head); + // Match the scope as written, or its basename — the chip may carry a path where an + // older entry carried only the script name. + if (!str_contains($head, $want) && !str_contains($head, $base)) continue; + $hits[] = '## ' . $b; + } + return array_slice(array_reverse($hits), 0, max(1, $max)); +} + function vv_ai_job_dir(): string { if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true); return VV_AI_JOB_DIR; diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 2e6fcd3..aa6f213 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -1062,6 +1062,8 @@ Still the same two servers, two households, the same media stack running itself. placeholder="Ask about what is on screen…" onkeydown="if(event.key==='Enter'){event.preventDefault();vvAiDockSend();}"> + @@ -2351,11 +2353,55 @@ function vvAiDockCollapse() { requestAnimationFrame(vvFitRight); } +// ── Recording a fix ─────────────────────────────────────────────────────────── +// The symptom is taken from the question that was being asked; the fix is typed by the operator. +// The model's diagnosis is deliberately not saved — it is a reading of evidence, and writing a +// hypothesis into institutional memory as settled fact is how a wrong answer outlives the +// incident it came from. What gets remembered is what actually worked. +let vvAiFixMode = false; +let vvAiLastQ = ''; + +function vvAiFixStart() { + const input = document.getElementById('vv-ai-dock-input'); + vvAiFixMode = true; + input.placeholder = 'What actually fixed it? (saved against ' + vvAiScope + ')'; + input.value = ''; + input.focus(); + document.getElementById('vv-ai-dock-send').textContent = 'Save'; + document.getElementById('vv-ai-dock-fix').style.display = 'none'; +} + +function vvAiFixCancel() { + vvAiFixMode = false; + const input = document.getElementById('vv-ai-dock-input'); + input.placeholder = 'Ask about what is on screen…'; + document.getElementById('vv-ai-dock-send').textContent = 'Ask'; + if (vvAiLastQ) document.getElementById('vv-ai-dock-fix').style.display = ''; +} + +function vvAiFixSave(text) { + fetch('/plugins/varaverk/api/ai.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: new URLSearchParams({ action: 'incident_add', scope: vvAiScope, + symptom: vvAiLastQ, fix: text }), + }).then(r => r.json()).then(d => { + vvAiDockAppend(d.ok + ? '
saved against ' + vvEscHtml(vvAiScope) + + ' — it will be shown next time this is diagnosed
' + : '
Could not save: ' + + vvEscHtml(d.error || 'unknown') + '
'); + }).catch(e => vvAiDockAppend('
Save failed: ' + + vvEscHtml(String(e)) + '
')); + vvAiFixCancel(); +} + function vvAiDockSend() { if (vvAiBusy) return; const input = document.getElementById('vv-ai-dock-input'); const q = input.value.trim(); if (!q) return; + if (vvAiFixMode) { input.value = ''; vvAiFixSave(q); return; } input.value = ''; vvAiBusy = true; document.getElementById('vv-ai-dock-send').disabled = true; @@ -2369,11 +2415,16 @@ function vvAiDockSend() { headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: new URLSearchParams({ action: 'ask', question: q, profile: vvAiProfile, scope: vvAiScope, - think: '0', history: JSON.stringify(vvAiHist), + // Thinking on for diagnosis only. Working out what a log means is reasoning, and it is + // the one thing here worth waiting ~15s for; a lookup like "what does this setting do" + // is not, and inline answers that stall feel broken. + think: vvAiProfile === 'troubleshoot' ? '1' : '0', + history: JSON.stringify(vvAiHist), }), }).then(r => r.json()).then(d => { if (!d.ok || !d.token) return vvAiDockDone(d.error || 'Could not start.', true); vvAiHist.push({ role: 'user', content: q }); + vvAiLastQ = q; vvAiDockPoll(d.token); }).catch(e => vvAiDockDone('Request failed: ' + e, true)); } @@ -2415,6 +2466,8 @@ function vvAiDockDone(text, isErr, sources) { } vvAiBusy = false; document.getElementById('vv-ai-dock-send').disabled = false; + // Offered only once there is something to attach a fix to. + if (!isErr && vvAiLastQ) document.getElementById('vv-ai-dock-fix').style.display = ''; const body = document.getElementById('vv-ai-dock-body'); body.scrollTop = body.scrollHeight; }