feat(secretary): slim-протокол — не возить надгробия в LLM (277 тестов зелёные)

This commit is contained in:
Дмитрий
2026-06-26 15:48:29 +03:00
parent 11acf18c40
commit ec349edc29
6 changed files with 147 additions and 5 deletions
+8 -5
View File
@@ -11,6 +11,7 @@ import { resolveModel } from './secretary-models.mjs';
import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG, renderExchangeText } from './secretary-harvest.mjs';
import { SYS_G, applyGardener } from './secretary-gardener.mjs';
import { applyHarvest } from './secretary-apply.mjs';
import { slimProtocol } from './secretary-slim.mjs';
/** Разобрать спан и вернуть НОВЫЙ протокол (вход не мутируется reconcile-веткой; collapse — копия).
* proto — текущий протокол; spanEx — склеенный обмен спана {user,assistant,actions};
@@ -47,19 +48,21 @@ export async function distillSpan(proto, spanEx, { start, end, note = '' }, { ca
const spanText = renderExchangeText(spanEx);
const txt = (r) => (typeof r === 'string' ? r : (r && r.text) || '');
const call = (role, system, user) => callModel({ system, user, model: resolveModel(role) });
// ПОДАЧА ролям — из live-only проекции (надгробия не возим): мёртвое отсеяно, мутации ниже идут по полному proto.
const view = slimProtocol(toWrite);
// сбор: 1.1 диагностика ∥ 1.2 ловец, затем 1.3 брейншторм (после них)
const [r11, r12] = await Promise.all([
call('diagnostic', SYS_11, user11(toWrite, spanText, { start, end })),
call('catcher', SYS_12, user12(toWrite, spanText)),
call('diagnostic', SYS_11, user11(view, spanText, { start, end })),
call('catcher', SYS_12, user12(view, spanText)),
]);
const d11 = diagFindings(parseLoose(txt(r11), 'lenses'));
const d12 = parseLoose(txt(r12), 'dropped');
const r13 = await call('brainstorm', SYS_13, user13(toWrite, spanText, { start, end }, JSON.stringify(d11.new), JSON.stringify(d12.dropped)));
const r13 = await call('brainstorm', SYS_13, user13(view, spanText, { start, end }, JSON.stringify(d11.new), JSON.stringify(d12.dropped)));
const d13 = parseLoose(txt(r13), 'forks');
// ЭТАП 1: урожай (новые ветки + рождение/заточка/полнота кандидатов) — садовник увидит свежую грядку
const withHarvest = applyHarvest(toWrite, start, d11, d12, d13);
// ЭТАП 2: садовник на СВЕЖЕМ proto (видит дифф ствола + свежие ветки И кандидаты для моста «повысить»)
const rG = await call('gardener', SYS_G, userG(withHarvest, spanText, diff));
// ЭТАП 2: садовник — подача из СВЕЖЕЙ live-проекции (видит свежие живые ветки и кандидаты), без надгробий
const rG = await call('gardener', SYS_G, userG(slimProtocol(withHarvest), spanText, diff));
const dG = parseLoose(txt(rG), 'tend');
return applyGardener(withHarvest, start, dG);
} catch (e) { if (typeof diag === 'function') diag({ turn: start, reason: 'fluffy-fail', error: e && e.message }); return collapseProtocol(toWrite); }
+35
View File
@@ -82,3 +82,38 @@ describe('distillSpan — новый конвейер «пушистое дер
expect(out.candidates).toEqual([]); // кандидаты теперь первоклассны (всегда инициализированы, пусты в классике)
});
});
describe('distillSpan — подача ролей без надгробий (slim)', () => {
it('catcher и gardener НЕ видят мёртвые ветки/кандидаты, editor видит полный', async () => {
const proto = {
...EMPTY_PROTOCOL(),
hidden: [
{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ЖИВАЯ_ВЕТКА' },
{ id: 'СВ-2', lens: 'Л4', status: 'закрыт', text: 'МЁРТВАЯ_ВЕТКА' },
],
candidates: [
{ id: 'КД-1', status: 'жив', branch: 'ЖИВОЙ_КАНД' },
{ id: 'КД-2', status: 'отпал', branch: 'МЁРТВЫЙ_КАНД' },
],
};
const seen = { editor: '', catcher: '', gardener: '' };
const callModel = async ({ system, user }) => {
if (/секретарь-редактор/.test(system)) { seen.editor = user; return JSON.stringify({ subject: 'd', decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } }); }
if (/сборщик скрытых веток/.test(system)) return '{"lenses":[]}';
if (/ловец/.test(system)) { seen.catcher = user; return '{"dropped":[]}'; }
if (/брейншторма/.test(system)) return '{"forks":[]}';
if (/САДОВНИК/.test(system)) { seen.gardener = user; return '{"tend":[]}'; }
return '{}';
};
await distillSpan(proto, { user: 'просьба достаточно длинная', assistant: 'ок', actions: [] },
{ start: 1, end: 1 }, { callModel, session: 's', flags: { fluffy: true } });
expect(seen.catcher).not.toContain('МЁРТВАЯ_ВЕТКА');
expect(seen.catcher).not.toContain('МЁРТВЫЙ_КАНД');
expect(seen.catcher).not.toContain('НАДГРОБИЯ');
expect(seen.gardener).not.toContain('МЁРТВАЯ_ВЕТКА');
expect(seen.gardener).not.toContain('МЁРТВЫЙ_КАНД');
expect(seen.catcher).toContain('ЖИВАЯ_ВЕТКА');
expect(seen.gardener).toContain('ЖИВОЙ_КАНД');
});
});
+22
View File
@@ -0,0 +1,22 @@
// tools/secretary-slim.mjs
// Live-only проекция протокола для подачи в LLM: мёртвые ветки/кандидаты («надгробия»)
// в неё не попадают совсем. Чистая, не мутирует вход. Полный протокол (источник истины)
// и архив строятся отдельно — здесь только то, что видит агент.
// Терминальные статусы ветки = мертва, в подачу не идёт (кандидат: закрыт/отпал/мутировал/повышен).
const DEAD_BRANCH = new Set(['закрыт', 'отпал', 'мутировал']);
/** Ветка жива, если статус не терминальный (нет статуса/«открыт» — живые). */
export function isLiveBranch(h) { return !DEAD_BRANCH.has(String((h && h.status) || '')); }
/** Кандидат жив, если без статуса или «жив» (то же, что фильтрует candidatesForPrompt). */
export function isLiveCandidate(c) { const s = String((c && c.status) || ''); return s === '' || s === 'жив'; }
/** Копия протокола только из живого. Вход не мутируется. null/не-объект — как есть. */
export function slimProtocol(proto) {
if (!proto || typeof proto !== 'object') return proto;
return {
...proto,
hidden: (proto.hidden || []).filter(isLiveBranch),
candidates: (proto.candidates || []).filter(isLiveCandidate),
};
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { slimProtocol, isLiveBranch, isLiveCandidate } from './secretary-slim.mjs';
const proto = () => ({
subject: 'дело', status: 'открыто', nextSvId: 3, nextKdId: 4,
decisions: [{ text: 'D1', struck: false }],
open: [{ text: 'O1' }], will: [], consequences: [], doneNext: [], history: [],
steps: [{ turn: 1, text: 'шаг 1' }],
hidden: [
{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'живая ветка' },
{ id: 'СВ-2', lens: 'Л4', status: 'закрыт', text: 'мёртвая (закрыт)' },
{ id: 'СВ-3', lens: 'Л6', status: 'отпал', text: 'мёртвая (отпал)' },
{ id: 'СВ-4', lens: 'Л7', status: 'мутировал', text: 'мёртвая (мутировал)' },
{ id: 'СВ-5', lens: 'Л8', text: 'живая без статуса' },
],
candidates: [
{ id: 'КД-1', status: 'жив', branch: 'живой кандидат' },
{ id: 'КД-2', branch: 'живой без статуса' },
{ id: 'КД-3', status: 'отпал', branch: 'мёртвый (отпал)' },
{ id: 'КД-4', status: 'мутировал', branch: 'мёртвый (мутировал)' },
{ id: 'КД-5', status: 'закрыт', branch: 'мёртвый (закрыт)' },
{ id: 'КД-6', status: 'повышен', branch: 'мёртвый (повышен)' },
],
extraNewField: 'forward-safe',
});
describe('slimProtocol', () => {
it('оставляет только живые ветки (выкидывает закрыт/отпал/мутировал)', () => {
const s = slimProtocol(proto());
expect(s.hidden.map((h) => h.id)).toEqual(['СВ-1', 'СВ-5']);
});
it('оставляет только живых кандидатов (нет статуса или жив)', () => {
const s = slimProtocol(proto());
expect(s.candidates.map((c) => c.id)).toEqual(['КД-1', 'КД-2']);
});
it('переносит ствол/шаги/счётчики/новые поля как есть', () => {
const s = slimProtocol(proto());
expect(s.decisions).toEqual([{ text: 'D1', struck: false }]);
expect(s.steps).toEqual([{ turn: 1, text: 'шаг 1' }]);
expect(s.nextSvId).toBe(3);
expect(s.nextKdId).toBe(4);
expect(s.subject).toBe('дело');
expect(s.extraNewField).toBe('forward-safe');
});
it('не мутирует вход (мёртвые остаются в исходном proto)', () => {
const p = proto();
slimProtocol(p);
expect(p.hidden).toHaveLength(5);
expect(p.candidates).toHaveLength(6);
});
it('терпит пустой/кривой вход', () => {
expect(slimProtocol({}).hidden).toEqual([]);
expect(slimProtocol({}).candidates).toEqual([]);
expect(slimProtocol(null)).toEqual(null);
});
it('предикаты живости', () => {
expect(isLiveBranch({ status: 'открыт' })).toBe(true);
expect(isLiveBranch({})).toBe(true);
expect(isLiveBranch({ status: 'закрыт' })).toBe(false);
expect(isLiveCandidate({ status: 'жив' })).toBe(true);
expect(isLiveCandidate({})).toBe(true);
expect(isLiveCandidate({ status: 'повышен' })).toBe(false);
});
});
+3
View File
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { join, basename } from 'node:path';
import { writeFileAtomic } from './secretary-layer1.mjs';
import { EMPTY_PROTOCOL } from './secretary-protocol.mjs';
import { slimProtocol } from './secretary-slim.mjs';
import { assembleSpan } from './secretary-span.mjs';
import { distillSpan } from './secretary-distill.mjs';
import { renderDoc } from './secretary-render-fluffy.mjs';
@@ -30,6 +31,7 @@ export async function runWorker(workDir, deps) {
distill = (proto, spanEx, span, ctx) => distillSpan(proto, spanEx, span, ctx),
render = (proto, opts) => renderDoc(proto, opts),
archive = (proto) => renderArchive(proto),
slim = (p) => slimProtocol(p),
writeArchive = fluffyPipelineOn(), // под fluffy-флагом пишем второй рендер (полный леджер)
now = () => Date.now(),
callModel = null,
@@ -51,6 +53,7 @@ export async function runWorker(workDir, deps) {
proto = await distill(proto, spanEx, { ...span }, { callModel, session: job.session });
mkdirSync(workDir, { recursive: true });
writeFileAtomic(join(workDir, 'protocol.json'), JSON.stringify(proto, null, 2));
writeFileAtomic(join(workDir, 'protocol-slim.json'), JSON.stringify(slim(proto), null, 2)); // видимая live-проекция (что видит агент)
const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
writeFileAtomic(join(workDir, 'protocol.md'), render(proto, { work, date: stamp }));
if (writeArchive) writeFileAtomic(join(workDir, 'архив.md'), archive(proto)); // полный леджер (сырьё ТЗ)
+15
View File
@@ -63,6 +63,21 @@ describe('runWorker', () => {
await runWorker(workDir, deps({ writeArchive: false }));
expect(existsSync(join(workDir, 'архив.md'))).toBe(false);
});
it('пишет protocol-slim.json только из живого; protocol.json — полный', async () => {
enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' });
const distill = async (proto) => ({
...proto, subject: 'd',
hidden: [{ id: 'СВ-1', status: 'открыт', text: 'жив' }, { id: 'СВ-2', status: 'закрыт', text: 'мёртв' }],
candidates: [{ id: 'КД-1', status: 'жив', branch: 'жив' }, { id: 'КД-2', status: 'отпал', branch: 'мёртв' }],
});
await runWorker(workDir, deps({ distill }));
const full = JSON.parse(readFileSync(join(workDir, 'protocol.json'), 'utf-8'));
const slim = JSON.parse(readFileSync(join(workDir, 'protocol-slim.json'), 'utf-8'));
expect(full.hidden).toHaveLength(2);
expect(full.candidates).toHaveLength(2);
expect(slim.hidden.map((h) => h.id)).toEqual(['СВ-1']);
expect(slim.candidates.map((c) => c.id)).toEqual(['КД-1']);
});
});
describe('pickModel (per-role model forwarding)', () => {