From 815ceef276c83ee7defc5e3a457918447093e236 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Thu, 6 Aug 2026 18:43:12 -0400 Subject: [PATCH] A question about how a run went should arrive with the run attached, not with directions to the log panel --- Plugin/unraid/Tools/ai_chat_worker.php | 67 ++++++++++++++++-- Plugin/unraid/include/ai.php | 97 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index bc8457c..606f337 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -67,6 +67,8 @@ // 3 history JSON array of {role, content}, already trimmed by the endpoint // 4 kind optional retrieval filter (header|readme|manual|template|doc) // 5 think "1" to allow the model's reasoning, "0" to suppress it +// 6 profile varaverk|chat|code|troubleshoot — decides the contract and the inputs +// 7 scope what the operator has open, e.g. Orchestrators/daily_sync_maintenance // // JOB FILE STATES // {"status":"retrieving"} @@ -154,7 +156,17 @@ if ($profile === 'varaverk' || $profile === 'troubleshoot') { // The troubleshooting profile is diagnostic by definition — the operator opened a log and asked // about it, which is a clearer signal than any phrasing test. For the assistant it stays a // keyword gate, since most of its questions are not about failures. -$diagnostic = $profile === 'troubleshoot' || ($profile === 'varaverk' && (bool)preg_match( +// "How did the daily orch go last night" is diagnostic too, and matched none of the words below +// — nothing had failed, so nothing in the question said failure. It retrieved the documentation +// on where logs live and answered with directions to a page the operator already had open. The +// question is about a run that happened, so the run itself has to be in context. +$runOutcome = (bool)preg_match( + '/\b(how did|how.d|did .{0,24}\b(run|go|finish|complete)|last run|latest run|last night|' + . 'go last|went last|how long did|run record|rundown|summar(y|ise|ize)|recap)\b/i', + $question +); + +$diagnostic = $profile === 'troubleshoot' || $runOutcome || ($profile === 'varaverk' && (bool)preg_match( '/\b(why|fail(ed|ing|ure)?|error|broken?|not work|isn.t work|wrong|stuck|hang|' . 'never runs?|didn.t|won.t|debug|troubleshoot|diagnos)/i', $question @@ -185,15 +197,39 @@ if ($diagnostic) { // The troubleshooting profile gets the actual tail of the one log the operator is looking at, // warnings and ordinary lines alike. The fleet-wide WARN/ERROR sweep above cannot answer "why // did this one stop" — the last line a script printed before dying is usually not labelled. +// The target is whatever the operator has open, and failing that whatever they named in the +// question. The second half is what makes a run-outcome question work from any view: asking how +// the daily orchestrator went while looking at the suggestions list is the ordinary case, not an +// edge one, and requiring them to open the log first is asking them to do the lookup themselves. +$runTarget = ($profile === 'troubleshoot' && $scope !== '') ? $scope : ''; +if ($runTarget === '' && $runOutcome) $runTarget = vv_ai_resolve_run_target($question); + $scopedLog = null; -if ($profile === 'troubleshoot' && $scope !== '') { - $scopedLog = vv_ai_scoped_log($scope, 120); +if ($runTarget !== '' && vv_ai_scope_ok($runTarget)) { + // The record first: it states the outcome, where the tail only implies it. A log that ends + // on a tidy summary block looks identical whether the script exited 0 or was killed on the + // next line, and the difference is the whole answer. + $rec = vv_ai_run_record($runTarget); + if ($rec['ok']) { + $diagBlock .= 'RUN RECORD for ' . $runTarget . " (authoritative — how the last run ended)\n" + . '- status: ' . $rec['status'] + . ($rec['exit'] !== null ? ' (exit ' . $rec['exit'] . ')' : '') . "\n" + . '- started: ' . date('Y-m-d H:i:s', $rec['start']) . "\n" + . '- ended: ' . ($rec['end'] ? date('Y-m-d H:i:s', $rec['end']) : 'no end recorded — ' + . 'it did not finish, or is still running') . "\n" + . ($rec['duration'] !== null + ? '- duration: ' . floor($rec['duration'] / 60) . 'm' . ($rec['duration'] % 60) . "s\n" + : '') + . "\n"; + } + + $scopedLog = vv_ai_scoped_log($runTarget, 120); if ($scopedLog['ok']) { $diagBlock .= 'LOG: ' . $scopedLog['path'] . ' (' . $scopedLog['total'] . " lines total, newest last)\n" . implode("\n", $scopedLog['tail']) . "\n\n"; } else { - $diagBlock .= "LOG: none found for " . $scope . " — it may never have run.\n\n"; + $diagBlock .= "LOG: none found for " . $runTarget . " — it may never have run.\n\n"; } } @@ -340,6 +376,29 @@ if ($profile === 'chat') { . "gap from general knowledge. Prefer the user's own terminology.\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 +// back on the documentation, which is how "how did the daily orch go" got answered with +// directions to the Recent Activity panel instead of the run it was asked about. +if ($runTarget !== '' && $runOutcome) { + $system .= "THIS IS A QUESTION ABOUT A RUN THAT ALREADY HAPPENED\n" + . "You have been given the run record and the tail of the log for " . $runTarget + . ". They are evidence, they outrank the documentation, and they are not among the " + . "numbered passages — use them directly and do not cite them.\n\n" + . "Answer it as a rundown of that run, in this order: whether it succeeded, when it " + . "ran and how long it took, what it actually did, and anything that failed, was " + . "skipped or looks off. Use the real figures — counts, durations, script names — " + . "rather than describing them in general terms. Summary blocks near the end of the " + . "log usually hold the totals worth leading with.\n\n" + . "Do NOT explain how to find the log, which panel shows recent runs, or how to read " + . "it in the WebGUI. They asked you to read it and you have it in front of you; " + . "telling them where to click is answering a question they did not ask.\n\n" + . "If the record and the log disagree — a clean summary under a non-zero exit, or a " + . "record with no end time — say so and lead with it. That contradiction is the most " + . "useful thing on the page.\n\n"; +} + // Memory first, before the passages. It is the standing context — who the operator is and what // has already been decided — so it should frame everything that follows rather than read as one // more retrieved document. Marked as operator-authored so the model treats it as fact about the diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index 11825ac..f7c8a49 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -736,6 +736,103 @@ function vv_ai_scoped_log(string $id, int $lines = 120): array { ]; } +// The run record a script's wrapper writes beside its log: how the last run ended, in four +// fields, without reading a line of the log. "How did it go" is answerable from this alone — +// status, exit code and the window it ran in — and the log tail then supplies the detail. +// Separate from vv_ai_scoped_log() because they fail independently: a script killed mid-run +// leaves a log and no record, and that difference is itself the answer. +function vv_ai_run_record(string $id): array { + $rel = preg_replace('/\.sh$/', '', trim($id)) . '.json'; + if ($rel === '' || str_contains($rel, "\0")) return ['ok' => false]; + + $base = realpath(LOG_DIR); + $path = realpath(LOG_DIR . '/' . $rel); + if ($base === false || $path === false) return ['ok' => false]; + if (!str_starts_with($path, $base . '/')) return ['ok' => false]; + + $r = json_decode((string)@file_get_contents($path), true); + if (!is_array($r) || !isset($r['start'])) return ['ok' => false]; + + $start = (int)$r['start']; + $end = isset($r['end']) ? (int)$r['end'] : 0; + return [ + 'ok' => true, + 'status' => (string)($r['status'] ?? '?'), + 'exit' => isset($r['exit']) ? (int)$r['exit'] : null, + 'start' => $start, + 'end' => $end ?: null, + 'duration' => $end > $start ? $end - $start : null, + ]; +} + +// Which script a run-outcome question is about, when the operator names it in prose rather than +// by opening its log. "How did the daily orch go" has to resolve to Orchestrators/daily_sync_ +// maintenance before anything can be attached to the context. +// +// Aliases are an explicit table, and matching against real log ids is exact on the underscore +// tokens — no similarity scoring. The failure mode of a scored match here is attaching the wrong +// script's log and answering confidently about a run the operator never asked about, which is +// indistinguishable from a correct answer unless they already knew. Ambiguity returns nothing so +// the question falls through to ordinary retrieval, which is merely unhelpful rather than wrong. +function vv_ai_resolve_run_target(string $question): string { + $q = strtolower($question); + + static $aliases = [ + 'Orchestrators/daily_sync_maintenance' => ['daily orch', 'daily orchestrator', + 'daily sync', 'daily maintenance', 'daily run'], + 'Orchestrators/weekly_sync_maintenance' => ['weekly orch', 'weekly orchestrator', + 'weekly sync', 'weekly maintenance', 'weekly run'], + 'Orchestrators/critical_sync_maintenance' => ['critical orch', 'critical sync', 'critical run'], + 'Orchestrators/intermediate_sync_maintenance' => ['intermediate orch', 'intermediate sync'], + 'Orchestrators/watchdog_orchestrator' => ['watchdog orch', 'watchdog orchestrator', + 'watchdogs'], + 'Orchestrators/transcode_management' => ['transcode orch', 'transcode management'], + 'Orchestrators/array_started' => ['array start', 'array started'], + 'Orchestrators/monthly_maintenance' => ['monthly orch', 'monthly maintenance'], + 'Orchestrators/sunday_morning_coffee_report' => ['coffee report', 'sunday report', + 'sunday morning coffee'], + ]; + foreach ($aliases as $id => $phrases) { + foreach ($phrases as $p) if (str_contains($q, $p)) return $id; + } + + // Anything with a log but no alias — named outright, either as an id or a bare script name. + $hits = []; + foreach (vv_ai_log_ids() as $id) { + $bare = basename($id); + if (str_contains($q, strtolower($id)) || str_contains($q, strtolower($bare))) $hits[] = $id; + } + $hits = array_unique($hits); + if (count($hits) === 1) return reset($hits); + + // Longest match wins only when one candidate contains every other — "daily_sync_maintenance" + // over "sync", not a coin toss between two unrelated scripts. + if (count($hits) > 1) { + usort($hits, fn($a, $b) => strlen($b) - strlen($a)); + $longest = $hits[0]; + foreach (array_slice($hits, 1) as $h) { + if (!str_contains(strtolower($longest), strtolower(basename($h)))) return ''; + } + return $longest; + } + return ''; +} + +// Every script id that has a log, relative to LOG_DIR and without the extension. One level of +// nesting, which is how the log tree is actually laid out (Orchestrators/, Plugin/). +function vv_ai_log_ids(): array { + $base = realpath(LOG_DIR); + if ($base === false) return []; + $ids = []; + foreach (glob($base . '/*.log') ?: [] as $f) { + $ids[] = basename($f, '.log'); + } + foreach (glob($base . '/*/*.log') ?: [] as $f) { + $ids[] = basename(dirname($f)) . '/' . basename($f, '.log'); + } + return $ids; +} + // Where a conf key actually lives. Deterministic on purpose: the model should narrate this, not // work it out. Searches the host's own conf and master, reports file, line and value. //