feat(round-memory): память кругов в промптах judge/mentor + split M/J (SP2b)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-16 15:59:39 +03:00
parent 51654894c8
commit 5d7035875c
8 changed files with 108 additions and 8 deletions
+5 -3
View File
@@ -14,6 +14,7 @@
import { canonicalJson } from './receipt-sign.mjs';
import { validateVerdictSlots } from './judge-verdict-slots.mjs';
import { partitionObjections } from './judge-anchor.mjs';
import { renderRoundMemory } from './round-memory-render.mjs';
import { requiredSubRunsPresent } from './judge-subrun-journal.mjs';
export const VOTE_LAYOUTS = Object.freeze({
@@ -41,10 +42,10 @@ export function requiredLensesFor(functionName, { risk = false } = {}) {
}
/** Чистый построитель промпта судьи: {system, user}. Детерминирован. */
export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [] }) {
export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [], roundMemory = {} }) {
const system = [
'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.',
'Ты СЛЕП к переписке и рассуждениям сторон — судишь только поданный продукт против цели.',
'Ты СЛЕП к переписке наставника со сторонами — судишь продукт против цели; учитываешь лишь СВОИ прошлые замечания и доводы контроллера лично тебе.',
'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).',
`Функция: ${functionName}.`,
`Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${requiredLenses.join(', ')}.`,
@@ -55,7 +56,8 @@ export function buildJudgePrompt({ functionName, requiredLenses = [], product =
`ЦЕЛЬ (неизменный якорь): ${goal}`,
`КАРТОЧКИ НАВЫКОВ: ${canonicalJson(cards)}`,
`ПРОДУКТ НА СУД: ${canonicalJson(product)}`,
].join('\n');
renderRoundMemory(roundMemory),
].filter(Boolean).join('\n');
return { system, user };
}
+6
View File
@@ -36,6 +36,12 @@ describe('buildJudgePrompt (чистая, детерминированная; с
for (const c of PREMORTEM_CLASSES) expect(system).toContain(c);
expect(user).toContain('лендинг');
});
it('круг 1 слеп: пустой roundMemory → нет блока; непустой → блок памяти в user', () => {
expect(buildJudgePrompt(args).user).not.toContain('ПАМЯТЬ КРУГОВ');
const withMem = buildJudgePrompt({ ...args, roundMemory: { objections: ['моё прошлое замечание'] } });
expect(withMem.user).toContain('ПАМЯТЬ КРУГОВ');
expect(withMem.user).toContain('моё прошлое замечание');
});
});
describe('classifyByReversibility (G1: фатальное=тяжёлое И необратимое → блок)', () => {
+7 -2
View File
@@ -39,6 +39,7 @@ export function isMentorVerdictSubstantive(verdict, { wired = false } = {}) {
// mentor-seam (дубль-литералы с дрейфом сняты; циклов нет — seam verdict не импортирует).
import { parseRouterResponse } from './router-engine.mjs';
import { DR1_LINE, renderVerifiedContext, renderNegotiation } from './mentor-seam.mjs';
import { renderRoundMemory } from './round-memory-render.mjs';
/**
* Промпт-производитель вердикта (§6.1): зеркало идиомы buildRouterPrompt {system,user},
@@ -47,10 +48,11 @@ import { DR1_LINE, renderVerifiedContext, renderNegotiation } from './mentor-sea
* контекст + журнал переговоров (единые рендеры seam, VA-1; пустой контекст НЕ молчит,
* VA-2) + граф-секция (опора наставника). A8 (нах.F6): system несёт ДР-1 гранулярность.
*/
export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null, skillContext = null } = {}) {
export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null, skillContext = null, roundMemory = {} } = {}) {
const system = [
'Ты — НАСТАВНИК. Разбери ПЛАН ПО ПУНКТАМ (не выбирай скил — это другой вызов).',
DR1_LINE,
'Переоцени ТЕКУЩУЮ версию заново по памяти кругов ниже; что уже снято — НЕ повторяй.',
// Smoke 2026-06-12: «статус+замечание» без типа элемента провоцировал массив объектов —
// валидатор (F-C3/М1-М4: слот = строки) браковал содержательный вердикт. Тип — явно.
'Вынеси РЕШЕНИЕ: decision="GO" если можно реализовывать, decision="NO-GO" если нужна переделка (тогда в recommendation — ЧТО править).',
@@ -60,6 +62,7 @@ export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], ne
`ПЛАН: ${plan ? JSON.stringify(plan) : '(нет)'}`,
renderVerifiedContext(verifiedContext),
renderNegotiation(negotiationLog),
renderRoundMemory(roundMemory),
graphSection ? `--- ГРАФ (карта районов) ---\n${JSON.stringify(graphSection.layer0 || [])}` : '',
skillContext ? skillContext : '',
].filter(Boolean).join('\n');
@@ -95,10 +98,11 @@ export async function runMentorVerdict({ plan = null, verifiedContext = [], nego
* Зеркало buildMentorVerdictPrompt, но просит разбор СПЕКИ по разделам (не плана по пунктам).
* Наставник ВИДИТ контекст (verified-context + переговоры — Р6), судья — нет. Те же слоты.
*/
export function buildMentorSpecVerdictPrompt({ specContent = '', verifiedContext = [], negotiationLog = [], graphSection = null } = {}) {
export function buildMentorSpecVerdictPrompt({ specContent = '', verifiedContext = [], negotiationLog = [], graphSection = null, roundMemory = {} } = {}) {
const system = [
'Ты — НАСТАВНИК. Разбери СПЕКУ ПО РАЗДЕЛАМ (это спецификация решения, не план; скил не выбираешь).',
DR1_LINE,
'Переоцени ТЕКУЩУЮ версию заново по памяти кругов ниже; что уже снято — НЕ повторяй.',
'Вынеси РЕШЕНИЕ: decision="GO" если спеку можно принять, decision="NO-GO" если нужна переделка (тогда в recommendation — ЧТО править).',
'ВЫВОД — строго JSON-вердикт со слотами: plan_points_addressed (массив СТРОК — по КАЖДОМУ разделу/решению спеки ровно одна строка вида «раздел X: статус — замечание»; элемент-объект недопустим, не объект а строка), reasoning (строка-разбор), recommendation (строка — что править), confidence (число 0..1), decision ("GO"|"NO-GO"). Пустой слот недопустим.',
].join('\n');
@@ -106,6 +110,7 @@ export function buildMentorSpecVerdictPrompt({ specContent = '', verifiedContext
`СПЕКА:\n${specContent || '(нет)'}`,
renderVerifiedContext(verifiedContext),
renderNegotiation(negotiationLog),
renderRoundMemory(roundMemory),
graphSection ? `--- ГРАФ (карта районов) ---\n${JSON.stringify(graphSection.layer0 || [])}` : '',
].filter(Boolean).join('\n');
return { system, user };
+5
View File
@@ -137,6 +137,11 @@ describe('buildMentorVerdictPrompt (§6.1 verdict-слоты, НЕ router)', ()
expect(p.system).toMatch(/массив СТРОК/);
expect(p.system).toMatch(/не объект/i);
});
it('roundMemory: пусто → нет блока; непусто → блок памяти (оба построителя)', () => {
expect(buildMentorVerdictPrompt({ plan: { steps: [] } }).user).not.toContain('ПАМЯТЬ КРУГОВ');
expect(buildMentorVerdictPrompt({ plan: { steps: [] }, roundMemory: { objections: ['замечание M'] } }).user).toContain('замечание M');
expect(buildMentorSpecVerdictPrompt({ specContent: '# с', roundMemory: { objections: ['замечание M2'] } }).user).toContain('замечание M2');
});
});
describe('runMentorVerdict (§6.1 производитель + binding нах.F4)', () => {
+15 -1
View File
@@ -15,7 +15,21 @@ export function parseNegotiationSection(md) {
const re = /(?:^|\n)###\s*Круг\s*(\d+)[^\n]*\n([\s\S]*?)(?=\n###\s|$)/gi;
let m;
while ((m = re.exec(body)) !== null) {
out.push({ round: Number(m[1]), position: m[2].trim() });
const position = m[2].trim();
out.push({
round: Number(m[1]),
position,
mentor: extractAddressee(position, 'Наставнику'),
judge: extractAddressee(position, 'Судье'),
});
}
return out;
}
/** Достаёт текст, адресованный стороне (`**Наставнику:**` / `**Судье:**`), до следующего
* адресата или конца. Нет адресата → пустая строка (обратная совместимость с position). */
function extractAddressee(body, label) {
const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*([\\s\\S]*?)(?=\\n?\\*\\*(?:Наставнику|Судье):\\*\\*|$)`, 'i');
const mm = String(body || '').match(re);
return mm ? mm[1].trim() : '';
}
+12 -2
View File
@@ -16,10 +16,20 @@ describe('parseNegotiationSection', () => {
it('достаёт круги с дословным текстом позиции', () => {
const r = parseNegotiationSection(PLAN);
expect(r).toEqual([
{ round: 1, position: 'Не согласен с замечанием про шаг 2: он атомарен.' },
{ round: 2, position: 'Принял про шаг 4, переписал.' },
{ round: 1, position: 'Не согласен с замечанием про шаг 2: он атомарен.', mentor: '', judge: '' },
{ round: 2, position: 'Принял про шаг 4, переписал.', mentor: '', judge: '' },
]);
});
it('делит доводы по дорожкам M/J внутри круга', () => {
const plan = `## Переговоры
### Круг 1
**Наставнику:** не согласен с пунктом 2, он атомарен
**Судье:** критерий в разделе K
## Шаги`;
const r = parseNegotiationSection(plan);
expect(r[0].mentor).toBe('не согласен с пунктом 2, он атомарен');
expect(r[0].judge).toBe('критерий в разделе K');
});
it('нет раздела → пустой массив', () => {
expect(parseNegotiationSection('# План\n## Цель\nY')).toEqual([]);
});
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
/** round-memory-render — чистый рендер блока «ПАМЯТЬ КРУГОВ» для промптов судьи/наставника (SP2b).
* Пустой вход → пустая строка (круг 1 слеп). Свои замечания и доводы контроллера — дословно.
* Что класть (свои J/M-замечания, J/M-доводы, какой diff, замечание судьи при возврате) — решает
* вызыватель (оркестрация, SP2c); этот модуль лишь рендерит то, что дали. */
export function renderRoundMemory(mem = {}) {
const {
versionDiff = '',
objections = [],
args = [],
judgeObjectionOnReturn = '',
} = mem || {};
const parts = [];
if (Array.isArray(objections) && objections.length) {
parts.push('--- ТВОИ ПРОШЛЫЕ ЗАМЕЧАНИЯ (дословно) ---');
objections.forEach((o, i) => parts.push(`${i + 1}. ${o}`));
}
if (Array.isArray(args) && args.length) {
parts.push('--- ДОВОДЫ КОНТРОЛЛЕРА (дословно) ---');
args.forEach((a, i) => parts.push(`${i + 1}. ${a}`));
}
if (versionDiff) {
parts.push('--- ИЗМЕНЕНИЯ С ПРОШЛОЙ ВЕРСИИ (diff) ---');
parts.push(versionDiff);
}
if (judgeObjectionOnReturn) {
parts.push('--- ЗАМЕЧАНИЕ СУДЬИ (учесть при доработке) ---');
parts.push(judgeObjectionOnReturn);
}
if (parts.length === 0) return '';
return ['=== ПАМЯТЬ КРУГОВ ===', ...parts].join('\n');
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { renderRoundMemory } from './round-memory-render.mjs';
describe('renderRoundMemory', () => {
it('пустой вход → пустая строка (круг 1 слеп)', () => {
expect(renderRoundMemory()).toBe('');
expect(renderRoundMemory({})).toBe('');
expect(renderRoundMemory({ objections: [], args: [] })).toBe('');
});
it('замечания дословно', () => {
const r = renderRoundMemory({ objections: ['правь раздел X'] });
expect(r).toContain('ТВОИ ПРОШЛЫЕ ЗАМЕЧАНИЯ');
expect(r).toContain('правь раздел X');
});
it('доводы контроллера дословно', () => {
const r = renderRoundMemory({ args: ['не согласен, потому что Y'] });
expect(r).toContain('ДОВОДЫ КОНТРОЛЛЕРА');
expect(r).toContain('не согласен, потому что Y');
});
it('diff версий и замечание судьи при возврате', () => {
const r = renderRoundMemory({ versionDiff: '+ новая строка', judgeObjectionOnReturn: 'судья: нет критерия' });
expect(r).toContain('+ новая строка');
expect(r).toContain('судья: нет критерия');
});
});