Token and poll rather than SSE, so the api layer keeps one response convention and reuses the pattern manual_sync already proved. History is capped at three turns because the model is only fully offloaded at 16384 context and unbounded history would cross that silently. The tab exists only while AI_ENABLED is true, rejected server-side and not merely hidden.
197 lines
8.2 KiB
PHP
197 lines
8.2 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.
|
||
//
|
||
// 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,
|
||
],
|
||
]);
|