568 lines
32 KiB
PHP
568 lines
32 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, $profile, $scope] =
|
||
array_slice($argv, 1, 7) + array_fill(0, 7, '');
|
||
|
||
if ($jobFile === '' || $question === '') exit(1);
|
||
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
|
||
|
||
$profile = in_array($profile, ['varaverk', 'chat', 'code', 'troubleshoot'], true) ? $profile : 'varaverk';
|
||
|
||
function jw(string $f, array $d): void {
|
||
file_put_contents($f, json_encode($d));
|
||
}
|
||
|
||
// Same log the endpoint writes to, tagged so the two are tellable apart. Defined here because
|
||
// vv_ai_log() belongs to api/ai.php and this runs detached, with no endpoint in the process.
|
||
function wlog(string $msg): void {
|
||
if (!is_dir('/var/log/varaverk')) return;
|
||
@file_put_contents('/var/log/varaverk/ai.log',
|
||
date('Y-m-d H:i:s') . ' worker ' . $msg . "\n", FILE_APPEND | LOCK_EX);
|
||
}
|
||
|
||
$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' || $profile === 'troubleshoot') {
|
||
jw($jobFile, ['status' => 'retrieving']);
|
||
|
||
// A definitional question with no explicit filter goes to the narrative docs. Left alone,
|
||
// intent routing boosts PURPOSE and returns every script's one-line purpose, so the model
|
||
// reports that the context does not define Varaverk while README.md sits in the index
|
||
// unread. An explicit --kind from the user always wins.
|
||
if ($kind === '' && vv_ai_is_definitional($question)) $kind = 'readme';
|
||
|
||
$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.
|
||
// The troubleshooting profile is diagnostic by definition — the operator opened a log and asked
|
||
// about it, which is a clearer signal than any phrasing test. For the assistant it stays a
|
||
// keyword gate, since most of its questions are not about failures.
|
||
$diagnostic = $profile === 'troubleshoot' || ($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";
|
||
}
|
||
}
|
||
|
||
// The troubleshooting profile gets the actual tail of the one log the operator is looking at,
|
||
// warnings and ordinary lines alike. The fleet-wide WARN/ERROR sweep above cannot answer "why
|
||
// did this one stop" — the last line a script printed before dying is usually not labelled.
|
||
$scopedLog = null;
|
||
if ($profile === 'troubleshoot' && $scope !== '') {
|
||
$scopedLog = vv_ai_scoped_log($scope, 120);
|
||
if ($scopedLog['ok']) {
|
||
$diagBlock .= 'LOG: ' . $scopedLog['path']
|
||
. ' (' . $scopedLog['total'] . " lines total, newest last)\n"
|
||
. implode("\n", $scopedLog['tail']) . "\n\n";
|
||
} else {
|
||
$diagBlock .= "LOG: none found for " . $scope . " — it may never have run.\n\n";
|
||
}
|
||
}
|
||
|
||
// What has gone wrong with this same thing before, and what actually fixed it. Operator-written,
|
||
// so it outranks anything the model would infer from the log — it is the only input here that
|
||
// records a confirmed outcome rather than a reading of evidence.
|
||
if ($scope !== '') {
|
||
$past = vv_ai_incidents_for($scope, 4);
|
||
if ($past) {
|
||
$diagBlock .= "PREVIOUSLY ON THIS, WRITTEN BY THE OPERATOR AFTER IT WAS RESOLVED\n"
|
||
. "Confirmed outcomes, not guesses. If the current symptom matches one of "
|
||
. "these, say so and lead with it. If it clearly does not, ignore them "
|
||
. "rather than forcing a fit.\n\n"
|
||
. implode("\n", $past) . "\n\n";
|
||
}
|
||
}
|
||
|
||
// Where a named conf key really lives, resolved before the model sees the question. Deterministic
|
||
// so the answer cannot be a guess: the operator may be certain a setting is in master.conf when
|
||
// it is in the host conf, and the useful reply names the file and line rather than not finding it.
|
||
if ($profile === 'varaverk' || $profile === 'troubleshoot') {
|
||
$seen = [];
|
||
if (preg_match_all('/\b([A-Z][A-Z0-9_]{4,})\b/', $question, $km)) {
|
||
foreach (array_slice(array_unique($km[1]), 0, 4) as $k) {
|
||
$hit = vv_ai_find_conf_key($k);
|
||
if (!$hit['ok']) continue;
|
||
$seen[] = '- ' . $k . ' is set in ' . $hit['file'] . ' at line ' . $hit['line']
|
||
. ($hit['commented'] ? ' (commented out, so it is NOT active)' : '')
|
||
. ': ' . $hit['text'];
|
||
}
|
||
}
|
||
if ($seen) {
|
||
$diagBlock .= "WHERE THESE SETTINGS ACTUALLY LIVE (looked up just now, authoritative)\n"
|
||
. implode("\n", $seen) . "\n"
|
||
. "If this is a different file from the one they have open, say so plainly.\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 === 'troubleshoot') {
|
||
// Different inputs and a different refusal rule from the assistant, which is what earns it a
|
||
// profile of its own. The assistant's contract is "answer only from the passages, refuse if
|
||
// absent" — exactly wrong here, where the evidence is a log that is not in the index and
|
||
// never should be. This one reasons from the log first and the documentation second.
|
||
$system = "You are helping the operator work out why something on their Varaverk server did "
|
||
. "not do what they expected. You have the tail of the relevant log, live system "
|
||
. "state measured just now, and documentation passages about the scripts involved.\n\n"
|
||
. "HOW TO ANSWER\n"
|
||
. "Lead with what the log actually shows. Quote the line that matters. Then say what "
|
||
. "it means, using the documentation to explain what the script was trying to do.\n\n"
|
||
. "Say plainly when the log does not explain it. \"The log ends at X with no error, "
|
||
. "so it was killed or is still running\" is a real and useful answer. Do not "
|
||
. "manufacture a cause to have one — a wrong cause sends someone to fix the wrong "
|
||
. "thing, which is worse than no answer.\n\n"
|
||
. "Distinguish what you observed from what you inferred. The log is evidence; "
|
||
. "anything beyond it is a hypothesis and should be labelled as one.\n\n"
|
||
. "START FROM CONFIGURATION, NOT FROM A DEFECT\n"
|
||
. "You are talking to someone running this software, not maintaining it. Varaverk "
|
||
. "itself is in daily use and mostly works, so the overwhelmingly likely cause of "
|
||
. "'it did not do what I expected' is a setting: a toggle left false, an array that "
|
||
. "is empty, a path that does not exist on this host, an entry commented out, a "
|
||
. "gate higher up the tier that is off. Check those before anything else, and use "
|
||
. "the live settings and health state you were given rather than assuming defaults.\n\n"
|
||
. "Only call something a bug when the evidence actually shows one, and then say "
|
||
. "which line shows it. 'This looks like a Varaverk problem' with nothing behind it "
|
||
. "sends someone hunting through code they did not write and cannot fix, which is "
|
||
. "the least useful place you can send them.\n\n"
|
||
. "Do not suggest editing conf files by hand. Settings on this page have controls, "
|
||
. "and the operator is reading this inside the WebGUI. Name the control.\n\n"
|
||
. "IF IT REALLY IS A DEFECT, FILE IT\n"
|
||
. "When — and only when — the evidence shows Varaverk itself misbehaving rather than "
|
||
. "a setting, finish your reply with exactly this block so it gets recorded:\n\n"
|
||
. "[VARAVERK-BUG]\n"
|
||
. "component: <the script or file at fault, e.g. Arrs_Stack/arr_sync.sh>\n"
|
||
. "summary: <one line, what is wrong>\n"
|
||
. "evidence: <the log line or lines that show it, quoted verbatim>\n"
|
||
. "[/VARAVERK-BUG]\n\n"
|
||
. "Leave the block out entirely for anything explained by configuration, by a host "
|
||
. "being offline, or by a job simply not having run. The evidence field is not "
|
||
. "optional and must be a line you actually saw — a report nobody can check is worse "
|
||
. "than no report, because it costs someone an investigation to disprove.\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"
|
||
. "These scripts run as root, on a schedule, unattended. So the danger is not "
|
||
. "complicated logic — it is a simple script aimed at the wrong path. If your script "
|
||
. "deletes, moves, overwrites or truncates anything (rm, mv, find -delete, "
|
||
. "rsync --delete, truncation with >, chown -R, chmod -R), then before the script "
|
||
. "say in one line exactly what it will destroy and under what conditions. Where the "
|
||
. "tool supports it, give the dry-run form first (rsync --dry-run, find without "
|
||
. "-delete) and tell them to run that and read the output before the real one. "
|
||
. "Guard every destructive path against an unset or empty variable — \"rm -rf "
|
||
. "\$DEST/\" with DEST unset is the mistake that actually happens.\n\n"
|
||
. "If the operator comes back with an error showing that something you suggested does "
|
||
. "not exist — an unknown option, a command not found — say plainly that you got it "
|
||
. "wrong and invented it. Do NOT explain it away as a version difference, a "
|
||
. "distribution quirk, or a newer/older release, unless you can name the specific "
|
||
. "version that added or removed it. Guessing at a cause is a second mistake on top "
|
||
. "of the first, and it sends them chasing an upgrade that does not exist. 'I made "
|
||
. "that flag up, the correct one is X' is the whole answer.\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.
|
||
// Where the operator is standing in the WebGUI. Sent by the scheduler page, absent from the AI
|
||
// tab, which has no location to speak of. It is what lets "what does this do" resolve — without
|
||
// it the model has to guess which of 120 settings "this" means, and it will guess confidently.
|
||
//
|
||
// Stated as context, never as an instruction to trust: the file named here is where to look
|
||
// first, not proof that the answer is in it. That distinction is the whole point of the
|
||
// fallback — a setting the operator expects in master.conf may actually live in the host conf,
|
||
// and saying so is more useful than failing to find it.
|
||
if ($scope !== '') {
|
||
$system .= "WHERE THE OPERATOR IS RIGHT NOW\n"
|
||
. "They have " . $scope . " open in the WebGUI. Read bare references — \"this "
|
||
. "setting\", \"this script\", \"here\" — as meaning that unless they clearly mean "
|
||
. "something else. Look there first. If what they are asking about is genuinely "
|
||
. "somewhere else, answer anyway and tell them plainly where it actually is.\n\n";
|
||
}
|
||
|
||
$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'
|
||
&& (vv_ai_mentions_varaverk($question)
|
||
|| preg_match('/\b[\w.-]+\.sh\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+)+\b/', $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);
|
||
}
|
||
|
||
// Destructive-operation scan of what was actually generated. The prompt asks the model to warn;
|
||
// this does not depend on it having done so. These scripts run as root on a schedule, so the
|
||
// expensive mistake is not tangled logic — it is a simple script pointed one directory too high.
|
||
// Scans only fenced code, so prose mentioning "rm" does not trip it.
|
||
$warnings = [];
|
||
if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $blocks)) {
|
||
$code = implode("\n", $blocks[1]);
|
||
$checks = [
|
||
'/(^|[;&|\s])rm\s+(-[a-zA-Z]*\s+)*/m' => 'deletes files (rm)',
|
||
'/(^|[;&|\s])mv\s/m' => 'moves files (mv)',
|
||
'/(?<!-)-delete\b/' => 'deletes files (find -delete)',
|
||
'/--delete\b/' => 'deletes at the destination (rsync --delete)',
|
||
'/(^|[;&|\s])(shred|truncate)\s/m' => 'destroys file contents',
|
||
'/(^|[;&|\s])(dd|mkfs\.\w+|mkfs)\s/m' => 'writes raw to a device',
|
||
'/(^|[;&|\s])ch(own|mod)\s+-[a-zA-Z]*R/m' => 'recursively changes ownership or permissions',
|
||
];
|
||
foreach ($checks as $re => $label) {
|
||
if (preg_match($re, $code)) $warnings[] = $label;
|
||
}
|
||
// An unquoted or unguarded path variable is what turns any of the above into a disaster.
|
||
if ($warnings && preg_match('/(rm|mv|rsync)[^\n]*\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?![\w"])/', $code)) {
|
||
$warnings[] = 'uses a path variable in a destructive command — confirm it can never be empty';
|
||
}
|
||
}
|
||
|
||
// Pull the bug block out of the answer and file it. Stripped from what the operator sees — the
|
||
// block is a machine contract, not prose — and replaced with a one-line confirmation so the
|
||
// filing is never silent. A malformed or evidence-free block is dropped rather than filed:
|
||
// the guard lives here, in code, not in the instruction that asked for it. A prompt is a
|
||
// request; this is the part that decides.
|
||
$bugFiled = null;
|
||
if ($profile === 'troubleshoot'
|
||
&& preg_match('/\[VARAVERK-BUG\](.*?)\[\/VARAVERK-BUG\]/s', $answer, $bm)) {
|
||
$answer = trim(preg_replace('/\[VARAVERK-BUG\].*?\[\/VARAVERK-BUG\]/s', '', $answer));
|
||
|
||
$field = function (string $k) use ($bm): string {
|
||
return preg_match('/^\s*' . $k . '\s*:\s*(.+?)\s*$/mi', $bm[1], $m) ? trim($m[1]) : '';
|
||
};
|
||
// evidence may run to several lines; take everything after the label.
|
||
$ev = preg_match('/^\s*evidence\s*:\s*(.*)$/mis', $bm[1], $em) ? trim($em[1]) : '';
|
||
|
||
$component = $field('component');
|
||
|
||
// Guard one: the component must be a real file here. A report against a path that does not
|
||
// exist is the model naming something plausible rather than something it saw.
|
||
//
|
||
// Guard two: the quoted evidence must actually appear in the log it was given. This is what
|
||
// keeps "misconfigured" out of the bug list — a genuine defect is visible in the log, while
|
||
// a setting being false produces no such line, so an invented quote fails here instead of
|
||
// becoming a report someone has to disprove.
|
||
//
|
||
// When no log was available there is nothing to check against; the report is still filed,
|
||
// because live health state is real evidence too, but it is marked unverified and says so
|
||
// on the card. Refusals are logged rather than swallowed — how often the model tries to file
|
||
// junk is worth knowing, and silence would hide it.
|
||
$verified = $scopedLog && !empty($scopedLog['ok'])
|
||
? vv_ai_evidence_in_log($ev, $scopedLog['tail'] ?? [])
|
||
: null;
|
||
|
||
if (!vv_ai_component_exists($component)) {
|
||
wlog('bug refused: component not a real file — ' . mb_substr($component, 0, 80));
|
||
$res = ['ok' => false];
|
||
} elseif ($verified === false) {
|
||
wlog('bug refused: evidence not found in ' . ($scopedLog['path'] ?? '?')
|
||
. ' — ' . mb_substr(preg_replace('/\s+/', ' ', $ev), 0, 100));
|
||
$res = ['ok' => false];
|
||
} else {
|
||
$res = vv_ai_bug_write($component, $field('summary'), $ev, [
|
||
'asked' => mb_substr($question, 0, 300),
|
||
'scope' => $scope,
|
||
'log' => $scopedLog['path'] ?? null,
|
||
'verified' => $verified,
|
||
'profile' => $profile,
|
||
]);
|
||
}
|
||
if ($res['ok']) {
|
||
$bugFiled = $res;
|
||
// Say what actually happened, not what sounds reassuring. This is written to a file on
|
||
// this host — it is not transmitted anywhere, and telling a user their report has been
|
||
// "sent" when it is sitting on their own disk is the exact species of confident wrong
|
||
// answer the rest of this profile is built to avoid. When a transport exists, this line
|
||
// changes with it.
|
||
$answer .= "\n\n_Recorded on this host as bug " . $res['id']
|
||
. ($res['seen'] > 1 ? ', seen ' . $res['seen'] . ' times' : '')
|
||
. ". It is listed on the AI tab and counted in the Sunday report._";
|
||
}
|
||
}
|
||
|
||
$evalCount = (int)($d['eval_count'] ?? 0);
|
||
$evalNs = (int)($d['eval_duration'] ?? 0);
|
||
$tokS = $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null;
|
||
|
||
// Accounting before the job file is written, so a turn is counted even if the tab has already
|
||
// been closed and nobody ever reads the result. Best-effort by contract — it cannot throw.
|
||
vv_ai_token_record($profile, 'webgui', (int)($d['prompt_eval_count'] ?? 0), $evalCount, $tokS);
|
||
|
||
jw($jobFile, [
|
||
'status' => 'done',
|
||
'answer' => $answer,
|
||
'thinking' => $thinking,
|
||
'sources' => $sources,
|
||
'warnings' => array_values(array_unique($warnings)),
|
||
'timing' => [
|
||
'retrieve_ms' => (int)round($tRetrieve * 1000),
|
||
'generate_ms' => (int)round((microtime(true) - $t1) * 1000),
|
||
'tokens' => $evalCount,
|
||
'tok_s' => $tokS,
|
||
],
|
||
]);
|