41deac7bc8
Phase 2 Task 9 of LLM-first router overhaul. Spec §4.1 — adds prefilter() Layer 1
with 7-check chain: manual override → continuation (inheritance ≤30 min) →
acknowledgment → cancellation → short-conversation + anchor → micro → fall-through.
- tools/router-classifier.mjs: +export prefilter(prompt, { prevState, registry }).
Pure (no fs/exec/net). Imports INHERITANCE_MAX_AGE_MIN from router-config.mjs.
Constants: CONTINUATION_PATTERNS (13), ACKNOWLEDGMENT_PATTERNS (10),
CANCELLATION_PATTERNS (8), MANUAL_OVERRIDE_RE, ANCHOR_NOUNS (28),
ANCHOR_IMPERATIVES (10, fires only when length > 30), SKILL_ALIAS_MAP
(well-known superpower aliases for manual override without registry).
Existing classifyByRegex / classifyByLLM untouched — Task 10 extracts
them to a fallback module.
- tools/router-classifier.test.mjs: +8 prefilter tests covering all 7 checks
plus content-prompt fall-through.
Tests in worktree: 118/118 PASS (8 new prefilter + 110 existing).
445 lines
18 KiB
JavaScript
445 lines
18 KiB
JavaScript
#!/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'е.
|
||
*
|
||
* Phase 2 Task 9 adds `prefilter(prompt, { prevState, registry })` — Layer 1
|
||
* 7-check chain per spec §4.1 (manual override / continuation / acknowledgment /
|
||
* cancellation / short conv + anchor / micro / fall-through). Pure, registry
|
||
* argument optional; INHERITANCE_MAX_AGE_MIN imported from router-config.mjs.
|
||
*
|
||
* Pure (Layer 1): read-only, никакого fs/exec/net. Caller передаёт registry.
|
||
*/
|
||
|
||
import { INHERITANCE_MAX_AGE_MIN } from './router-config.mjs';
|
||
|
||
// Порядок ключей значим: 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));
|
||
}
|
||
|
||
// ─── Prefilter constants (spec §4.1, Phase 2 Task 9) ────────────────────────
|
||
|
||
const CONTINUATION_PATTERNS = [
|
||
'да', 'делай', 'давай', 'продолжай', 'дальше', 'ага', 'валяй',
|
||
'поехали', 'утверждаю', 'одобряю', 'ок делай', 'хорошо делай', 'согласен делай',
|
||
];
|
||
|
||
const ACKNOWLEDGMENT_PATTERNS = [
|
||
'спасибо', 'понял', 'ок', 'хорошо', 'отлично', 'верно',
|
||
'круто', 'годится', 'молодец', 'норм',
|
||
];
|
||
|
||
const CANCELLATION_PATTERNS = [
|
||
'стоп', 'нет', 'отмени', 'отбой', 'не надо',
|
||
'забей', 'хватит', 'достаточно',
|
||
];
|
||
|
||
const MANUAL_OVERRIDE_RE = /^(делай|сделай|используй|применя[йи]|запусти|вызови)\s+(через|с\s+помощью|skill|skill[оа]м)\s+([\w\-:]+)/i;
|
||
|
||
// 28 RU/EN nouns + 10 imperatives (spec §4.1 check 5). Список финализирован v2.2.
|
||
const ANCHOR_NOUNS = [
|
||
'аудит', 'баг', 'план', 'спека', 'фича', 'тест', 'миграция', 'endpoint', 'файл', 'функция',
|
||
'класс', 'компонент', 'view', 'модель', 'биллинг', 'маркетинг', 'безопасность', 'пдн', 'регион',
|
||
'портал', 'проект', 'сделка', 'лид', 'админка', 'база', 'схема', 'воронка', 'хук',
|
||
];
|
||
|
||
const ANCHOR_IMPERATIVES = [
|
||
'проанализируй', 'проверь', 'исправь', 'почини', 'создай', 'добавь',
|
||
'удали', 'переименуй', 'улучши', 'расширь',
|
||
];
|
||
|
||
// Hardcoded alias map for well-known superpower skills (manual override без
|
||
// доступа к registry). Закрывает spec §4.1 «fuzzy matching для requested_node».
|
||
const SKILL_ALIAS_MAP = {
|
||
tdd: 'test-driven-development',
|
||
'test-driven-development': 'test-driven-development',
|
||
brainstorming: 'brainstorming',
|
||
brainstorm: 'brainstorming',
|
||
debugging: 'systematic-debugging',
|
||
'systematic-debugging': 'systematic-debugging',
|
||
debug: 'systematic-debugging',
|
||
'writing-plans': 'writing-plans',
|
||
plan: 'writing-plans',
|
||
plans: 'writing-plans',
|
||
'verification-before-completion': 'verification-before-completion',
|
||
verify: 'verification-before-completion',
|
||
parallel: 'dispatching-parallel-agents',
|
||
'dispatching-parallel-agents': 'dispatching-parallel-agents',
|
||
worktree: 'using-git-worktrees',
|
||
'using-git-worktrees': 'using-git-worktrees',
|
||
review: 'requesting-code-review',
|
||
'requesting-code-review': 'requesting-code-review',
|
||
};
|
||
|
||
function containsAnchor(prompt) {
|
||
const p = lower(prompt);
|
||
if (ANCHOR_NOUNS.some((a) => p.includes(a))) return true;
|
||
if (prompt.length > 30 && ANCHOR_IMPERATIVES.some((a) => p.includes(a))) return true;
|
||
return false;
|
||
}
|
||
|
||
function resolveNodeAlias(extracted, registry) {
|
||
if (!extracted) return null;
|
||
const norm = String(extracted).toLowerCase();
|
||
if (SKILL_ALIAS_MAP[norm]) return SKILL_ALIAS_MAP[norm];
|
||
if (registry?.nodes) {
|
||
const exact = registry.nodes.find((n) => n.slug === norm);
|
||
if (exact) return exact.slug;
|
||
const fuzzy = registry.nodes.find((n) => {
|
||
const slug = String(n.slug || '').toLowerCase();
|
||
const name = String(n.name || '').toLowerCase();
|
||
return (slug && (slug.includes(norm) || norm.includes(slug))) || (name && name.includes(norm));
|
||
});
|
||
if (fuzzy) return fuzzy.slug;
|
||
}
|
||
return `unknown_${extracted}`;
|
||
}
|
||
|
||
/**
|
||
* Prefilter — Layer 1, 7-check chain (spec §4.1).
|
||
* Pure: no fs/exec/net. Returns:
|
||
* - { task_type, source, ... } on a positive match
|
||
* - null when fall-through to Layer 2 (LLM classifier) is required
|
||
*
|
||
* @param {string} prompt — user prompt
|
||
* @param {object} options
|
||
* @param {object} [options.prevState] — `~/.claude/runtime/router-state-<session>.json`
|
||
* for continuation/cancellation context. Schema: { classification, timestamp, task_id }.
|
||
* @param {object} [options.registry] — node registry from loadRegistry() for fuzzy
|
||
* slug resolution in manual override. Optional — falls back to SKILL_ALIAS_MAP.
|
||
*/
|
||
export function prefilter(prompt, { prevState, registry } = {}) {
|
||
if (!prompt) return null;
|
||
const raw = String(prompt);
|
||
const p = raw.trim().toLowerCase();
|
||
|
||
// 1) Manual override
|
||
const m = raw.match(MANUAL_OVERRIDE_RE);
|
||
if (m) {
|
||
return {
|
||
task_type: 'manual_override',
|
||
node: 'direct',
|
||
source: 'prefilter',
|
||
requested_node: resolveNodeAlias(m[3], registry),
|
||
};
|
||
}
|
||
|
||
// 2) Continuation — inherit prev classification within 30 min
|
||
if (CONTINUATION_PATTERNS.includes(p) && prevState?.classification && prevState.timestamp) {
|
||
const ageMs = Date.now() - new Date(prevState.timestamp).getTime();
|
||
const ageMin = ageMs / 60000;
|
||
if (ageMin <= INHERITANCE_MAX_AGE_MIN) {
|
||
return {
|
||
task_type: prevState.classification.task_type,
|
||
node: 'direct',
|
||
source: 'prefilter_inherited',
|
||
recommendedNode: prevState.classification.recommendedNode ?? null,
|
||
inheritance: {
|
||
inherited_from_task_id: prevState.task_id ?? null,
|
||
inheritance_age_minutes: Math.round(ageMin),
|
||
},
|
||
};
|
||
}
|
||
// age > 30 → fall through to checks 3-7
|
||
}
|
||
|
||
// 3) Acknowledgment
|
||
if (ACKNOWLEDGMENT_PATTERNS.includes(p)) {
|
||
return { task_type: 'conversation', node: 'direct', source: 'prefilter' };
|
||
}
|
||
|
||
// 4) Cancellation
|
||
if (CANCELLATION_PATTERNS.includes(p)) {
|
||
return {
|
||
task_type: 'conversation',
|
||
node: 'direct',
|
||
source: 'prefilter',
|
||
previous_rejected: !!prevState?.task_id,
|
||
};
|
||
}
|
||
|
||
// 5) Short conversation + anchor protection (length < 15 && no anchor)
|
||
if (raw.length < 15 && !containsAnchor(raw)) {
|
||
return { task_type: 'conversation', node: 'direct', source: 'prefilter' };
|
||
}
|
||
|
||
// 6) Micro
|
||
if (detectMicro(raw)) {
|
||
return { task_type: 'micro', node: 'direct', source: 'prefilter' };
|
||
}
|
||
|
||
// 7) Fall-through to Layer 2 (LLM classifier)
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
// LLM Layer 2 ходит через реселлера ProxyAPI (официальный api.anthropic.com
|
||
// недоступен из РФ). Базовый URL переопределяется ROUTER_LLM_BASE_URL — на
|
||
// случай смены реселлера или возврата на официальный эндпоинт.
|
||
const DEFAULT_LLM_BASE_URL = 'https://api.proxyapi.ru/anthropic';
|
||
|
||
export async function callAnthropicAPI(prompt, {
|
||
apiKey,
|
||
baseUrl = DEFAULT_LLM_BASE_URL,
|
||
model = 'claude-haiku-4-5',
|
||
fetchImpl = fetch,
|
||
}) {
|
||
const url = `${String(baseUrl).replace(/\/+$/, '')}/v1/messages`;
|
||
const r = await fetchImpl(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
// ProxyAPI ждёт Bearer, официальный API — x-api-key. Шлём оба:
|
||
// каждый эндпоинт берёт нужный заголовок и игнорирует чужой.
|
||
'authorization': `Bearer ${apiKey}`,
|
||
'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(`Router LLM ${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 () => {
|
||
// Ключ берём из ОТДЕЛЬНОЙ переменной ROUTER_LLM_KEY, НЕ из ANTHROPIC_API_KEY:
|
||
// иначе ключ перехватит сам Claude Code и уведёт основную сессию с подписки
|
||
// на платный API. Нет ключа → Layer 2 выключен, тихо остаёмся на regex.
|
||
const apiKey = process.env.ROUTER_LLM_KEY;
|
||
if (!apiKey) return null;
|
||
const llmPrompt = buildLLMPrompt(prompt, registry);
|
||
const text = await callAnthropicAPI(llmPrompt, {
|
||
apiKey,
|
||
baseUrl: process.env.ROUTER_LLM_BASE_URL || undefined,
|
||
});
|
||
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;
|
||
}
|