Files
portal/tools/observer-choice-detector.mjs
T
Дмитрий 7f379bd6a2 feat(observer): choice detector — user_chose_from_options kind
Pure module — extracts options (numbered/lettered/bullets/AskUserQuestion)
from last assistant message, detects user reference (position-based +
substring), returns decision_provenance for the 3rd kind.

23/23 tests GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:57:36 +03:00

119 lines
4.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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],
};
}