diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index 2d362e7..33c8faf 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -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, при срыве — diff --git a/tools/secretary-reconcile.test.mjs b/tools/secretary-reconcile.test.mjs index 29b0c75..9fb3da9 100644 --- a/tools/secretary-reconcile.test.mjs +++ b/tools/secretary-reconcile.test.mjs @@ -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: [] }; diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index 082e23f..42b8208 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -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 = '';