Files
brain/tools/secretary-reconcile.test.mjs
T
Дмитрий ab8abe2c87 feat(secretary): История+многоходовый провенанс в хуке, нарезка сырья по ходам, обезвреживание маркеров, все Шаги
- stampProvenance ведёт История-таймлайн (in/out) и многоходовый провенанс при смене зачёркивания строки
- splitRawIntoTurns/prepareTurnFiles: нарезка raw на <дело>/ходы/turn-N.log; Шаги ссылаются на файл хода
- buildStepsFromRaw + обработчик off: Шаг на КАЖДЫЙ ход (без пропусков выкл-ходов)
- neutralizeMarkers в buildRawRecord: защита от самозагрязнения лога копиями маркеров
- полная форма протокола (9 категорий) + дело создание-секретаря приведено к виду; набор секретаря 56/56

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:24:20 +03:00

156 lines
9.7 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { parseReconcileResponse, reconcileGuard, buildGuardRemark, stampProvenance, buildReconcilePrompt, reconcileTurn } from './secretary-reconcile.mjs';
describe('parseReconcileResponse', () => {
it('парсит весь протокол (с обёрткой/хвостовой запятой)', () => {
const out = parseReconcileResponse('```json\n{ "subject":"S", "decisions":[{"text":"A","why":"w","struck":false}], "open":[{"text":"Q","struck":true}], }\n```');
expect(out.subject).toBe('S');
expect(out.decisions[0]).toEqual({ text: 'A', why: 'w', struck: false });
expect(out.open[0]).toEqual({ text: 'Q', struck: true });
expect(out.will).toEqual([]);
expect(out.doneNext).toEqual([]);
});
it('мусор → null', () => {
expect(parseReconcileResponse('не json')).toBeNull();
expect(parseReconcileResponse('')).toBeNull();
});
});
describe('reconcileGuard', () => {
const old = { decisions: [{ text: 'Берём Postgres' }], open: [{ text: 'Хайку или Sonnet?' }], will: [], doneNext: [] };
it('всё на месте (в т.ч. зачёркнутое) → ok', () => {
const ret = { decisions: [{ text: 'берём postgres', struck: false }], open: [{ text: 'Хайку или Sonnet?', struck: true }], will: [], doneNext: [] };
expect(reconcileGuard(old, ret).ok).toBe(true);
});
it('строка пропала → не ok + список потерь', () => {
const ret = { decisions: [{ text: 'берём postgres' }], open: [], will: [], doneNext: [] };
const g = reconcileGuard(old, ret);
expect(g.ok).toBe(false);
expect(g.lost).toContain('Хайку или Sonnet?');
});
});
describe('buildGuardRemark', () => {
it('обоснованное замечание называет потерянные строки и что делать', () => {
const r = buildGuardRemark(['Хайку или Sonnet?']);
expect(r).toContain('Хайку или Sonnet?');
expect(r.toLowerCase()).toContain('верни');
expect(r.toLowerCase()).toContain('не удаляй');
});
});
describe('stampProvenance', () => {
const old = { subject: 'тема', history: [{ oldText: 'x', newText: 'y', turns: [1] }],
decisions: [{ text: 'A', why: 'w', turns: [3], session: 'sessOLD', struck: false }],
will: [], open: [], doneNext: [] };
const returned = { subject: 'тема',
decisions: [{ text: 'A', why: 'w', struck: false }, { text: 'B', why: 'w2', struck: false }],
will: [], open: [], doneNext: [] };
it('старая запись сохраняет свой turns/session, новая получает текущие', () => {
const p = stampProvenance(old, returned, 9, 'sessNEW');
expect(p.decisions[0]).toMatchObject({ text: 'A', turns: [3], session: 'sessOLD' });
expect(p.decisions[1]).toMatchObject({ text: 'B', turns: [9], session: 'sessNEW' });
});
it('history прежнего протокола сохраняется', () => {
const p = stampProvenance(old, returned, 9, 'sessNEW');
expect(p.history).toEqual(old.history);
});
});
describe('stampProvenance — многоходовый провенанс + История-таймлайн (живой хук)', () => {
it('смена статуса (зачёркивание) дописывает текущий ход в turns: [33] → [33,50]', () => {
const old = { subject: 't', history: [], decisions: [], alternatives: [], consequences: [], will: [],
open: [{ text: 'Q?', struck: false, turns: [33], session: 's0' }], doneNext: [] };
const ret = { open: [{ text: 'Q?', struck: true }], decisions: [], alternatives: [], consequences: [], will: [], doneNext: [] };
const p = stampProvenance(old, ret, 50, 's1');
expect(p.open[0].turns).toEqual([33, 50]);
});
it('строка без смены статуса НЕ копит ходы', () => {
const old = { subject: 't', history: [], decisions: [{ text: 'A', struck: false, turns: [3], session: 's0' }], alternatives: [], consequences: [], will: [], open: [], doneNext: [] };
const ret = { decisions: [{ text: 'A', struck: false }], alternatives: [], consequences: [], will: [], open: [], doneNext: [] };
const p = stampProvenance(old, ret, 9, 's1');
expect(p.decisions[0].turns).toEqual([3]);
});
it('зачёркивание заводит запись в История: [→добавлен] [←ход]', () => {
const old = { subject: 't', history: [], decisions: [], alternatives: [], consequences: [], will: [],
open: [{ text: 'Q?', struck: false, turns: [41], session: 's0' }], doneNext: [] };
const ret = { open: [{ text: 'Q?', struck: true }], decisions: [], alternatives: [], consequences: [], will: [], doneNext: [] };
const p = stampProvenance(old, ret, 43, 's1');
expect(p.history).toContainEqual({ text: 'Q?', events: [{ turn: 41, dir: 'in' }, { turn: 43, dir: 'out' }] });
});
it('возврат строки (снятие зачёркивания) дописывает [→ход] в тайм-линию', () => {
const old = { subject: 't',
history: [{ text: 'Q?', events: [{ turn: 41, dir: 'in' }, { turn: 43, dir: 'out' }] }],
decisions: [], alternatives: [], consequences: [], will: [],
open: [{ text: 'Q?', struck: true, turns: [41, 43], session: 's0' }], doneNext: [] };
const ret = { open: [{ text: 'Q?', struck: false }], decisions: [], alternatives: [], consequences: [], will: [], doneNext: [] };
const p = stampProvenance(old, ret, 55, 's1');
const h = p.history.find((x) => x.text === 'Q?');
expect(h.events).toEqual([{ turn: 41, dir: 'in' }, { turn: 43, dir: 'out' }, { turn: 55, dir: 'in' }]);
});
});
describe('buildReconcilePrompt', () => {
const proto = { subject: 'дело', decisions: [{ text: 'A' }], open: [{ text: 'Q?' }], will: [], doneNext: [] };
const ex = { user: 'ответ на Q', assistant: 'ок', actions: [] };
it('правила: не удалять, только зачёркивать, воля у [ЮЗЕР], шум игнор', () => {
const { system } = buildReconcilePrompt({ protocol: proto, lastExchange: ex });
expect(system.toLowerCase()).toContain('не удаляй');
expect(system.toLowerCase()).toContain('зачерк');
expect(system).toContain('[ЮЗЕР]');
expect(system.toLowerCase()).toContain('служебн');
});
it('в user — текущий протокол и обмен; замечание добавляется при возврате', () => {
const { user } = buildReconcilePrompt({ protocol: proto, lastExchange: ex, remark: 'ВЕРНИ X' });
expect(user).toContain('Q?');
expect(user).toContain('ответ на Q');
expect(user).toContain('ВЕРНИ X');
});
});
describe('reconcile — 9 категорий + стабильная тема', () => {
it('parse читает alternatives и consequences', () => {
const out = parseReconcileResponse('{ "subject":"S", "alternatives":[{"text":"ALT","struck":false}], "consequences":[{"text":"C","struck":false}] }');
expect(out.alternatives[0]).toEqual({ text: 'ALT', struck: false });
expect(out.consequences[0]).toEqual({ text: 'C', struck: false });
});
it('guard сторожит и альтернативы', () => {
const old = { decisions: [], will: [], open: [], doneNext: [], alternatives: [{ text: 'ALT' }], consequences: [] };
const ret = { decisions: [], will: [], open: [], doneNext: [], alternatives: [], consequences: [] };
expect(reconcileGuard(old, ret).ok).toBe(false);
});
it('stamp штампует альтернативы и держит ПЕРВУЮ тему (стабильность)', () => {
const old = { subject: 'создание', history: [], decisions: [], will: [], open: [], doneNext: [], alternatives: [], consequences: [] };
const ret = { subject: 'узкая тема хода', alternatives: [{ text: 'ALT', struck: false }], decisions: [], will: [], open: [], doneNext: [], consequences: [] };
const p = stampProvenance(old, ret, 9, 's1');
expect(p.alternatives[0]).toMatchObject({ text: 'ALT', turns: [9], session: 's1' });
expect(p.subject).toBe('создание');
});
});
describe('reconcileTurn', () => {
const proto = { subject: 'дело', decisions: [{ text: 'A', turns: [1], session: 's0' }], will: [], open: [{ text: 'Q?' }], doneNext: [], history: [] };
const ex = { user: 'ответ', assistant: 'ок', actions: [] };
it('чистый ответ модели → штампует и возвращает протокол', async () => {
const callModel = async () => '{ "subject":"дело", "decisions":[{"text":"A","why":null,"struck":false}], "open":[{"text":"Q?","struck":true}], "will":[], "doneNext":[] }';
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel });
expect(out).not.toBeNull();
expect(out.open[0]).toMatchObject({ text: 'Q?', struck: true });
expect(out.decisions[0]).toMatchObject({ turns: [1], session: 's0' });
});
it('потерял строку дважды → null (оставляем прежний протокол)', async () => {
let n = 0;
const callModel = async () => { n++; return '{ "subject":"дело", "decisions":[{"text":"A","struck":false}], "open":[], "will":[], "doneNext":[] }'; };
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel });
expect(out).toBeNull();
expect(n).toBe(3); // первый + 2 возврата
});
it('кривой JSON → null без ретраев', async () => {
let n = 0;
const callModel = async () => { n++; return 'не json'; };
const out = await reconcileTurn({ proto, ex, turn: 5, session: 's1', callModel });
expect(out).toBeNull();
expect(n).toBe(1);
});
});