Files
portal/tools/router-engine.mjs
T
Дмитрий 22b84fbb2e feat(m5): classifyDestructive двухуровневый + rewire a2CaseSelect/detectHighRisk (§4, N1)
Пакет 1 Машины 5 (роутер-наставник, пол). Единый источник разрушительности
classify-destructive.mjs: floor (точный необратимый набор, hard-block) + suspicious
(грубый набор для голосов судьи), инвариант floor => suspicious.

- N1: голый migrate/migrate:rollback/migrate --force => suspicious, НЕ floor (деплой не ломается).
- rewire a2CaseSelect (M4) и detectHighRisk (M3) на classifyDestructive.suspicious;
  оба локальных DESTRUCTIVE_RE удалены (Δ9-б — единственный источник).
- Δ9(б) seed CI-инвариант m5-floor-invariants.test.mjs (positive-control, не вакуумный).
- sharp-edges (Step 1.9): floor force-push выровнен с каноном shell-content — закрыт
  обход кавычками git push "--force" (длинные флаги без обязательного \s; -f/+ с \s).
- parity к двум прежним regex сохранён (format/db:wipe/force-push-литерал).

Регрессия tools-only: 2608 passed + 2 skip (+48). Residuals (chaining/reset-quote)
переданы Пакету 2 (tokenizeBash посегментно).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:21:38 +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';
import { classifyDestructive } from './classify-destructive.mjs';
// Разрушительные глаголы — единый источник classify-destructive.mjs (§4, Δ9-б):
// локальный DESTRUCTIVE_RE удалён, риск-проверка берёт .suspicious из классификатора.
/**
* 6.1 — ДЕТЕРМИНИРОВАННЫЙ высокий риск (по операции/прод-выкату/многошаговости/
* инъецированному sensitive-флагу). LLM-оценка риска — ТОЛЬКО совет, НЕ триггер
* блока (жёсткий блок нельзя вешать на догадку модели). stepThreshold — ручка.
*/
export function detectHighRisk({ op, command = '', prodDeploy = false, stepCount = 0, sensitive = false } = {}, { stepThreshold = 7 } = {}) {
const reasons = [];
if (classifyDestructive(command).suspicious) 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 };
}