Files
brain/tools/router-engine.test.mjs
T

258 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
detectHighRisk, validateLevelSkip, LEVEL_SKIP_CATEGORIES, cheaperOf,
validateTrace, groundTrace, TRACE_SLOTS,
buildRouterPrompt, parseRouterResponse, runRouter,
} from './router-engine.mjs';
import { buildNodeGraph } from './node-graph.mjs';
import { classifyDestructive } from './classify-destructive.mjs';
const GRAPH = buildNodeGraph({ nodes: [
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' },
{ id: '#55', slug: 'discovery-interview', name: 'discovery-interview', status: 'active' },
], chains: {} });
describe('detectHighRisk — parity/consolidation на classifyDestructive (Step 1.7)', () => {
// командная-проверка detectHighRisk делегирует classifyDestructive.suspicious.
// format сохранён; новые: голый migrate / db:wipe (старый локальный DESTRUCTIVE_RE их терял).
const CASES = ['rm -rf x', 'git push --force', 'format D:', 'php artisan migrate', 'php artisan db:wipe', 'git status'];
for (const command of CASES) {
it(`detectHighRisk.high (только command) согласован с classifyDestructive.suspicious для ${command}`, () => {
const expected = classifyDestructive(command).suspicious;
expect(detectHighRisk({ op: 'Bash', command }).high).toBe(expected);
});
}
});
const CATALOG = { nodes: [
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', capabilities: 'plans', status: 'active' },
] };
const FULL_TRACE = {
candidates: ['writing-plans', 'discovery-interview'],
chosen: 'writing-plans',
why_chosen: 'multi-step feature plan',
twins_considered: 'discovery-interview — отброшен: цель не интервью',
confidence: 0.8,
};
describe('detectHighRisk (6.1 — ДЕТЕРМИНИРОВАННО, LLM-оценка только совет)', () => {
it('разрушительная операция → high', () => {
expect(detectHighRisk({ op: 'Bash', command: 'rm -rf x' }).high).toBe(true);
expect(detectHighRisk({ op: 'Bash', command: 'git push --force' }).high).toBe(true);
});
it('прод-выкат → high', () => {
expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
});
it('многошаговость над порогом → high', () => {
expect(detectHighRisk({ op: 'Skill', stepCount: 9 }).high).toBe(true);
});
it('инъецированный sensitive-флаг → high (портативно, без зашитых путей)', () => {
expect(detectHighRisk({ op: 'Edit', sensitive: true }).high).toBe(true);
});
it('обычное чтение → не high', () => {
expect(detectHighRisk({ op: 'Read' }).high).toBe(false);
});
it('reasons перечисляет причины', () => {
expect(detectHighRisk({ op: 'Bash', command: 'DROP TABLE x' }).reasons.length).toBeGreaterThan(0);
});
});
describe('validateLevelSkip (6.2 — только закрытый список категорий)', () => {
it('категория из закрытого списка → ok', () => {
expect(validateLevelSkip(LEVEL_SKIP_CATEGORIES[0]).ok).toBe(true);
});
it('свободный текст → отклонён', () => {
expect(validateLevelSkip('не хочу заморачиваться').ok).toBe(false);
});
it('пусто → отклонён', () => {
expect(validateLevelSkip('').ok).toBe(false);
expect(validateLevelSkip(null).ok).toBe(false);
});
});
describe('cheaperOf (6.1 — цена РАЗДЕЛИТЕЛЬ равноценных, не глушилка)', () => {
it('при равном качестве берёт дешевле', () => {
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 5 }, { skill: 'B', qualityRank: 1, costRank: 2 }]);
expect(r.skill).toBe('B');
});
it('лучшее качество побеждает несмотря на цену (цена НЕ важнее правильности)', () => {
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 9 }, { skill: 'B', qualityRank: 2, costRank: 1 }]);
expect(r.skill).toBe('A');
});
it('пусто → null', () => { expect(cheaperOf([])).toBe(null); });
});
describe('validateTrace (5.1 структурные слоты; пустой слот = красный флаг, §6.2 A)', () => {
it('полная трасса → ok', () => {
expect(validateTrace(FULL_TRACE)).toEqual({ ok: true, missingSlots: [] });
});
it('пустой слот → невалидно + перечень', () => {
const r = validateTrace({ ...FULL_TRACE, why_chosen: '' });
expect(r.ok).toBe(false); expect(r.missingSlots).toContain('why_chosen');
});
it('нет кандидатов → невалидно', () => {
expect(validateTrace({ ...FULL_TRACE, candidates: [] }).ok).toBe(false);
});
it('TRACE_SLOTS перечисляет обязательные слоты', () => {
expect(TRACE_SLOTS).toContain('chosen');
});
});
// fix: tools/router-engine.mjs (F-8, аудит M1-M4) — confidence обязан быть в [0,1]
describe('F-8: validateTrace отвергает confidence вне диапазона 0..1', () => {
it('confidence > 1 → невалидная трасса (обход воздержания закрыт)', () => {
const v = validateTrace({ ...FULL_TRACE, confidence: 5 });
expect(v.ok).toBe(false);
expect(v.missingSlots).toContain('confidence');
});
it('confidence < 0 → невалидная', () => {
expect(validateTrace({ ...FULL_TRACE, confidence: -3 }).ok).toBe(false);
});
it('Infinity / NaN → невалидная', () => {
expect(validateTrace({ ...FULL_TRACE, confidence: Infinity }).ok).toBe(false);
expect(validateTrace({ ...FULL_TRACE, confidence: NaN }).ok).toBe(false);
});
it('confidence в [0,1] по-прежнему валиден', () => {
expect(validateTrace({ ...FULL_TRACE, confidence: 0 }).ok).toBe(true);
expect(validateTrace({ ...FULL_TRACE, confidence: 1 }).ok).toBe(true);
});
});
describe('groundTrace (ОВ-Д2 C — все кандидаты в реальные узлы; выдумка → отклонение)', () => {
it('все кандидаты резолвятся → grounded', () => {
expect(groundTrace(FULL_TRACE, GRAPH)).toMatchObject({ grounded: true, invented: [] });
});
it('выдуманный кандидат → not grounded + перечень', () => {
const r = groundTrace({ ...FULL_TRACE, candidates: ['writing-plans', 'elasticsearch-magic'] }, GRAPH);
expect(r.grounded).toBe(false); expect(r.invented).toContain('elasticsearch-magic');
});
it('выдуманный chosen → not grounded', () => {
const r = groundTrace({ ...FULL_TRACE, chosen: 'made-up' }, GRAPH);
expect(r.grounded).toBe(false);
});
});
describe('buildRouterPrompt (чистая, {system,user}; граф+каталог в system для кэша 6.2)', () => {
it('детерминирована: тот же вход → тот же текст', () => {
const a = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
const b = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
expect(a.system).toBe(b.system);
expect(a.user).toBe(b.user);
});
it('system несёт вшитый брейнсторм + слоты трассы; user — задачу', () => {
const { system, user } = buildRouterPrompt({ prompt: 'add feature X', graph: GRAPH, catalog: CATALOG });
expect(system).toMatch(/вариант|brainstorm|2.?3/i);
expect(system).toMatch(/candidates/);
expect(user).toMatch(/add feature X/);
});
it('фикс-3: system несёт нюх 5.3 (подозрение к размытому) + интервьюер 4.4 (уточняющий вопрос)', () => {
const { system } = buildRouterPrompt({ prompt: 'настрой красиво', graph: GRAPH, catalog: CATALOG });
expect(system).toMatch(/нюх|5\.3|размыт/i);
expect(system).toMatch(/4\.4|уточня|переспрос/i);
});
it('фикс-2: контракты в промпте + look-ahead (нужды/решения вызываемых навыков вперёд)', () => {
const contracts = [{ skill: 'writing-plans', needs: ['spec'], 'key-decisions': ['file structure'], 'acceptance-criteria': ['each step 2-5 min'] }];
const { system } = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG, contracts });
expect(system).toMatch(/look-ahead|вперёд|вызываемых/i);
expect(system).toMatch(/spec/);
expect(system).toMatch(/file structure/);
});
it('фикс-2: без контрактов — раздел КОНТРАКТЫ не добавляется (детерминизм сохранён)', () => {
const a = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG });
const b = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG, contracts: [] });
expect(a.system).toBe(b.system);
expect(a.system).not.toMatch(/КОНТРАКТЫ ВЫЗЫВАЕМЫХ/);
});
// W1 (C2, нах.F1/C-2/CD-R6-G): catalog ≠ graph — карта районов отдельной секцией
it('W1: graph.layer0 → ОТДЕЛЬНАЯ секция КАРТА РАЙОНОВ в system (имена районов + staleness)', () => {
const graphSection = { kind: 'project-graph', districtCount: 2, layer0: [{ district: 'tools', nodeCount: 3, topNodes: [{ id: 'a' }] }, { district: 'app-backend', nodeCount: 7 }], staleness: { stale: true, commits_behind: null, uncommitted: 2 } };
const { system } = buildRouterPrompt({ prompt: 'x', graph: graphSection, catalog: CATALOG });
expect(system).toMatch(/КАРТА РАЙОНОВ/);
expect(system).toMatch(/app-backend/);
expect(system).toMatch(/stale/i);
});
it('W1: каталог берётся ТОЛЬКО из catalog.nodes — граф больше НЕ фолбэк каталога', () => {
const { system } = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: null });
expect(system).not.toMatch(/#19/); // узлы GRAPH не рендерятся как каталог
});
it('W1: заголовка «(100%)» больше нет — каталог подписан как skill-каталог', () => {
const { system } = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: CATALOG });
expect(system).not.toMatch(/\(100%\)/);
expect(system).toMatch(/КАТАЛОГ УЗЛОВ \(skill-каталог\)/);
});
it('F-C2-3: catalog не передан/пуст → явный маркер пустого каталога + воздержание (не молча)', () => {
const a = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: null });
expect(a.system).toMatch(/КАТАЛОГ ПУСТ/);
expect(a.system).toMatch(/воздержание/i);
const b = buildRouterPrompt({ prompt: 'x', graph: GRAPH, catalog: { nodes: [] } });
expect(b.system).toMatch(/КАТАЛОГ ПУСТ/);
});
});
describe('parseRouterResponse', () => {
it('парсит JSON-трассу', () => {
const t = parseRouterResponse('```json\n{"chosen":"writing-plans","candidates":["writing-plans"],"why_chosen":"x","twins_considered":"y","confidence":0.9}\n```');
expect(t.chosen).toBe('writing-plans');
});
it('мусор → null', () => { expect(parseRouterResponse('не json')).toBe(null); });
// FR-4 (финревью 2026-06-11): жадный {…} ловил от первой { до ПОСЛЕДНЕЙ } —
// два JSON-объекта в тексте роняли парс в null; берём первый сбалансированный.
it('FR-4: два JSON-объекта в тексте → парсится ПЕРВЫЙ, не null', () => {
const t = parseRouterResponse('вот трасса {"chosen":"a","confidence":0.7} а вот мусор {"другой":"объект"}');
expect(t).not.toBe(null);
expect(t.chosen).toBe('a');
});
it('FR-4: вложенные скобки в первом объекте не ломают балансный срез', () => {
const t = parseRouterResponse('x {"a":{"b":1},"c":2} y {"d":3}');
expect(t).toEqual({ a: { b: 1 }, c: 2 });
});
});
// FR-2 router-engine-часть (финревью 2026-06-11): пустой layer0 — НЕ пустой заголовок
// секции (маркер отсутствия — забота mentor-seam F-C6; здесь секция просто не рендерится).
describe('FR-2: пустой layer0 не рендерит пустую секцию районов', () => {
it('graph.layer0=[] → ни заголовка КАРТА РАЙОНОВ, ни staleness-строки в system', () => {
const graphSection = { kind: 'project-graph', districtCount: 0, layer0: [], staleness: { stale: true, commits_behind: null, uncommitted: 2 } };
const { system } = buildRouterPrompt({ prompt: 'x', graph: graphSection, catalog: CATALOG });
expect(system).not.toMatch(/КАРТА РАЙОНОВ/);
expect(system).not.toMatch(/staleness:/);
});
});
describe('runRouter (оркестратор; llmCall мокается)', () => {
const okTrace = { candidates: ['writing-plans'], chosen: 'writing-plans', why_chosen: 'feature', twins_considered: 'none', confidence: 0.85 };
it('валидная заземлённая трасса с уверенностью → ok-результат', async () => {
const r = await runRouter({ prompt: 'add feature', graph: GRAPH, llmCall: async () => okTrace });
expect(r.ok).toBe(true); expect(r.trace.chosen).toBe('writing-plans'); expect(r.abstain).toBe(false);
});
it('пустой слот трассы → невалидно (мех. флаг, не результат)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, why_chosen: '' }) });
expect(r.ok).toBe(false); expect(r.reason).toMatch(/слот|trace|missing/i);
});
it('выдуманный кандидат → отклонение (заземление)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, candidates: ['made-up'], chosen: 'made-up' }) });
expect(r.ok).toBe(false); expect(r.reason).toMatch(/выдум|invent|заземл/i);
});
it('низкая уверенность → воздержание (5.2), не угадывание', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, confidenceThreshold: 0.7, llmCall: async () => ({ ...okTrace, confidence: 0.3 }) });
expect(r.abstain).toBe(true);
});
it('llmCall вернул мусор → ok=false (не падает)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => null });
expect(r.ok).toBe(false);
});
});
describe('F1 — выбор ОБЯЗАН быть среди перечисленных кандидатов (§6.3 метод-брейнсторм)', () => {
it('chosen — реальный узел графа, но НЕ среди candidates → отклонение (обход брейнсторма)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ candidates: ['writing-plans'], chosen: 'discovery-interview', why_chosen: 'w', twins_considered: 't', confidence: 0.9 }) });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/кандидат|candidates|выбор/i);
});
it('chosen среди candidates → проходит (контроль не ломает легитимный выбор)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ candidates: ['writing-plans', 'discovery-interview'], chosen: 'discovery-interview', why_chosen: 'w', twins_considered: 't', confidence: 0.9 }) });
expect(r.ok).toBe(true);
});
});