From 5a813eb4c8b404f8fc680dd5bb0ad6b897ce3d1f Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Wed, 5 Aug 2026 20:18:05 -0400 Subject: [PATCH] Add a troubleshooting profile with the scoped log, and a deterministic conf-key lookup --- Plugin/unraid/Tools/ai_chat_worker.php | 67 +++++++++++++++++++++-- Plugin/unraid/api/ai.php | 4 +- Plugin/unraid/include/ai.php | 74 ++++++++++++++++++++++++++ Plugin/unraid/pages/scheduler.php | 16 ++++-- 4 files changed, 151 insertions(+), 10 deletions(-) diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index de0c44c..bcf5cc1 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -88,7 +88,7 @@ require_once dirname(__DIR__) . '/include/ai.php'; if ($jobFile === '' || $question === '') exit(1); if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); -$profile = in_array($profile, ['varaverk', 'chat', 'code'], true) ? $profile : 'varaverk'; +$profile = in_array($profile, ['varaverk', 'chat', 'code', 'troubleshoot'], true) ? $profile : 'varaverk'; function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); @@ -104,7 +104,7 @@ $sources = []; $context = ''; $tRetrieve = 0.0; -if ($profile === 'varaverk') { +if ($profile === 'varaverk' || $profile === 'troubleshoot') { jw($jobFile, ['status' => 'retrieving']); // A definitional question with no explicit filter goes to the narrative docs. Left alone, @@ -143,11 +143,14 @@ if ($profile === 'varaverk') { // supposed to do; only the logs and the current config say what it actually did. Attached only // when the question is asking why something failed — otherwise it is a few thousand tokens of // noise competing with the retrieved passages for a context budget that is already tight. -$diagnostic = $profile === 'varaverk' && (bool)preg_match( +// 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( '/\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 -); +)); $diagBlock = ''; if ($diagnostic) { @@ -171,6 +174,42 @@ 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. +$scopedLog = null; +if ($profile === 'troubleshoot' && $scope !== '') { + $scopedLog = vv_ai_scoped_log($scope, 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"; + } +} + +// 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. +if ($profile === 'varaverk' || $profile === 'troubleshoot') { + $seen = []; + if (preg_match_all('/\b([A-Z][A-Z0-9_]{4,})\b/', $question, $km)) { + foreach (array_slice(array_unique($km[1]), 0, 4) as $k) { + $hit = vv_ai_find_conf_key($k); + if (!$hit['ok']) continue; + $seen[] = '- ' . $k . ' is set in ' . $hit['file'] . ' at line ' . $hit['line'] + . ($hit['commented'] ? ' (commented out, so it is NOT active)' : '') + . ': ' . $hit['text']; + } + } + if ($seen) { + $diagBlock .= "WHERE THESE SETTINGS ACTUALLY LIVE (looked up just now, authoritative)\n" + . implode("\n", $seen) . "\n" + . "If this is a different file from the one they have open, say so plainly.\n\n"; + } +} + // One prompt per profile. Explicit profiles rather than an automatic router: misclassifying a // Varaverk question as chat produces a confident invention about the user's system, which is // precisely what retrieval exists to prevent. The user always knows which contract is in force, @@ -196,6 +235,26 @@ 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 === '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 + // absent" — exactly wrong here, where the evidence is a log that is not in the index and + // never should be. This one reasons from the log first and the documentation second. + $system = "You are helping the operator work out why something on their Varaverk server did " + . "not do what they expected. You have the tail of the relevant log, live system " + . "state measured just now, and documentation passages about the scripts involved.\n\n" + . "HOW TO ANSWER\n" + . "Lead with what the log actually shows. Quote the line that matters. Then say what " + . "it means, using the documentation to explain what the script was trying to do.\n\n" + . "Say plainly when the log does not explain it. \"The log ends at X with no error, " + . "so it was killed or is still running\" is a real and useful answer. Do not " + . "manufacture a cause to have one — a wrong cause sends someone to fix the wrong " + . "thing, which is worse than no answer.\n\n" + . "Distinguish what you observed from what you inferred. The log is evidence; " + . "anything beyond it is a hypothesis and should be labelled as one.\n\n" + . "Do not suggest editing conf files by hand. Settings on this page have controls, " + . "and the operator is reading this inside the WebGUI. Name the control.\n\n"; + } elseif ($profile === 'code') { $system = "You are drafting a short shell script for the operator of an Unraid server, to be " . "saved as a Varaverk Custom Script. Assume bash on Unraid: GNU coreutils, paths " diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index 62e8fdc..c65dfa8 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -94,7 +94,9 @@ require_once dirname(__DIR__) . '/include/ai.php'; // spends ~2500 of its 16384 on retrieved passages, so it cannot afford deep history; the other // two retrieve nothing and can carry a real conversation. Reasoning is not stored in history, // so it does not compound. -const VV_AI_PROFILES = ['varaverk' => 3, 'chat' => 8, 'code' => 4]; +// troubleshoot carries a whole log tail into context, so its history is the shallowest of the +// four — the evidence for "why did this fail" is the log in front of it, not the conversation. +const VV_AI_PROFILES = ['varaverk' => 3, 'chat' => 8, 'code' => 4, 'troubleshoot' => 2]; const VV_AI_MAX_TURNS = 3; // fallback when a profile is not recognised const VV_AI_MAX_QUESTION = 4000; // characters const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index f1d5124..a56671b 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -707,6 +707,80 @@ function vv_ai_token_stats(): array { return $out; } +// ── Scoped lookups for the WebGUI dock ─────────────────────────────────────────────────────── + +// The tail of one named script's log, for the troubleshooting profile. vv_ai_recent_logs() +// answers "is anything wrong anywhere"; this answers "why did THIS fail", which is a different +// question and needs the ordinary lines too, not just WARN and ERROR — the last thing a script +// printed before stopping is usually not labelled as a warning. +// +// Contained to LOG_DIR by realpath. The id arrives from a request, and a log path that escaped +// would read arbitrary files into a model's context. +function vv_ai_scoped_log(string $id, int $lines = 120): array { + $rel = preg_replace('/\.sh$/', '', trim($id)) . '.log'; + if ($rel === '' || str_contains($rel, "\0")) return ['ok' => false, 'error' => 'bad id']; + + $base = realpath(LOG_DIR); + $path = realpath(LOG_DIR . '/' . $rel); + if ($base === false || $path === false) return ['ok' => false, 'error' => 'no log yet']; + if (!str_starts_with($path, $base . '/')) return ['ok' => false, 'error' => 'outside log dir']; + if (!is_file($path)) return ['ok' => false, 'error' => 'no log yet']; + + $all = @file($path, FILE_IGNORE_NEW_LINES) ?: []; + return [ + 'ok' => true, + 'path' => $rel, + 'total' => count($all), + 'mtime' => @filemtime($path) ?: null, + 'tail' => array_slice($all, -max(10, min($lines, 400))), + ]; +} + +// 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. +// +// This is the "Jonny" case — someone is sure a setting is in master.conf and it is really in the +// host conf. Failing to find it is the unhelpful answer; finding it and saying where is the +// useful one, and neither should depend on the model guessing which file to trust. +function vv_ai_find_conf_key(string $key): array { + if (!preg_match('/^[A-Za-z][A-Za-z0-9_]{1,63}$/', $key)) return ['ok' => false]; + + $me = vv_detect_host(); + $order = array_values(array_unique(array_filter([ + $me !== 'unknown' ? $me . '.conf' : null, + 'master.conf', + ]))); + foreach (vv_get_conf_files() as $f) if (!in_array($f, $order, true)) $order[] = $f; + + // Two passes, active before commented, across every file — not first-match-wins per line. + // These confs document each setting in a comment block above it, and those blocks contain + // lines like "# RSYNC_ENABLED=false → ALL rsync stops everywhere". A single pass matched + // that prose at line 529 and reported the setting as commented out while the live value sat + // active at line 546. Reporting an enabled setting as disabled is the exact failure this + // lookup exists to prevent, so the active definition always wins. + $q = preg_quote($key, '/'); + $files = []; + foreach ($order as $file) $files[$file] = @file(CONF_DIR . '/' . $file, FILE_IGNORE_NEW_LINES) ?: []; + + foreach ($files as $file => $lines) { + foreach ($lines as $i => $line) { + if (preg_match('/^\s*' . $q . '\s*=/', $line)) { + return ['ok' => true, 'file' => $file, 'line' => $i + 1, + 'text' => trim($line), 'commented' => false]; + } + } + } + foreach ($files as $file => $lines) { + foreach ($lines as $i => $line) { + if (preg_match('/^\s*#\s*' . $q . '\s*=/', $line)) { + return ['ok' => true, 'file' => $file, 'line' => $i + 1, + 'text' => trim($line), 'commented' => true]; + } + } + } + return ['ok' => false]; +} + 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 a4e483b..2e6fcd3 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -1243,7 +1243,9 @@ window.addEventListener('resize', vvFitRight); new ResizeObserver(vvFitRight).observe(document.getElementById('vv-sched-left')); function vvShowLogMode(id) { - vvAiDockScope('varaverk', String(id).replace(/\.sh$/, '') + ' log'); + vvAiDockScope('troubleshoot', + String(id).replace(/\.sh$/, '').split('/').pop() + ' log', + String(id).replace(/\.sh$/, '')); document.getElementById('vv-suggestions').style.display = 'none'; document.getElementById('vv-editor').style.display = 'none'; document.getElementById('vv-si-view').style.display = 'none'; @@ -2298,15 +2300,19 @@ function vvAiDockOn() { return !!document.getElementById('vv-ai-dock'); } // Called by every view switch. Profile and scope are derived from what is open and shown on the // chip — never chosen, never hidden. If the user can see what it thinks it is looking at, a // wrong inference costs a glance instead of a confidently wrong answer. -function vvAiDockScope(profile, label) { +// label is what the chip shows; target is what the worker resolves. They differ for logs, where +// the chip wants "daily_sync_maintenance log" and the worker needs the script id it can turn +// into a path under LOG_DIR. +function vvAiDockScope(profile, label, target) { if (!vvAiDockOn()) return; - if (profile === vvAiProfile && label === vvAiScope) return; + target = target || label; + if (profile === vvAiProfile && target === vvAiScope) return; const had = vvAiHist.length > 0; vvAiProfile = profile; - vvAiScope = label; + vvAiScope = target; document.getElementById('vv-ai-dock-chip').textContent = - (profile === 'code' ? 'Code' : 'Assistant') + ' · ' + label; + ({ code: 'Code', troubleshoot: 'Troubleshoot' }[profile] || 'Assistant') + ' · ' + label; // Transcript stays, history sent to the model resets — the same rule the AI tab's profile // buttons already use. A troubleshooting thread carrying log excerpts must not bleed into a