Files
Varaverk/Plugin/unraid/Tools/ai_chat_worker.php
T

485 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Detached worker for one AI chat turn. Retrieves grounding chunks, asks the generation
// model, and writes progress and the final answer to a job file the AI tab polls.
//
// OPERATIONAL MODEL
// Not an HTTP endpoint. Generation takes 25-76 seconds on this hardware — far past what a
// page request should hold open — so api/ai.php spawns this detached and returns a token.
// The job file is the only channel between the two, exactly as docker_pull_worker.php works
// for container updates. It lives under api/'s sibling Tools/ because it is part of that
// endpoint's implementation, not a scheduled script.
//
// Writes a terminal state on every exit path. A worker that dies without one leaves the tab
// polling forever, so the states are: retrieving -> generating -> done | error.
//
// Retrieval happens here rather than in the endpoint so the tab gets a token immediately.
// Embedding a query is fast but not free, and it is the first thing that would make the
// "send" button feel slow.
//
// DESIGN PRINCIPLES
// Context is assembled here, not by the model's own tooling.
// The retrieved chunks go into a system message with explicit citation and refusal
// instructions. That instruction is the difference between a grounded answer and the
// model filling a gap from training data it does not have for a private project.
//
// History arrives already trimmed.
// The endpoint caps turns before spawning. The worker does not re-derive the policy,
// so there is one place that decides how much context history may consume.
//
// Thinking is captured separately, never discarded.
// qwen3 emits reasoning that is often more useful than the answer for judgement calls.
// It is stored in its own field so the page can collapse it rather than lose it.
//
// OPERATIONAL SAFEGUARDS
// Refuses to run under a web server.
// PHP_SAPI is checked first. Over HTTP there is no $argv, so every argument below would
// be undefined — and this process talks to Ollama and writes job files.
//
// The job file path is validated as hex before anything is written.
// It is supplied on the command line; the pattern is what keeps writes inside the job
// directory even if the caller is ever wrong.
//
// The Ollama request is time-boxed.
// AI_REQUEST_TIMEOUT bounds it, and a timeout is written as an error state rather than
// leaving the job file at "generating" forever.
//
// Retrieval failure ends the turn.
// An empty or failed retrieval writes an error instead of asking the model anyway. A
// generated answer with no grounding is exactly the confident hallucination this whole
// subsystem exists to prevent.
//
// Live state is attached only to diagnostic questions.
// Failing health checks and recent log warnings are several thousand tokens. On a
// 16384 context that is budget taken directly from the retrieved passages, so it is
// spent only when the question is asking why something broke. Log lines are marked as
// evidence rather than citable sources, so the model cannot cite a log line as though
// it were documentation.
//
// Every failure path writes the job file.
// Including the ones that would otherwise be silent — unreachable Ollama, unparseable
// response, empty content — so the tab always converges on a state it can render.
//
// ARGUMENTS
// 1 jobFile absolute path, hex-named, written by api/ai.php
// 2 question the user's message
// 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
//
// JOB FILE STATES
// {"status":"retrieving"}
// {"status":"generating","sources":[…]}
// {"status":"done","answer":…,"thinking":…,"sources":[…],"timing":{…}}
// {"status":"error","error":…}
// ═══════════════════════════════════════════════════════════════════════════════════════════════
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
require_once dirname(__DIR__) . '/include/ai.php';
[$jobFile, $question, $historyJson, $kind, $think, $profile, $scope] =
array_slice($argv, 1, 7) + array_fill(0, 7, '');
if ($jobFile === '' || $question === '') exit(1);
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
$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));
}
$cfg = vv_ai_config();
$t0 = microtime(true);
// Only the Varaverk profile retrieves. The other two answer from memory and the model's own
// knowledge, so passages would be noise competing for context — documentation cannot help write
// a folder-copy script, and it has nothing to say about how someone's day is going.
$sources = [];
$context = '';
$tRetrieve = 0.0;
if ($profile === 'varaverk' || $profile === 'troubleshoot') {
jw($jobFile, ['status' => 'retrieving']);
// A definitional question with no explicit filter goes to the narrative docs. Left alone,
// intent routing boosts PURPOSE and returns every script's one-line purpose, so the model
// reports that the context does not define Varaverk while README.md sits in the index
// unread. An explicit --kind from the user always wins.
if ($kind === '' && vv_ai_is_definitional($question)) $kind = 'readme';
$r = vv_ai_retrieve($question, $kind);
if (!$r['ok']) {
jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']);
exit(1);
}
if (!$r['results']) {
jw($jobFile, ['status' => 'error',
'error' => 'No relevant documentation found. Try rephrasing, use the readme filter '
. 'for questions about what something is, or switch to General Chat if this '
. 'is not a Varaverk question.']);
exit(0);
}
$tRetrieve = microtime(true) - $t0;
$sources = array_map(fn($x) => [
'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0,
], $r['results']);
foreach ($r['results'] as $i => $x) {
$label = implode(' ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
$context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
}
}
// Diagnostic questions get live state as well as documentation. The docs say what a script is
// 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.
// 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) {
// Every check, not only the failing ones. Passing checks are what tell the model the
// current value of a setting — without them it has no live signal for anything healthy,
// and a documented default fills the gap. That produced a confidently wrong answer:
// asked what was wrong, it reported "AI_ENABLED=false" read from the conf *template*
// while the live value was true. Documentation records defaults; only this block records
// what is actually set.
$diagBlock .= "LIVE SYSTEM STATE (authoritative — measured just now)\n";
foreach (vv_ai_health() as $c) {
$mark = ['ok' => 'OK', 'warn' => 'WARNING', 'bad' => 'PROBLEM'][$c['state']] ?? '?';
$diagBlock .= '- [' . $mark . '] ' . $c['label'] . ': ' . $c['detail']
. ($c['state'] !== 'ok' && $c['fix'] !== '' ? ' — FIX: ' . $c['fix'] : '') . "\n";
}
$diagBlock .= "\n";
$logs = vv_ai_recent_logs(40);
if ($logs) {
$diagBlock .= "RECENT WARNINGS AND ERRORS (newest last)\n" . implode("\n", $logs) . "\n\n";
}
}
// 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";
}
}
// 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.
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,
// and the strict profile is the default.
if ($profile === 'chat') {
// A polite instruction is not a guard. Asked "what does mover_stop.sh do in my setup", the
// model invented an answer and dressed it in real memory facts so it read as authoritative.
// The rule therefore leads, is stated absolutely, and is reinforced by a deterministic
// check below when the question names something Varaverk-shaped.
$system = "You are the Varaverk assistant, talking with the operator of a private two-server "
. "Unraid media ecosystem called Varaverk. This is ordinary conversation.\n\n"
. "ABSOLUTE RULE, BEFORE ANYTHING ELSE: in this mode you have NOT been given "
. "Varaverk's documentation. You therefore do not know what any Varaverk script, "
. "config variable, orchestrator or safeguard actually does. If asked, you must say "
. "you cannot see the documentation in this mode and that the Varaverk Assistant "
. "profile can answer it — then stop. Do not describe what a script 'typically' or "
. "'probably' does. Do not reason from its name. Do not combine facts from the memory "
. "section into an explanation of a component you were not told about. A confident "
. "wrong answer about their own system is the single worst thing you can do here; "
. "sending them one click away costs nothing.\n\n"
. "Everything else is ordinary conversation. Talk like a knowledgeable colleague, be "
. "warm and direct, follow a tangent if it is interesting, and use your general "
. "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"
. "START FROM CONFIGURATION, NOT FROM A DEFECT\n"
. "You are talking to someone running this software, not maintaining it. Varaverk "
. "itself is in daily use and mostly works, so the overwhelmingly likely cause of "
. "'it did not do what I expected' is a setting: a toggle left false, an array that "
. "is empty, a path that does not exist on this host, an entry commented out, a "
. "gate higher up the tier that is off. Check those before anything else, and use "
. "the live settings and health state you were given rather than assuming defaults.\n\n"
. "Only call something a bug when the evidence actually shows one, and then say "
. "which line shows it. 'This looks like a Varaverk problem' with nothing behind it "
. "sends someone hunting through code they did not write and cannot fix, which is "
. "the least useful place you can send them.\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 "
. "under /mnt/user, no systemd, and no packages beyond what Unraid ships.\n\n"
. "Write the smallest script that does the job. Include a shebang, quote variables, "
. "check that inputs exist, and exit non-zero on failure. Prefer plain coreutils and "
. "rsync over anything exotic.\n\n"
. "This is the important part: you sometimes invent command-line flags that sound "
. "plausible but do not exist. After the script, list every flag or command you are "
. "not completely certain about, and say how to check it — 'verify with rsync --help' "
. "or similar. A stated assumption the operator can check beats a confident mistake "
. "they run at 3am. If you are unsure a flag exists, say so rather than picking the "
. "one that sounds right.\n\n"
. "Treat this as a first draft to be tested, and say so.\n\n"
. "These scripts run as root, on a schedule, unattended. So the danger is not "
. "complicated logic — it is a simple script aimed at the wrong path. If your script "
. "deletes, moves, overwrites or truncates anything (rm, mv, find -delete, "
. "rsync --delete, truncation with >, chown -R, chmod -R), then before the script "
. "say in one line exactly what it will destroy and under what conditions. Where the "
. "tool supports it, give the dry-run form first (rsync --dry-run, find without "
. "-delete) and tell them to run that and read the output before the real one. "
. "Guard every destructive path against an unset or empty variable — \"rm -rf "
. "\$DEST/\" with DEST unset is the mistake that actually happens.\n\n"
. "If the operator comes back with an error showing that something you suggested does "
. "not exist — an unknown option, a command not found — say plainly that you got it "
. "wrong and invented it. Do NOT explain it away as a version difference, a "
. "distribution quirk, or a newer/older release, unless you can name the specific "
. "version that added or removed it. Guessing at a cause is a second mistake on top "
. "of the first, and it sends them chasing an upgrade that does not exist. 'I made "
. "that flag up, the correct one is X' is the whole answer.\n\n";
} else {
$system = "You are Varaverk's documentation assistant. Varaverk is this user's private "
. "two-server Unraid media ecosystem; it is not in your training data, so the material "
. "below is the only thing you know about it.\n\n"
. "Answer only from this material and cite the passages inline as [1], [2]. If it does "
. "not contain the answer, say so plainly and name what is missing — do not fill the "
. "gap from general knowledge. Prefer the user's own terminology.\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
// installation rather than as a source to cite.
// Where the operator is standing in the WebGUI. Sent by the scheduler page, absent from the AI
// tab, which has no location to speak of. It is what lets "what does this do" resolve — without
// it the model has to guess which of 120 settings "this" means, and it will guess confidently.
//
// Stated as context, never as an instruction to trust: the file named here is where to look
// first, not proof that the answer is in it. That distinction is the whole point of the
// fallback — a setting the operator expects in master.conf may actually live in the host conf,
// and saying so is more useful than failing to find it.
if ($scope !== '') {
$system .= "WHERE THE OPERATOR IS RIGHT NOW\n"
. "They have " . $scope . " open in the WebGUI. Read bare references — \"this "
. "setting\", \"this script\", \"here\" — as meaning that unless they clearly mean "
. "something else. Look there first. If what they are asking about is genuinely "
. "somewhere else, answer anyway and tell them plainly where it actually is.\n\n";
}
$mem = vv_ai_memory_read();
if ($mem['exists'] && trim($mem['text']) !== '') {
$system .= "WHAT YOU ALREADY KNOW ABOUT THIS OPERATOR AND INSTALLATION\n"
. "Written by the operator, not retrieved. Treat it as established fact about this "
. "system, prefer it over anything in the passages that contradicts it, and do not "
. "cite it as a numbered source.\n\n"
. trim($mem['text']) . "\n\n";
}
if ($diagBlock !== '') {
$system .= "This is a diagnostic question, so live system state is included alongside the "
. "documentation.\n\n"
. "CRITICAL: where the live state contradicts anything in the passages, the live "
. "state is correct. The passages include conf templates and design notes that "
. "record DEFAULT values, not this system's current ones — a template showing "
. "AI_ENABLED=false says what a fresh install ships with, never what is set here. "
. "Never report a documented default as the current value.\n\n"
. "Use the passages to explain how a thing is supposed to work, and the live state "
. "to say what is actually happening. When a problem is listed, name the specific "
. "setting and the file it lives in. Log lines are evidence, not citations — cite "
. "only the numbered passages.\n\n"
. $diagBlock;
}
// Deterministic backstop for the chat profile. The prompt asks the model to defer on Varaverk
// internals; this does not rely on it complying. If the question names something that can only
// be a Varaverk component — a shell script, a SCREAMING_CASE conf key, or the project itself —
// the instruction is repeated immediately before the user's message, where it is hardest to
// ignore. Detection only ever makes the model MORE cautious, so a false positive costs a
// redirect rather than a wrong answer.
if ($profile === 'chat'
&& (vv_ai_mentions_varaverk($question)
|| preg_match('/\b[\w.-]+\.sh\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+)+\b/', $question))) {
$system .= "NOTE: the operator's message appears to name a specific Varaverk component. "
. "You cannot see the documentation in this mode, so you do not know what it does. "
. "Say that plainly, point them at the Varaverk Assistant profile, and do not "
. "speculate about its behaviour — not even a hedged guess.\n\n";
}
if ($context !== '') $system .= "PASSAGES\n" . $context;
$messages = [['role' => 'system', 'content' => $system]];
$hist = json_decode($historyJson ?: '[]', true);
if (is_array($hist)) {
foreach ($hist as $m) {
$role = $m['role'] ?? '';
$text = trim((string)($m['content'] ?? ''));
if (in_array($role, ['user', 'assistant'], true) && $text !== '') {
$messages[] = ['role' => $role, 'content' => $text];
}
}
}
$messages[] = ['role' => 'user', 'content' => $question];
$payload = json_encode([
'model' => $cfg['model'],
'messages' => $messages,
'stream' => false,
'think' => $think === '1',
'options' => ['num_ctx' => 16384],
]);
$t1 = microtime(true);
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $payload,
'timeout' => max(30, $cfg['timeout']),
'ignore_errors' => true,
]]);
$raw = @file_get_contents($cfg['url'] . '/api/chat', false, $ctx);
if ($raw === false) {
jw($jobFile, ['status' => 'error',
'error' => 'Ollama did not respond within ' . max(30, $cfg['timeout']) . 's at ' . $cfg['url'],
'sources' => $sources]);
exit(1);
}
$d = json_decode($raw, true);
if (!is_array($d) || !isset($d['message'])) {
jw($jobFile, ['status' => 'error', 'error' => 'Unparseable response from Ollama',
'sources' => $sources]);
exit(1);
}
$answer = trim((string)($d['message']['content'] ?? ''));
$thinking = trim((string)($d['message']['thinking'] ?? ''));
if ($answer === '') {
jw($jobFile, ['status' => 'error',
'error' => 'The model returned no answer' . ($thinking !== '' ? ' (only reasoning)' : ''),
'thinking' => $thinking, 'sources' => $sources]);
exit(1);
}
// Destructive-operation scan of what was actually generated. The prompt asks the model to warn;
// this does not depend on it having done so. These scripts run as root on a schedule, so the
// expensive mistake is not tangled logic — it is a simple script pointed one directory too high.
// Scans only fenced code, so prose mentioning "rm" does not trip it.
$warnings = [];
if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $blocks)) {
$code = implode("\n", $blocks[1]);
$checks = [
'/(^|[;&|\s])rm\s+(-[a-zA-Z]*\s+)*/m' => 'deletes files (rm)',
'/(^|[;&|\s])mv\s/m' => 'moves files (mv)',
'/(?<!-)-delete\b/' => 'deletes files (find -delete)',
'/--delete\b/' => 'deletes at the destination (rsync --delete)',
'/(^|[;&|\s])(shred|truncate)\s/m' => 'destroys file contents',
'/(^|[;&|\s])(dd|mkfs\.\w+|mkfs)\s/m' => 'writes raw to a device',
'/(^|[;&|\s])ch(own|mod)\s+-[a-zA-Z]*R/m' => 'recursively changes ownership or permissions',
];
foreach ($checks as $re => $label) {
if (preg_match($re, $code)) $warnings[] = $label;
}
// An unquoted or unguarded path variable is what turns any of the above into a disaster.
if ($warnings && preg_match('/(rm|mv|rsync)[^\n]*\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?![\w"])/', $code)) {
$warnings[] = 'uses a path variable in a destructive command — confirm it can never be empty';
}
}
$evalCount = (int)($d['eval_count'] ?? 0);
$evalNs = (int)($d['eval_duration'] ?? 0);
$tokS = $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null;
// Accounting before the job file is written, so a turn is counted even if the tab has already
// been closed and nobody ever reads the result. Best-effort by contract — it cannot throw.
vv_ai_token_record($profile, 'webgui', (int)($d['prompt_eval_count'] ?? 0), $evalCount, $tokS);
jw($jobFile, [
'status' => 'done',
'answer' => $answer,
'thinking' => $thinking,
'sources' => $sources,
'warnings' => array_values(array_unique($warnings)),
'timing' => [
'retrieve_ms' => (int)round($tRetrieve * 1000),
'generate_ms' => (int)round((microtime(true) - $t1) * 1000),
'tokens' => $evalCount,
'tok_s' => $tokS,
],
]);