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] = array_slice($argv, 1, 5) + array_fill(0, 5, ''); if ($jobFile === '' || $question === '') exit(1); if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); } $cfg = vv_ai_config(); $t0 = microtime(true); 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, or use the readme filter ' . 'for questions about what something is.']); 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']); jw($jobFile, ['status' => 'generating', 'sources' => $sources]); $context = ''; 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 = (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"; } } $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"; 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; } $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, ], ]);