2026-05-19 11:57:36 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-05-19 13:33:46 +03:00
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
|
}
|