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).
286 lines
12 KiB
JavaScript
286 lines
12 KiB
JavaScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { classifyByRegex, prefilter } from './router-classifier.mjs';
|
|
|
|
describe('prefilter — Phase 2 Task 9 (spec §4.1, 7 checks)', () => {
|
|
it('manual override has priority over continuation (delai cherez TDD)', () => {
|
|
const r = prefilter('делай через TDD', { prevState: null });
|
|
expect(r.task_type).toBe('manual_override');
|
|
expect(r.source).toBe('prefilter');
|
|
expect(r.requested_node).toContain('test-driven-development');
|
|
});
|
|
|
|
it('continuation inherits classification within 30 min', () => {
|
|
const prevState = {
|
|
classification: { task_type: 'feature', recommendedNode: '#19' },
|
|
timestamp: new Date().toISOString(),
|
|
task_id: 'prev-abc',
|
|
};
|
|
const r = prefilter('делай', { prevState });
|
|
expect(r.source).toBe('prefilter_inherited');
|
|
expect(r.task_type).toBe('feature');
|
|
expect(r.inheritance?.inherited_from_task_id).toBe('prev-abc');
|
|
});
|
|
|
|
it('continuation falls through to short-conversation when prev state > 30 min', () => {
|
|
const old = new Date(Date.now() - 31 * 60000).toISOString();
|
|
const r = prefilter('делай', { prevState: { classification: { task_type: 'feature' }, timestamp: old } });
|
|
expect(r.task_type).toBe('conversation');
|
|
});
|
|
|
|
it('acknowledgment is plain conversation (spasibo)', () => {
|
|
expect(prefilter('спасибо', {}).task_type).toBe('conversation');
|
|
});
|
|
|
|
it('cancellation flags previous task rejected (net)', () => {
|
|
expect(prefilter('нет', { prevState: { task_id: 'abc' } }).previous_rejected).toBe(true);
|
|
});
|
|
|
|
it('anchor protection saves "делай аудит" from short-conversation → null fall through', () => {
|
|
expect(prefilter('делай аудит', {})).toBeNull();
|
|
});
|
|
|
|
it('micro keyword fires (poprav\' typo v stroke)', () => {
|
|
expect(prefilter('поправь typo в строке', {}).task_type).toBe('micro');
|
|
});
|
|
|
|
it('content prompt with anchor returns null (forwards to Layer 2)', () => {
|
|
expect(prefilter('добавь endpoint для экспорта сделок', {})).toBeNull();
|
|
});
|
|
});
|
|
|
|
const fakeRegistry = {
|
|
nodes: [
|
|
{ id: '#19', name: 'Superpowers', status: 'active', triggers: [
|
|
{ classification: 'feature', weight: 1.0 },
|
|
{ classification: 'planning', weight: 1.0 },
|
|
] },
|
|
{ id: '#62', name: 'billing-audit', status: 'active', triggers: [
|
|
{ keyword: 'списание', weight: 1.0 },
|
|
{ keyword: 'биллинг', weight: 1.0 },
|
|
{ classification: 'bugfix', weight: 0.5 },
|
|
] },
|
|
{ id: '#74', name: 'marketing', status: 'active', triggers: [
|
|
{ keyword: 'email-рассылка', weight: 1.0 },
|
|
{ keyword: 'кампания', weight: 1.0 },
|
|
{ classification: 'marketing', weight: 1.0 },
|
|
] },
|
|
{ id: '#11', name: 'pint', status: 'active', triggers: [
|
|
{ classification: 'refactor', weight: 1.0 },
|
|
{ classification: 'cleanup', weight: 1.0 },
|
|
] },
|
|
],
|
|
};
|
|
|
|
describe('classifyByRegex — task type', () => {
|
|
it('detects feature from RU keyword «фича»', () => {
|
|
const r = classifyByRegex('давай сделаем новую фичу для биллинга', fakeRegistry);
|
|
expect(r.taskType).toBe('feature');
|
|
});
|
|
|
|
it('detects planning from RU «план»', () => {
|
|
const r = classifyByRegex('напиши план рефакторинга модуля X', fakeRegistry);
|
|
expect(r.taskType).toBe('planning');
|
|
});
|
|
|
|
it('detects bugfix from EN «bug»', () => {
|
|
const r = classifyByRegex('there is a bug in the auth flow', fakeRegistry);
|
|
expect(r.taskType).toBe('bugfix');
|
|
});
|
|
|
|
it('detects micro for typo', () => {
|
|
const r = classifyByRegex('опечатка в файле X', fakeRegistry);
|
|
expect(r.micro).toBe(true);
|
|
});
|
|
|
|
it('detects micro for rename', () => {
|
|
const r = classifyByRegex('переименуй функцию foo в bar', fakeRegistry);
|
|
expect(r.micro).toBe(true);
|
|
});
|
|
|
|
it('returns taskType=unknown when no signal', () => {
|
|
const r = classifyByRegex('просто привет', fakeRegistry);
|
|
expect(r.taskType).toBe('unknown');
|
|
expect(r.micro).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('classifyByRegex — domain node match', () => {
|
|
it('picks #62 billing-audit on «списание»', () => {
|
|
const r = classifyByRegex('почини двойное списание лида', fakeRegistry);
|
|
expect(r.recommendedNode).toBe('#62');
|
|
});
|
|
|
|
it('picks #74 marketing on «email-рассылка»', () => {
|
|
const r = classifyByRegex('составь email-рассылку для тарифа Бизнес', fakeRegistry);
|
|
expect(r.recommendedNode).toBe('#74');
|
|
});
|
|
|
|
it('falls back to classification trigger when no keyword match', () => {
|
|
const r = classifyByRegex('рефакторинг кода', fakeRegistry);
|
|
// 'рефакторинг' → classification: refactor → #11 pint
|
|
expect(r.recommendedNode).toBe('#11');
|
|
});
|
|
|
|
it('returns null when no node matched', () => {
|
|
const r = classifyByRegex('просто вопрос', fakeRegistry);
|
|
expect(r.recommendedNode).toBeNull();
|
|
});
|
|
|
|
it('case-insensitive keyword match', () => {
|
|
const r = classifyByRegex('СПИСАНИЕ дублируется', fakeRegistry);
|
|
expect(r.recommendedNode).toBe('#62');
|
|
});
|
|
});
|
|
|
|
describe('classifyByRegex — source tag', () => {
|
|
it('always marks source: regex', () => {
|
|
const r = classifyByRegex('test', fakeRegistry);
|
|
expect(r.source).toBe('regex');
|
|
});
|
|
});
|
|
|
|
describe('classifyByRegex — confidence', () => {
|
|
it('returns confidence>=0.8 for clean keyword match', () => {
|
|
const r = classifyByRegex('списание дублируется', fakeRegistry);
|
|
expect(r.confidence).toBeGreaterThanOrEqual(0.8);
|
|
});
|
|
|
|
it('returns confidence<0.5 when ambiguous (no clean match)', () => {
|
|
const r = classifyByRegex('что-то непонятное', fakeRegistry);
|
|
expect(r.confidence).toBeLessThan(0.5);
|
|
});
|
|
});
|
|
|
|
import { buildLLMPrompt, parseLLMResponse, shouldEscalate, classify, callAnthropicAPI } from './router-classifier.mjs';
|
|
|
|
describe('buildLLMPrompt', () => {
|
|
it('serializes active nodes with id+name+top-3 triggers', () => {
|
|
const prompt = buildLLMPrompt('почини списание', fakeRegistry);
|
|
expect(prompt).toMatch(/#62/);
|
|
expect(prompt).toMatch(/billing-audit/);
|
|
expect(prompt).toMatch(/списание/);
|
|
expect(prompt).toMatch(/почини списание/);
|
|
});
|
|
|
|
it('excludes inactive nodes', () => {
|
|
const reg = { nodes: [...fakeRegistry.nodes, { id: '#999', name: 'gone', status: 'historic', triggers: [] }] };
|
|
const prompt = buildLLMPrompt('test', reg);
|
|
expect(prompt).not.toMatch(/#999/);
|
|
});
|
|
});
|
|
|
|
describe('parseLLMResponse', () => {
|
|
it('parses JSON object', () => {
|
|
const r = parseLLMResponse('{"taskType":"bugfix","micro":false,"recommendedNode":"#62","confidence":0.9,"recommendedChain":null,"reasoning":"keyword списание"}');
|
|
expect(r.taskType).toBe('bugfix');
|
|
expect(r.recommendedNode).toBe('#62');
|
|
expect(r.confidence).toBe(0.9);
|
|
});
|
|
|
|
it('parses JSON wrapped in ```json``` block', () => {
|
|
const r = parseLLMResponse('```json\n{"taskType":"feature","micro":false,"recommendedNode":"#19","confidence":0.8}\n```');
|
|
expect(r.taskType).toBe('feature');
|
|
});
|
|
|
|
it('returns null on unparseable response', () => {
|
|
expect(parseLLMResponse('I cannot help with this')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('shouldEscalate', () => {
|
|
it('escalates when confidence < 0.7', () => {
|
|
expect(shouldEscalate({ confidence: 0.6, taskType: 'bugfix' })).toBe(true);
|
|
});
|
|
|
|
it('does NOT escalate on micro', () => {
|
|
expect(shouldEscalate({ confidence: 0.4, taskType: 'unknown', micro: true })).toBe(false);
|
|
});
|
|
|
|
it('does NOT escalate when confidence >= 0.7', () => {
|
|
expect(shouldEscalate({ confidence: 0.9, taskType: 'bugfix' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('classify — full integration (with mock LLM)', () => {
|
|
it('returns regex result when confidence high', async () => {
|
|
const r = await classify('почини списание дублируется', fakeRegistry, { llmCall: () => { throw new Error('should not call LLM'); } });
|
|
expect(r.source).toBe('regex');
|
|
expect(r.recommendedNode).toBe('#62');
|
|
});
|
|
|
|
it('escalates to LLM when confidence low', async () => {
|
|
const r = await classify('что-то непонятное', fakeRegistry, {
|
|
llmCall: async () => ({ taskType: 'question', micro: false, recommendedNode: null, confidence: 0.95, recommendedChain: null })
|
|
});
|
|
expect(r.source).toBe('llm');
|
|
expect(r.taskType).toBe('question');
|
|
});
|
|
|
|
it('uses cache on second call with same prompt', async () => {
|
|
let calls = 0;
|
|
const llmCall = async () => { calls++; return { taskType: 'feature', micro: false, recommendedNode: '#19', confidence: 0.9, recommendedChain: 'L1' }; };
|
|
const cache = new Map();
|
|
await classify('ambiguous query', fakeRegistry, { llmCall, cache });
|
|
await classify('ambiguous query', fakeRegistry, { llmCall, cache });
|
|
expect(calls).toBe(1); // Second hit cache.
|
|
});
|
|
});
|
|
|
|
describe('callAnthropicAPI — ProxyAPI wiring', () => {
|
|
it('posts to ProxyAPI base by default with Bearer auth', async () => {
|
|
let captured;
|
|
const fetchImpl = async (url, opts) => {
|
|
captured = { url, opts };
|
|
return { ok: true, json: async () => ({ content: [{ text: '{"taskType":"question"}' }] }) };
|
|
};
|
|
const text = await callAnthropicAPI('hi', { apiKey: 'sk-test', fetchImpl });
|
|
expect(captured.url).toBe('https://api.proxyapi.ru/anthropic/v1/messages');
|
|
expect(captured.opts.headers.authorization).toBe('Bearer sk-test');
|
|
expect(text).toContain('question');
|
|
});
|
|
|
|
it('honors a custom baseUrl and strips trailing slash', async () => {
|
|
let capturedUrl;
|
|
const fetchImpl = async (url) => {
|
|
capturedUrl = url;
|
|
return { ok: true, json: async () => ({ content: [{ text: 'x' }] }) };
|
|
};
|
|
await callAnthropicAPI('hi', { apiKey: 'k', baseUrl: 'https://example.test/', fetchImpl });
|
|
expect(capturedUrl).toBe('https://example.test/v1/messages');
|
|
});
|
|
|
|
it('throws on non-ok response', async () => {
|
|
const fetchImpl = async () => ({ ok: false, status: 401, text: async () => 'Invalid API Key' });
|
|
await expect(callAnthropicAPI('hi', { apiKey: 'bad', fetchImpl })).rejects.toThrow(/401/);
|
|
});
|
|
});
|
|
|
|
describe('classify — isolation from Claude Code auth', () => {
|
|
it('skips LLM and falls back to regex when ROUTER_LLM_KEY is absent', async () => {
|
|
const saved = process.env.ROUTER_LLM_KEY;
|
|
delete process.env.ROUTER_LLM_KEY;
|
|
try {
|
|
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
|
expect(r.source).toBe('regex');
|
|
} finally {
|
|
if (saved !== undefined) process.env.ROUTER_LLM_KEY = saved;
|
|
}
|
|
});
|
|
|
|
it('does NOT read ANTHROPIC_API_KEY (would hijack the main session)', async () => {
|
|
const savedRouter = process.env.ROUTER_LLM_KEY;
|
|
const savedAnthropic = process.env.ANTHROPIC_API_KEY;
|
|
delete process.env.ROUTER_LLM_KEY;
|
|
process.env.ANTHROPIC_API_KEY = 'sk-should-not-be-used';
|
|
try {
|
|
const r = await classify('что-то совсем непонятное', fakeRegistry);
|
|
// No ROUTER_LLM_KEY → must stay on regex even though ANTHROPIC_API_KEY is set.
|
|
expect(r.source).toBe('regex');
|
|
} finally {
|
|
if (savedRouter !== undefined) process.env.ROUTER_LLM_KEY = savedRouter;
|
|
if (savedAnthropic !== undefined) process.env.ANTHROPIC_API_KEY = savedAnthropic;
|
|
else delete process.env.ANTHROPIC_API_KEY;
|
|
}
|
|
});
|
|
});
|