ab8abe2c87
- 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>
156 lines
9.7 KiB
JavaScript
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);
|
|
});
|
|
});
|