feat(secretary): единый богатый контекст ролям (ствол+база+ветки+кандидаты+N ходов+шаги-N)
This commit is contained in:
@@ -8,7 +8,7 @@ import { fluffyPipelineOn } from './secretary-flag.mjs';
|
||||
import { diffTrunk } from './secretary-trunkdiff.mjs';
|
||||
import { parseLoose } from './secretary-armor.mjs';
|
||||
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_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG } from './secretary-harvest.mjs';
|
||||
import { SYS_G, applyGardener } from './secretary-gardener.mjs';
|
||||
import { applyHarvest } from './secretary-apply.mjs';
|
||||
import { slimProtocol } from './secretary-slim.mjs';
|
||||
@@ -45,24 +45,23 @@ export async function distillSpan(proto, spanEx, { start, end, note = '' }, { ca
|
||||
if (callModel && fluffy) {
|
||||
try {
|
||||
const diff = diffTrunk(proto, toWrite); // движение ствола до/после редактора
|
||||
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.
|
||||
// ПОДАЧА ролям — из live-only проекции (надгробия не возим). spanEx несёт turns — сборщики рендерят N ходов сами.
|
||||
const view = slimProtocol(toWrite);
|
||||
// сбор: 1.1 диагностика ∥ 1.2 ловец, затем 1.3 брейншторм (после них)
|
||||
const [r11, r12] = await Promise.all([
|
||||
call('diagnostic', SYS_11, user11(view, spanText, { start, end })),
|
||||
call('catcher', SYS_12, user12(view, spanText)),
|
||||
call('diagnostic', SYS_11, user11(view, spanEx)),
|
||||
call('catcher', SYS_12, user12(view, spanEx)),
|
||||
]);
|
||||
const d11 = diagFindings(parseLoose(txt(r11), 'lenses'));
|
||||
const d12 = parseLoose(txt(r12), 'dropped');
|
||||
const r13 = await call('brainstorm', SYS_13, user13(view, spanText, { start, end }, JSON.stringify(d11.new), JSON.stringify(d12.dropped)));
|
||||
const r13 = await call('brainstorm', SYS_13, user13(view, spanEx, JSON.stringify(d11.new), JSON.stringify(d12.dropped)));
|
||||
const d13 = parseLoose(txt(r13), 'forks');
|
||||
// ЭТАП 1: урожай (новые ветки + рождение/заточка/полнота кандидатов) — садовник увидит свежую грядку
|
||||
const withHarvest = applyHarvest(toWrite, start, d11, d12, d13);
|
||||
// ЭТАП 2: садовник — подача из СВЕЖЕЙ live-проекции (видит свежие живые ветки и кандидаты), без надгробий
|
||||
const rG = await call('gardener', SYS_G, userG(slimProtocol(withHarvest), spanText, diff));
|
||||
const rG = await call('gardener', SYS_G, userG(slimProtocol(withHarvest), spanEx, 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); }
|
||||
|
||||
+50
-24
@@ -119,37 +119,63 @@ function clamp(s) {
|
||||
return s.slice(0, cap) + `…[вырезано ${s.length - cap} знаков]`;
|
||||
}
|
||||
|
||||
// Сколько ПОСЛЕДНИХ действий хода показывать ролям/редактору: длинный ход с десятками Read
|
||||
// не возим целиком (анти-раздувание). Суть хода всё равно фиксирует «step» редактора.
|
||||
export const MAX_EXCHANGE_ACTIONS = 7;
|
||||
// Сколько последних ходов спана подавать целиком: роли — 4, садовник/редактор — 7.
|
||||
export const TURNS_ROLE = 4;
|
||||
export const TURNS_GARDENER = 7;
|
||||
|
||||
/** Текст обмена из спана {user, assistant, actions}. Показываем последние MAX_EXCHANGE_ACTIONS действий. */
|
||||
export function renderExchangeText(spanEx) {
|
||||
const all = spanEx.actions || [];
|
||||
const shown = all.slice(-MAX_EXCHANGE_ACTIONS);
|
||||
const head = all.length > shown.length ? ` (показаны последние ${shown.length} из ${all.length} действий)\n` : '';
|
||||
const acts = (shown.map((a) => ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${clamp(String(a.result))}` : ''}`).join('\n')) || '—';
|
||||
return `[ЮЗЕР]: ${spanEx.user || ''}\n[АССИСТЕНТ]: ${spanEx.assistant || ''}\n[ДЕЙСТВИЯ]:\n${head}${acts}`;
|
||||
/** Последние n ходов спана целиком: [ХОД K] юзер+ассистент+ВСЕ действия. Пометка если ходов больше. */
|
||||
export function renderTurns(turns, n) {
|
||||
const all = Array.isArray(turns) ? turns : [];
|
||||
const shown = all.slice(-n);
|
||||
const head = all.length > shown.length ? `(показаны последние ${shown.length} из ${all.length} ходов)\n\n` : '';
|
||||
const acts = (list) => ((list || []).map((a) => ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${clamp(String(a.result))}` : ''}`).join('\n')) || ' —';
|
||||
return head + shown.map((t) =>
|
||||
`[ХОД ${t.turn}]\n[ЮЗЕР]: ${t.user || ''}\n[АССИСТЕНТ]: ${t.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts(t.actions)}`).join('\n\n');
|
||||
}
|
||||
|
||||
// ── USER-сборщики (берут готовый spanText) ────────────────────────────────
|
||||
export function user11(proto, spanText, span = {}) {
|
||||
return `НЕДАВНИЕ ШАГИ (ствол, последние 10):\n${recentSteps(proto)}\n\n${registry(proto)}\n\n`
|
||||
+ `=== ПОЛНЫЙ ОБМЕН (ходы ${span.start ?? '?'}-${span.end ?? '?'}) ===\n${spanText}`;
|
||||
/** Краткий лог ВСЕХ ходов минус последние n (они показаны целиком — не дублируем). */
|
||||
export function stepsMinusLast(proto, n) {
|
||||
const all = (proto.steps || []).slice().sort((a, b) => (a.turn || 0) - (b.turn || 0));
|
||||
const keep = n > 0 ? all.slice(0, Math.max(0, all.length - n)) : all;
|
||||
return keep.map((s) => `ход ${s.turn}: ${s.text}`).join('\n') || '(пока нет)';
|
||||
}
|
||||
export function user12(proto, spanText) {
|
||||
const will = ((proto.will || []).filter((e) => !e.struck).map((e) => `- ${e.text}`).join('\n')) || '(нет)';
|
||||
return `${trunkForCatcher(proto)}\nВОЛЯ (уже решено — НЕ лови):\n${will}\nЖИВЫЕ ВЕТКИ (уже ведутся — НЕ лови):\n${registry(proto)}${tombstones(proto)}`
|
||||
+ `\n\nВСЕ ШАГИ РАЗГОВОРА:\n${fullSteps(proto)}\n\n=== ТЕКУЩИЙ ОБМЕН ===\n${spanText}`;
|
||||
|
||||
/** Полный ствол: все 6 разделов, зачёркнутые помечены. */
|
||||
export function fullTrunk(proto) {
|
||||
const sec = (name, arr) => `${name}:\n` + (((arr || []).map((e) => ` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n')) || ' (пусто)');
|
||||
return [
|
||||
sec('Решения', proto.decisions), sec('Альтернативы', proto.alternatives),
|
||||
sec('Последствия', proto.consequences), sec('Воля', proto.will),
|
||||
sec('Открытые', proto.open), sec('Сделано', proto.doneNext),
|
||||
].join('\n');
|
||||
}
|
||||
export function user13(proto, spanText, span, found11, found12) {
|
||||
return `${user11(proto, spanText, span)}\nКАНДИДАТЫ (живые — точи своих, НЕ дублируй):\n${candidatesForPrompt(proto)}${THEMES_BLOCK(proto)}`
|
||||
+ `\n\nВСЕ ШАГИ РАЗГОВОРА:\n${fullSteps(proto)}`
|
||||
|
||||
/** База знаний: строки «знание — источник». */
|
||||
export function knowledgeBlock(proto) {
|
||||
return ((proto.knowledge || []).map((e) => ` - ${e.text}${e.ref ? ' — ' + e.ref : ''}`).join('\n')) || ' (пусто)';
|
||||
}
|
||||
|
||||
// ── USER-сборщики (богатый контекст: тема+база+ствол+ветки+кандидаты+N ходов+шаги−N) ──
|
||||
/** Общий контекст для diagnostic/catcher/brainstorm: n=TURNS_ROLE ходов целиком. */
|
||||
function roleContext(proto, spanEx, n) {
|
||||
return `ТЕМА: ${proto.subject || '(нет)'}\n\n`
|
||||
+ `БАЗА ЗНАНИЙ:\n${knowledgeBlock(proto)}\n\n`
|
||||
+ `СТВОЛ:\n${fullTrunk(proto)}\n\n`
|
||||
+ `${registry(proto)}\n\nЖИВЫЕ КАНДИДАТЫ:\n${candidatesForPrompt(proto)}${THEMES_BLOCK(proto)}\n\n`
|
||||
+ `=== ПОСЛЕДНИЕ ХОДЫ (целиком) ===\n${renderTurns(spanEx && spanEx.turns, n)}\n\n`
|
||||
+ `КРАТКИЙ ЛОГ ОСТАЛЬНЫХ ХОДОВ:\n${stepsMinusLast(proto, n)}`;
|
||||
}
|
||||
export function user11(proto, spanEx) { return roleContext(proto, spanEx, TURNS_ROLE); }
|
||||
export function user12(proto, spanEx) { return roleContext(proto, spanEx, TURNS_ROLE); }
|
||||
export function user13(proto, spanEx, found11, found12) {
|
||||
return `${roleContext(proto, spanEx, TURNS_ROLE)}`
|
||||
+ `\n\n=== УЖЕ НАЙДЕНО ЭТОТ ХОД (не повторяй) ===\nДиагностика: ${found11}\nБрошенное: ${found12}`;
|
||||
}
|
||||
export function userG(proto, spanText, diff) {
|
||||
return `ЧТО ИЗМЕНИЛОСЬ В СТВОЛЕ ЭТОТ ХОД (дифф редактора):\n${diff}\n\n${registry(proto)}\nЖИВЫЕ КАНДИДАТЫ (для моста «повысить»):\n${candidatesForPrompt(proto)}${tombstones(proto)}\n\n`
|
||||
+ `НЕДАВНИЕ ШАГИ:\n${recentSteps(proto)}\n\n=== ПОЛНЫЙ ОБМЕН ===\n${spanText}`;
|
||||
export function userG(proto, spanEx, diff) {
|
||||
return `ЧТО ИЗМЕНИЛОСЬ В СТВОЛЕ ЭТОТ ХОД (дифф редактора):\n${diff}\n\n`
|
||||
+ `${registry(proto)}\nЖИВЫЕ КАНДИДАТЫ (для моста «повысить»):\n${candidatesForPrompt(proto)}${THEMES_BLOCK(proto)}\n\n`
|
||||
+ `=== ПОСЛЕДНИЕ ХОДЫ (целиком) ===\n${renderTurns(spanEx && spanEx.turns, TURNS_GARDENER)}\n\n`
|
||||
+ `КРАТКИЙ ЛОГ ОСТАЛЬНЫХ ХОДОВ:\n${stepsMinusLast(proto, TURNS_GARDENER)}`;
|
||||
}
|
||||
|
||||
// Короткий заголовок пункта (для надгробий/реестра, когда title пуст).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SYS_11, SYS_12, SYS_13, diagFindings, renderExchangeText, user11, user12, user13, themesInUse, fullSteps, tombstones } from './secretary-harvest.mjs';
|
||||
import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG, themesInUse, fullSteps, tombstones } from './secretary-harvest.mjs';
|
||||
import { renderTurns, stepsMinusLast, fullTrunk, knowledgeBlock } from './secretary-harvest.mjs';
|
||||
|
||||
describe('secretary-harvest промпты', () => {
|
||||
it('SYS_11 требует отчёт по 8 линзам, finding|clean', () => {
|
||||
@@ -34,31 +35,43 @@ describe('diagFindings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('USER-сборщики', () => {
|
||||
it('renderExchangeText форматирует обмен с действиями', () => {
|
||||
const t = renderExchangeText({ user: 'спросил', assistant: 'ответил', actions: [{ tool: 'Read', input: 'f', result: 'r' }] });
|
||||
expect(t).toContain('[ЮЗЕР]: спросил');
|
||||
expect(t).toContain('[АССИСТЕНТ]: ответил');
|
||||
expect(t).toContain('• Read');
|
||||
describe('USER-сборщики (богатый контекст)', () => {
|
||||
const spanEx = (n = 2) => ({ turns: Array.from({ length: n }, (_, i) => ({ turn: i + 1, user: `u${i + 1}`, assistant: `a${i + 1}`, actions: [{ tool: 'Read', input: `f${i + 1}`, result: 'r' }] })) });
|
||||
const proto = () => ({
|
||||
subject: 'дело', knowledge: [{ ref: 'a.mjs:1', text: 'факт' }],
|
||||
decisions: [{ text: 'решено-X' }], alternatives: [], consequences: [], will: [{ text: 'воля-один' }], open: [{ text: 'открыто-Y' }], doneNext: [],
|
||||
hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }],
|
||||
candidates: [{ id: 'КД-1', branch: 'идея-кандидат', status: 'жив', тема: 't' }],
|
||||
steps: Array.from({ length: 6 }, (_, i) => ({ turn: i + 1, text: `шаг-${i + 1}` })),
|
||||
});
|
||||
it('user12 даёт ловцу ствол (решения/открытые) + обмен', () => {
|
||||
const proto = { decisions: [{ text: 'решено-X', struck: false }], open: [{ text: 'открыто-Y', struck: false }] };
|
||||
const u = user12(proto, '[ЮЗЕР]: q');
|
||||
expect(u).toContain('решено-X');
|
||||
expect(u).toContain('открыто-Y');
|
||||
expect(u).toContain('[ЮЗЕР]: q');
|
||||
it('user11/user12 несут тему, базу, ствол, ветки, кандидатов, 4 хода, шаги−4', () => {
|
||||
for (const build of [user11, user12]) {
|
||||
const u = build(proto(), spanEx(6));
|
||||
expect(u).toContain('дело');
|
||||
expect(u).toContain('факт — a.mjs:1');
|
||||
expect(u).toContain('решено-X');
|
||||
expect(u).toContain('воля-один');
|
||||
expect(u).toContain('СВ-1');
|
||||
expect(u).toContain('КД-1');
|
||||
expect(u).toContain('[ХОД 6]');
|
||||
expect(u).toMatch(/последние 4 из 6 ходов/);
|
||||
expect(u).toContain('шаг-1');
|
||||
expect(u).not.toContain('шаг-6');
|
||||
}
|
||||
});
|
||||
it('user11 несёт реестр и недавние шаги', () => {
|
||||
const proto = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }], steps: [{ text: 'Ход 1 — …' }] };
|
||||
const u = user11(proto, 'обмен', { start: 3, end: 4 });
|
||||
it('user13 = контекст + хвост «уже найдено»', () => {
|
||||
const u = user13(proto(), spanEx(2), '["находка"]', '["брошено"]');
|
||||
expect(u).toContain('СВ-1');
|
||||
expect(u).toContain('Ход 1');
|
||||
});
|
||||
it('user13 добавляет уже найденное', () => {
|
||||
const u = user13({}, 'обмен', { start: 3, end: 4 }, '["находка"]', '["брошено"]');
|
||||
expect(u).toContain('УЖЕ НАЙДЕНО');
|
||||
expect(u).toContain('находка');
|
||||
});
|
||||
it('userG = дифф + ветки + кандидаты + 7 ходов + шаги−7', () => {
|
||||
const u = userG(proto(), spanEx(2), 'ДИФФ-СТРОКА');
|
||||
expect(u).toContain('ДИФФ-СТРОКА');
|
||||
expect(u).toContain('СВ-1');
|
||||
expect(u).toContain('КД-1');
|
||||
expect(u).toContain('[ХОД 2]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('поля рождения (title/тема/важность)', () => {
|
||||
@@ -92,13 +105,6 @@ describe('брейншторм v2 (точит своих + полнота + ша
|
||||
const s = fullSteps({ steps: [{ turn: 2, text: 'b' }, { turn: 1, text: 'a' }] });
|
||||
expect(s.indexOf('ход 1')).toBeLessThan(s.indexOf('ход 2'));
|
||||
});
|
||||
it('user13 включает шаги, темы и живых кандидатов', () => {
|
||||
const p = { steps: [{ turn: 1, text: 'шаг-один' }], hidden: [],
|
||||
candidates: [{ id: 'КД-1', branch: 'идея-кандидат', status: 'жив', тема: 't' }], decisions: [], open: [] };
|
||||
const u = user13(p, 'обмен', { start: 1, end: 1 }, '[]', '[]');
|
||||
expect(u).toMatch(/шаг-один/);
|
||||
expect(u).toMatch(/идея-кандидат/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ловец прозрел (шаги+воля+реестр+надгробия)', () => {
|
||||
@@ -106,48 +112,42 @@ describe('ловец прозрел (шаги+воля+реестр+надгро
|
||||
const p = { hidden: [{ id: 'СВ-1', status: 'отпал', title: 'мёртвая', endedTurn: 2 }], candidates: [] };
|
||||
expect(tombstones(p)).toMatch(/СВ-1/);
|
||||
});
|
||||
it('user12 включает шаги, волю и реестр живых веток', () => {
|
||||
const p = { steps: [{ turn: 1, text: 'почта-вскользь' }],
|
||||
hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }],
|
||||
will: [{ text: 'воля-один' }], decisions: [], open: [], candidates: [], acceptance: [], tails: [] };
|
||||
const u = user12(p, 'обмен');
|
||||
expect(u).toMatch(/почта-вскользь/);
|
||||
expect(u).toMatch(/воля-один/);
|
||||
expect(u).toMatch(/СВ-1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderExchangeText input cap', () => {
|
||||
const bigSpan = () => ({ user: 'u', assistant: 'a', actions: [{ tool: 'perplexity', input: 'q', result: 'X'.repeat(9000) }] });
|
||||
it('default: no truncation — full result passes through', () => {
|
||||
delete process.env.SECRETARY_INPUT_CAP;
|
||||
const t = renderExchangeText(bigSpan());
|
||||
expect(t).toContain('X'.repeat(9000));
|
||||
expect(t).not.toContain('вырезано');
|
||||
describe('harvest — новые кирпичи подачи', () => {
|
||||
const turns = (n) => Array.from({ length: n }, (_, i) => ({ turn: i + 1, user: `u${i + 1}`, assistant: `a${i + 1}`, actions: [{ tool: 'Read', input: `f${i + 1}`, result: 'r' }] }));
|
||||
it('renderTurns — последние n ходов целиком, с пометкой если больше', () => {
|
||||
const t = renderTurns(turns(6), 4);
|
||||
expect(t).toContain('[ХОД 6]');
|
||||
expect(t).toContain('[ХОД 3]');
|
||||
expect(t).not.toContain('[ХОД 2]');
|
||||
expect(t).toContain('• Read in=f6');
|
||||
expect(t).toMatch(/последние 4 из 6 ходов/);
|
||||
});
|
||||
it('finite cap truncates the result with a marker', () => {
|
||||
process.env.SECRETARY_INPUT_CAP = '100';
|
||||
const t = renderExchangeText(bigSpan());
|
||||
expect(t).toContain('X'.repeat(100));
|
||||
expect(t).not.toContain('X'.repeat(101));
|
||||
expect(t).toMatch(/вырезано \d+ знаков/);
|
||||
delete process.env.SECRETARY_INPUT_CAP;
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderExchangeText — последние 7 действий', () => {
|
||||
it('режет до последних 7 и помечает сколько было', () => {
|
||||
const actions = Array.from({ length: 10 }, (_, i) => ({ tool: 'Read', input: `f${i}`, result: `r${i}` }));
|
||||
const t = renderExchangeText({ user: 'u', assistant: 'a', actions });
|
||||
expect(t).toContain('f9'); // последнее показано
|
||||
expect(t).toContain('f3'); // граница последних 7 (индексы 3..9)
|
||||
expect(t).not.toContain('f2'); // 8-е с конца отброшено
|
||||
expect(t).toMatch(/последние 7 из 10/);
|
||||
});
|
||||
it('≤7 действий — без пометки', () => {
|
||||
const actions = Array.from({ length: 5 }, (_, i) => ({ tool: 'Read', input: `f${i}`, result: `r${i}` }));
|
||||
const t = renderExchangeText({ user: 'u', assistant: 'a', actions });
|
||||
expect(t).toContain('f0');
|
||||
it('renderTurns — ≤n ходов: без пометки, все', () => {
|
||||
const t = renderTurns(turns(2), 4);
|
||||
expect(t).toContain('[ХОД 1]');
|
||||
expect(t).not.toMatch(/последние \d+ из/);
|
||||
});
|
||||
it('stepsMinusLast — все шаги минус последние n', () => {
|
||||
const p = { steps: Array.from({ length: 6 }, (_, i) => ({ turn: i + 1, text: `шаг-${i + 1}` })) };
|
||||
const s = stepsMinusLast(p, 4);
|
||||
expect(s).toContain('шаг-1');
|
||||
expect(s).toContain('шаг-2');
|
||||
expect(s).not.toContain('шаг-3');
|
||||
expect(s).not.toContain('шаг-6');
|
||||
});
|
||||
it('fullTrunk — все 6 разделов, зачёркнутые помечены', () => {
|
||||
const p = { decisions: [{ text: 'жив-реш' }, { text: 'мёртв-реш', struck: true }], will: [{ text: 'воля' }] };
|
||||
const t = fullTrunk(p);
|
||||
expect(t).toContain('Решения:');
|
||||
expect(t).toContain('жив-реш');
|
||||
expect(t).toContain('[зачёркнуто] мёртв-реш');
|
||||
expect(t).toContain('Альтернативы:');
|
||||
expect(t).toContain('Сделано:');
|
||||
});
|
||||
it('knowledgeBlock — строки знание — источник; пусто → (пусто)', () => {
|
||||
expect(knowledgeBlock({ knowledge: [{ ref: 'a.mjs:1', text: 'факт' }] })).toContain('факт — a.mjs:1');
|
||||
expect(knowledgeBlock({})).toContain('(пусто)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Секретарь-«редактор»: модель правит весь протокол, хук сторожит потери (спека reconcile).
|
||||
import { stepsMinusLast } from './secretary-harvest.mjs';
|
||||
|
||||
// Сколько ПОСЛЕДНИХ ходов спана подавать редактору целиком (юзер+ассистент+ВСЕ действия хода).
|
||||
const MAX_EXCHANGE_TURNS = 7;
|
||||
@@ -54,6 +55,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),
|
||||
'', 'Краткий лог остальных ходов:',
|
||||
stepsMinusLast(protocol, MAX_EXCHANGE_TURNS),
|
||||
'', 'Последние ходы (обмен):',
|
||||
exchangeText,
|
||||
remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '',
|
||||
|
||||
@@ -146,6 +146,16 @@ describe('buildReconcilePrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReconcilePrompt — шаги−7', () => {
|
||||
it('добавляет краткий лог всех ходов минус последние 7', () => {
|
||||
const steps = Array.from({ length: 9 }, (_, i) => ({ turn: i + 1, text: `шаг-${i + 1}` }));
|
||||
const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [], steps }, lastExchange: { turns: [] } });
|
||||
expect(user).toContain('шаг-1');
|
||||
expect(user).toContain('шаг-2');
|
||||
expect(user).not.toContain('шаг-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile — 9 категорий + стабильная тема', () => {
|
||||
it('parse читает alternatives и consequences', () => {
|
||||
const out = parseReconcileResponse('{ "subject":"S", "alternatives":[{"text":"ALT","struck":false}], "consequences":[{"text":"C","struck":false}] }');
|
||||
|
||||
@@ -14,5 +14,13 @@ export function diffTrunk(oldP, newP) {
|
||||
else if (prev.struck && !e.struck) lines.push(`${label} расчёркнут (снова актуален): «${e.text}»`);
|
||||
}
|
||||
}
|
||||
const oldK = new Map(((oldP.knowledge) || []).map((e) => [String(e.ref || '').trim(), String(e.text || '').trim()]));
|
||||
for (const e of (newP.knowledge) || []) {
|
||||
const ref = String(e.ref || '').trim();
|
||||
const text = String(e.text || '').trim();
|
||||
if (!ref) continue;
|
||||
if (!oldK.has(ref)) lines.push(`ЗНАНИЕ добавлено: «${text} — ${ref}»`);
|
||||
else if (oldK.get(ref) !== text) lines.push(`ЗНАНИЕ уточнено: «${text} — ${ref}»`);
|
||||
}
|
||||
return lines.length ? lines.join('\n') : '(ствол не двигался этот ход)';
|
||||
}
|
||||
|
||||
@@ -14,3 +14,15 @@ describe('diffTrunk', () => {
|
||||
expect(d).toContain('ОТКРЫТЫЙ зачёркнут (закрыт ствол): «Q»');
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffTrunk — база знаний', () => {
|
||||
it('показывает добавленное и уточнённое знание', () => {
|
||||
const oldP = { knowledge: [{ ref: 'a.mjs:1', text: 'старое' }] };
|
||||
const newP = { knowledge: [{ ref: 'a.mjs:1', text: 'уточнённое' }, { ref: 'b.mjs:2', text: 'новое' }] };
|
||||
const d = diffTrunk(oldP, newP);
|
||||
expect(d).toContain('ЗНАНИЕ уточнено');
|
||||
expect(d).toContain('a.mjs:1');
|
||||
expect(d).toContain('ЗНАНИЕ добавлено');
|
||||
expect(d).toContain('b.mjs:2');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user