From 3be6cedd232e7215bbb14bee5d4cba17167e28dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 23 Jun 2026 11:54:39 +0300 Subject: [PATCH] =?UTF-8?q?fix(secretary):=20=D1=81=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B2=D0=BF=D0=B5=D1=87=D1=91=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B8=20=C2=AB[=D0=B7=D0=B0=D1=87=D1=91?= =?UTF-8?q?=D1=80=D0=BA=D0=BD=D1=83=D1=82=D0=BE]=C2=BB=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=B7=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модель копировала служебный префикс «[зачёркнуто] » (которым запрос помечает зачёркнутые строки) в поле text, и он компаундился ([зачёркнуто] [зачёркнуто] …), плодя марочные клоны. canonicalClauses теперь срезает ведущий маркер перед сборкой канона: метка не персистится, клоны сливаются с чистой записью (ходы объединяются). Только ведущий наш маркер — реальный текст с него не начинается. Дедуп/История/провенанс/Шаги не тронуты. Свод секретаря 113/113. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/secretary-reconcile.mjs | 6 +++++- tools/secretary-reconcile.test.mjs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index 44881dd..a8e088b 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -92,7 +92,11 @@ function allTexts(p) { /** Куски записи без дословных повторов: split(' — ') от «text — why», dedup по первому вхождению. */ function canonicalClauses(text, why) { - const raw = (why != null && String(why).trim() !== '') ? `${text}${SEP}${why}` : String(text || ''); + // Срез служебной метки зачёркивания, впечённой моделью в текст (запрос рисует «[зачёркнуто] X», + // слабая модель копирует строку в поле text). Только ведущий наш маркер — реальный текст с него + // не начинается; марочные клоны после среза дают тот же канон и сливаются с чистой записью. + const cleanText = String(text || '').replace(/^(?:\[зачёркнуто\]\s*)+/, ''); + const raw = (why != null && String(why).trim() !== '') ? `${cleanText}${SEP}${why}` : cleanText; const seen = new Set(); const out = []; for (const part of raw.split(SEP).map((s) => s.trim()).filter(Boolean)) { diff --git a/tools/secretary-reconcile.test.mjs b/tools/secretary-reconcile.test.mjs index 005a6b0..cfdbf0b 100644 --- a/tools/secretary-reconcile.test.mjs +++ b/tools/secretary-reconcile.test.mjs @@ -231,6 +231,17 @@ describe('collapseProtocol — детерминированное схлопыв const p = { open: [{ text: 'Хайку или Sonnet? — Хайку или Sonnet?', struck: false, turns: [1] }] }; expect(collapseProtocol(p).open[0].text).toBe('Хайку или Sonnet?'); }); + it('срезает впечённую метку «[зачёркнуто]» из текста и сливает с чистой записью', () => { + const p = { decisions: [ + { text: 'Выбрано X', why: 'причина', struck: true, turns: [3] }, + { text: '[зачёркнуто] [зачёркнуто] Выбрано X', why: 'причина', struck: true, turns: [31] }, + ] }; + const out = collapseProtocol(p); + expect(out.decisions).toHaveLength(1); + expect(out.decisions[0].text).toBe('Выбрано X'); + expect(out.decisions[0].turns).toEqual([3, 31]); + expect(JSON.stringify(out)).not.toContain('[зачёркнуто]'); + }); it('hidden / steps / nextSvId не трогаются', () => { const p = { decisions: [], hidden: [{ id: 'СВ-1' }], steps: [{ turn: 1 }], nextSvId: 5 }; const out = collapseProtocol(p);