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:
@@ -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, при срыве —
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user