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] = array_slice($argv, 1, 6) + array_fill(0, 6, ''); 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'; 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') { jw($jobFile, ['status' => 'retrieving']); $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. $diagnostic = $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"; } } // 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 === '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"; } 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. $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' && preg_match('/\b[\w.-]+\.sh\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+){2,}\b|\bvaraverk\b/i', $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); } $evalCount = (int)($d['eval_count'] ?? 0); $evalNs = (int)($d['eval_duration'] ?? 0); jw($jobFile, [ 'status' => 'done', 'answer' => $answer, 'thinking' => $thinking, 'sources' => $sources, 'timing' => [ 'retrieve_ms' => (int)round($tRetrieve * 1000), 'generate_ms' => (int)round((microtime(true) - $t1) * 1000), 'tokens' => $evalCount, 'tok_s' => $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null, ], ]);