#!/usr/bin/env node /** * router-verity — роутер в роли КРИТИКА ВЕРНОСТИ для разговорной фазы (1а). * Поправка P16-e (spec 2026-06-05): роли НЕ меняются — контроллер ПРОИЗВОДИТ * разговорный план, роутер КРИТИКУЕТ его против СЫРЫХ слов владельца (промпт + * вся история ответов по порядку), НЕ против пересказа Claude. Анти-внушение: * роутер сперва САМ выводит «что просил владелец» (owner_asks), ПОТОМ сверяет с * планом (discrepancies) — оба слота обязательны по форме (пустой слот = красный * флаг, как validateTrace). РАССУЖДЕНИЕ — у модели (llmCall мокается); все * ПРОВЕРКИ вокруг — тупо-механические («умный не баран»). */ import { parseRouterResponse } from './router-engine.mjs'; // Обязательные структурные слоты трассы верности (анти-внушение §2.4). export const VERITY_SLOTS = Object.freeze(['owner_asks', 'discrepancies']); // Закрытый список видов расхождения (свободный текст в kind отклоняется). export const DISCREPANCY_KINDS = Object.freeze(['missing', 'distorted']); function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; } function isSourceRef(v) { return v === 'prompt' || (typeof v === 'number' && Number.isInteger(v)); } /** * Механический валидатор трассы верности (ОТДЕЛЬНЫЙ код, не сам роутер — §6.2 B, * аналог A0). ФОРМА: owner_asks — НЕПУСТОЙ массив пунктов {ask, source}; * discrepancies — массив (может быть пуст = план верен) пунктов * {kind∈DISCREPANCY_KINDS, detail, source}. Пустой/неверный слот = красный флаг. * НЕ судит «правильность мысли» и НЕ резолвит номер источника (groundVerityTrace). */ export function validateVerityTrace(trace) { const missingSlots = []; const badItems = []; if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...VERITY_SLOTS], badItems }; if (!Array.isArray(trace.owner_asks) || trace.owner_asks.length === 0) { missingSlots.push('owner_asks'); } else { trace.owner_asks.forEach((it, i) => { if (!it || typeof it !== 'object' || !isNonEmptyString(it.ask) || !isSourceRef(it.source)) badItems.push(`owner_asks[${i}]`); }); } if (!Array.isArray(trace.discrepancies)) { missingSlots.push('discrepancies'); } else { trace.discrepancies.forEach((it, i) => { const kindOk = it && typeof it === 'object' && DISCREPANCY_KINDS.includes(it.kind); if (!kindOk || !isNonEmptyString(it.detail) || !isSourceRef(it.source)) badItems.push(`discrepancies[${i}]`); }); } return { ok: missingSlots.length === 0 && badItems.length === 0, missingSlots, badItems }; } /** * Заземление источников (§2.2 — «вся история по порядку, не потерять ранний * ответ»): каждый source в owner_asks/discrepancies обязан резолвиться в реальный * источник — 'prompt' ИЛИ целочисленный индекс ответа в [0, answersCount). * Ссылка вне диапазона → dangling (роутер сослался на несуществующий ответ). */ export function groundVerityTrace(trace, { answersCount = 0 } = {}) { const dangling = []; const check = (src, where) => { if (src === 'prompt') return; if (typeof src === 'number' && Number.isInteger(src) && src >= 0 && src < answersCount) return; dangling.push(`${where}:${JSON.stringify(src)}`); }; (trace?.owner_asks || []).forEach((it, i) => check(it?.source, `owner_asks[${i}]`)); (trace?.discrepancies || []).forEach((it, i) => check(it?.source, `discrepancies[${i}]`)); return { grounded: dangling.length === 0, dangling }; } /** * Построитель промпта верности (чистый, {system,user}). Анти-внушение §2.4 зашито * ДВАЖДЫ: (1) инструкция порядка в system (сперва owner_asks из сырых слов, потом * discrepancies против плана); (2) физически — сырые слова в user ПЕРЕД планом. * Вся история ответов нумеруется (0-based, под groundVerityTrace). */ export function buildVerityPrompt({ rawPrompt = '', answers = [], plan = null } = {}) { const ans = Array.isArray(answers) ? answers : []; const answersText = ans.length ? ans.map((a, i) => `Ответ ${i}: ${typeof a === 'string' ? a : JSON.stringify(a)}`).join('\n') : '(ответов пока нет)'; const planText = plan == null ? '(плана пока нет)' : (typeof plan === 'string' ? plan : JSON.stringify(plan)); const system = [ 'Ты — РОУТЕР-НАСТАВНИК в роли КРИТИКА ВЕРНОСТИ (разговорная фаза). Роли НЕ меняются: план производит контроллер, ты его КРИТИКУЕШЬ против СЫРЫХ слов владельца, а не против пересказа.', 'ПОРЯДОК (обязателен, анти-внушение): СНАЧАЛА, читая ТОЛЬКО сырые слова владельца (промпт + ответы), независимо выпиши, что владелец РЕАЛЬНО просил → заполни owner_asks. Сделай это ДО того, как смотреть план.', 'ПОТОМ сверь план с owner_asks и выпиши discrepancies. Не поддакивай плану. Если ранний ответ владельца в плане НЕ отражён или искажён — это расхождение, не пропусти.', 'Каждый пункт owner_asks ОБЯЗАН ссылаться на источник: source = "prompt" ИЛИ номер ответа (целое, как в нумерации ниже).', 'discrepancies: каждый пункт {kind: "missing"|"distorted", detail, source}. Пустой массив discrepancies = план ВЕРЕН (расхождений нет) — это допустимо.', 'ВЫВОД — строго JSON: {"owner_asks":[{"ask":"…","source":"prompt"|N}], "discrepancies":[{"kind":"missing"|"distorted","detail":"…","source":"prompt"|N}]}. Пустой owner_asks недопустим.', ].join('\n'); const user = [ '--- СЫРОЙ ПРОМПТ ВЛАДЕЛЬЦА (дословно) ---', rawPrompt, '--- СЫРЫЕ ОТВЕТЫ ВЛАДЕЛЬЦА ПО ПОРЯДКУ (полная история) ---', answersText, '--- ПЛАН КОНТРОЛЛЕРА (разговорный, читать ПОСЛЕ owner_asks) ---', planText, ].join('\n'); return { system, user }; } /** * Оркестратор верности: построить промпт → llmCall (инъектируется; в проде * callAnthropicAPI) → распарсить → МЕХАНИЧЕСКИ провалидировать (слоты+форма) → * заземлить источники → вывести faithful = (нет расхождений). faithful считает * КОД из массива discrepancies, не со слов роутера (не доверяем самоотчёту). */ export async function runVerityCheck({ rawPrompt = '', answers = [], plan = null, llmCall }) { const ans = Array.isArray(answers) ? answers : []; let trace; try { trace = await llmCall({ buildPrompt: () => buildVerityPrompt({ rawPrompt, answers: ans, plan }) }); } catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; } if (typeof trace === 'string') trace = parseRouterResponse(trace); if (!trace) return { ok: false, reason: 'пустой/неразборный ответ роутера', trace: null }; const v = validateVerityTrace(trace); if (!v.ok) return { ok: false, reason: `невалидная трасса верности: слоты [${v.missingSlots.join(', ')}] пункты [${v.badItems.join(', ')}]`, trace }; const g = groundVerityTrace(trace, { answersCount: ans.length }); if (!g.grounded) return { ok: false, reason: `висящие ссылки на источники: [${g.dangling.join(', ')}]`, trace }; const faithful = trace.discrepancies.length === 0; return { ok: true, faithful, discrepancies: trace.discrepancies, trace }; }