Files
brain/tools/router-engine.mjs

206 lines
15 KiB
JavaScript
Raw Permalink 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 = [] } = {}) {
// W1 (C2, нах.F1/CD-R6-G): catalog ≠ graph. Каталог скилов — ТОЛЬКО из catalog.nodes
// (граф больше не фолбэк каталога); project-граф (graphSection B: {layer0, staleness})
// рендерится ОТДЕЛЬНОЙ секцией ниже.
const nodesForCtx = (catalog && catalog.nodes) || [];
// F-C2-3 (sharp-edges C2): пустой каталог НЕ молчит (зеркало F-C6 «сигнал всегда») —
// иначе «кандидаты обязаны быть из каталога» при пустом каталоге тихо гонит в выдумку.
const catalogText = nodesForCtx.length
? nodesForCtx.map((n) => `${n.id || ''} ${n.slug || ''}${n.capabilities || n.name || ''}`).join('\n')
: 'КАТАЛОГ ПУСТ (catalog не передан/пуст) — кандидатов нет: не выдумывай узлы, ставь низкую уверенность (воздержание 5.2)';
// FR-2 (финревью 2026-06-11): пустой layer0 → null (не пустой заголовок секции);
// маркер ОТСУТСТВИЯ карты для mentor-пути — забота mentor-seam (F-C6).
const layer0 = (graph && Array.isArray(graph.layer0) && graph.layer0.length) ? graph.layer0 : null;
const districtLines = layer0 ? layer0.map((d) => {
const top = Array.isArray(d.topNodes) ? d.topNodes.map((n) => n && n.id).filter(Boolean).join(', ') : '';
return `${d.district} (${d.nodeCount} узлов)${top ? ` — топ: ${top}` : ''}`;
}) : null;
const st = graph && graph.staleness;
const stalenessLine = st ? `staleness: stale=${!!st.stale} commits_behind=${st.commits_behind ?? '?'} uncommitted=${st.uncommitted ?? '?'}` : '';
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). Пустой слот недопустим.',
'Кандидаты ОБЯЗАНЫ быть реальными узлами из каталога ниже — не выдумывай.',
'--- КАТАЛОГ УЗЛОВ (skill-каталог) ---',
catalogText,
// W1 (CD-R6-G): project-граф — ОТДЕЛЬНАЯ секция (прунённая карта районов B +
// inline staleness ✅O16), НЕ skill-каталог; заземление groundTrace — против
// ПОЛНОГО нод-графа (groundingGraph в runMentorRound), не против этой карты.
districtLines ? '--- ГРАФ ПРОЕКТА: КАРТА РАЙОНОВ (прунённая; заземление против полного каталога) ---' : '',
districtLines ? districtLines.join('\n') : '',
districtLines ? stalenessLine : '',
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 };
}
/** FR-4: первый СБАЛАНСИРОВАННЫЙ {...}-срез текста (учёт строк/экранирования) | null. */
function firstBalancedObject(text) {
const start = text.indexOf('{');
if (start < 0) return null;
let depth = 0; let inStr = false; let esc = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (esc) { esc = false; continue; }
if (inStr) {
if (ch === '\\') esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') inStr = true;
else if (ch === '{') depth++;
else if (ch === '}') { depth--; if (depth === 0) return text.slice(start, i + 1); }
}
return null;
}
/** Парсер ответа роутера → трасса-объект | null (терпимый к ```json fence).
* FR-4 (финревью 2026-06-11): жадный {…}-regex ловил от первой { до ПОСЛЕДНЕЙ } —
* два JSON-объекта в тексте роняли парс в null; фолбэк — балансный срез ПЕРВОГО. */
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;
const tryParse = (s) => {
try { const o = JSON.parse(s); return (o && typeof o === 'object') ? o : null; }
catch { return null; }
};
const direct = tryParse(body);
if (direct) return direct;
const balanced = firstBalancedObject(body);
return balanced ? tryParse(balanced) : 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 };
}