Files
brain/tools/mentor-verdict.test.mjs
T

180 lines
12 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.
// tools/mentor-verdict.test.mjs
import { describe, it, expect } from 'vitest';
import { validateMentorVerdict, isMentorVerdictSubstantive, MENTOR_VERDICT_SLOTS, runMentorSpecVerdict, buildMentorSpecVerdictPrompt } from './mentor-verdict.mjs';
describe('runMentorSpecVerdict (Фаза 3 — наставник судит СПЕКУ, видит контекст Р6)', () => {
const GOOD = { plan_points_addressed: ['раздел §2 ок'], reasoning: 'разбор', recommendation: 'править §3', confidence: 0.9, decision: 'GO' };
it('валидный вердикт + wired:true + binding к хешу артефакта спеки', async () => {
const r = await runMentorSpecVerdict({ specContent: '# Спека\n## §2\nтекст', specHash: 'SH1', verifiedContext: [], negotiationLog: [], llmCall: async () => GOOD });
expect(r.ok).toBe(true);
expect(r.wired).toBe(true);
expect(r.verdict.plan_hash).toBe('SH1');
});
it('Р6: промпт несёт КОНТЕКСТ наставнику (verified-context), но просит разбор СПЕКИ', async () => {
let captured = null;
await runMentorSpecVerdict({
specContent: '# Спека', specHash: 'SH',
verifiedContext: [{ id: '1', kind: 'EXTRACTED', claim: 'утв', ref: 'x:1', anchor: 'якорь' }],
negotiationLog: [], llmCall: async ({ buildPrompt }) => { captured = buildPrompt(); return GOOD; },
});
expect(captured.system).toMatch(/спек/i);
expect(JSON.stringify(captured)).toMatch(/EXTRACTED|контекст/i);
});
it('сбой транспорта → wired:false (не суд, SE-R6-6)', async () => {
const r = await runMentorSpecVerdict({ specContent: 's', specHash: 'SH', llmCall: async () => { throw new Error('сеть'); } });
expect(r.wired).toBe(false);
});
it('несодержательный вердикт → ok:false, wired:true', async () => {
const r = await runMentorSpecVerdict({ specContent: 's', specHash: 'SH', llmCall: async () => ({ reasoning: 'r' }) });
expect(r.ok).toBe(false);
expect(r.wired).toBe(true);
});
});
describe('mentor-verdict (§6.1 СВОИ слоты)', () => {
it('MENTOR_VERDICT_SLOTS заморожен и СВОЙ (не судейский)', () => {
expect(Object.isFrozen(MENTOR_VERDICT_SLOTS)).toBe(true);
expect(MENTOR_VERDICT_SLOTS).toEqual(['plan_points_addressed', 'reasoning', 'recommendation', 'confidence', 'decision']);
});
it('валидный вердикт → ok', () => {
const v = { plan_points_addressed: ['шаг 1 ок', 'шаг 2 риск'], reasoning: 'разбор', recommendation: 'править шаг 2', confidence: 0.8, decision: 'GO' };
expect(validateMentorVerdict(v).ok).toBe(true);
});
it('пустой слот → не ok', () => {
const v = { plan_points_addressed: [], reasoning: 'x', recommendation: 'y', confidence: 0.5 };
expect(validateMentorVerdict(v).ok).toBe(false);
expect(validateMentorVerdict(v).missingSlots).toContain('plan_points_addressed');
});
it('F-C3 (sharp-edges): элементы plan_points_addressed обязаны быть непустыми строками — [""]/[null]/[{}] не «содержательны»', () => {
const base = { reasoning: 'r', recommendation: 'rec', confidence: 0.5 };
expect(validateMentorVerdict({ ...base, plan_points_addressed: [''] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: [null] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: [{}] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: ['шаг 1 ок', ''] }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, plan_points_addressed: [''] }).missingSlots).toContain('plan_points_addressed');
});
it('confidence вне [0,1]/NaN → не ok', () => {
const base = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec' };
expect(validateMentorVerdict({ ...base, confidence: 5 }).ok).toBe(false);
expect(validateMentorVerdict({ ...base, confidence: NaN }).ok).toBe(false);
});
it('substance: wired:true + валиден → содержателен; wired:false → НЕ содержателен (SE-R6-6)', () => {
const v = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec', confidence: 0.7, decision: 'GO' };
expect(isMentorVerdictSubstantive(v, { wired: true })).toBe(true);
expect(isMentorVerdictSubstantive(v, { wired: false })).toBe(false);
});
});
describe('validateMentorVerdict — decision (Р7/мерж)', () => {
const base = { plan_points_addressed: ['п1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9 };
it('decision="GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'GO' }).ok).toBe(true); });
it('decision="NO-GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'NO-GO' }).ok).toBe(true); });
it('decision отсутствует → невалиден', () => {
const r = validateMentorVerdict(base);
expect(r.ok).toBe(false); expect(r.missingSlots).toContain('decision');
});
it('decision мусор → невалиден', () => {
expect(validateMentorVerdict({ ...base, decision: 'maybe' }).ok).toBe(false);
});
});
describe('промпты просят decision (Р7/мерж)', () => {
it('промпт плана требует decision GO/NO-GO', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] } });
expect(p.system).toMatch(/decision/i);
expect(p.system).toMatch(/NO-GO/);
});
it('промпт спеки требует decision GO/NO-GO', () => {
const p = buildMentorSpecVerdictPrompt({ specContent: '# с' });
expect(p.system).toMatch(/decision/i);
expect(p.system).toMatch(/NO-GO/);
});
});
// Task 3b — производитель вердикта (C-1, нах.F4/F5): импорт внизу перед describe (ESM hoisting)
import { buildMentorVerdictPrompt, runMentorVerdict } from './mentor-verdict.mjs';
describe('buildMentorVerdictPrompt (§6.1 verdict-слоты, НЕ router)', () => {
it('возвращает {system,user}; user — план по-пунктам + проверенный контекст; system просит verdict-слоты', () => {
const p = buildMentorVerdictPrompt({
plan: { steps: [{ n: 1, op: 'Write', object: 'a.mjs' }] },
verifiedContext: [{ kind: 'EXTRACTED', claim: 'есть runRouter', ref: 'router-engine.mjs:140' }],
negotiationLog: [{ round: 1, side: 'mentor', utterance: 'расширь', justification: 'риск' }],
graphSection: { layer0: [{ district: 'tools', nodeCount: 3 }] },
});
expect(typeof p.system).toBe('string');
expect(typeof p.user).toBe('string');
expect(p.system).toMatch(/plan_points_addressed/); // СВОИ слоты, не router
expect(p.system).not.toMatch(/candidates|twins_considered/); // НЕ router-слоты
expect(p.user).toMatch(/runRouter/); // проверенный контекст
});
it('system несёт ДР-1 гранулярность (A8): растяжка до неизвестности + громкость по риску', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] } });
expect(p.system).toMatch(/растяжка до следующей НЕИЗВЕСТНОСТИ/);
expect(p.system).toMatch(/detectHighRisk/);
});
// VA-1 (финревью 3/5): рендер контекста/переговоров — ЕДИНЫЙ источник (mentor-seam);
// verdict-промпт несёт тот же дисклеймер «НЕ истина» и «обоснование:» что и seam.
it('VA-1: user несёт дисклеймер «НЕ истина» и «обоснование:» (единый рендер с seam)', () => {
const p = buildMentorVerdictPrompt({
plan: { steps: [] },
verifiedContext: [{ kind: 'EXTRACTED', claim: 'c', ref: 'f:1' }],
negotiationLog: [{ round: 1, side: 'mentor', utterance: 'u', justification: 'j' }],
});
expect(p.user).toMatch(/НЕ истина/);
expect(p.user).toMatch(/обоснование:/);
});
// VA-2 (финревью 3/5, «сигнал-всегда»): пустой verifiedContext НЕ молчит.
it('VA-2: пустой verifiedContext → явный маркер КОНТЕКСТ ПУСТ в user', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] }, verifiedContext: [] });
expect(p.user).toMatch(/КОНТЕКСТ ПУСТ/);
});
// Smoke 2026-06-12: «статус+замечание» провоцировал массив ОБЪЕКТОВ → валидатор
// (массив строк, F-C3/М1-М4) браковал живой содержательный вердикт. Контракт элементов
// обязан быть в промпте явно: строки, не объекты.
it('system требует plan_points_addressed массивом СТРОК (не объектов) — контракт валидатора F-C3', () => {
const p = buildMentorVerdictPrompt({ plan: { steps: [] } });
expect(p.system).toMatch(/массив СТРОК/);
expect(p.system).toMatch(/не объект/i);
});
it('roundMemory: пусто → нет блока; непусто → блок памяти (оба построителя)', () => {
expect(buildMentorVerdictPrompt({ plan: { steps: [] } }).user).not.toContain('ПАМЯТЬ КРУГОВ');
expect(buildMentorVerdictPrompt({ plan: { steps: [] }, roundMemory: { objections: ['замечание M'] } }).user).toContain('замечание M');
expect(buildMentorSpecVerdictPrompt({ specContent: '# с', roundMemory: { objections: ['замечание M2'] } }).user).toContain('замечание M2');
});
});
describe('runMentorVerdict (§6.1 производитель + binding нах.F4)', () => {
const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.8, decision: 'GO' };
it('валидный вердикт → ok + wired + verdict.plan_hash проставлен (binding)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => goodVerdict });
expect(r.ok).toBe(true);
expect(r.wired).toBe(true);
expect(r.verdict.plan_hash).toBe('PH1');
});
it('пустой слот → ok:false (не содержателен)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => ({ ...goodVerdict, plan_points_addressed: [] }) });
expect(r.ok).toBe(false);
});
it('сбой вызова → ok:false, wired:false (SE-R6-6: не суд)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => { throw new Error('сеть'); } });
expect(r.ok).toBe(false);
expect(r.wired).toBe(false);
});
it('сбой вызова → reason несёт деталь ошибки (smoke 2026-06-12: тихий catch не давал отличить 401 от сети)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => { throw new Error('Router LLM 401: invalid api key'); } });
expect(r.reason).toMatch(/сбой вызова наставника-вердикта/);
expect(r.reason).toMatch(/401/);
});
it('сбой вызова: деталь усечена (не тащим мегабайт тела ответа в вердикт-файл)', async () => {
const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => { throw new Error('x'.repeat(5000)); } });
expect(r.reason.length).toBeLessThan(400);
});
it('строковый ответ модели парсится (json-fence), вердикт валиден → ok', async () => {
const json = JSON.stringify(goodVerdict);
const r = await runMentorVerdict({ plan: {}, planHash: 'PH2', llmCall: async () => '```json\n' + json + '\n```' });
expect(r.ok).toBe(true);
expect(r.verdict.plan_hash).toBe('PH2');
});
});