/** * observer-choice-detector.mjs * Pure module — no I/O, no side effects. * Detects `decision_provenance.kind = user_chose_from_options`. */ // ── extractOptions ──────────────────────────────────────────────────────────── const NUMBERED_RE = /^(\d+)[.\)]\s+(.+)$/; const LETTERED_RE = /^([A-Za-zА-Яа-яЁё])[.\)]\s+(.+)$/; const BULLET_RE = /^[-*]\s+(.+)$/; function extractFromText(text) { const lines = String(text).split(/\r?\n/); const numbered = []; const lettered = []; const bulleted = []; for (const line of lines) { const trimmed = line.trim(); let m; if ((m = NUMBERED_RE.exec(trimmed))) numbered.push(m[2].trim()); else if ((m = LETTERED_RE.exec(trimmed))) lettered.push(m[2].trim()); else if ((m = BULLET_RE.exec(trimmed))) bulleted.push(m[1].trim()); } if (numbered.length >= 2) return numbered; if (lettered.length >= 2) return lettered; if (bulleted.length >= 2) return bulleted; return null; } function extractFromAskUser(toolUse) { if (!toolUse || toolUse.type !== 'tool_use' || toolUse.name !== 'AskUserQuestion') return null; const questions = toolUse.input?.questions; if (!Array.isArray(questions) || questions.length === 0) return null; const options = questions[0]?.options; if (!Array.isArray(options) || options.length < 2) return null; return options.map((o) => o?.label).filter((l) => typeof l === 'string'); } export function extractOptions(content) { if (content == null) return null; if (typeof content === 'string') return extractFromText(content); if (typeof content === 'object') { const fromTool = extractFromAskUser(content); if (fromTool && fromTool.length >= 2) return fromTool; if (Array.isArray(content)) { for (const block of content) { const r = extractOptions(block); if (r) return r; } } if (typeof content.text === 'string') return extractFromText(content.text); } return null; } // ── detectReference ─────────────────────────────────────────────────────────── const VERBS = ['делай', 'выбираю', 'выбери', 'беру', 'хочу', 'вариант', 'option', 'pick', 'choose']; const VERBS_RE = new RegExp('^(?:' + VERBS.join('|') + ')\\s+', 'i'); const LATIN_TO_INDEX = { a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, h: 7, }; const CYR_TO_INDEX = { 'а': 0, 'б': 1, 'в': 2, 'г': 3, 'д': 4, 'е': 5, 'ж': 6, 'з': 7, }; function tryPosition(prompt, options) { const lowered = prompt.toLowerCase(); const stripped = lowered.replace(VERBS_RE, ''); const m = stripped.match(/^(\d+|[a-zа-я])(?=[\s,\.\):;-]|$)/); if (!m) return null; const token = m[1]; let idx; if (/^\d+$/.test(token)) { idx = parseInt(token, 10) - 1; } else { idx = LATIN_TO_INDEX[token] ?? CYR_TO_INDEX[token]; } if (idx == null || idx < 0 || idx >= options.length) return null; return { index: idx, label: options[idx] }; } function trySubstring(prompt, options) { const lowered = prompt.toLowerCase(); for (let i = 0; i < options.length; i++) { const opt = String(options[i]).toLowerCase(); const words = opt.split(/\s+/).filter(Boolean); if (words.length < 2) continue; for (let n = Math.min(4, words.length); n >= 2; n--) { const prefix = words.slice(0, n).join(' '); if (lowered.includes(prefix)) return { index: i, label: options[i] }; } } return null; } export function detectReference(prompt, options) { const text = String(prompt || '').trim(); if (!text || !Array.isArray(options) || options.length < 2) return null; return tryPosition(text, options) ?? trySubstring(text, options); } // ── detectChoiceProvenance ──────────────────────────────────────────────────── export function detectChoiceProvenance(promptText, lastAssistantContent) { const options = extractOptions(lastAssistantContent); if (!options) return null; const ref = detectReference(promptText, options); if (!ref) return null; return { kind: 'user_chose_from_options', node: ref.label, options_offered: options.slice(), claude_would_have_chosen: options[0], }; } // ── detectAskUserQuestionChoice ─────────────────────────────────────────────── // In-turn choice via the AskUserQuestion tool. The answered entry carries a // structured `toolUseResult` with questions[].options[].label + an answers map. // Only an exact label match counts — a custom "Other" free-text answer is the // user steering, not a pick from offered options. export function detectAskUserQuestionChoice(turnEntries) { if (!Array.isArray(turnEntries)) return null; for (let i = turnEntries.length - 1; i >= 0; i--) { const tur = turnEntries[i] && turnEntries[i].toolUseResult; if (!tur || !Array.isArray(tur.questions) || !tur.answers || typeof tur.answers !== 'object') { continue; } const q = tur.questions[0]; if (!q || !Array.isArray(q.options)) continue; const options = q.options.map((o) => o && o.label).filter((l) => typeof l === 'string'); if (options.length < 2) continue; const answer = tur.answers[q.question]; if (typeof answer !== 'string') continue; const a = answer.trim(); const matched = options.find((o) => o.trim() === a); if (!matched) continue; return { kind: 'user_chose_from_options', node: matched, options_offered: options.slice(), claude_would_have_chosen: options[0], }; } return null; }