#!/usr/bin/env node /** * Router classifier — Phase 2 (LLM-first router overhaul). * * Architecture (spec §3, §4.1, §4.2): * Layer 1: prefilter() — pure regex, 7 checks (manual override / continuation / * acknowledgment / cancellation / short conv + anchor / micro / null). * Layer 2: Sonnet 4.6 classifier via ProxyAPI. Memory pamyatka (4 patterns) * injected when prompt-enrichment-mode=on. Output schema per §4.2. * Layer 3 (fallback): regex fallback in router-classifier-regex-fallback.mjs. * Layer 4 (degraded): { task_type: 'unknown', source: 'fallback', degraded: true } * with explicit chat marker. * * Pure (Layer 1): no fs/exec/net. callers pass registry + optional prevState. * Layer 2: HTTP via callAnthropicAPI (ProxyAPI, header reseller-isolation). * * Legacy exports buildLLMPrompt / parseLLMResponse retained for backward * compatibility with older accuracy-runner snapshots and tests; not on the * Phase 2 hot path. The Phase 1 regex Layer 1 (classifyByRegex, TASK_TYPE_KEYWORDS, * HARD_KEYWORD_STEMS) moved verbatim to router-classifier-regex-fallback.mjs; * re-exported here for callers that still reach for it through this module. */ import { CLASSIFIER_MODEL, INHERITANCE_MAX_AGE_MIN } from './router-config.mjs'; import { classifyByRegex } from './router-classifier-regex-fallback.mjs'; import { Agent } from 'undici'; // Keep-alive dispatcher for ProxyAPI — skips TLS handshake on subsequent calls, // reduces tail latency 100-300ms per request. Only attached to the default // fetchImpl; tests passing their own fetchImpl are unaffected. const KEEPALIVE_DISPATCHER = new Agent({ keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000, connections: 4, }); async function defaultFetch(url, opts) { return fetch(url, { ...opts, dispatcher: KEEPALIVE_DISPATCHER }); } export { classifyByRegex }; const MICRO_KEYWORDS = [ 'опечатк', 'typo', 'переименуй', 'rename', 'удали мёртв', 'dead code', 'формат', 'format', 'константу', 'one constant', 'увеличь', 'уменьши', 'поменяй значени', 'измени константу', 'одну строку', 'bump', ]; function lower(s) { return String(s || '').toLowerCase(); } function detectMicro(prompt) { const p = lower(prompt); return MICRO_KEYWORDS.some((kw) => p.includes(kw)); } // ─── Prefilter constants (spec §4.1, Phase 2 Task 9) ──────────────────────── const CONTINUATION_PATTERNS = [ 'да', 'делай', 'давай', 'продолжай', 'дальше', 'ага', 'валяй', 'поехали', 'утверждаю', 'одобряю', 'ок делай', 'хорошо делай', 'согласен делай', ]; const ACKNOWLEDGMENT_PATTERNS = [ 'спасибо', 'понял', 'ок', 'хорошо', 'отлично', 'верно', 'круто', 'годится', 'молодец', 'норм', ]; const CANCELLATION_PATTERNS = [ 'стоп', 'нет', 'отмени', 'отбой', 'не надо', 'забей', 'хватит', 'достаточно', ]; const MANUAL_OVERRIDE_RE = /^(делай|сделай|используй|применя[йи]|запусти|вызови)\s+(через|с\s+помощью|skill|skill[оа]м)\s+([\w\-:]+)/i; const ANCHOR_NOUNS = [ 'аудит', 'баг', 'план', 'спека', 'фича', 'тест', 'миграция', 'endpoint', 'файл', 'функция', 'класс', 'компонент', 'view', 'модель', 'биллинг', 'маркетинг', 'безопасность', 'пдн', 'регион', 'портал', 'проект', 'сделка', 'лид', 'админка', 'база', 'схема', 'воронка', 'хук', ]; const ANCHOR_IMPERATIVES = [ 'проанализируй', 'проверь', 'исправь', 'почини', 'создай', 'добавь', 'удали', 'переименуй', 'улучши', 'расширь', ]; const SKILL_ALIAS_MAP = { tdd: 'test-driven-development', 'test-driven-development': 'test-driven-development', brainstorming: 'brainstorming', brainstorm: 'brainstorming', debugging: 'systematic-debugging', 'systematic-debugging': 'systematic-debugging', debug: 'systematic-debugging', 'writing-plans': 'writing-plans', plan: 'writing-plans', plans: 'writing-plans', 'verification-before-completion': 'verification-before-completion', verify: 'verification-before-completion', parallel: 'dispatching-parallel-agents', 'dispatching-parallel-agents': 'dispatching-parallel-agents', worktree: 'using-git-worktrees', 'using-git-worktrees': 'using-git-worktrees', review: 'requesting-code-review', 'requesting-code-review': 'requesting-code-review', }; function containsAnchor(prompt) { const p = lower(prompt); if (ANCHOR_NOUNS.some((a) => p.includes(a))) return true; if (prompt.length > 30 && ANCHOR_IMPERATIVES.some((a) => p.includes(a))) return true; return false; } function resolveNodeAlias(extracted, registry) { if (!extracted) return null; const norm = String(extracted).toLowerCase(); if (SKILL_ALIAS_MAP[norm]) return SKILL_ALIAS_MAP[norm]; if (registry?.nodes) { const exact = registry.nodes.find((n) => n.slug === norm); if (exact) return exact.slug; const fuzzy = registry.nodes.find((n) => { const slug = String(n.slug || '').toLowerCase(); const name = String(n.name || '').toLowerCase(); return (slug && (slug.includes(norm) || norm.includes(slug))) || (name && name.includes(norm)); }); if (fuzzy) return fuzzy.slug; } return `unknown_${extracted}`; } /** * Prefilter — Layer 1, 7-check chain (spec §4.1). Pure. * * @returns object on a positive match, or null when fall-through to Layer 2 is required. */ export function prefilter(prompt, { prevState, registry } = {}) { if (!prompt) return null; const raw = String(prompt); const p = raw.trim().toLowerCase(); const m = raw.match(MANUAL_OVERRIDE_RE); if (m) { return { task_type: 'manual_override', node: 'direct', source: 'prefilter', requested_node: resolveNodeAlias(m[3], registry), }; } if (CONTINUATION_PATTERNS.includes(p) && prevState?.classification && prevState.timestamp) { const ageMs = Date.now() - new Date(prevState.timestamp).getTime(); const ageMin = ageMs / 60000; if (ageMin <= INHERITANCE_MAX_AGE_MIN) { return { task_type: prevState.classification.task_type, node: 'direct', source: 'prefilter_inherited', recommendedNode: prevState.classification.recommendedNode ?? null, inheritance: { inherited_from_task_id: prevState.task_id ?? null, inheritance_age_minutes: Math.round(ageMin), }, }; } } if (ACKNOWLEDGMENT_PATTERNS.includes(p)) { return { task_type: 'conversation', node: 'direct', source: 'prefilter' }; } if (CANCELLATION_PATTERNS.includes(p)) { return { task_type: 'conversation', node: 'direct', source: 'prefilter', previous_rejected: !!prevState?.task_id, }; } if (raw.length < 15 && !containsAnchor(raw)) { return { task_type: 'conversation', node: 'direct', source: 'prefilter' }; } if (detectMicro(raw)) { return { task_type: 'micro', node: 'direct', source: 'prefilter' }; } return null; } // ─── Layer 2: Sonnet 4.6 classifier (spec §4.2) ───────────────────────────── const PAMYATKA = `=== ПАМЯТКА (8 паттернов) === ПАТТЕРН 1 (brainstorming): обязательно рассмотри минимум 3 alternative_considered. Один кандидат без альтернатив — плохо. ПАТТЕРН 2 (discovery-interview): если запрос можно интерпретировать двумя+ способами — НЕ угадывай. Верни no_skill_found=true с no_skill_found_suggestion: "ambiguous — clarify A vs B vs C". ПАТТЕРН 3 (writing-plans): различай single-step и multi-step. - Один глагол + объект ("поправь typo") → chain 1 элемент. - "и"/"потом"/"затем" или подразумевается несколько этапов → chain ≥2 в порядке. ПАТТЕРН 4 (systematic-debugging): для task_type=bugfix — проверь, чётко ли описаны system/expected/actual. Если хотя бы одного нет — рекомендуй superpowers:systematic-debugging (он сам потребует прояснить). ПАТТЕРН 5 (writing-plans — feature): для task_type=feature если запрос содержит «добавь», «реализуй», «сделай», «создай функционал», «нужна фича» И задача требует ≥3 шагов — рекомендуй writing-plans (#19) ПЕРЕД кодом. Если запрос ≤2 шага («поменяй текст», «добавь поле») — direct ok, plan излишен. ПАТТЕРН 6 (bugfix-chain — TDD): для task_type=bugfix если фикс касается живого кода (regex, parser, hook, race condition, catastrophic backtracking, performance) — рекомендуй CHAIN из 2 узлов: systematic-debugging (анализ причины) + Pest #18 (test-first для регрессии). PATTERN 4 покрывает выбор systematic-debugging, PATTERN 6 расширяет до chain с TDD-инструментом. ПАТТЕРН 7 (production runtime errors → Sentry): если запрос упоминает «ошибка на боевом», «клиент сообщил», «не работает в проде», «liderra.ru упало», «в логах ошибка», «выкатили и сломалось» — recommended_chain ОБЯЗАН начинаться с Sentry MCP (#34). Не предлагай чтение кода ПЕРЕД просмотром реального stack-trace из боевого журнала. ПАТТЕРН 8 (mechanical/repetitive → delegation): если задача описана как «напиши N однотипных», «перенеси все Y по шаблону», «обнови импорты в M файлах», «массовая правка», «однотипная работа во многих местах» — рекомендуй coder-agent (#19) через Task tool делегирование. Это не «обычная фича» (PATTERN 5) — это механическая работа, должна уйти свежему субагенту, не делаться в основном контексте.`; function escapeYamlStr(s) { return String(s || '').replace(/"/g, '\\"').replace(/\n/g, ' '); } function buildNodesBlock(registry) { const nodes = (registry.nodes || []).filter((n) => n.status === 'active'); return nodes.map((n) => { const triggers = (n.triggers || []) .slice(0, 5) .map((t) => t.keyword ? `"${t.keyword}"` : t.classification ? `"cls:${t.classification}"` : null) .filter(Boolean) .join(', '); const cap = n.capabilities ? `\n capabilities: "${escapeYamlStr(n.capabilities)}"` : ''; return `- skill_id: ${n.id}\n name: ${n.name}${cap}\n triggers: [${triggers}]`; }).join('\n'); } function buildChainsBlock(registry) { return Object.entries(registry.chains || {}) .map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`) .join('\n'); } /** * Build Sonnet 4.6 classifier prompt per spec §4.2. * * Returns the prompt as a single string for backward compatibility * (snapshot tests, accuracy-runner historical mode). The classifier * hot-path uses buildClassifierPromptStructured() instead, which separates * cacheable (system + registry) from dynamic (user prompt) content. * * @param {string} userPrompt — raw user prompt * @param {object} registry — { nodes, chains } * @param {object} [options] * @param {boolean} [options.enrichment=true] — inject pamyatka (4 patterns) */ export function buildClassifierPrompt(userPrompt, registry, { enrichment = true } = {}) { const { system, user } = buildClassifierPromptStructured(userPrompt, registry, { enrichment }); return `\n${system}\n\n\n\n${user}\n`; } /** * Build classifier prompt as { system, user } blocks for Anthropic prompt * caching (ephemeral 5m TTL). The `system` block is identical across all * classifier calls within a 5-minute window (instruction + памятка + node * registry + chains) and gets billed at 10% rate after the first call. * The `user` block is the only dynamic per-call content. * * Cache-eligibility: Sonnet requires ≥1024 tokens in the cached block. * Active node registry (~85 nodes × ~100 tokens) easily clears this. */ export function buildClassifierPromptStructured(userPrompt, registry, { enrichment = true, classifierContext = 'CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3)' } = {}) { const pamyatka = enrichment ? `\n\n${PAMYATKA}\n` : '\n'; const nodesBlock = buildNodesBlock(registry); const chainsBlock = buildChainsBlock(registry); const system = `Ты классификатор задач для ${classifierContext}. ОБЯЗАТЕЛЬНЫЕ выходные правила: 0. task_type — ОБЯЗАТЕЛЬНОЕ поле (ровно так, snake_case), одно из: feature, planning, bugfix, refactor, cleanup, marketing, security, analysis, monitoring, memory-sync, question, unknown. 1. Верни ровно один из: skill ИЛИ chain ИЛИ no_skill_found. 2. "direct" НЕ разрешён. Conversation/micro обрабатываются ДО тебя. 3. Верни топ-3 alternatives_considered со score (0-1) и причиной отклонения. 4. reason_for_choice — конкретно, со ссылкой на capability. 5. recommended_chain — массив из 1-5 skill IDs. 6. Если ни один узел не подходит — no_skill_found=true + suggestion. ${pamyatka} === РЕЕСТР УЗЛОВ === ${nodesBlock} === РЕЕСТР ЦЕПОЧЕК (справочно) === ${chainsBlock} Пример формата вывода (значения иллюстративные, не подсказка по содержанию): {"task_type":"bugfix","skill":"#18","chain":null,"no_skill_found":false,"recommended_chain":["#18","#19"],"confidence":0.9,"alternatives_considered":[{"id":"#62","score":0.3,"reason":"не про деньги"}],"reason_for_choice":"keyword 'regex' → systematic-debugging"} Output — ONLY JSON object, no prose, no code fences.`; const user = `Prompt: ${userPrompt}`; return { system, user }; } /** * Parse Sonnet 4.6 classifier response per spec §4.2. * Accepts: * - raw JSON object * - JSON wrapped in ```json ... ``` fence * - JSON wrapped in plain ``` fence * Returns null on parse failure or when required `task_type` is missing. * `recommended_chain_id` may be null (custom chain not in L1-L16). */ /** * Try-best-effort fix common LLM JSON quirks before re-parsing: * 1. Raw newlines inside double-quoted string values → \n (LLM often emits * multi-line reason_for_choice as literal newlines, breaking strict JSON). * 2. Trailing commas before } or ] (Sonnet occasionally inserts them). * Pure. Returns sanitized string. If we can't tell what's inside a string, * we leave the input alone — this is a heuristic, not a JSON5 parser. */ function fixLLMJsonQuirks(s) { // Walk char-by-char, tracking whether we're inside a string. Replace raw // newlines / tabs INSIDE strings with their escaped forms. Backslash before // a quote keeps that quote inside the string. Multi-byte unicode is fine — // we only act on ASCII control codepoints. let out = ''; let inStr = false; let prev = ''; for (let i = 0; i < s.length; i++) { const c = s[i]; if (inStr) { if (c === '"' && prev !== '\\') { inStr = false; out += c; } else if (c === '\n') { out += '\\n'; } else if (c === '\r') { out += '\\r'; } else if (c === '\t') { out += '\\t'; } else { out += c; } } else { if (c === '"') { inStr = true; } out += c; } prev = c; } // Strip trailing commas: ",}" → "}", ",]" → "]" (only outside strings — at // this stage all string-internal commas are still strings, the regex below // is only confused by ",}" sequences in strings which is rare). return out.replace(/,(\s*[}\]])/g, '$1'); } // Accept a parsed object iff it carries task_type (snake_case) or taskType // (camelCase — Sonnet emits camelCase ~half the time, which the structured // prompt never forbade). Normalize to snake_case so every downstream consumer // reads `task_type`. Returns the (mutated) object or null. function acceptClassified(parsed) { if (!parsed || typeof parsed !== 'object') return null; const tt = typeof parsed.task_type === 'string' ? parsed.task_type : typeof parsed.taskType === 'string' ? parsed.taskType : null; if (tt === null) return null; if (typeof parsed.task_type !== 'string') parsed.task_type = tt; return parsed; } export function parseClassifierResponse(text) { if (!text) return null; const trimmed = String(text).trim(); const stripped = trimmed.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim(); // Pass 1: clean JSON (after fence strip). try { const parsed = JSON.parse(stripped); const accepted = acceptClassified(parsed); if (accepted) return accepted; } catch { /* fall through to extraction */ } // Pass 2: JSON object embedded in prose ("Here is the classification: { ... }"). // Greedy match from first `{` to last `}` — works because the classifier // produces exactly one top-level object; outer braces are reliable anchors. const start = stripped.indexOf('{'); const end = stripped.lastIndexOf('}'); if (start !== -1 && end > start) { const slice = stripped.slice(start, end + 1); try { const parsed = JSON.parse(slice); const accepted = acceptClassified(parsed); if (accepted) return accepted; } catch { /* try quirk-fix below */ } // Pass 3 (G, 2026-05-26): brain-retro #6 surfaced parse_null on real // Sonnet output. Common quirks: raw newlines inside string values // (multi-line reason_for_choice), trailing commas. Try sanitization // before giving up. try { const fixed = fixLLMJsonQuirks(slice); const parsed = JSON.parse(fixed); const accepted = acceptClassified(parsed); if (accepted) return accepted; } catch { /* unrecoverable */ } } return null; } // ─── Legacy LLM prompt/parser (kept for backward compat) ──────────────────── const LEGACY_LLM_SYSTEM_PROMPT = `You are a router classifier for an AI coding assistant. Given a user prompt and a registry of available skills/tools (nodes), choose: - taskType: one of {feature, planning, bugfix, refactor, cleanup, marketing, security, analysis, monitoring, memory-sync, question, unknown} - micro: true if the task is a tiny edit (≤2 files, ≤20 lines, e.g. typo / rename / single constant) - recommendedNode: id of the single best-matching active node, or null if nothing matches - confidence: 0.0-1.0 - recommendedChain: id of the chain (L1-L16) if the task fits a known chain, else null - reasoning: 1-2 sentences why Reply with ONLY a JSON object, no prose. Example: {"taskType":"bugfix","micro":false,"recommendedNode":"#62","confidence":0.9,"recommendedChain":null,"reasoning":"keyword 'списание' matches #62 billing-audit"}`; export function buildLLMPrompt(prompt, registry) { const nodes = (registry.nodes || []).filter((n) => n.status === 'active'); const nodeLines = nodes.map((n) => { const triggers = (n.triggers || []) .slice(0, 3) .map((t) => t.keyword || `cls:${t.classification}`) .filter(Boolean) .join(', '); return `- ${n.id} ${n.name} [${triggers}]`; }).join('\n'); const chains = Object.entries(registry.chains || {}) .map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`) .join('\n'); return `${LEGACY_LLM_SYSTEM_PROMPT} ## Available nodes ${nodeLines} ## Available chains ${chains} ## User prompt ${prompt} Reply with JSON object only.`; } export function parseLLMResponse(text) { if (!text) return null; const trimmed = String(text).trim(); const stripped = trimmed.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim(); try { const parsed = JSON.parse(stripped); if (typeof parsed.taskType !== 'string') return null; return parsed; } catch { return null; } } // ─── HTTP transport (ProxyAPI, header reseller-isolation) ─────────────────── const DEFAULT_LLM_BASE_URL = 'https://api.proxyapi.ru/anthropic'; /** * POST to ProxyAPI /v1/messages. * * First argument is overloaded: * - string → legacy single-message body (no prompt caching). * - { system, user } → split body with ephemeral cache_control on the * `system` block. ~70-80% cost reduction on the cacheable portion * after the first call within a 5-minute window. * * Optional `onUsage(usage)` callback receives Anthropic's usage object * (input_tokens / output_tokens / cache_creation_input_tokens / * cache_read_input_tokens) for observability. */ export async function callAnthropicAPI(promptOrMessages, { apiKey, // Смена оператора 2026-06-12: env-переключатель в САМОМ дефолте — судья/наставник // зовут транспорт без baseUrl и иначе навсегда прибиты к захардкоженному прокси. baseUrl = process.env.ROUTER_LLM_BASE_URL || DEFAULT_LLM_BASE_URL, model = CLASSIFIER_MODEL, fetchImpl = defaultFetch, maxRetries = 4, retryBaseDelayMs = 1000, perAttemptTimeoutMs = 30_000, sleepImpl = (ms) => new Promise((res) => setTimeout(res, ms)), onUsage, onMetrics, }) { const url = `${String(baseUrl).replace(/\/+$/, '')}/v1/messages`; let body; if (typeof promptOrMessages === 'string') { body = JSON.stringify({ model, max_tokens: 15000, messages: [{ role: 'user', content: promptOrMessages }], }); } else { const { system, user } = promptOrMessages; body = JSON.stringify({ model, max_tokens: 15000, system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }], messages: [{ role: 'user', content: user }], }); } const headers = { 'authorization': `Bearer ${apiKey}`, 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', }; // Pass 2 metric capture (project-brain-factor-analysis-4passes). const started = Date.now(); let attempt = 0; const emitMetrics = () => { if (!onMetrics) return; try { onMetrics({ latency_ms: Date.now() - started, retry_count_internal: attempt }); } catch { /* swallow */ } }; let lastError; try { for (attempt = 0; attempt <= maxRetries; attempt++) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(new Error(`per-attempt timeout ${perAttemptTimeoutMs}ms`)), perAttemptTimeoutMs); try { const r = await fetchImpl(url, { method: 'POST', headers, body, signal: ctrl.signal }); if (r.ok) { const data = await r.json(); if (onUsage && data.usage) { try { onUsage(data.usage); } catch { /* swallow callback errors */ } } const _blocks = Array.isArray(data.content) ? data.content : []; const _textBlock = _blocks.find((b) => b && b.type === 'text' && typeof b.text === 'string') || _blocks.find((b) => b && typeof b.text === 'string'); return (_textBlock && _textBlock.text) || ''; } // Retry on 5xx and 429; fail fast on 4xx (auth/quota/bad request — retry won't help). if (r.status >= 500 || r.status === 429) { lastError = new Error(`Router LLM ${r.status}: ${await r.text()}`); } else { const fatal = new Error(`Router LLM ${r.status}: ${await r.text()}`); fatal.fatal = true; throw fatal; } } catch (err) { // Re-throw fatal errors (4xx) instead of retrying them. if (err && err.fatal) { clearTimeout(timer); throw err; } // Network-level failure (fetch failed / ECONNRESET / TLS / per-attempt timeout). Retry-eligible. lastError = err; } finally { clearTimeout(timer); } if (attempt < maxRetries) { await sleepImpl(retryBaseDelayMs * 2 ** attempt); } } throw lastError; } finally { emitMetrics(); } } // Pass 2 — categorize the LLM transport failure for the factor-analysis // error_type axis. Looks at err.fatal + message keywords (no err.code on // undici fetch failures — message is the only reliable signal). export function classifyLLMError(err) { if (!err) return 'other'; const msg = String(err.message || err); if (err.fatal && /\b4\d\d\b/.test(msg)) return 'http_4xx'; if (/\b5\d\d\b/.test(msg) || /429\b/.test(msg)) return 'http_5xx'; if (/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(msg)) return 'econnreset'; if (err.name === 'AbortError' || /\btimeout\b/i.test(msg)) return 'timeout'; return 'other'; } function hashPrompt(s) { let h = 0; for (let i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; } return String(h); } /** * classify — full Layer 1 + Layer 2 pipeline (spec §4.1, §4.2). * * Flow: * 1. prefilter(prompt, prevState, registry). If non-null → return. * 2. Cache check (hash(prompt)). * 3. Sonnet 4.6 via ProxyAPI (default model = CLASSIFIER_MODEL). * 4. On LLM error → regex fallback (router-classifier-regex-fallback.mjs). * 5. On LLM null (no key / unparseable) → regex fallback. * * Options: * - prevState: passed to prefilter for continuation/cancellation context. * - cache: Map for hash(prompt) → result. * - llmCall: function() → parsed-result-or-null. Used by tests to mock. * - enrichment: bool, controls pamyatka in classifier prompt (default true). * - model: classifier model id override. */ export async function classify(prompt, registry, options = {}) { // Layer 1 — prefilter. Пропускается при options.skipPrefilter: наставник зовёт classify // на ЦЕЛИ ПЛАНА (не на промпте пользователя) — micro/conversation-эвристики prefilter здесь // ложны (напр. подстрока 'format' в имени модуля), нужен реальный LLM/граф-роутер. if (!options.skipPrefilter) { const pre = prefilter(prompt, { prevState: options.prevState, registry }); if (pre !== null) return pre; } // Cache. const cache = options.cache; const key = hashPrompt(prompt); if (cache && cache.has(key)) { return { ...cache.get(key), source: 'cache' }; } // Layer 2 — Sonnet 4.6 with prompt caching (ephemeral 5m TTL on system block). // llmCall receives { onMetrics } so callAnthropicAPI can report latency / retries // (Pass 2 factor-analysis extension); tests pass synthetic metrics directly. const llmCall = options.llmCall || (async ({ onMetrics } = {}) => { const apiKey = process.env.ROUTER_LLM_KEY; if (!apiKey) return null; let classifierContext = options.classifierContext; if (classifierContext === undefined) { try { const { loadConfig } = await import('./brain-config.mjs'); classifierContext = loadConfig().classifier_context; } catch { /* дефолт в buildClassifierPromptStructured */ } } const structured = buildClassifierPromptStructured(prompt, registry, { enrichment: options.enrichment ?? true, ...(classifierContext !== undefined ? { classifierContext } : {}), }); const text = await callAnthropicAPI(structured, { apiKey, baseUrl: process.env.ROUTER_LLM_BASE_URL || undefined, model: options.model || CLASSIFIER_MODEL, // DeepSeek (reasoning) дольше дефолтных 30с — даём роутеру тот же потолок (5 мин), что наставнику/судье perAttemptTimeoutMs: 300_000, onUsage: options.onUsage, onMetrics, }); return parseClassifierResponse(text); }); let metrics = null; const captureMetrics = (m) => { metrics = m; }; let llmResult; try { llmResult = await llmCall({ onMetrics: captureMetrics }); } catch (err) { // Layer 3 — regex fallback on LLM transport error. const r = classifyByRegex(prompt, registry); return { ...r, llmError: err.message, llm_error_type: classifyLLMError(err), latency_ms: metrics?.latency_ms ?? null, retry_count_internal: metrics?.retry_count_internal ?? null, degraded: true, }; } if (!llmResult) { // Layer 3 — regex fallback on no key (metrics null) / unparseable response // (metrics set, classify as parse_null so the analyzer error_type axis // distinguishes "API never called" from "API returned garbage"). const r = classifyByRegex(prompt, registry); return { ...r, llm_error_type: metrics ? 'parse_null' : 'no_key', latency_ms: metrics?.latency_ms ?? null, retry_count_internal: metrics?.retry_count_internal ?? null, }; } const finalResult = { ...llmResult, source: 'llm', latency_ms: metrics?.latency_ms ?? null, retry_count_internal: metrics?.retry_count_internal ?? null, }; if (cache) cache.set(key, finalResult); return finalResult; }