Files
portal/tools/router-engine.mjs
T
Дмитрий 69e20099db fix(router-mentor): sharp-edges audit M1-M4 — close 8 misuse-resistance holes
Второй аудит машин 1-4 другим объективом (sharp-edges: устойчивость к
неправильному применению / мягкие умолчания / совпадение по пустоте-подстроке).
Криптоядра здоровы (подтверждено). 8 реальных дыр закрыты по TDD:

M3:
- coverage-machine F-1: покрытие считалось по двусторонней ПОДСТРОКЕ — produces
  "a" покрывал запрос "audit-rls-policy" (ложное «всё покрыто»). Новый tokensCover:
  точное равенство ИЛИ подмножество слов по границам. coveringSkill + coverageRegistry.
- router-engine F-8: confidence не проверялся на диапазон — 5/Infinity проходили как
  «уверен» (обход воздержания 5.2), -3 как принуд. abstain. validateTrace: [0,1] finite.
- round-control C: пустой roundKey="" активировал managed-режим (!= null) → все сессии
  делили один счётчик-бакет. Теперь managed требует непустую строку.
- router-learning-queue G: повторное approve уже-решённого id повторно клало запись в
  фонд (дубль). applyApprovalBatch: переводит только status==='pending'.

M2:
- plan-lock F5: шаг с пустым object был джокером (object:'' матчил действие, чей путь
  не извлёкся → object''). actionMatchesStep: пустой object шага не матчит ничего.

M4 (инертна; чистые fail-closed правки кода, корректны и при включении):
- judge-slop-counter H: битый/null вердикт в списке ронял счёт (v.missing на null).
  Теперь не крашит, считается халтурой (безопасная сторона).
- judge-engine J: consensusDecision на пустом/битом списке дрейфовал к GO. Теперь GO
  только если есть голоса И каждый чистый GO; иначе NO-GO (fail-closed для hard-risk).
- judge-orchestrator K: finalGate снимал вето пола на любой falsy floorBlocked
  (undefined от упавшей проверки = fail-open). Теперь снять может только явный false.

Регрессия tools-only 2555 passed + 2 skip (+15 TDD-тестов, 0 регрессий).

Осознанно НЕ менялось (без призраков):
- M1 receipt-sign domain default '' / разделитель пробел — backward-compat контракт
  (тест 18-19), инъективен на enum-доменах без пробелов.
- M1 action-journal атомарность записи головы + битая .jsonl строка — fail-closed
  (битьё → verifyChain ok:false → стена блокирует); чистого behavioral-теста нет.
- M3 round-control requiredSkills=[] — контракт вызывающего (пустой = не требуется).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:24:21 +03:00

156 lines
12 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.
#!/usr/bin/env node
/**
* router-engine — думающая голова роутера (3-D). РАССУЖДЕНИЕ — у модели (llmCall
* инъектируется/мокается, идиома router-classifier); все ПРОВЕРКИ/ЗАЩИТЫ вокруг —
* тупо-механические и покрыты тестами («умный не баран»). Группы 1/2/5/6 + look-ahead
* + L-ядро + риск-фильтр W1+W2 + деньги 6.1/6.2. K4-поправку к стене и live-wiring
* НЕ трогает (см. журнал вопросов: только после Машины 4).
*/
import { resolveNode } from './node-graph.mjs';
// Универсальные разрушительные глаголы (портативно — не project-хардкод путей).
const DESTRUCTIVE_RE = /\b(rm|rmdir|drop|delete|truncate|format)\b|--force|force-push|reset\s+--hard|migrate:fresh|push\s+--force/i;
/**
* 6.1 — ДЕТЕРМИНИРОВАННЫЙ высокий риск (по операции/прод-выкату/многошаговости/
* инъецированному sensitive-флагу). LLM-оценка риска — ТОЛЬКО совет, НЕ триггер
* блока (жёсткий блок нельзя вешать на догадку модели). stepThreshold — ручка.
*/
export function detectHighRisk({ op, command = '', prodDeploy = false, stepCount = 0, sensitive = false } = {}, { stepThreshold = 7 } = {}) {
const reasons = [];
if (DESTRUCTIVE_RE.test(command)) reasons.push('разрушительная операция');
if (prodDeploy) reasons.push('прод-выкат');
if (stepCount >= stepThreshold) reasons.push(`многошаговость (${stepCount}${stepThreshold})`);
if (sensitive) reasons.push('чувствительная зона (инъецированный флаг)');
return { high: reasons.length > 0, reasons };
}
// 6.2 — закрытый список категорий пропуска уровня (свободный текст отклоняется).
export const LEVEL_SKIP_CATEGORIES = Object.freeze(['atomic', 'trivial', 'obvious-single-path', 'no-skill-needed']);
/** 6.2 — пропуск уровня только ЯВНОЙ категорией из закрытого списка. */
export function validateLevelSkip(category) {
const ok = typeof category === 'string' && LEVEL_SKIP_CATEGORIES.includes(category.trim());
return { ok, allowed: LEVEL_SKIP_CATEGORIES };
}
/**
* 6.1 — цена как РАЗДЕЛИТЕЛЬ равноценных: сначала лучшее качество (min qualityRank),
* среди равных по качеству — min costRank. Цена НЕ важнее правильности.
*/
export function cheaperOf(candidates) {
if (!Array.isArray(candidates) || candidates.length === 0) return null;
return [...candidates].sort((a, b) =>
(a.qualityRank - b.qualityRank) || (a.costRank - b.costRank))[0];
}
// 5.1 — обязательные структурные слоты трассы (схема=метод, §6.2 A).
export const TRACE_SLOTS = Object.freeze(['candidates', 'chosen', 'why_chosen', 'twins_considered', 'confidence']);
/**
* Механический валидатор трассы (ОТДЕЛЬНЫЙ код, не сам роутер — §6.2 B, аналог A0):
* пустой/отсутствующий слот = красный флаг. candidates — непустой массив,
* confidence — число, остальные — непустые строки.
*/
export function validateTrace(trace) {
const missingSlots = [];
if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...TRACE_SLOTS] };
if (!Array.isArray(trace.candidates) || trace.candidates.length === 0) missingSlots.push('candidates');
for (const slot of ['chosen', 'why_chosen', 'twins_considered']) {
if (typeof trace[slot] !== 'string' || !trace[slot].trim()) missingSlots.push(slot);
}
// F-8 (аудит M1-M4): confidence обязан быть конечным числом в [0,1]. Иначе
// (NaN/Infinity/5/-3) проходил `typeof === 'number'`, а порог `< threshold`
// трактовал 5 как «уверен» (обход воздержания 5.2) либо -3 как принуд. abstain.
if (typeof trace.confidence !== 'number' || !Number.isFinite(trace.confidence) || trace.confidence < 0 || trace.confidence > 1) missingSlots.push('confidence');
return { ok: missingSlots.length === 0, missingSlots };
}
/**
* ОВ-Д2 / §6.3 C — заземление: каждый кандидат И выбранный обязаны резолвиться в
* реальный узел графа (3-B resolveNode); выдумка → grounded=false + перечень invented.
*/
export function groundTrace(trace, graph) {
const invented = [];
const check = (ref) => { if (resolveNode(graph, ref) === null) invented.push(ref); };
for (const cand of (trace?.candidates || [])) check(cand);
if (trace?.chosen) check(trace.chosen);
return { grounded: invented.length === 0, invented };
}
/**
* F1 — выбор ОБЯЗАН быть среди перечисленных кандидатов (§6.3 метод-брейнсторм:
* сперва 2–3 кандидата, ПОТОМ выбор ИЗ них). Реальный, но не перечисленный chosen —
* обход метода («выбор с потолка»), который заземление пропускает. Чистая проверка.
*/
export function chosenIsCandidate(trace) {
return Array.isArray(trace?.candidates)
&& typeof trace?.chosen === 'string' && trace.chosen.trim().length > 0
&& trace.candidates.includes(trace.chosen);
}
/**
* Построитель промпта (чистый, {system,user} — идиома buildClassifierPromptStructured).
* system: статичные инструкции + вшитый брейнсторм (2–3 варианта → спор → выбор) +
* L-ядро (L1/L3/L4/L5/L7) + схема слотов трассы 5.1 + ПОЛНЫЙ граф+каталог (6.2-исключение:
* в system для кэша). user: задача (+план/прогресс) — волатильное.
*/
export function buildRouterPrompt({ prompt = '', plan = null, graph = null, catalog = null, contracts = [] } = {}) {
const nodesForCtx = (catalog && catalog.nodes) || (graph && graph.nodes) || [];
const catalogText = nodesForCtx.map((n) => `${n.id || ''} ${n.slug || ''}${n.capabilities || n.name || ''}`).join('\n');
const cs = Array.isArray(contracts) ? contracts : [];
const lookAheadText = cs.map((ct) =>
`${ct.skill || '(?)'}: нужды[${(ct.needs || []).join(', ')}] ключевые-решения[${(ct['key-decisions'] || []).join(', ')}] критерии[${(ct['acceptance-criteria'] || []).join(', ')}]`,
).join('\n');
const system = [
'Ты — РОУТЕР-НАСТАВНИК. Выбираешь навык по СМЫСЛУ задачи рассуждением (ключевые слова сняты).',
'МЕТОД (вшитый брейнсторм): придумай 2–3 варианта-кандидата с разных углов графа → поспорь с собой (почему не сосед-близнец? а если я неправ?) → выбери лучший с обоснованием.',
'НЮХ (5.3): размытый/общий шаг («сделай красиво», «настрой») — ПОДОЗРИТЕЛЬНО, не глотай молча.',
'ИНТЕРВЬЮЕР (4.4): на размытом/неоднозначном — поставь низкую уверенность и сформулируй уточняющий вопрос (переспрос), не угадывай.',
'LOOK-AHEAD: ДО выбора посмотри нужды/ключевые-решения/критерии вызываемых навыков (раздел КОНТРАКТЫ ниже, если есть) и вытащи их вопросы ВПЕРЁД — заложи решения в план заранее.',
'L-ядро: L1 предложи дешёвый образец до полной стройки; L3 давай разумный дефолт-на-вето вместо открытого вопроса; L4 порядок решений по влиянию (фундамент раньше мелочи); L5 ограничения как пре-мортем; L7 объяви критерий приёмки заранее.',
'ВЫВОД — строго JSON-трасса со слотами: candidates (массив реальных узлов графа), chosen, why_chosen, twins_considered (почему не близнец), confidence (0..1). Пустой слот недопустим.',
'Кандидаты ОБЯЗАНЫ быть реальными узлами из каталога ниже — не выдумывай.',
'--- ГРАФ+КАТАЛОГ УЗЛОВ (100%) ---',
catalogText,
cs.length ? '--- КОНТРАКТЫ ВЫЗЫВАЕМЫХ НАВЫКОВ (look-ahead) ---' : '',
cs.length ? lookAheadText : '',
].filter(Boolean).join('\n');
const user = [
`ЗАДАЧА: ${prompt}`,
plan ? `ПЛАН (прогресс): ${typeof plan === 'string' ? plan : JSON.stringify(plan)}` : '',
].filter(Boolean).join('\n');
return { system, user };
}
/** Парсер ответа роутера → трасса-объект | null (терпимый к ```json fence). */
export function parseRouterResponse(text) {
if (typeof text !== 'string') return null;
const m = text.match(/```json\s*([\s\S]*?)```/i) || text.match(/(\{[\s\S]*\})/);
const body = m ? m[1] : text;
try { const o = JSON.parse(body); return (o && typeof o === 'object') ? o : null; }
catch { return null; }
}
/**
* Оркестратор: построить промпт → llmCall (инъектируется; в проде callAnthropicAPI) →
* распарсить → МЕХАНИЧЕСКИ провалидировать трассу (слоты) + заземлить кандидатов (ОВ-Д2) →
* воздержание 5.2 при низкой уверенности. Сам выбор — у модели; всё вокруг — механика.
*/
export async function runRouter({ prompt, plan = null, graph, catalog = null, contracts = [], llmCall, confidenceThreshold = 0.5 }) {
let trace;
try { trace = await llmCall({ buildPrompt: () => buildRouterPrompt({ prompt, plan, graph, catalog, contracts }) }); }
catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; }
if (typeof trace === 'string') trace = parseRouterResponse(trace);
if (!trace) return { ok: false, reason: 'пустой/неразборный ответ роутера', trace: null };
const v = validateTrace(trace);
if (!v.ok) return { ok: false, reason: `невалидная трасса: пустые слоты [${v.missingSlots.join(', ')}]`, trace };
const g = groundTrace(trace, graph);
if (!g.grounded) return { ok: false, reason: `выдуманные узлы (нет заземления): [${g.invented.join(', ')}]`, trace };
if (!chosenIsCandidate(trace))
return { ok: false, reason: `выбор не среди кандидатов (обход брейнсторма §6.3): chosen=${JSON.stringify(trace.chosen)} ∉ [${(trace.candidates || []).join(', ')}]`, trace };
if (typeof trace.confidence === 'number' && trace.confidence < confidenceThreshold)
return { ok: true, abstain: true, reason: 'низкая уверенность — воздержание (5.2): переспросить/эскалация', trace };
return { ok: true, abstain: false, trace };
}