A small operator-written file handed to the model at the start of every conversation — who you are, how this install is set up, what has already been decided. Injected ahead of the retrieved passages and marked operator-authored so it outranks anything they contradict, and never cited as a source. Deliberately not indexed and deliberately under DATA_DIR: it changes constantly, vector similarity is the wrong way to retrieve things you were told to remember, and gitignoring it keeps personal notes out of a pushed repository. The character cap is a context budget — this text costs its share of 16k on every single turn.
265 lines
12 KiB
PHP
265 lines
12 KiB
PHP
<?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] = 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";
|
||
|
||
// 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;
|
||
}
|
||
|
||
$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,
|
||
],
|
||
]);
|