397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
206 lines
15 KiB
JavaScript
206 lines
15 KiB
JavaScript
#!/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 };
|
||
}
|