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