#!/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 }; }