import { describe, it, expect, beforeEach } from 'vitest'; import { classifyByRegex } from './router-classifier.mjs'; 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 } 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. }); });