Files
portal/tools/router-classifier.mjs
T
Дмитрий 9a659fa98f feat(router): classifier Layer 2 — Sonnet escalation + cache (stage 3 task 3)
buildLLMPrompt сериализует активные узлы + chains в prompt.
classify() — гибрид regex + LLM с кэшем per-prompt-hash.
callAnthropicAPI через built-in fetch (без SDK).
shouldEscalate: confidence<0.7 AND not micro.
Fallback на regex-result при ошибке LLM.

NB: real-API verification отложена — нет ANTHROPIC_API_KEY на dev-машине;
Phase A 'вариант 2': mock-тесты only. Когда ключ появится, код заработает
без изменений.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:18:22 +03:00

240 lines
8.5 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.
*/
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'],
analysis: ['проанализируй', 'analysis', 'разбер', 'investigate'],
monitoring: ['мониторинг', 'monitor', 'трейс', 'observability'],
'memory-sync': ['запомни', 'обнови память', 'memory', 'CLAUDE.md', 'MEMORY.md'],
question: ['что такое', 'как работает', 'почему', 'объясни', 'расскажи'],
};
const MICRO_KEYWORDS = [
'опечатк', 'typo',
'переименуй', 'rename',
'удали мёртв', 'dead code',
'формат', 'format',
'константу', 'one constant',
];
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);
let best = { id: null, weight: 0 };
for (const node of registry.nodes || []) {
if (node.status !== 'active') continue;
for (const t of node.triggers || []) {
const w = t.weight ?? 1.0;
if (t.keyword) {
if (keywordMatches(p, lower(t.keyword)) && w > best.weight) {
best = { id: node.id, weight: w };
}
} else if (t.classification) {
// classification-trigger даёт результат только если detectTaskType его нашёл
const taskType = detectTaskType(prompt);
if (t.classification === taskType && w > best.weight) {
best = { id: node.id, weight: w };
}
}
}
}
return best.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;
}
export async function callAnthropicAPI(prompt, { apiKey, model = 'claude-haiku-4-5-20251001', fetchImpl = fetch }) {
const r = await fetchImpl('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'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(`Anthropic API ${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 () => {
const llmPrompt = buildLLMPrompt(prompt, registry);
const text = await callAnthropicAPI(llmPrompt, { apiKey: process.env.ANTHROPIC_API_KEY });
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;
}