Add a troubleshooting profile with the scoped log, and a deterministic conf-key lookup

This commit is contained in:
Gmer4Lfe
2026-08-05 20:18:05 -04:00
parent 185abdb442
commit 5a813eb4c8
4 changed files with 151 additions and 10 deletions
+63 -4
View File
@@ -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 "
+3 -1
View File
@@ -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
+74
View File
@@ -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;
+11 -5
View File
@@ -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