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.
252 lines
12 KiB
PHP
252 lines
12 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// The AI tab's library. Resolves AI configuration, reports the health of the retrieval
|
|
// subsystem for the status banner, runs retrieval against the index, and owns the job-file
|
|
// contract the chat worker and the polling endpoint share.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Retrieval is delegated, not reimplemented. AI/lib/search.js already embeds the query,
|
|
// scans the index and applies intent routing; this file shells to that CLI with --json. A
|
|
// second PHP implementation of vector search would be a second set of scoring bugs, and the
|
|
// two would drift the first time the chunker changed.
|
|
//
|
|
// Generation is not performed here. It takes 25-76 seconds and belongs in a detached worker;
|
|
// this file only describes where that worker writes and what the states mean.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Every reported statistic distinguishes "unknown" from "bad".
|
|
// A banner that shows a healthy default when it cannot reach Ollama is worse than one
|
|
// that shows nothing, because the operator stops checking. Each probe returns null when
|
|
// it could not determine the answer.
|
|
//
|
|
// Offload percentage is a first-class metric.
|
|
// size_vram against size is the difference between 74 tok/s and 19 on this hardware, and
|
|
// nothing else on the host surfaces it. It is computed here so the banner and any future
|
|
// watchdog read the same number.
|
|
//
|
|
// Staleness is measured against tracked files, not the filesystem.
|
|
// The index only ever contains git-tracked content, so comparing to an untracked scratch
|
|
// file would report permanent staleness. git ls-files is the same source the indexer uses.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Read-only. Nothing here writes to the index, the conf, or the job files — it reads state
|
|
// and runs a retrieval query. The worker owns every write.
|
|
//
|
|
// Every external call is time-boxed.
|
|
// Retrieval and the Ollama probes carry explicit timeouts, because this library is
|
|
// loaded by a page render and by a polling endpoint; a hung Ollama must degrade the
|
|
// banner, not hang the tab.
|
|
//
|
|
// Retrieval arguments are validated before they reach the shell.
|
|
// kind is checked against the five real values and k is clamped, then every value is
|
|
// escapeshellarg'd. An unknown kind would match zero rows in SQL and read as "the index
|
|
// has no answer", which is the most misleading failure this subsystem can produce.
|
|
//
|
|
// Tokens are validated as hex before they compose a path.
|
|
// Job files live in a fixed directory and are named from a caller-supplied token; the
|
|
// pattern is what keeps that token from escaping the directory.
|
|
//
|
|
// A missing index is reported, never treated as empty.
|
|
// Empty results and an absent index look identical to a caller that only counts rows,
|
|
// and they need completely different responses from the user.
|
|
//
|
|
// EXPORTS
|
|
// Config vv_ai_enabled(), vv_ai_config()
|
|
// Status vv_ai_stats(), vv_ai_index_stats(), vv_ai_runtime_stats()
|
|
// Retrieval vv_ai_retrieve()
|
|
// Jobs vv_ai_job_dir(), vv_ai_job_path(), vv_ai_job_read()
|
|
//
|
|
// CONFIGURATION
|
|
// master.conf AI_ENABLED, AI_INDEX_DB, AI_SEARCH_K, AI_SEARCH_PER_FILE,
|
|
// AI_REQUEST_TIMEOUT, AI_CONNECT_TIMEOUT
|
|
// host*.conf HOST*_OLLAMA_URL, HOST*_OLLAMA_MODEL, HOST*_OLLAMA_EMBED_MODEL
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs');
|
|
const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc'];
|
|
|
|
function vv_ai_config(): array {
|
|
static $cfg = null;
|
|
if ($cfg !== null) return $cfg;
|
|
|
|
$vars = vv_conf_vars();
|
|
$host = strtoupper(vv_detect_host());
|
|
|
|
$cfg = [
|
|
'enabled' => strtolower(trim($vars['AI_ENABLED'] ?? 'false')) === 'true',
|
|
'url' => rtrim(trim($vars["{$host}_OLLAMA_URL"] ?? ''), '/'),
|
|
'model' => trim($vars["{$host}_OLLAMA_MODEL"] ?? ''),
|
|
'embed_model' => trim($vars["{$host}_OLLAMA_EMBED_MODEL"] ?? 'nomic-embed-text'),
|
|
'db' => trim($vars['AI_INDEX_DB'] ?? '') ?: DATA_DIR . '/ai_index.db',
|
|
'k' => (int)($vars['AI_SEARCH_K'] ?? 8),
|
|
'per_file' => (int)($vars['AI_SEARCH_PER_FILE'] ?? 3),
|
|
'timeout' => (int)($vars['AI_REQUEST_TIMEOUT'] ?? 240),
|
|
'connect' => (int)($vars['AI_CONNECT_TIMEOUT'] ?? 5),
|
|
];
|
|
// AI_INDEX_DB is written as "$DATA_DIR/ai_index.db" in conf; the shell expands it, PHP does
|
|
// not. Left as a literal it would name a file that cannot exist.
|
|
$cfg['db'] = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $cfg['db']);
|
|
return $cfg;
|
|
}
|
|
|
|
function vv_ai_enabled(): bool {
|
|
return vv_ai_config()['enabled'];
|
|
}
|
|
|
|
// Index size, coverage and staleness. Staleness compares the newest git-tracked file against
|
|
// the last build: the indexer only ever ingests tracked content, so anything else would report
|
|
// permanent drift.
|
|
function vv_ai_index_stats(): array {
|
|
$cfg = vv_ai_config();
|
|
$out = ['exists' => false, 'chunks' => 0, 'files' => 0, 'size' => 0,
|
|
'built' => null, 'newest_source' => null, 'stale' => null, 'kinds' => []];
|
|
|
|
if (!file_exists($cfg['db'])) return $out;
|
|
$out['exists'] = true;
|
|
$out['size'] = (int)@filesize($cfg['db']);
|
|
|
|
$db = escapeshellarg($cfg['db']);
|
|
$q = fn(string $sql) => trim((string)@shell_exec('sqlite3 ' . $db . ' ' . escapeshellarg($sql) . ' 2>/dev/null'));
|
|
|
|
$out['chunks'] = (int)$q('SELECT COUNT(*) FROM vv_chunks;');
|
|
$out['files'] = (int)$q('SELECT COUNT(*) FROM vv_files;');
|
|
$built = (int)$q('SELECT MAX(indexed) FROM vv_files;');
|
|
$out['built'] = $built ?: null;
|
|
|
|
foreach (explode("\n", $q('SELECT kind, COUNT(*) FROM vv_chunks GROUP BY kind;')) as $line) {
|
|
if (!$line) continue;
|
|
[$k, $n] = array_pad(explode('|', $line, 2), 2, 0);
|
|
if ($k !== '') $out['kinds'][$k] = (int)$n;
|
|
}
|
|
|
|
$newest = trim((string)@shell_exec(
|
|
'cd ' . escapeshellarg(SCRIPTS_DIR) . ' && git ls-files -z 2>/dev/null'
|
|
. ' | xargs -0 stat -c %Y 2>/dev/null | sort -rn | head -1'
|
|
));
|
|
if ($newest !== '') {
|
|
$out['newest_source'] = (int)$newest;
|
|
if ($out['built'] !== null) $out['stale'] = (int)$newest > $out['built'];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
// Ollama reachability and — the number that matters on this hardware — whether the generation
|
|
// model is fully resident on the GPU. size_vram below size means layers are on the CPU, which
|
|
// costs roughly 4x throughput and is invisible everywhere else.
|
|
function vv_ai_runtime_stats(): array {
|
|
$cfg = vv_ai_config();
|
|
$out = ['reachable' => false, 'loaded' => null, 'offload_pct' => null,
|
|
'context' => null, 'vram_used' => null, 'vram_total' => null, 'gpu' => null];
|
|
|
|
if ($cfg['url'] === '') return $out;
|
|
|
|
$ctx = stream_context_create(['http' => [
|
|
'method' => 'GET', 'timeout' => max(2, $cfg['connect']), 'ignore_errors' => true,
|
|
]]);
|
|
$raw = @file_get_contents($cfg['url'] . '/api/ps', false, $ctx);
|
|
if ($raw === false) return $out;
|
|
$out['reachable'] = true;
|
|
|
|
$ps = json_decode($raw, true);
|
|
foreach ($ps['models'] ?? [] as $m) {
|
|
if (($m['name'] ?? '') !== $cfg['model']) continue;
|
|
$size = (int)($m['size'] ?? 0);
|
|
$vram = (int)($m['size_vram'] ?? 0);
|
|
$out['loaded'] = true;
|
|
$out['context'] = $m['context_length'] ?? null;
|
|
$out['vram_used'] = $vram;
|
|
$out['vram_total'] = $size;
|
|
$out['offload_pct'] = $size > 0 ? (int)round($vram / $size * 100) : null;
|
|
break;
|
|
}
|
|
if ($out['loaded'] === null) $out['loaded'] = false;
|
|
|
|
$gpu = trim((string)@shell_exec(
|
|
'nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu '
|
|
. '--format=csv,noheader,nounits 2>/dev/null | tail -1'
|
|
));
|
|
if ($gpu !== '') {
|
|
$p = array_map('trim', explode(',', $gpu));
|
|
if (count($p) >= 4) {
|
|
$out['gpu'] = ['name' => $p[0], 'mem_used' => (int)$p[1],
|
|
'mem_total' => (int)$p[2], 'util' => (int)$p[3]];
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
function vv_ai_stats(): array {
|
|
$cfg = vv_ai_config();
|
|
return [
|
|
'enabled' => $cfg['enabled'],
|
|
'model' => $cfg['model'],
|
|
'embed_model' => $cfg['embed_model'],
|
|
'url' => $cfg['url'],
|
|
'k' => $cfg['k'],
|
|
'index' => vv_ai_index_stats(),
|
|
'runtime' => vv_ai_runtime_stats(),
|
|
'ts' => time(),
|
|
];
|
|
}
|
|
|
|
// Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string].
|
|
function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array {
|
|
$cfg = vv_ai_config();
|
|
|
|
if ($query === '') return ['ok' => false, 'error' => 'query is required', 'results' => []];
|
|
if ($cfg['url'] === '') return ['ok' => false, 'error' => 'Ollama URL is not configured', 'results' => []];
|
|
if (!file_exists($cfg['db'])) return ['ok' => false, 'error' => 'No index — run AI/ai_index.sh', 'results' => []];
|
|
if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
|
|
return ['ok' => false, 'error' => 'Unknown kind: ' . $kind, 'results' => []];
|
|
}
|
|
|
|
$cli = SCRIPTS_DIR . '/AI/lib/cli.js';
|
|
if (!file_exists($cli)) return ['ok' => false, 'error' => 'AI/lib/cli.js not found', 'results' => []];
|
|
|
|
$k = max(1, min($k ?? $cfg['k'], 25));
|
|
|
|
$cmd = 'timeout ' . max(10, $cfg['connect'] * 6) . ' node --no-warnings '
|
|
. escapeshellarg($cli) . ' search'
|
|
. ' --db=' . escapeshellarg($cfg['db'])
|
|
. ' --url=' . escapeshellarg($cfg['url'])
|
|
. ' --model=' . escapeshellarg($cfg['embed_model'])
|
|
. ' --query=' . escapeshellarg($query)
|
|
. ' --k=' . $k
|
|
. ' --per-file=' . max(1, $cfg['per_file'])
|
|
. ($kind !== '' ? ' --kind=' . escapeshellarg($kind) : '')
|
|
. ($section !== '' ? ' --section=' . escapeshellarg($section) : '')
|
|
. ' --json 2>/dev/null';
|
|
|
|
$raw = trim((string)@shell_exec($cmd));
|
|
if ($raw === '') return ['ok' => false, 'error' => 'Retrieval produced no output', 'results' => []];
|
|
|
|
$d = json_decode($raw, true);
|
|
if (!is_array($d) || !isset($d['results'])) {
|
|
return ['ok' => false, 'error' => 'Retrieval returned unparseable output', 'results' => []];
|
|
}
|
|
return ['ok' => true, 'results' => $d['results'], 'intents' => $d['intents'] ?? [],
|
|
'scanned' => $d['scanned'] ?? 0];
|
|
}
|
|
|
|
function vv_ai_job_dir(): string {
|
|
if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true);
|
|
return VV_AI_JOB_DIR;
|
|
}
|
|
|
|
// Hex-only, fixed length. The token composes a path, so the pattern is what confines it to the
|
|
// job directory.
|
|
function vv_ai_job_path(string $token): ?string {
|
|
if (!preg_match('/^[0-9a-f]{32}$/', $token)) return null;
|
|
return VV_AI_JOB_DIR . '/' . $token . '.json';
|
|
}
|
|
|
|
function vv_ai_job_read(string $token): ?array {
|
|
$p = vv_ai_job_path($token);
|
|
if ($p === null || !file_exists($p)) return null;
|
|
$d = json_decode(@file_get_contents($p) ?: '', true);
|
|
return is_array($d) ? $d : null;
|
|
}
|