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. // // 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"; } $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 passages " . "below are the only thing you know about it.\n\n" . "Answer only from these passages and cite them inline as [1], [2]. If they do 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" . "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, ], ]);