diff --git a/tools/secretary-protocol.mjs b/tools/secretary-protocol.mjs index 5afacff..c3da3a1 100644 --- a/tools/secretary-protocol.mjs +++ b/tools/secretary-protocol.mjs @@ -2,6 +2,7 @@ export function EMPTY_PROTOCOL() { return { subject: '', status: 'открыто', + knowledge: [], decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], history: [], steps: [], hidden: [], acceptance: [], tails: [], nextSvId: 1, diff --git a/tools/secretary-protocol.test.mjs b/tools/secretary-protocol.test.mjs index d108256..96ff089 100644 --- a/tools/secretary-protocol.test.mjs +++ b/tools/secretary-protocol.test.mjs @@ -5,6 +5,7 @@ describe('EMPTY_PROTOCOL', () => { it('пустой протокол со всеми 9 разделами', () => { expect(EMPTY_PROTOCOL()).toEqual({ subject: '', status: 'открыто', + knowledge: [], decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], history: [], steps: [], hidden: [], acceptance: [], tails: [], nextSvId: 1, @@ -145,3 +146,9 @@ describe('renderProtocol — 9 категорий + шаги', () => { expect(md.indexOf('—— сессия sБ ——')).toBeLessThan(md.indexOf('Ход 3')); }); }); + +describe('EMPTY_PROTOCOL — база знаний', () => { + it('инициализирует knowledge пустым массивом', () => { + expect(EMPTY_PROTOCOL().knowledge).toEqual([]); + }); +}); diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index 180abb6..40546b4 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -10,7 +10,7 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark 'Ты — секретарь-редактор протокола работ. Тебе дают ВЕСЬ протокол дела и последний обмен.', 'Верни ВЕСЬ обновлённый протокол ТОЛЬКО как JSON (subject, decisions[{text,why,struck}],', 'alternatives[{text,struck}], consequences[{text,struck}],', - 'will[{text,struck}], open[{text,struck}], doneNext[{text,done,struck}]).', + 'will[{text,struck}], open[{text,struck}], doneNext[{text,done,struck}], knowledge[{text,ref}]).', 'ПРАВИЛА:', '1. НИЧЕГО НЕ УДАЛЯЙ. Решённое/отменённое/дубль — ЗАЧЕРКНИ ("struck":true), строка остаётся.', '2. Существующие строки НЕ переписывай — только зачёркивай старую и добавляй новую.', @@ -28,6 +28,11 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark ' в вопрос о деталях — вынеси САМО принятое решение в "decisions" (а требование/запрет лично', ' к тебе — в "will") ОТДЕЛЬНО; ещё не решённую часть (формат/способ/детали) положи в "open".', ' НЕ прячь уже принятое решение целиком в "open" или "doneNext".', + '10. БАЗА ЗНАНИЙ ("knowledge"): из действий хода (чтения файлов/спек/url с содержимым) вынеси', + ' ДОБЫТЫЕ ФАКТЫ — каждый как {"text":"<что узнал>","ref":"<источник: файл:строка/url/раздел>"}.', + ' Источник ОБЯЗАТЕЛЕН (нет источника → это НЕ знание, не пиши; вывод/решение → "decisions").', + ' Знание ВПЕРЁД, источник В КОНЦЕ. Если про этот источник запись уже есть — УТОЧНИ/РАСШИРЬ её', + ' (тот же "ref"), не дублируй. НИЧЕГО из базы не удаляй и не сокращай.', ].join('\n'); const sec = (name, arr) => `${name}:\n` + ((arr || []).map((e) => ` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n') || ' (пусто)'); @@ -36,8 +41,10 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark const actsHead = allActs.length > shownActs.length ? ` (показаны последние ${shownActs.length} из ${allActs.length} действий)\n` : ''; const acts = actsHead + ((shownActs.map((a) => ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? `\n → ${String(a.result).replace(/\n/g, '\n ')}` : ''}`).join('\n')) || '—'); + const knowLines = ((protocol.knowledge || []).map((e) => ` - ${e.text}${e.ref ? ' — ' + e.ref : ''}`).join('\n')) || ' (пусто)'; const user = [ `Тема дела: ${protocol.subject || '(нет)'}`, + `База знаний (что уже добыто — НЕ удаляй, дубль по источнику уточняй):\n${knowLines}`, sec('Решения', protocol.decisions), sec('Альтернативы', protocol.alternatives), sec('Последствия', protocol.consequences), sec('Воля', protocol.will), sec('Открытые', protocol.open), sec('Сделано', protocol.doneNext), @@ -68,6 +75,9 @@ export function parseReconcileResponse(llmText) { }; return { subject: typeof parsed.subject === 'string' ? parsed.subject.trim() : '', + knowledge: list(parsed.knowledge) + .map((e) => ({ ref: String((e && e.ref) || '').trim(), text: String((e && e.text) || '').trim() })) + .filter((e) => e.ref && e.text), decisions: list(parsed.decisions).map((e) => ({ ...ent(e), why: (e && e.why) || null })), alternatives: list(parsed.alternatives).map(ent), consequences: list(parsed.consequences).map(ent), @@ -214,6 +224,20 @@ export function restoreLostLines(oldProtocol, returned) { return out; } +/** Слить базы знаний по ключу ref: старое не теряем, общий ref берёт новый текст, новые добавляем. + * Реализует «копим; дубль уточняем/расширяем; не удаляем» (knowledge не в SECTIONS — своя защита). */ +export function preserveKnowledge(oldK = [], newK = []) { + const norm = (e) => ({ ref: String((e && e.ref) || '').trim(), text: String((e && e.text) || '').trim() }); + const old = (Array.isArray(oldK) ? oldK : []).map(norm).filter((e) => e.ref); + const fresh = (Array.isArray(newK) ? newK : []).map(norm).filter((e) => e.ref); + const freshByRef = new Map(fresh.map((e) => [e.ref, e])); + const out = []; + const seen = new Set(); + for (const e of old) { out.push(freshByRef.get(e.ref) || e); seen.add(e.ref); } // старое сохранено; общий ref → новый текст + for (const e of fresh) if (!seen.has(e.ref)) { out.push(e); seen.add(e.ref); } // новые ref после старых + return out; +} + /** Обоснованное замечание для возврата модели на доработку (§D4). */ export function buildGuardRemark(lost) { const list = (lost || []).map((t) => `- ${t}`).join('\n'); @@ -273,6 +297,7 @@ export function stampProvenance(oldProtocol, returned, turn, session) { doneNext: (returned.doneNext || []).map(stamp), history: [...legacy, ...toggleMap.values()], steps: Array.isArray(oldProtocol.steps) ? oldProtocol.steps : [], + knowledge: Array.isArray(returned.knowledge) ? returned.knowledge : [], }; } @@ -295,6 +320,7 @@ export async function reconcileTurn({ proto, ex, turn, session, callModel, diag const step = parsed.step || null; const finish = (p) => { const out = collapseProtocol(p); return step ? { ...out, step } : out; }; const returned = collapseProtocol(parsed); + returned.knowledge = preserveKnowledge(clean.knowledge, returned.knowledge); // «не удаляем»: выпавший ref вернётся const guard = reconcileGuard(clean, returned); if (guard.ok) return finish(stampProvenance(clean, returned, turn, session)); // Потеряны строки → НЕ выкидываем ход: возвращаем пропавшие старые строки на место (модель-агностично). diff --git a/tools/secretary-reconcile.test.mjs b/tools/secretary-reconcile.test.mjs index e2a7a96..812f9eb 100644 --- a/tools/secretary-reconcile.test.mjs +++ b/tools/secretary-reconcile.test.mjs @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { parseReconcileResponse, reconcileGuard, buildGuardRemark, stampProvenance, buildReconcilePrompt, reconcileTurn } from './secretary-reconcile.mjs'; +import { parseReconcileResponse, reconcileGuard, buildGuardRemark, stampProvenance, buildReconcilePrompt, reconcileTurn, preserveKnowledge } from './secretary-reconcile.mjs'; +import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; describe('parseReconcileResponse', () => { it('парсит весь протокол (с обёрткой/хвостовой запятой)', () => { @@ -387,3 +388,61 @@ describe('restoreLostLines — возврат потерянных строк ( expect(merged.decisions[0].struck).toBe(true); }); }); + +describe('parseReconcileResponse — knowledge', () => { + it('читает knowledge [{ref,text}] и отсеивает записи без ref', () => { + const out = parseReconcileResponse(JSON.stringify({ + subject: 'S', + knowledge: [ + { text: 'функция Y делает Z', ref: 'a.mjs:10' }, + { text: 'без источника — не знание' }, + { text: '', ref: 'b.mjs:1' }, + ], + })); + expect(out.knowledge).toEqual([{ ref: 'a.mjs:10', text: 'функция Y делает Z' }]); + }); +}); + +describe('preserveKnowledge — копим, дубль уточняем, не удаляем', () => { + it('общий ref берёт новый текст (уточнение)', () => { + expect(preserveKnowledge([{ ref: 'x', text: 'old' }], [{ ref: 'x', text: 'new' }])) + .toEqual([{ ref: 'x', text: 'new' }]); + }); + it('выпавший ref восстанавливается из старого', () => { + expect(preserveKnowledge([{ ref: 'x', text: 'old' }], [])) + .toEqual([{ ref: 'x', text: 'old' }]); + }); + it('новый ref добавляется после старых', () => { + expect(preserveKnowledge([{ ref: 'x', text: 'a' }], [{ ref: 'y', text: 'b' }])) + .toEqual([{ ref: 'x', text: 'a' }, { ref: 'y', text: 'b' }]); + }); +}); + +describe('reconcileTurn — база знаний переживает ход', () => { + it('запись, выпавшую из ответа модели, восстанавливает', async () => { + const proto = { ...EMPTY_PROTOCOL(), knowledge: [{ ref: 'a.mjs:1', text: 'старое знание' }] }; + const callModel = async () => JSON.stringify({ + subject: 'd', decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], + knowledge: [], step: { user: 'u', assistant: 'a' }, + }); + const out = await reconcileTurn({ proto, ex: { user: 'u', assistant: 'a', actions: [] }, turn: 2, session: 's', callModel }); + expect(out.knowledge.map((k) => k.ref)).toContain('a.mjs:1'); + }); +}); + +describe('buildReconcilePrompt — база знаний', () => { + it('система просит поле knowledge и правило источника', () => { + const { system } = buildReconcilePrompt({ protocol: {}, lastExchange: {} }); + expect(system).toMatch(/knowledge/); + expect(system).toMatch(/источник/i); + }); + it('на вход подаёт текущую базу знаний (между темой и решениями)', () => { + const { user } = buildReconcilePrompt({ + protocol: { subject: 'тема', knowledge: [{ ref: 'a.mjs:5', text: 'факт A' }], decisions: [] }, + lastExchange: {}, + }); + expect(user).toContain('факт A — a.mjs:5'); + expect(user.indexOf('База знаний')).toBeGreaterThan(user.indexOf('Тема дела')); + expect(user.indexOf('База знаний')).toBeLessThan(user.indexOf('Решения')); + }); +}); diff --git a/tools/secretary-render-archive.mjs b/tools/secretary-render-archive.mjs index 0460cc0..cfbd8eb 100644 --- a/tools/secretary-render-archive.mjs +++ b/tools/secretary-render-archive.mjs @@ -16,6 +16,13 @@ export function renderArchive(p) { for (const [k, t] of [['decisions', 'Решения'], ['will', 'Воля'], ['open', 'Открытые'], ['consequences', 'Последствия'], ['doneNext', 'Сделано/дальше']]) L.push(`**${t}**`, ...((proto[k] || []).map((e) => `- ${e.struck ? '~~' + e.text + '~~' : e.text}`)), ''); + // База знаний — добытые факты с источником в конце. + const know = (proto.knowledge || []).filter((e) => e && e.text); + L.push('## База знаний'); + if (!know.length) L.push('(пусто)'); + for (const e of know) L.push(`- ${e.text}${e.ref ? ` — ${e.ref}` : ''}`); + L.push(''); + // Все ветки и кандидаты — живые и мёртвые, со статусом, наследником, историей по шагам и родословной. const all = [...(proto.hidden || []), ...(proto.candidates || [])]; L.push('## Все ветки и кандидаты (живые + мёртвые)'); diff --git a/tools/secretary-render-archive.test.mjs b/tools/secretary-render-archive.test.mjs index a6fe476..f2bd012 100644 --- a/tools/secretary-render-archive.test.mjs +++ b/tools/secretary-render-archive.test.mjs @@ -29,3 +29,12 @@ describe('renderArchive — полный леджер', () => { expect(renderArchive(base())).toContain('ПОЛНЫЙ ЛЕДЖЕР'); }); }); + +describe('renderArchive — база знаний', () => { + const base2 = (over) => ({ subject: '', decisions: [], will: [], open: [], consequences: [], doneNext: [], steps: [], hidden: [], candidates: [], ...over }); + it('печатает раздел со строкой знание — источник', () => { + const md = renderArchive(base2({ knowledge: [{ ref: 'f.mjs:10', text: 'факт' }] })); + expect(md).toContain('## База знаний'); + expect(md).toContain('факт — f.mjs:10'); + }); +}); diff --git a/tools/secretary-render-fluffy.mjs b/tools/secretary-render-fluffy.mjs index 2e82d34..2d04e90 100644 --- a/tools/secretary-render-fluffy.mjs +++ b/tools/secretary-render-fluffy.mjs @@ -31,6 +31,14 @@ export function renderFluffy(protocol, opts = {}) { L.push(`# 📋 Протокол: ${p.subject || '(без темы)'}`); L.push(`*статус: ${p.status || 'открыто'}${opts.date ? ' · ' + opts.date : ''} · каждая строка тянется до ходы/turn-N.log*`, ''); + // 🧠 БАЗА ЗНАНИЙ — добытые факты с источником в конце (между темой и стволом) + const know = (p.knowledge || []).filter((e) => e && e.text); + if (know.length) { + L.push('## 🧠 База знаний', ''); + for (const e of know) L.push(`- ${e.text}${e.ref ? ` — ${e.ref}` : ''}`); + L.push(''); + } + // 🌳 СТВОЛ — живое наверху, зачёркнутое собираем в свёрнутый блок L.push('## 🌳 Ствол', ''); const struckLines = []; diff --git a/tools/secretary-render-fluffy.test.mjs b/tools/secretary-render-fluffy.test.mjs index bfbc65c..e2cc069 100644 --- a/tools/secretary-render-fluffy.test.mjs +++ b/tools/secretary-render-fluffy.test.mjs @@ -70,3 +70,12 @@ describe('renderDoc — развилка по флагу', () => { expect(md).not.toContain('🌳'); }); }); + +describe('renderFluffy — база знаний', () => { + it('печатает раздел между темой и стволом', () => { + const md = renderFluffy({ ...base(), subject: 'тема', knowledge: [{ ref: 'f.mjs:10', text: 'факт' }] }); + expect(md).toContain('## 🧠 База знаний'); + expect(md).toContain('факт — f.mjs:10'); + expect(md.indexOf('База знаний')).toBeLessThan(md.indexOf('🌳 Ствол')); + }); +}); diff --git a/tools/secretary-slim.test.mjs b/tools/secretary-slim.test.mjs index 359b4cc..2d4a339 100644 --- a/tools/secretary-slim.test.mjs +++ b/tools/secretary-slim.test.mjs @@ -62,3 +62,10 @@ describe('slimProtocol', () => { expect(isLiveCandidate({ status: 'повышен' })).toBe(false); }); }); + +describe('slimProtocol — knowledge', () => { + it('сохраняет базу знаний целиком', () => { + const s = slimProtocol({ knowledge: [{ ref: 'a', text: 'b' }], hidden: [], candidates: [] }); + expect(s.knowledge).toEqual([{ ref: 'a', text: 'b' }]); + }); +});