Files
brain/tools/observer-choice-detector.mjs
T

151 lines
5.8 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],
};
}
// ── 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;
}