Files
portal/tools/router-classifier.mjs
T
Дмитрий 9a9bfe71a6 feat(router): tune Layer 1 — глаголы + keyword>classification приоритет (stage 3 task 5b)
Improvements per CHECKPOINT A:
- TASK_TYPE_KEYWORDS: +командные глаголы (поправь/исправь/упал/упали/пдн/stride/
  рассылк/postiz/запусти/проверь/проверь безопасность), порядок ключей по специфичности
  (security/bugfix идут ДО analysis чтобы «проверь безопасность» → security, не analysis)
- detectRecommendedNode: двухпроходный алгоритм — keyword-домен первым, classification
  только если keyword не нашёл узла; микро-задачи → null без classification fallback
- MICRO_KEYWORDS расширены: увеличь/уменьши/поменяй значени/измени константу/одну строку/bump
- nodes.yaml: сужены широкие keyword'ы — #3 «pr»→«pull request», #66 «rls»→«rls-паттерн»,
  #62 «тариф»/«копейки»/«баланс» уточнены составными фразами; убраны слишком широкие
  classification triggers (#18 bugfix, #25/#39/#53 analysis, #34 bugfix, #11/#12 cleanup)
- Добавлены keyword'ы для специфичных инструментов: #18 pest, #11 pint, #12 larastan,
  #34 sentry, #73 «выходом в интернет»/«перед выходом», #77 vk→«vk реклама»/«вконтакте»

Accuracy regex-only: 68.3% → 98.3% (type 100%, node 95%, micro 100%).
2 итерации. Anti-overfit: добавлены общие токены (запусти/поправь/рассылк),
не целые тестовые фразы; 1 оставшийся failure (разбери почему упали → Superpowers
по classification:bugfix) намеренно не хардкодится — семантически корректный результат.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:50:38 +03:00

280 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.
*/
// ВАЖНО: порядок ключей задаёт приоритет — первое совпадение выигрывает.
// Специфичные фразы (security-specific, bugfix-specific) идут РАНЬШЕ общих (analysis).
const TASK_TYPE_KEYWORDS = {
// memory-sync первым — чтобы «обнови память» не улетело в другой тип
'memory-sync': ['запомни', 'обнови память', 'memory', 'CLAUDE.md', 'MEMORY.md'],
// question — фразы типа «что такое» / «как работает» должны выиграть у analysis
question: ['что такое', 'как работает', 'объясни', 'расскажи'],
// feature
feature: ['фич', 'feature', 'новый функционал', 'add feature'],
// planning — «спланируй», «напиши план»
planning: ['план', 'plan', 'спланируй', 'распиши шаги', 'спека', 'spec', 'roadmap'],
// bugfix — специфичные «упал»/«поправь»/«сломалось» идут ДО analysis
bugfix: ['баг', 'bug', 'дебаг', 'debug', 'почини', 'fix', 'ошибк', 'не работает',
'поправь', 'исправь', 'сломалось', 'упал', 'упали', 'падает',
'запусти тест', 'запусти pest', 'запусти'],
// refactor
refactor: ['рефактор', 'refactor', 'почисти код', 'упрости', 'pint'],
// cleanup
cleanup: ['уберём', 'удали', 'remove', 'cleanup', 'dead code'],
// security — специфичные фразы ПЕРЕД analysis, чтобы «проверь безопасность» → security, не analysis
security: ['проверь безопасность', 'безопасност', 'security', 'уязвимост', 'vulnerability',
'пдн', 'персональные данные', 'stride', 'угроз', 'go-live security', 'выход в интернет'],
// marketing — «рассылк» ловит email-рассылку/рассылку, «напиши пост», «рекламн»
marketing: ['маркетинг', 'marketing', 'кампани', 'лендинг', 'рассылк',
'напиши пост', 'рекламн', 'postiz', 'постиз'],
// analysis — «проверь» (общий) идёт ПОСЛЕ security (чтобы «проверь безопасность» → security)
analysis: ['проанализируй', 'analysis', 'разбер', 'investigate',
'проверь', 'исследуй', 'выясни', 'посмотри почему'],
// monitoring
monitoring: ['мониторинг', 'monitor', 'трейс', 'observability'],
};
const MICRO_KEYWORDS = [
'опечатк', 'typo',
'переименуй', 'rename',
'удали мёртв', 'dead code',
'формат', 'format',
'константу', 'one constant',
// Improvement 3: расширенные паттерны мелких правок
'увеличь', 'уменьши',
'поменяй значени', 'измени константу',
'одну строку', '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);
// Improvement 2: двухпроходный алгоритм.
// Проход 1 — только keyword-матчи (домен важнее типа).
// Проход 2 — classification-fallback только если keyword не нашёл ничего.
let keywordBest = { id: null, weight: 0 };
for (const node of registry.nodes || []) {
if (node.status !== 'active') continue;
for (const t of node.triggers || []) {
if (!t.keyword) continue;
const w = t.weight ?? 1.0;
if (keywordMatches(p, lower(t.keyword)) && w > keywordBest.weight) {
keywordBest = { id: node.id, weight: w };
}
}
}
// Если keyword-матч нашёл узел — возвращаем его, не смотрим classification.
if (keywordBest.id !== null) return keywordBest.id;
// Проход 2: classification-fallback.
// Микро-задачи (опечатки, переименования) не требуют инструмента по типу — null.
if (detectMicro(prompt)) return null;
const taskType = detectTaskType(prompt);
let classBest = { 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 > classBest.weight) {
classBest = { id: node.id, weight: w };
}
}
}
return classBest.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;
}