diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index e3e18a1..6217c63 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -915,6 +915,132 @@ function vv_ai_phrase_lookup(string $term, int $minSeen = 3): ?array { return vv_ai_phrase_aliases($minSeen)[strtolower(trim($term))] ?? null; } +// ── Resolving what the operator named ──────────────────────────────────────────────────────── +// "turn off the zfs scrub", "change the emby api key" — a phrase in, an exact target out, or an +// honest refusal with the candidates that were considered. +// +// Four layers, most certain first, and each either answers exactly or declines: +// +// 1. A learned alias. The operator has used this term before and it settled on one target. +// 2. The literal key. "HOST1_EMBY_URL" or "host1 emby url" is not a phrase to interpret. +// 3. Every conf key containing all of the significant words. Exactly one is an answer; more +// than one is a question. +// 4. Script ids from the orchestrator arrays, matched the same way. +// +// What this deliberately does not do is score similarity. There is no closest match, no edit +// distance, no "did you mean". The same reasoning as resolve_tailscale_ip() refusing to guess at +// host identity: the failure mode of a near-match is silent and confident, and here it would +// write to a key the operator never named. Ambiguity is returned as ambiguity, and the assistant +// asks — which is also how the phrasebook learns, because the answer to that question is a +// correction worth recording. +// +// Stop words exist because "the", "for" and "in" appear in a conf key somewhere and would make +// every phrase match everything. +const VV_AI_RESOLVE_STOPWORDS = [ + 'the','a','an','to','for','in','on','of','and','or','is','it','this','that','my','our', + 'please','can','you','set','change','turn','make','update','put','value','key','conf','config', + 'setting','settings','off','on_','now','back','again','me','we','let','lets', +]; + +// A key or script id broken into its own words. HOST1_EMBY_API_KEY is four words, and +// Tools/zfs_pool_scrub.sh is five — the path separator and the extension are word boundaries too. +function vv_ai_resolve_segments(string $name): array { + $n = strtolower(preg_replace('/\.sh$/', '', $name)); + return array_values(array_filter(preg_split('/[^a-z0-9]+/', $n, -1, PREG_SPLIT_NO_EMPTY) ?: [])); +} + +function vv_ai_resolve_tokens(string $phrase): array { + $p = strtolower(trim($phrase)); + $p = preg_replace('/[^a-z0-9_\s-]+/', ' ', $p); + $words = preg_split('/[\s_-]+/', $p, -1, PREG_SPLIT_NO_EMPTY) ?: []; + return array_values(array_filter($words, + fn($w) => strlen($w) > 1 && !in_array($w, VV_AI_RESOLVE_STOPWORDS, true))); +} + +// Script ids named in any *_SCRIPTS array, so "zfs scrub" can resolve to Tools/zfs_pool_scrub.sh +// — which is the shape of request that has no conf key at all. +function vv_ai_resolve_script_ids(): array { + $ids = []; + foreach (vv_get_conf_files() as $f) { + if (preg_match_all('/^\s*#?\s*"([A-Za-z0-9_\/.-]+\.sh)(?:\s[^"]*)?"/m', + vv_read_conf_raw($f), $m)) { + foreach ($m[1] as $id) $ids[$id] = true; + } + } + return array_keys($ids); +} + +// Returns: +// ok=true with target, kind and via — one certain answer +// ok=false with candidates — several, and the caller must ask +// ok=false with candidates empty — nothing recognised +function vv_ai_resolve_target(string $phrase): array { + $none = ['ok' => false, 'target' => null, 'kind' => null, 'via' => 'none', 'candidates' => []]; + $tokens = vv_ai_resolve_tokens($phrase); + if (!$tokens) return $none; + + // 1 — learned + $alias = vv_ai_phrase_lookup(strtolower(trim($phrase))); + if ($alias === null) { + // Also try the significant words alone, since "turn off ai repair" and "ai repair" are + // the same instruction with different framing. + $alias = vv_ai_phrase_lookup(implode(' ', $tokens)); + } + if ($alias !== null) { + return ['ok' => true, 'target' => $alias['target'], 'kind' => $alias['kind'], + 'via' => 'alias', 'candidates' => []]; + } + + $vars = vv_conf_vars(); + + // 2 — the literal key, however it was spaced or cased + $literal = strtoupper(implode('_', $tokens)); + if (array_key_exists($literal, $vars)) { + return ['ok' => true, 'target' => $literal, 'kind' => 'conf_key', + 'via' => 'exact', 'candidates' => []]; + } + + // 3 — conf keys whose own words include every significant word + // + // Whole segments, not substrings. Substring matching made "mov" resolve to + // MOVER_STOP_TIMEOUT with full confidence, which is exactly the near-match this is supposed + // to refuse: a short fragment that happens to be unique is not the operator naming a key. + $keyHits = []; + foreach (array_keys($vars) as $key) { + $segs = vv_ai_resolve_segments($key); + foreach ($tokens as $t) { + if (!in_array($t, $segs, true)) continue 2; + } + $keyHits[] = $key; + } + if (count($keyHits) === 1) { + return ['ok' => true, 'target' => $keyHits[0], 'kind' => 'conf_key', + 'via' => 'match', 'candidates' => []]; + } + + // 4 — script ids, same rule + $scriptHits = []; + foreach (vv_ai_resolve_script_ids() as $id) { + $segs = vv_ai_resolve_segments($id); + foreach ($tokens as $t) { + if (!in_array($t, $segs, true)) continue 2; + } + $scriptHits[] = $id; + } + if (!$keyHits && count($scriptHits) === 1) { + return ['ok' => true, 'target' => $scriptHits[0], 'kind' => 'script', + 'via' => 'match', 'candidates' => []]; + } + + $all = array_merge($keyHits, $scriptHits); + if (!$all) return $none; + + // Several. Returned rather than ranked — picking one here is the guess this avoids. + sort($all); + return ['ok' => false, 'target' => null, 'kind' => null, 'via' => 'ambiguous', + 'candidates' => array_slice($all, 0, 12)]; +} + // ── Spellings ──────────────────────────────────────────────────────────────────────────────── // The operator types quickly and knows it: "haversync" for "have rsync", "as it to the list" for // "add it". Recorded when the meaning was obvious in context, so the next occurrence is read