From 801288c88c7171570fac7868aab63e20916176e7 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 9 Aug 2026 20:23:49 -0400 Subject: [PATCH] Keep a record of what was asked for and what it was taken to mean A term that resolved the same way three times stops being an inference and becomes a lookup; the corrections are the rows worth having, since a term that meant two things is still a guess and is never promoted. --- Plugin/unraid/include/ai_repair.php | 119 ++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index a480270..c8887ac 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -808,6 +808,125 @@ function vv_ai_triage_log(array $lines, string $sourceLog = ''): array { return array_values($found); } +// ── The phrasebook ─────────────────────────────────────────────────────────────────────────── +// What the operator said, what it was taken to mean, and whether that was right. +// +// The point is not to fine-tune anything. It is that "daily", said three times and meaning +// daily_sync_maintenance.sh all three, stops being an inference and becomes a lookup. This file +// is how a term earns that promotion, and everything it promotes is exact — the model keeps the +// language, the resolution stays deterministic. Same division as the rest of this subsystem. +// +// The corrections are the rows that matter. A resolution that was right confirms what was +// already believed; a resolution that was wrong, with what it should have been, is the only +// record of a mistake that would otherwise be repeated indefinitely. +// +// JSON Lines rather than the pipe-delimited shape the token ledger uses: that file holds numbers +// and a hostname, this one holds whatever the operator typed, and a delimiter that occurs in the +// data is not a delimiter. Append-only, one object per line, so a truncated write costs the last +// row rather than the corpus. + +function vv_ai_phrasebook_path(): string { + return AI_DATA_DIR . '/ai_phrasebook.jsonl'; +} + +// $said what the operator actually wrote, verbatim but redacted +// $term the fragment that carried the meaning — "daily", "critical rsync", "emby key" +// $target what it resolved to: a conf key, a script id, an array name +// $kind conf_key | script | array | section | unknown +// $outcome accepted | corrected | rejected +// $correctedTo what it should have been, when the resolution was wrong +// +// $target and $correctedTo must be a canonical identifier — CONF_BACKUP_DIR, not +// "CONF_BACKUP_DIR=${DATA_DIR}/Backups/Confs" and not "the backups directory". Promotion works +// by counting how often a term resolved to the same thing, so a target described three different +// ways is three meanings, and the term never promotes. Learned the hard way while seeding this +// with real corrections: the same fix, written up three ways, taught nothing. +function vv_ai_phrase_record(string $said, string $term, string $target, string $kind, + string $outcome = 'accepted', string $correctedTo = ''): bool { + $said = trim($said); + $term = strtolower(trim($term)); + if ($said === '' || $term === '') return false; + if (!in_array($outcome, ['accepted', 'corrected', 'rejected'], true)) return false; + + if (!is_dir(AI_DATA_DIR)) @mkdir(AI_DATA_DIR, 0755, true); + + // An operator asking to set a credential types the credential. This file is long-lived and + // read back for years; it is the last place a key should be preserved verbatim. + $row = [ + 'ts' => time(), + 'said' => mb_substr(vv_ai_redact($said), 0, 500), + 'term' => mb_substr($term, 0, 80), + 'target' => mb_substr(trim($target), 0, 160), + 'kind' => $kind, + 'outcome' => $outcome, + ]; + if ($correctedTo !== '') $row['corrected_to'] = mb_substr(vv_ai_redact($correctedTo), 0, 160); + + return @file_put_contents(vv_ai_phrasebook_path(), + json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n", + FILE_APPEND | LOCK_EX) !== false; +} + +function vv_ai_phrase_all(): array { + $out = []; + foreach ((array)@file(vv_ai_phrasebook_path(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $r = json_decode($line, true); + if (is_array($r) && isset($r['term'])) $out[] = $r; + } + return $out; +} + +// Terms that have earned a deterministic mapping: seen at least $minSeen times, resolving to one +// target every time, and never corrected away from it. +// +// A single contradiction disqualifies the term outright rather than going with the majority. The +// whole value of promoting a term is that it stops being a guess — a term that meant two things +// is still a guess, and a confident wrong alias is worse than no alias, because nothing downstream +// will question it. +function vv_ai_phrase_aliases(int $minSeen = 3): array { + $seen = []; + foreach (vv_ai_phrase_all() as $r) { + $term = (string)$r['term']; + // A correction records both the wrong reading and the right one. The right one is what + // the term means; the wrong one is what it must never be promoted to again. + $target = ($r['outcome'] === 'corrected' && !empty($r['corrected_to'])) + ? (string)$r['corrected_to'] : (string)$r['target']; + if ($r['outcome'] === 'rejected' || $target === '') continue; + + $seen[$term]['targets'][$target] = ($seen[$term]['targets'][$target] ?? 0) + 1; + $seen[$term]['kind'] = (string)($r['kind'] ?? 'unknown'); + if ($r['outcome'] === 'corrected') $seen[$term]['wrong'][(string)$r['target']] = true; + } + + $aliases = []; + foreach ($seen as $term => $d) { + if (count($d['targets']) !== 1) continue; // meant two things — still a guess + $target = array_key_first($d['targets']); + if (isset($d['wrong'][$target])) continue; // was itself corrected away from + if ($d['targets'][$target] < $minSeen) continue; + $aliases[$term] = ['target' => $target, 'kind' => $d['kind'], 'seen' => $d['targets'][$target]]; + } + return $aliases; +} + +// Exact alias hit for a term, or null. Deliberately not fuzzy: an alias exists precisely so that +// this lookup is certain, and a near-match would reintroduce the guessing it replaced. +function vv_ai_phrase_lookup(string $term, int $minSeen = 3): ?array { + return vv_ai_phrase_aliases($minSeen)[strtolower(trim($term))] ?? null; +} + +// Terms that have been corrected and have not yet earned promotion — what the assistant is still +// getting wrong, and the thing worth reading when asking why it keeps mistaking something. +function vv_ai_phrase_unsettled(): array { + $out = []; + foreach (vv_ai_phrase_all() as $r) { + if (($r['outcome'] ?? '') !== 'corrected') continue; + $out[(string)$r['term']][] = ['said' => $r['said'], 'took_it_as' => $r['target'], + 'meant' => $r['corrected_to'] ?? '', 'ts' => $r['ts'] ?? 0]; + } + return $out; +} + // ── What the arrs say about themselves ─────────────────────────────────────────────────────── // Sonarr, Radarr and Lidarr each publish a health endpoint listing what they believe is wrong, // already structured and already graded. No log parsing, no pattern that goes stale when a