#!/usr/bin/env node /** * Router classifier — REGEX FALLBACK module (Phase 2 Task 10). * * Extracted from router-classifier.mjs as a self-contained fallback for when * both Sonnet 4.6 and Haiku 4.5 LLM endpoints are unreachable. Pure: no * fs/exec/net. Caller passes registry. * * Routing in router-classifier.mjs: * prefilter() → Sonnet 4.6 (LLM) → Haiku 4.5 (LLM) → classifyByRegex (here) → degraded * * This module is also imported by tools/router-accuracy-runner.mjs which runs * offline regex-only accuracy checks against a curated prompt set. */ // Порядок ключей значим: detectTaskType возвращает первое совпадение. // Специфичные домены (marketing/security) идут ДО общего analysis, чтобы // «проверь пдн» ушло в security, а «проверь индекс» — в analysis. // brain-retro #7 C1 (2026-05-27): добавлен deploy bucket + memory-sync // расширен translit-фразами заказчика («обнови мозг / эталон / пилот») — // см. MEMORY.md anchor entry и memory `feedback_communication.md`. // memory-sync стоит ДО analysis: «обнови…» должно матчить memory-sync, // а не падать в «проверь» analysis-bucket. export const TASK_TYPE_KEYWORDS = { feature: ['фич', 'feature', 'новый функционал', 'add feature'], planning: ['план', 'plan', 'спека', 'spec', 'roadmap', 'распиши', 'спланируй'], bugfix: ['баг', 'bug', 'дебаг', 'debug', 'почини', 'fix', 'ошибк', 'не работает', 'поправь', 'исправь', 'упал', 'падает', 'сломал'], refactor: ['рефактор', 'refactor', 'почисти код', 'упрости'], cleanup: ['уберём', 'удали', 'remove', 'cleanup', 'dead code'], // memory-sync — translit-фразы заказчика для обновления brain-артефактов: // МОЗГ = MEMORY.md, ЭТАЛОН = локальный dev-снимок, ПИЛОТ = боевой снимок // (см. MEMORY.md anchor). 'memory-sync': ['обнови мозг', 'обнови эталон', 'обнови пилот', 'запомни', 'обнови память', 'memory dump', 'memory', 'CLAUDE.md', 'MEMORY.md'], // deploy — «пуш» как императив у заказчика (git push + опциональный follow-up // выкат). Отдельный от feature/planning bucket. deploy: ['пуш', 'push', 'запушь', 'запуш', 'deploy', 'развёртывани', 'выкатить', 'выкат на прод', 'выкатить на боевой'], marketing: ['маркетинг', 'marketing', 'кампани', 'лендинг', 'рассылк', 'реклам', 'постинг'], security: ['безопасност', 'security', 'уязвимост', 'vulnerability', 'пдн', '152-фз', 'stride', 'угроз', 'выход в интернет', 'go-live'], analysis: ['проанализируй', 'analysis', 'разбер', 'investigate', 'проверь', 'выясни', 'посмотри почему', 'медленн'], monitoring: ['мониторинг', 'monitor', 'трейс', 'observability'], question: ['что такое', 'как работает', 'почему', 'объясни', 'расскажи'], }; const MICRO_KEYWORDS = [ 'опечатк', 'typo', 'переименуй', 'rename', 'удали мёртв', 'dead code', 'формат', 'format', 'константу', 'one constant', 'увеличь', 'уменьши', 'поменяй значени', 'измени константу', 'одну строку', 'bump', ]; // Hard keyword stems that signal a high-confidence regex match (last-resort // degraded path — отделено от Layer 1 prefilter SKILL_ALIAS_MAP). export const HARD_KEYWORD_STEMS = [ 'списан', 'биллинг', 'маркетинг', 'email-рассылк', '152-фз', 'go-live', 'фич', 'план', 'баг', ]; 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)); } function keywordMatches(promptLower, keywordLower) { if (promptLower.includes(keywordLower)) return true; if (keywordLower.length >= 6) { const stem = keywordLower.slice(0, -1); if (promptLower.includes(stem)) return true; } return false; } function detectRecommendedNode(prompt, registry) { const p = lower(prompt); // Pass 1 — keyword-домен приоритетнее classification-типа. 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-триггер. 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; } function computeConfidence(taskType, recommendedNode, prompt) { if (recommendedNode === null && taskType === 'unknown') return 0.1; if (recommendedNode === null) return 0.4; 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' }; }