a16023743c
Glue читает loadConfig().classifier_context; pure-fns и их тесты уже были. Дефолт = brain.local.md Лидерра-строка; greenfield без файла = generic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
715 lines
30 KiB
JavaScript
715 lines
30 KiB
JavaScript
#!/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 `<system>\n${system}\n</system>\n\n<user>\n${user}\n</user>`;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|