Files
portal/tools/router-classifier.mjs
T
Дмитрий af441961d9 fix(router): LLM Layer 2 через ProxyAPI с отдельным ключом ROUTER_LLM_KEY
router-classifier больше не ходит в недоступный api.anthropic.com и не читает ANTHROPIC_API_KEY (это перехватывало основную сессию Claude Code с подписки). callAnthropicAPI теперь ходит в ProxyAPI по умолчанию, ключ берёт из отдельной ROUTER_LLM_KEY, базовый URL — ROUTER_LLM_BASE_URL (опционально). Нет ключа → Layer 2 тихо выключен, откат на regex. +6 тестов (30/30 GREEN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:07:02 +03:00

283 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Router classifier — pure regex Layer 1 + LLM Layer 2 (escalation).
* Stage 3 of router discipline overhaul.
*
* Layer 1: regex по реестровым keyword/classification триггерам активных узлов.
* Возвращает { taskType, micro, recommendedNode, confidence, source: 'regex' }.
*
* Layer 2 (см. classifyByLLM): Sonnet с реестром в prompt'е.
*
* Pure (Layer 1): read-only, никакого fs/exec/net. Caller передаёт registry.
*/
// Порядок ключей значим: detectTaskType возвращает первое совпадение.
// Специфичные домены (marketing/security) идут ДО общего analysis, чтобы
// «проверь пдн» ушло в security, а «проверь индекс» — в analysis.
const TASK_TYPE_KEYWORDS = {
feature: ['фич', 'feature', 'новый функционал', 'add feature'],
planning: ['план', 'plan', 'спека', 'spec', 'roadmap', 'распиши', 'спланируй'],
bugfix: ['баг', 'bug', 'дебаг', 'debug', 'почини', 'fix', 'ошибк', 'не работает',
'поправь', 'исправь', 'упал', 'падает', 'сломал'],
refactor: ['рефактор', 'refactor', 'почисти код', 'упрости'],
cleanup: ['уберём', 'удали', 'remove', 'cleanup', 'dead code'],
marketing: ['маркетинг', 'marketing', 'кампани', 'лендинг', 'рассылк', 'реклам', 'постинг'],
security: ['безопасност', 'security', 'уязвимост', 'vulnerability',
'пдн', '152-фз', 'stride', 'угроз', 'выход в интернет', 'go-live'],
analysis: ['проанализируй', 'analysis', 'разбер', 'investigate',
'проверь', 'выясни', 'посмотри почему', 'медленн'],
monitoring: ['мониторинг', 'monitor', 'трейс', 'observability'],
'memory-sync': ['запомни', 'обнови память', 'memory', 'CLAUDE.md', 'MEMORY.md'],
question: ['что такое', 'как работает', 'почему', 'объясни', 'расскажи'],
};
const MICRO_KEYWORDS = [
'опечатк', 'typo',
'переименуй', 'rename',
'удали мёртв', 'dead code',
'формат', 'format',
'константу', 'one constant',
'увеличь', 'уменьши', 'поменяй значени', 'измени константу',
'одну строку', 'bump',
];
function lower(s) { return String(s || '').toLowerCase(); }
function detectTaskType(prompt) {
const p = lower(prompt);
for (const [t, kws] of Object.entries(TASK_TYPE_KEYWORDS)) {
for (const kw of kws) {
if (p.includes(kw)) return t;
}
}
return 'unknown';
}
function detectMicro(prompt) {
const p = lower(prompt);
return MICRO_KEYWORDS.some((kw) => p.includes(kw));
}
/**
* Flexible keyword matching: handles RU morphology by checking if
* - prompt contains the keyword (exact), OR
* - keyword contains the prompt fragment (keyword starts with what's in prompt), OR
* - prompt fragment starts with the keyword stem (first 6+ chars of keyword)
*/
function keywordMatches(promptLower, keywordLower) {
if (promptLower.includes(keywordLower)) return true;
// Stem match: use first 6 chars of keyword as stem (handles inflections like рассылку vs рассылка)
if (keywordLower.length >= 6) {
const stem = keywordLower.slice(0, -1); // drop last char for RU inflection tolerance
if (promptLower.includes(stem)) return true;
}
return false;
}
function detectRecommendedNode(prompt, registry) {
const p = lower(prompt);
// Pass 1 — keyword-домен приоритетнее classification-типа: точное доменное
// слово в промпте («списание» → #62) выигрывает у общего classification-узла
// («bugfix» → #18 Pest). Длиннее keyword = специфичнее → выше приоритет
// при равных весах.
let bestKw = { id: null, score: 0 };
for (const node of registry.nodes || []) {
if (node.status !== 'active') continue;
for (const t of node.triggers || []) {
if (!t.keyword) continue;
const kw = lower(t.keyword);
if (keywordMatches(p, kw)) {
const score = (t.weight ?? 1.0) + kw.length / 1000;
if (score > bestKw.score) bestKw = { id: node.id, score };
}
}
}
if (bestKw.id) return bestKw.id;
// Pass 2 — fallback на classification-триггер, если ни один keyword не совпал.
const taskType = detectTaskType(prompt);
let bestCls = { id: null, weight: 0 };
for (const node of registry.nodes || []) {
if (node.status !== 'active') continue;
for (const t of node.triggers || []) {
if (!t.classification) continue;
const w = t.weight ?? 1.0;
if (t.classification === taskType && w > bestCls.weight) {
bestCls = { id: node.id, weight: w };
}
}
}
return bestCls.id;
}
// Hard keyword stems that signal a high-confidence match
const HARD_KEYWORD_STEMS = [
'списан', 'биллинг', 'маркетинг', 'email-рассылк',
'152-фз', 'go-live', 'фич', 'план', 'баг',
];
function computeConfidence(taskType, recommendedNode, prompt) {
if (recommendedNode === null && taskType === 'unknown') return 0.1;
if (recommendedNode === null) return 0.4;
// Keyword match даёт high confidence; classification-only — medium.
const p = lower(prompt);
const hasHardKeyword = HARD_KEYWORD_STEMS.some((stem) => p.includes(stem));
if (hasHardKeyword) return 0.9;
if (taskType === 'unknown') return 0.5;
return 0.7;
}
export function classifyByRegex(prompt, registry) {
const taskType = detectTaskType(prompt);
const micro = detectMicro(prompt);
const recommendedNode = detectRecommendedNode(prompt, registry);
const confidence = computeConfidence(taskType, recommendedNode, prompt);
return { taskType, micro, recommendedNode, confidence, source: 'regex' };
}
// ─── Layer 2: LLM escalation ────────────────────────────────────────────────
const 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 `${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();
// Strip ```json``` wrapper if present
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;
}
}
export function shouldEscalate(regexResult) {
if (regexResult.micro) return false;
if (regexResult.confidence >= 0.7) return false;
return true;
}
// LLM Layer 2 ходит через реселлера ProxyAPI (официальный api.anthropic.com
// недоступен из РФ). Базовый URL переопределяется ROUTER_LLM_BASE_URL — на
// случай смены реселлера или возврата на официальный эндпоинт.
const DEFAULT_LLM_BASE_URL = 'https://api.proxyapi.ru/anthropic';
export async function callAnthropicAPI(prompt, {
apiKey,
baseUrl = DEFAULT_LLM_BASE_URL,
model = 'claude-haiku-4-5',
fetchImpl = fetch,
}) {
const url = `${String(baseUrl).replace(/\/+$/, '')}/v1/messages`;
const r = await fetchImpl(url, {
method: 'POST',
headers: {
// ProxyAPI ждёт Bearer, официальный API — x-api-key. Шлём оба:
// каждый эндпоинт берёт нужный заголовок и игнорирует чужой.
'authorization': `Bearer ${apiKey}`,
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model,
max_tokens: 300,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!r.ok) {
throw new Error(`Router LLM ${r.status}: ${await r.text()}`);
}
const data = await r.json();
return data.content?.[0]?.text || '';
}
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);
}
export async function classify(prompt, registry, options = {}) {
const regexResult = classifyByRegex(prompt, registry);
if (!shouldEscalate(regexResult)) return regexResult;
const cache = options.cache;
const key = hashPrompt(prompt);
if (cache && cache.has(key)) {
return { ...cache.get(key), source: 'cache' };
}
const llmCall = options.llmCall || (async () => {
// Ключ берём из ОТДЕЛЬНОЙ переменной ROUTER_LLM_KEY, НЕ из ANTHROPIC_API_KEY:
// иначе ключ перехватит сам Claude Code и уведёт основную сессию с подписки
// на платный API. Нет ключа → Layer 2 выключен, тихо остаёмся на regex.
const apiKey = process.env.ROUTER_LLM_KEY;
if (!apiKey) return null;
const llmPrompt = buildLLMPrompt(prompt, registry);
const text = await callAnthropicAPI(llmPrompt, {
apiKey,
baseUrl: process.env.ROUTER_LLM_BASE_URL || undefined,
});
return parseLLMResponse(text);
});
let llmResult;
try {
llmResult = await llmCall();
} catch (err) {
// LLM-down — fallback to regex result with diagnostic flag
return { ...regexResult, llmError: err.message };
}
if (!llmResult) return regexResult; // unparseable — fallback
const finalResult = { ...llmResult, source: 'llm' };
if (cache) cache.set(key, finalResult);
return finalResult;
}