diff --git a/tools/askuser-answer-parser.mjs b/tools/askuser-answer-parser.mjs index 3a96152b..7bf0ba51 100644 --- a/tools/askuser-answer-parser.mjs +++ b/tools/askuser-answer-parser.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env node /** * AskUserQuestion answer parsing library (router-gate v4, Stream E). * @@ -53,14 +53,17 @@ const STOP_TOKENS = new Set(STOP_KEYWORDS.filter((k) => !k.includes(' '))); /** * True if a free-form answer is a stop/abort/cancel intent (S27). * Keyword-based; normalizes (E33 invisible strip + ws-collapse + lowercase) first. + * Punctuation attached to tokens (e.g. "нет,") is stripped before matching. */ export function isStopAnswer(text) { const norm = normalizeAnswer(text); if (!norm) return false; + const depunct = (s) => s.replace(/[.,;:!?…«»"'()\[\]{}]+/g, ' ').split(/\s+/).filter(Boolean).join(' '); + const cleaned = depunct(norm); for (const phrase of STOP_PHRASES) { - if (norm.includes(normalizeAnswer(phrase))) return true; + if (cleaned.includes(depunct(normalizeAnswer(phrase)))) return true; } - const tokens = norm.split(' '); + const tokens = cleaned.split(' '); for (const t of tokens) { if (STOP_TOKENS.has(t)) return true; } @@ -72,6 +75,7 @@ export function isStopAnswer(text) { * @param {string} text * @param {{llmJudge?: (text:string)=>Promise}} opts * llmJudge default-stub returns false (never escalates). Stream D wires real judge. + * The injected llmJudge receives whitespace-collapsed lowercase text (post-normalizeAnswer), not the raw input. * @returns {Promise} */ export async function detectStopWithFallback(text, { llmJudge } = {}) { @@ -149,7 +153,7 @@ export function detectOtherSocialEng(controllerText) { */ export function buildApprovalRecord({ kind, pattern, sessionId, nowMs }) { return { - kind: String(kind || 'approve_generic'), + kind: String(kind ?? 'approve_generic'), approved_action_pattern: normalizeCommand(pattern), session_id: sessionId || 'unknown', approved_at_ms: typeof nowMs === 'number' ? nowMs : Date.now(), diff --git a/tools/askuser-answer-parser.test.mjs b/tools/askuser-answer-parser.test.mjs index 3b45259d..012070c2 100644 --- a/tools/askuser-answer-parser.test.mjs +++ b/tools/askuser-answer-parser.test.mjs @@ -3,6 +3,13 @@ import { stripInvisible, normalizeAnswer, normalizeCommand, + STOP_KEYWORDS, + isStopAnswer, + detectStopWithFallback, + parseAskUserResult, + matchesApproval, + detectOtherSocialEng, + buildApprovalRecord, } from './askuser-answer-parser.mjs'; describe('askuser-answer-parser / stripInvisible (E33)', () => { @@ -49,11 +56,6 @@ describe('askuser-answer-parser / normalizeCommand (E34)', () => { }); }); -import { - STOP_KEYWORDS, - isStopAnswer, - detectStopWithFallback, -} from './askuser-answer-parser.mjs'; describe('askuser-answer-parser / STOP_KEYWORDS (S27)', () => { it('includes core Russian + English stop tokens', () => { @@ -98,6 +100,15 @@ describe('askuser-answer-parser / isStopAnswer', () => { it('returns false for non-string', () => { expect(isStopAnswer(null)).toBe(false); }); + + it('matches a stop token with a trailing comma', () => { + expect(isStopAnswer('нет, это лишнее')).toBe(true); + expect(isStopAnswer('стоп.')).toBe(true); + }); + + it('still matches multi-word phrase without the comma', () => { + expect(isStopAnswer('всё поехали назад')).toBe(true); + }); }); describe('askuser-answer-parser / detectStopWithFallback', () => { @@ -127,12 +138,6 @@ describe('askuser-answer-parser / detectStopWithFallback', () => { }); }); -import { - parseAskUserResult, - matchesApproval, - detectOtherSocialEng, - buildApprovalRecord, -} from './askuser-answer-parser.mjs'; describe('askuser-answer-parser / parseAskUserResult', () => { it('extracts a single selected answer label', () => {