From 955f50b94e70910c357a44e1d8b9f67013b20611 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 9 Aug 2026 19:30:44 -0400 Subject: [PATCH] Let a finding be answered in words, matched rather than judged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reply to "shall I fix it" ends in a conf write, so the three actions are matched by pattern with an ambiguity refusal instead of being inferred — and a phrase that supports two readings asks again rather than picking one. --- Plugin/unraid/include/ai_repair.php | 100 ++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index 2bc1952..4dc93d9 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -383,6 +383,106 @@ function vv_ai_findings_for_chat(int $limit = 3): array { return array_slice(vv_ai_findings_list(['needs_operator']), 0, max(1, $limit)); } +// ── Answering a finding in words ───────────────────────────────────────────────────────────── +// The buttons are unambiguous by construction. This is for the other path — replying "yeah go +// ahead" in the chat that raised the finding — and it is matched here rather than asked of the +// model, because the model's answer would be a conf write and a wrong reading of "no, leave it" +// is not recoverable by apologising. +// +// Same shape as vv_ai_route_from_chat(): anchored patterns, most specific first, and anything +// unrecognised returns null so the assistant asks again instead of guessing. Two actions both +// matching is also null — "leave it, I know" and "leave it for now" differ by one clause and +// mean different things, so a phrase that supports both is not an instruction yet. +const VV_AI_ACTION_PATTERNS = [ + // Acknowledge — "this is deliberate, stop telling me". + 'ack' => [ + '/\b(i|we) know\b/u', + '/\b(that|this|it)(?:\'s| is) (fine|expected|intentional|deliberate|on purpose)\b/u', + '/\bon purpose\b/u', + '/\b(aware|acknowledge|ack)\b/u', + '/\bmeant to be\b/u', + ], + // Apply the proposed value. + 'fix' => [ + '/\bfix (it|that|this|them)?\b/u', + '/\b(go ahead|do it|apply|make the change|change it|update it|correct it)\b/u', + // A bare affirmative, as the whole message — "yes" answering "shall I fix it" is an + // instruction, "yes it looks wrong" is agreement about the diagnosis and nothing more. + // A trailing please is still bare. + '/\b(yes|yeah|yep|yup|sure|ok|okay)\b(\s*,?\s*please)?[\s,.!]*$/u', + '/\bplease do\b/u', + ], + // Not now — no state written, it comes back next sweep. + 'cancel' => [ + '/\b(not now|later|leave it (alone|for now)|skip( it)?|cancel|ignore for now)\b/u', + '/\b(no|nope|nah)\b[\s,.!]*$/u', + '/\b(don\'?t|do not) (fix|touch|change|write|apply)\b/u', + ], +]; + +// Returns 'fix' | 'ack' | 'cancel', or null when the reply does not clearly mean one of them. +// +// Only call this when a finding is actually pending. A bare "yes" means fix in answer to "shall +// I fix it" and means nothing at all on its own, and the difference is context this function +// cannot see. +function vv_ai_finding_action_from_text(string $text): ?string { + $t = strtolower(trim($text)); + if ($t === '') return null; + $t = preg_replace('/\s+/', ' ', $t); + + $matched = []; + foreach (VV_AI_ACTION_PATTERNS as $action => $patterns) { + foreach ($patterns as $re) { + if (preg_match($re, $t)) { $matched[$action] = true; break; } + } + } + + // Exactly one reading, or none. "leave it, I know" hits both ack and cancel; that is a + // sentence the operator should be asked to restate, not one to pick a winner from. + return count($matched) === 1 ? array_key_first($matched) : null; +} + +// Carry out an answered action against a stored finding. +// +// Fix goes through the same guarded write path as everything else, and is the one place a +// toggle may be written — because reaching here means the operator asked for it by name. The +// unattended sweep never calls this. +function vv_ai_finding_apply_action(string $id, string $action, string $note = ''): array { + $f = vv_ai_finding_get($id); + if ($f === null) return ['ok' => false, 'error' => 'no such finding']; + + switch ($action) { + case 'ack': + return ['ok' => vv_ai_finding_ack($id, $note), 'action' => 'ack']; + + case 'cancel': + // Deliberately writes nothing at all. "Not now" is not a state, it is the absence of + // one — recording it would make the finding look decided when it is still open. + return ['ok' => true, 'action' => 'cancel']; + + case 'fix': + $proposed = $f['proposed'] ?? null; + if ($proposed === null) return ['ok' => false, 'error' => 'nothing proposed to write']; + + $key = (string)$f['conf_key']; + $ok = vv_conf_write_changes([[ + 'file' => (string)($f['conf_file'] ?? 'master.conf'), + 'key' => $key, + 'value' => (string)$proposed, + 'type' => 'scalar', + ]]); + $wrote = !in_array(false, $ok, true); + + if ($wrote) { + vv_ai_finding_set_state($id, 'fixed', + $note !== '' ? $note : 'Wrote ' . $key . ' at the operator\'s request.'); + } + return ['ok' => $wrote, 'action' => 'fix', + 'error' => $wrote ? null : 'conf write refused — see conf_changes.log']; + } + return ['ok' => false, 'error' => 'unknown action']; +} + // ── Resolving a log line back to a conf key ────────────────────────────────────────────────── // By value wherever possible, by name only as a fallback. //