fix(secretary): схлопывание дублей протокола (идемпотентный канон)

Конвейер reconcile только ДОПИСЫВАЛ строки (модель копит, restoreLostLines
возвращает) — раздел «Решения» рос вечно (на «линзе» 714 записей, на
«протоколе» 40+ копий одного решения; «История» спамила [<-9][<-9]...).

Добавлен collapseProtocol: канон по кускам строки (split ' — ', убираем
ДОСЛОВНЫЕ повторы кусков, порядок и первое вхождение храним), слияние по
канону+статусу (struck/done), объединение ходов (union). Идемпотентно и
lossless — контроль смысла: ни одна уникальная строка не теряется и не
выдумывается. События Истории дедупятся по (turn,dir).

Вшит в reconcileTurn (вход = самолечение раздутого дела, выход = дубли не
копятся) и в stop-hook перед записью (финальный чокпоинт: ловит срыв
reconcile / работу без ключа / уже накопленный мусор).

9 новых тестов (вкл. идемпотентность и контроль смысла); свод секретаря
106/106 зелёных. Реестр СВ (hidden/nextSvId), шаги, тема — не трогаются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-23 06:37:14 +03:00
parent 2772b197b3
commit 4d7f355dca
3 changed files with 197 additions and 10 deletions
+108 -6
View File
@@ -56,6 +56,7 @@ export function parseReconcileResponse(llmText) {
const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
const SECTIONS = ['decisions', 'alternatives', 'consequences', 'will', 'open', 'doneNext'];
const SEP = ' — ';
function allTexts(p) {
const out = [];
@@ -63,6 +64,103 @@ function allTexts(p) {
return out;
}
// ── Схлопывание дублей (детерминированное, lossless, идемпотентное) ───────────
// Конвейер reconcile только ДОПИСЫВАЕТ строки (модель копит, restoreLostLines возвращает),
// поэтому без явного схлопывания протокол растёт вечно. Ниже — один проход, который сливает
// одинаковые строки и убирает ДОСЛОВНЫЕ повторы кусков внутри строки.
//
// Критерий тождества — КАНОНИЧЕСКАЯ строка: всю запись («решение — причина — причина…»)
// режем по ' — ' на куски, убираем точные повторы кусков (по первому вхождению, порядок храним),
// склеиваем обратно. Это:
// • убирает повтор «A — A — A» → «A» (модель дублирует обоснования);
// • объединяет «решение» и «решение — причина», где причина уже вписана в текст;
// • НИКОГДА не переставляет и не добавляет куски — что не дубль, то нетронуто;
// • идемпотентно (повторный прогон даёт ту же строку) — безопасно гонять каждый ход.
// Сливаются записи с одинаковой канонической строкой и статусом (struck/done); turns
// объединяются (провенанс не теряется), разные смыслы остаются раздельными.
/** Куски записи без дословных повторов: split(' — ') от «text — why», dedup по первому вхождению. */
function canonicalClauses(text, why) {
const raw = (why != null && String(why).trim() !== '') ? `${text}${SEP}${why}` : String(text || '');
const seen = new Set();
const out = [];
for (const part of raw.split(SEP).map((s) => s.trim()).filter(Boolean)) {
const k = part.toLowerCase();
if (seen.has(k)) continue;
seen.add(k); out.push(part);
}
return out;
}
function unionTurns(a, b) {
const out = Array.isArray(a) ? [...a] : [];
for (const t of Array.isArray(b) ? b : []) if (!out.includes(t)) out.push(t);
return out;
}
/** Схлопнуть одну корзину по канонической строке. У записей с «почему» (решения) первый кусок —
* это text, остальные — why; у прочих корзин (один текст) канон кладётся в text. */
function collapseSection(entries) {
const map = new Map();
for (const e of Array.isArray(entries) ? entries : []) {
const hasWhy = Object.prototype.hasOwnProperty.call(e, 'why');
const clauses = canonicalClauses(e.text, e.why);
const canonical = clauses.join(SEP);
const key = [norm(canonical), e.struck ? 1 : 0, e.done ? 1 : 0].join('|');
if (map.has(key)) {
map.get(key).turns = unionTurns(map.get(key).turns, e.turns);
continue;
}
const survivor = { ...e };
if (hasWhy) {
survivor.text = clauses[0] || '';
survivor.why = clauses.length > 1 ? clauses.slice(1).join(SEP) : null;
} else {
survivor.text = canonical;
}
survivor.turns = Array.isArray(e.turns) ? [...e.turns] : e.turns;
map.set(key, survivor);
}
return [...map.values()];
}
/** События Истории: убрать повторы пары (turn,dir) — спам «[←9][←9][←9]». */
function dedupeEvents(events) {
const seen = new Set();
const out = [];
for (const ev of Array.isArray(events) ? events : []) {
const k = ev.turn + '|' + ev.dir;
if (seen.has(k)) continue;
seen.add(k); out.push(ev);
}
return out;
}
/** История: слить toggle-записи по тексту (объединив события), legacy-записи пронести как есть. */
function collapseHistory(history) {
const legacy = [];
const map = new Map();
for (const h of Array.isArray(history) ? history : []) {
if (!Array.isArray(h.events)) { legacy.push(h); continue; }
const key = norm(h.text);
if (!map.has(key)) map.set(key, { ...h, text: h.text, events: [] });
map.get(key).events.push(...h.events);
}
for (const h of map.values()) h.events = dedupeEvents(h.events);
return [...legacy, ...map.values()];
}
/** Схлопнуть весь протокол. Трогает только 6 корзин + Историю; hidden/steps/nextSvId/subject —
* как есть. Применять и к ответу модели (выход reconcile), и к загруженному протоколу
* (самолечение уже раздутых дел). Не мутирует вход. */
export function collapseProtocol(p) {
if (!p || typeof p !== 'object') return p;
const out = { ...p };
for (const sec of SECTIONS) if (Array.isArray(p[sec])) out[sec] = collapseSection(p[sec]);
if (Array.isArray(p.history)) out.history = collapseHistory(p.history);
return out;
}
/** Сторож: каждая прежняя строка обязана присутствовать в новом протоколе (живой или
* зачёркнутой). Возвращает { ok, lost: [оригинальные тексты] }. */
export function reconcileGuard(oldProtocol, returned) {
@@ -157,18 +255,22 @@ export function stampProvenance(oldProtocol, returned, turn, session) {
* на успехе НЕ зовётся. Без diag поведение прежнее. */
export async function reconcileTurn({ proto, ex, turn, session, callModel, diag }) {
const report = (info) => { if (typeof diag === 'function') { try { diag(info); } catch { /* лог вторичен */ } } };
const { system, user } = buildReconcilePrompt({ protocol: proto, lastExchange: ex });
// Самолечение: вход схлопываем — guard/restore/stamp работают с чистым протоколом (уже
// раздутое дело чистится на первом же ходу). Выход тоже схлопываем — дубли не копятся.
const clean = collapseProtocol(proto || {});
const { system, user } = buildReconcilePrompt({ protocol: clean, lastExchange: ex });
let text;
try { text = await callModel({ system, user }); }
catch (e) { report({ reason: 'model-threw', error: String((e && e.message) || e) }); return null; }
const returned = parseReconcileResponse(typeof text === 'string' ? text : '');
if (!returned) { report({ reason: 'bad-json' }); return null; } // кривой JSON — прежний протокол цел
const guard = reconcileGuard(proto, returned);
if (guard.ok) return stampProvenance(proto, returned, turn, session);
const parsed = parseReconcileResponse(typeof text === 'string' ? text : '');
if (!parsed) { report({ reason: 'bad-json' }); return null; } // кривой JSON — прежний протокол цел
const returned = collapseProtocol(parsed);
const guard = reconcileGuard(clean, returned);
if (guard.ok) return collapseProtocol(stampProvenance(clean, returned, turn, session));
// Потеряны строки → НЕ выкидываем ход: возвращаем пропавшие старые строки на место (модель-агностично).
// Что модель уронила — хук вернул; что обновила (закрыла вопрос, добавила решение) — сохранено.
report({ reason: 'guard-restored', lost: guard.lost });
return stampProvenance(proto, restoreLostLines(proto, returned), turn, session);
return collapseProtocol(stampProvenance(clean, restoreLostLines(clean, returned), turn, session));
}
/** Протокол к записи независимо от исхода reconcile: при успехе база — updated, при срыве —
+81 -1
View File
@@ -155,7 +155,87 @@ describe('reconcileTurn', () => {
});
});
import { mergeTurnIntoProtocol, formatReconcileLogLine, restoreLostLines } from './secretary-reconcile.mjs';
import { mergeTurnIntoProtocol, formatReconcileLogLine, restoreLostLines, collapseProtocol } from './secretary-reconcile.mjs';
describe('collapseProtocol — детерминированное схлопывание дублей', () => {
it('8 одинаковых решений → 1, ходы объединены (union)', () => {
const dup = (turns) => ({ text: 'D', why: 'W', struck: true, turns });
const p = { decisions: Array.from({ length: 8 }, () => dup([3, 9])) };
const out = collapseProtocol(p);
expect(out.decisions).toHaveLength(1);
expect(out.decisions[0]).toMatchObject({ text: 'D', why: 'W', struck: true });
expect(out.decisions[0].turns).toEqual([3, 9]);
});
it('повторяющиеся клаузы в «почему» схлопываются: A — A — A → A', () => {
const p = { decisions: [{ text: 'D', why: 'A — A — A', struck: false, turns: [1] }] };
expect(collapseProtocol(p).decisions[0].why).toBe('A');
});
it('«почему», вшитое в text, разворачивается и сливается с обычной записью (ходы объединены)', () => {
const p = { decisions: [
{ text: 'D', why: 'W', struck: false, turns: [1] },
{ text: 'D — W', why: 'W', struck: false, turns: [2] },
] };
const out = collapseProtocol(p);
expect(out.decisions).toHaveLength(1);
expect(out.decisions[0]).toMatchObject({ text: 'D', why: 'W' });
expect(out.decisions[0].turns).toEqual([1, 2]);
});
it('живая и зачёркнутая с одним текстом НЕ сливаются (история статуса цела)', () => {
const p = { decisions: [
{ text: 'D', why: 'W', struck: false, turns: [1] },
{ text: 'D', why: 'W', struck: true, turns: [2] },
] };
expect(collapseProtocol(p).decisions).toHaveLength(2);
});
it('история: события (turn,dir) дедупятся, записи по тексту сливаются', () => {
const p = { history: [
{ text: 'X', events: [{ turn: 3, dir: 'in' }, { turn: 9, dir: 'out' }, { turn: 9, dir: 'out' }, { turn: 9, dir: 'out' }] },
{ text: 'X', events: [{ turn: 9, dir: 'out' }, { turn: 10, dir: 'in' }] },
] };
const out = collapseProtocol(p);
expect(out.history).toHaveLength(1);
expect(out.history[0].events).toEqual([{ turn: 3, dir: 'in' }, { turn: 9, dir: 'out' }, { turn: 10, dir: 'in' }]);
});
it('идемпотентность: повторный прогон ничего не меняет (форма стабильна по ходам)', () => {
const p = { decisions: [
{ text: 'Берём Postgres', why: 'дешевле — дешевле', struck: false, turns: [1] },
{ text: 'Берём Postgres — дешевле', why: 'дешевле', struck: false, turns: [2] },
{ text: 'Берём Postgres', why: 'дешевле', struck: true, turns: [3] },
] };
const once = collapseProtocol(p);
const twice = collapseProtocol(once);
expect(twice).toEqual(once);
// живая (две формы слились) + зачёркнутая = 2 записи
expect(once.decisions).toHaveLength(2);
});
it('дословные повторы кусков внутри строки убираются без перестановки', () => {
const p = { open: [{ text: 'Хайку или Sonnet? — Хайку или Sonnet?', struck: false, turns: [1] }] };
expect(collapseProtocol(p).open[0].text).toBe('Хайку или Sonnet?');
});
it('hidden / steps / nextSvId не трогаются', () => {
const p = { decisions: [], hidden: [{ id: 'СВ-1' }], steps: [{ turn: 1 }], nextSvId: 5 };
const out = collapseProtocol(p);
expect(out.hidden).toEqual([{ id: 'СВ-1' }]);
expect(out.steps).toEqual([{ turn: 1 }]);
expect(out.nextSvId).toBe(5);
});
it('контроль смысла: ни одна уникальная строка не теряется и не выдумывается', () => {
const fp = (p) => {
const set = new Set();
for (const e of p.decisions || []) set.add(`${e.text}|${e.why}|${e.struck ? 1 : 0}`);
return set;
};
const p = { decisions: [
{ text: 'D', why: 'W', struck: true, turns: [3] },
{ text: 'D', why: 'W', struck: true, turns: [9] },
{ text: 'E', why: 'V', struck: false, turns: [4] },
] };
const out = collapseProtocol(p);
// E/V и D/W(struck) — два разных смысла; оба обязаны остаться, лишнего не появиться
expect(out.decisions).toHaveLength(2);
expect(fp(out)).toEqual(new Set(['D|W|1', 'E|V|0']));
});
});
describe('reconcileTurn — diag сообщает причину срыва (видимый сигнал)', () => {
const proto = { subject: 'дело', decisions: [{ text: 'A', turns: [1], session: 's0' }], will: [], open: [{ text: 'Q?' }], doneNext: [], history: [] };
+8 -3
View File
@@ -9,7 +9,7 @@ import { homedir } from 'node:os';
import { parseLastExchange } from './secretary-transcript.mjs';
import { secretaryModeFileName } from './secretary-flag.mjs';
import { buildRawRecord, buildStepLine, writeFileAtomic } from './secretary-layer1.mjs';
import { reconcileTurn, mergeTurnIntoProtocol, formatReconcileLogLine } from './secretary-reconcile.mjs';
import { reconcileTurn, mergeTurnIntoProtocol, formatReconcileLogLine, collapseProtocol } from './secretary-reconcile.mjs';
import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs';
import { upsertIndexEntry } from './secretary-index.mjs';
import { sanitize } from './observer-pii-filter.mjs';
@@ -116,11 +116,16 @@ async function main() {
} catch (e) { logReason({ reason: 'audit-fail', error: e && e.message }); }
}
// Самолечение дублей: финальный чокпоинт перед записью. Ловит ВСЕ исходы (reconcile-успех,
// срыв/без-ключа = прежний раздутый proto, уже накопленные дубли) — на выходе всегда чисто.
// Трогает только 6 корзин + Историю; реестр СВ (hidden/nextSvId), шаги, тема — нетронуты.
const finalProto = collapseProtocol(toWrite);
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
mkdirSync(workDir, { recursive: true });
// Атомарная запись (temp→rename): параллельная сессия не увидит полузаписанный файл.
writeFileAtomic(protoJson, JSON.stringify(toWrite, null, 2));
writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(toWrite, { work, date: stamp }));
writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2));
writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp }));
const idxFile = join(secdir, 'содержание.md');
let idxMd = '';