Files
brain/tools/router-verity.mjs
T

126 lines
8.8 KiB
JavaScript

#!/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 };
}