#!/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; }