feat(secretary): layer1 + чистое ядро обёрток (extract prompt/parse, encoding/links/period/index-context, TDD)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-22 05:48:40 +03:00
parent bb7633b318
commit 101c08d447
6 changed files with 167 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
// LLM-извлечение сути (обёртка зовёт мотор; здесь — чистые prompt-builder + parser).
/** Собрать запрос к LLM: из последнего обмена + списка открытых дел → {system, user}. */
export function buildExtractionPrompt({ lastExchange = {}, worksIndex = [] } = {}) {
const system = [
'Ты — секретарь протокола работ. Извлеки СУТЬ последнего обмена по 9 пунктам.',
'Верни ТОЛЬКО JSON без markdown, поля:',
'{ "work":"<slug дела или NEW>", "decisions":[{"text","why","turns":[]}],',
' "supersede":[{"oldText","newText","turns":[]}], "will":[{"text","turns":[]}],',
' "open":[{"text","turns":[]}], "doneNext":[{"text","done":false,"turns":[]}] }',
'Если сути нет — все массивы пустые.',
].join('\n');
const works = worksIndex.length
? worksIndex.map((w) => `- ${w.slug}: ${w.title}${w.goal}`).join('\n')
: '(нет открытых дел)';
const acts = (lastExchange.actions ?? []).map((a) => a.tool).join(', ') || '—';
const user = [
'Открытые дела:', works, '',
'Последний обмен:',
`Пользователь: ${lastExchange.user ?? ''}`,
`Ассистент: ${lastExchange.assistant ?? ''}`,
`Действия: ${acts}`,
'', 'Извлеки суть. Верни JSON.',
].join('\n');
return { system, user };
}
/** Разобрать ответ LLM в структуру для applyExtraction; null при сбое (тихо). */
export function parseExtractionResponse(llmText) {
if (typeof llmText !== 'string' || !llmText.trim()) return null;
let s = llmText.trim().replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim();
s = s.replace(/,(\s*[}\]])/g, '$1'); // хвостовые запятые — частый quirk LLM
let parsed;
try { parsed = JSON.parse(s); } catch { return null; }
if (!parsed || typeof parsed !== 'object') return null;
const arr = (x) => (Array.isArray(x) ? x : []);
return {
work: typeof parsed.work === 'string' ? parsed.work : null,
decisions: arr(parsed.decisions),
supersede: arr(parsed.supersede),
will: arr(parsed.will),
open: arr(parsed.open),
doneNext: arr(parsed.doneNext),
};
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { buildExtractionPrompt, parseExtractionResponse } from './secretary-extract.mjs';
describe('buildExtractionPrompt', () => {
it('включает дела и обмен в запрос', () => {
const { system, user } = buildExtractionPrompt({
lastExchange: { user: 'привет', assistant: 'ответ', actions: [{ tool: 'Read' }] },
worksIndex: [{ slug: 'sec', title: 'Секретарь', goal: 'память сути' }],
});
expect(system).toContain('JSON');
expect(user).toContain('sec');
expect(user).toContain('привет');
expect(user).toContain('Read');
});
it('без дел — помечает отсутствие', () => {
const { user } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] });
expect(user).toContain('нет открытых дел');
});
});
describe('parseExtractionResponse', () => {
it('парсит JSON в обёртке с хвостовой запятой', () => {
const out = parseExtractionResponse('```json\n{ "work":"sec", "decisions":[{"text":"A","turns":[7]}], }\n```');
expect(out.work).toBe('sec');
expect(out.decisions[0].text).toBe('A');
expect(out.supersede).toEqual([]);
});
it('мусор → null', () => {
expect(parseExtractionResponse('не json вовсе')).toBeNull();
expect(parseExtractionResponse('')).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
// Чистые утилиты для тонких переходников секретаря.
/** Проверка пригодности файла-хода перед записью ссылок: UTF-8, без BOM, непусто. */
export function verifyEncoding(content) {
if (typeof content !== 'string' || content.length === 0) return { ok: false, reason: 'empty' };
if (content.charCodeAt(0) === 0xFEFF) return { ok: false, reason: 'BOM' };
return { ok: true, reason: 'utf8' };
}
/** Провенанс-метка из номеров ходов: [7,12] → "[→7, →12]". */
export function buildStepLinks(turns) {
const arr = Array.isArray(turns) ? turns.filter((t) => t != null) : [];
if (!arr.length) return '';
return `[${arr.map((t) => `${t}`).join(', ')}]`;
}
/** Период нарезки из состояния флажка и текущего хода. */
export function computePeriod(flagState = {}, currentTurn = 0) {
const from = Number.isInteger(flagState.startedAtTurn) ? flagState.startedAtTurn : 0;
return { from, to: currentTurn };
}
/** Оглавление дел как подсказка для старта сессии. */
export function renderIndexContext(indexMd) {
const body = typeof indexMd === 'string' && indexMd.trim() ? indexMd.trim() : '(дел пока нет)';
return `Открытые дела (протоколы секретаря):\n${body}`;
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { verifyEncoding, buildStepLinks, computePeriod, renderIndexContext } from './secretary-hookutil.mjs';
describe('verifyEncoding', () => {
it('пустое — не ок', () => { expect(verifyEncoding('').ok).toBe(false); });
it('BOM — не ок', () => { expect(verifyEncoding('текст').ok).toBe(false); });
it('нормальный UTF-8 — ок', () => { expect(verifyEncoding('текст').ok).toBe(true); });
});
describe('buildStepLinks', () => {
it('номера → метка', () => { expect(buildStepLinks([7, 12])).toBe('[→7, →12]'); });
it('пусто → пустая строка', () => { expect(buildStepLinks([])).toBe(''); });
});
describe('computePeriod', () => {
it('из флажка и текущего хода', () => {
expect(computePeriod({ startedAtTurn: 3 }, 9)).toEqual({ from: 3, to: 9 });
});
});
describe('renderIndexContext', () => {
it('оборачивает оглавление', () => {
const out = renderIndexContext('- [X](x/protocol.md) — цель · открыто · 2026-06-22');
expect(out).toContain('Открытые дела');
expect(out).toContain('x/protocol.md');
});
it('пустое — помечает отсутствие', () => {
expect(renderIndexContext('')).toContain('дел пока нет');
});
});
+13
View File
@@ -0,0 +1,13 @@
// Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи;
// чтение источника (transcript_path) — в хук-обёртке. Здесь — только формат.
export function buildRawRecord({ turn, time, session, user, assistant, actions = [] } = {}) {
const acts = Array.isArray(actions) ? actions : [];
const lines = [`=== ХОД turn=${turn} · ${time} · session=${session} ===`,
'[ЮЗЕР]', String(user ?? ''), '[АССИСТЕНТ]', String(assistant ?? '')];
for (const a of acts) {
lines.push(`[ДЕЙСТВИЕ] ${a.tool} in=${a.input ?? ''}`);
lines.push(`[ВЫДАЧА] ${a.tool}`, String(a.result ?? ''));
}
lines.push('=== КОНЕЦ ХОДА ===', '');
return lines.join('\n');
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { buildRawRecord } from './secretary-layer1.mjs';
describe('buildRawRecord', () => {
it('содержит заголовок с turn, реплики и действие', () => {
const rec = buildRawRecord({
turn: 7, time: '2026-06-22T10:00:00Z', session: 'abc',
user: 'привет', assistant: 'ответ',
actions: [{ tool: 'Read', input: '{"f":"x"}', result: 'текст' }],
});
expect(rec).toContain('turn=7');
expect(rec).toContain('[ЮЗЕР]');
expect(rec).toContain('[ДЕЙСТВИЕ] Read');
expect(rec.trim().endsWith('=== КОНЕЦ ХОДА ===')).toBe(true);
});
it('без действий — блок без [ДЕЙСТВИЕ]', () => {
const rec = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'u', assistant: 'a' });
expect(rec).not.toContain('[ДЕЙСТВИЕ]');
});
});