Files
portal/tools/spec-verity.mjs
T

97 lines
6.9 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* spec-verity — роутер-шаг ВЫХОДНОЙ лесенки (spec 2026-06-05 §3А «контроль на
* выходе», шаг 2): когда контроллер объявил «готово», роутер сверяет ГОТОВУЮ
* спеку против СЫРЫХ ответов владельца + ОДОБРЕННОГО плана. Ловит «прошёл
* движения, а написал своё» (kind fabricated — в спеке есть, в ответах/плане нет).
* Существование ($0) и судья (полнота+пре-мортем) — Машина 4, НЕ здесь.
* РАССУЖДЕНИЕ — у модели (llmCall мокается); проверки вокруг — механические.
* R8: это ИИ — грубое «написал не то» ловит, тонкую подмену смыслом может
* пропустить → последний рубеж владелец.
*/
import { parseRouterResponse } from './router-engine.mjs';
export const SPEC_VERITY_SLOTS = Object.freeze(['promised', 'discrepancies']);
export const SPEC_DISCREPANCY_KINDS = Object.freeze(['missing', 'distorted', 'fabricated']);
function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; }
function isSpecSourceRef(v) { return v === 'prompt' || v === 'plan' || (typeof v === 'number' && Number.isInteger(v)); }
/**
* Валидатор формы (отдельный код): promised — НЕПУСТОЙ массив {item, source};
* discrepancies — массив (пуст = спека верна) {kind∈SPEC_DISCREPANCY_KINDS,
* detail, source}. source: 'prompt' | 'plan' | номер ответа.
*/
export function validateSpecVerityTrace(trace) {
const missingSlots = [];
const badItems = [];
if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...SPEC_VERITY_SLOTS], badItems };
if (!Array.isArray(trace.promised) || trace.promised.length === 0) {
missingSlots.push('promised');
} else {
trace.promised.forEach((it, i) => {
if (!it || typeof it !== 'object' || !isNonEmptyString(it.item) || !isSpecSourceRef(it.source)) badItems.push(`promised[${i}]`);
});
}
if (!Array.isArray(trace.discrepancies)) {
missingSlots.push('discrepancies');
} else {
trace.discrepancies.forEach((it, i) => {
const kindOk = it && typeof it === 'object' && SPEC_DISCREPANCY_KINDS.includes(it.kind);
if (!kindOk || !isNonEmptyString(it.detail) || !isSpecSourceRef(it.source)) badItems.push(`discrepancies[${i}]`);
});
}
return { ok: missingSlots.length === 0 && badItems.length === 0, missingSlots, badItems };
}
/** Заземление: source резолвится 'prompt' | 'plan' | индекс ответа в [0, answersCount). */
export function groundSpecVerityTrace(trace, { answersCount = 0 } = {}) {
const dangling = [];
const check = (src, where) => {
if (src === 'prompt' || src === 'plan') return;
if (typeof src === 'number' && Number.isInteger(src) && src >= 0 && src < answersCount) return;
dangling.push(`${where}:${JSON.stringify(src)}`);
};
(trace?.promised || []).forEach((it, i) => check(it?.source, `promised[${i}]`));
(trace?.discrepancies || []).forEach((it, i) => check(it?.source, `discrepancies[${i}]`));
return { grounded: dangling.length === 0, dangling };
}
/** Построитель промпта (чистый, {system,user}). Анти-внушение: promised из якорей ДО чтения спеки. */
export function buildSpecVerityPrompt({ rawPrompt = '', answers = [], approvedPlan = null, spec = '' } = {}) {
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 = approvedPlan == null ? '(одобренного плана нет)' : (typeof approvedPlan === 'string' ? approvedPlan : JSON.stringify(approvedPlan));
const specText = typeof spec === 'string' ? spec : JSON.stringify(spec);
const system = [
'Ты — РОУТЕР-НАСТАВНИК, ВЫХОДНАЯ сверка верности спеки. Контроллер объявил «готово».',
'ПОРЯДОК (анти-внушение): СНАЧАЛА, читая ТОЛЬКО якоря (сырые ответы владельца + одобренный план), независимо выпиши, что было ОБЕЩАНО → promised (source: "prompt"|"plan"|номер ответа). ДО чтения спеки.',
'ПОТОМ сверь готовую спеку с promised и выпиши discrepancies: missing (обещано, в спеке нет), distorted (в спеке искажено), fabricated (в спеке есть, но НИ в ответах НИ в плане не обещано — «написал своё»).',
'Спека верна (расхождений нет) → discrepancies пустой — допустимо.',
'ВЫВОД — строго JSON: {"promised":[{"item":"…","source":"prompt"|"plan"|N}], "discrepancies":[{"kind":"missing"|"distorted"|"fabricated","detail":"…","source":"prompt"|"plan"|N}]}. Пустой promised недопустим.',
].join('\n');
const user = [
'--- СЫРОЙ ПРОМПТ ВЛАДЕЛЬЦА ---', rawPrompt,
'--- СЫРЫЕ ОТВЕТЫ ВЛАДЕЛЬЦА ПО ПОРЯДКУ ---', answersText,
'--- ОДОБРЕННЫЙ ПЛАН (якорь) ---', planText,
'--- ГОТОВАЯ СПЕКА (читать ПОСЛЕ promised) ---', specText,
].join('\n');
return { system, user };
}
/** Оркестратор: построить → llmCall → распарсить → валидировать → заземлить → faithful=код. */
export async function runSpecVerityCheck({ rawPrompt = '', answers = [], approvedPlan = null, spec = '', llmCall }) {
const ans = Array.isArray(answers) ? answers : [];
let trace;
try { trace = await llmCall({ buildPrompt: () => buildSpecVerityPrompt({ rawPrompt, answers: ans, approvedPlan, spec }) }); }
catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; }
if (typeof trace === 'string') trace = parseRouterResponse(trace);
if (!trace) return { ok: false, reason: 'пустой/неразборный ответ роутера', trace: null };
const v = validateSpecVerityTrace(trace);
if (!v.ok) return { ok: false, reason: `невалидная трасса спеки: слоты [${v.missingSlots.join(', ')}] пункты [${v.badItems.join(', ')}]`, trace };
const g = groundSpecVerityTrace(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 };
}