Resolve what the operator named, or say which things it could have been

Learned alias, then the literal key, then the keys and script ids whose own words contain
every word said. No similarity scoring anywhere: a near-match fails silently and confidently,
and here it would write to a key nobody named. Several matches is a question, not a ranking.
This commit is contained in:
Gmer4Lfe
2026-08-09 20:39:19 -04:00
parent 92157aa5da
commit 4ab3387db4
+126
View File
@@ -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; 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 ──────────────────────────────────────────────────────────────────────────────── // ── Spellings ────────────────────────────────────────────────────────────────────────────────
// The operator types quickly and knows it: "haversync" for "have rsync", "as it to the list" for // 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 // "add it". Recorded when the meaning was obvious in context, so the next occurrence is read