feat(secretary): редактору обмен = последние 7 ходов целиком (не 7 действий)

This commit is contained in:
Дмитрий
2026-06-26 17:10:41 +03:00
parent 72aa716a1c
commit fa9e2a0b30
4 changed files with 43 additions and 20 deletions
+16 -12
View File
@@ -1,8 +1,7 @@
// Секретарь-«редактор»: модель правит весь протокол, хук сторожит потери (спека reconcile).
// Сколько ПОСЛЕДНИХ действий хода подавать редактору (анти-раздувание: длинный ход с десятками
// Read не возим целиком). Держим в синхроне с MAX_EXCHANGE_ACTIONS из secretary-harvest.mjs.
const MAX_EXCHANGE_ACTIONS = 7;
// Сколько ПОСЛЕДНИХ ходов спана подавать редактору целиком (юзер+ассистент+ВСЕ действия хода).
const MAX_EXCHANGE_TURNS = 7;
/** Запрос к модели-редактору: весь протокол + обмен → весь обновлённый протокол. */
export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark = null } = {}) {
@@ -36,11 +35,18 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark
].join('\n');
const sec = (name, arr) => `${name}:\n` + ((arr || []).map((e) =>
` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n') || ' (пусто)');
const allActs = lastExchange.actions || [];
const shownActs = allActs.slice(-MAX_EXCHANGE_ACTIONS);
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 renderActs = (acts) => ((acts || []).map((a) =>
`${a.tool} in=${a.input ?? ''}${a.result != null ? `\n${String(a.result).replace(/\n/g, '\n ')}` : ''}`).join('\n')) || ' —';
let exchangeText;
if (Array.isArray(lastExchange.turns) && lastExchange.turns.length) {
const all = lastExchange.turns;
const shown = all.slice(-MAX_EXCHANGE_TURNS);
const head = all.length > shown.length ? `(показаны последние ${shown.length} из ${all.length} ходов)\n\n` : '';
exchangeText = head + shown.map((t) =>
`[ХОД ${t.turn}]\n[ЮЗЕР]: ${t.user || ''}\n[АССИСТЕНТ]: ${t.assistant || ''}\n[ДЕЙСТВИЯ]:\n${renderActs(t.actions)}`).join('\n\n');
} else {
exchangeText = `[ЮЗЕР]: ${lastExchange.user || ''}\n[АССИСТЕНТ]: ${lastExchange.assistant || ''}\n[ДЕЙСТВИЯ]:\n${renderActs(lastExchange.actions)}`;
}
const knowLines = ((protocol.knowledge || []).map((e) => ` - ${e.text}${e.ref ? ' — ' + e.ref : ''}`).join('\n')) || ' (пусто)';
const user = [
`Тема дела: ${protocol.subject || '(нет)'}`,
@@ -48,10 +54,8 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark
sec('Решения', protocol.decisions), sec('Альтернативы', protocol.alternatives),
sec('Последствия', protocol.consequences), sec('Воля', protocol.will),
sec('Открытые', protocol.open), sec('Сделано', protocol.doneNext),
'', 'Последний обмен:',
`[ЮЗЕР]: ${lastExchange.user || ''}`,
`[АССИСТЕНТ]: ${lastExchange.assistant || ''}`,
`Действия (с содержимым):\n${acts}`,
'', 'Последние ходы (обмен):',
exchangeText,
remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '',
'', 'Верни ВЕСЬ обновлённый протокол как JSON.',
].join('\n');
+18 -7
View File
@@ -125,13 +125,24 @@ describe('buildReconcilePrompt', () => {
expect(user).toContain('{"f":"x"}');
expect(user).toContain('СОДЕРЖИМОЕ');
});
it('Последний обмен — только последние 7 действий с пометкой', () => {
const actions = Array.from({ length: 10 }, (_, i) => ({ tool: 'Read', input: `f${i}`, result: `R${i}` }));
const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: { user: 'u', assistant: 'a', actions } });
expect(user).toContain('f9');
expect(user).toContain('f3');
expect(user).not.toContain('f2');
expect(user).toMatch(/последние 7 из 10/);
it('обмен редактора — последние 7 ходов целиком, действия не урезаны', () => {
const turns = Array.from({ length: 9 }, (_, i) => ({
turn: i + 1, user: `u${i + 1}`, assistant: `a${i + 1}`,
actions: [{ tool: 'Read', input: `fA${i + 1}`, result: 'r' }, { tool: 'Read', input: `fB${i + 1}`, result: 'r' }],
}));
const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: { turns } });
expect(user).toMatch(/последние 7 из 9 ходов/);
expect(user).toContain('[ХОД 9]');
expect(user).toContain('[ХОД 3]');
expect(user).not.toContain('[ХОД 2]');
expect(user).toContain('fA9');
expect(user).toContain('fB9');
});
it('обмен редактора — фолбэк на единый блок, если turns нет', () => {
const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: { user: 'спросил', assistant: 'ответил', actions: [{ tool: 'Read', input: 'x', result: 'СОДЕРЖ' }] } });
expect(user).toContain('спросил');
expect(user).toContain('ответил');
expect(user).toContain('СОДЕРЖ');
});
});
+2 -1
View File
@@ -52,5 +52,6 @@ export function assembleSpan(rawText, { start, end }) {
const startTurn = parsed.find((p) => p.turn === start) || parsed[0] || {};
const assistant = parsed.map((p) => p.assistant).filter(Boolean).join('\n');
const actions = parsed.flatMap((p) => p.actions);
return { user: startTurn.user || '', assistant, actions };
const turns = parsed.map((p) => ({ turn: p.turn, user: p.user, assistant: p.assistant, actions: p.actions }));
return { user: startTurn.user || '', assistant, actions, turns };
}
+7
View File
@@ -76,4 +76,11 @@ describe('assembleSpan', () => {
expect(ex.user).toBe('настоящий промпт');
expect(ex.actions).toHaveLength(1);
});
it('возвращает по-ходовый список turns (для редактора)', () => {
const ex = assembleSpan(raw, { start: 3, end: 4 });
expect(ex.turns).toEqual([
{ turn: 3, user: 'настоящий промпт', assistant: 'первый ответ', actions: [{ tool: 'Read', input: 'a', result: 'r1' }] },
{ turn: 4, user: 'Stop hook feedback: x', assistant: 'второй ответ', actions: [{ tool: 'Grep', input: 'b', result: 'r2' }] },
]);
});
});