Files
portal/tools/router-classifier.mjs
T
Дмитрий 89441d95c3 feat(router): tune Layer 1 — глаголы + keyword>classification приоритет (stage 3 task 5b)
Подкрутка classifier'а БЕЗ правки реестра (доменная разметка Task 1 сохранена):
- TASK_TYPE_KEYWORDS +командные глаголы (проверь/составь/поправь/распиши/...);
  порядок ключей: marketing/security ДО analysis для «проверь пдн»→security.
- detectRecommendedNode → two-pass: keyword-домен приоритетнее classification-типа
  (Pass 1 keyword, Pass 2 classification fallback).
- MICRO_KEYWORDS +увеличь/уменьши/одну строку/bump.

Accuracy regex-only: 68.3% → 80.0% (type 55%→85%, micro 95%→100%, node 55%).
Node остался 55%: конфликт «feature+домен» в одном промпте (баланс→#62 vs
feature→#19) Layer 1 одним узлом не разрешает — это работа Layer 2 (Sonnet).
Ground truth НЕ переписан ради цифры (отказ от overfit, в отличие от
реверченного 112591a где субагент удалял реестровые keyword'ы).

489/489 tools GREEN.

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

261 lines
10 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;
}
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;
}