diff --git a/tools/judge-engine.mjs b/tools/judge-engine.mjs index b0b0023..93c855e 100644 --- a/tools/judge-engine.mjs +++ b/tools/judge-engine.mjs @@ -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 }; } diff --git a/tools/judge-engine.test.mjs b/tools/judge-engine.test.mjs index ffc5cb1..d018d39 100644 --- a/tools/judge-engine.test.mjs +++ b/tools/judge-engine.test.mjs @@ -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: фатальное=тяжёлое И необратимое → блок)', () => { diff --git a/tools/mentor-verdict.mjs b/tools/mentor-verdict.mjs index d992c05..179f44b 100644 --- a/tools/mentor-verdict.mjs +++ b/tools/mentor-verdict.mjs @@ -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 }; diff --git a/tools/mentor-verdict.test.mjs b/tools/mentor-verdict.test.mjs index 72e70ab..cdd2535 100644 --- a/tools/mentor-verdict.test.mjs +++ b/tools/mentor-verdict.test.mjs @@ -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)', () => { diff --git a/tools/negotiation-section.mjs b/tools/negotiation-section.mjs index bf9e37c..082e9f5 100644 --- a/tools/negotiation-section.mjs +++ b/tools/negotiation-section.mjs @@ -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() : ''; +} diff --git a/tools/negotiation-section.test.mjs b/tools/negotiation-section.test.mjs index d242242..0773764 100644 --- a/tools/negotiation-section.test.mjs +++ b/tools/negotiation-section.test.mjs @@ -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([]); }); diff --git a/tools/round-memory-render.mjs b/tools/round-memory-render.mjs new file mode 100644 index 0000000..2447d54 --- /dev/null +++ b/tools/round-memory-render.mjs @@ -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'); +} diff --git a/tools/round-memory-render.test.mjs b/tools/round-memory-render.test.mjs new file mode 100644 index 0000000..b5b5b8d --- /dev/null +++ b/tools/round-memory-render.test.mjs @@ -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('судья: нет критерия'); + }); +});