feat(secretary): база знаний в протоколе — факты из изучения с источником
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
// Потеряны строки → НЕ выкидываем ход: возвращаем пропавшие старые строки на место (модель-агностично).
|
||||
|
||||
@@ -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('Решения'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('## Все ветки и кандидаты (живые + мёртвые)');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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('🌳 Ствол'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user