Files
brain/tools/mentor-seam.mjs
T

172 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* mentor-seam (§6) — живой шов наставника. ч.1 buildMentorPrompt: обёртка buildRouterPrompt
* + проверенный контекст (из A, данные) + журнал переговоров + граф-секция (выход B, данные).
* НЕ импортирует A/B — секция/контекст ПЕРЕДАЮТСЯ (автономность C; боевая проводка передаёт
* реальные). Граф = ОТДЕЛЬНАЯ секция (CD-R6-G); контекст = ПРОВЕРЕННЫЙ артефакт (CD-R6-H).
* FR-2 (финревью 2026-06-11, снял Д-1а-обход): карту районов+staleness рендерит БАЗА
* buildRouterPrompt в system (W1-канон, CD-R6-G); seam НЕ дублирует. Extras добавляют
* их в user ТОЛЬКО при пустом/отсутствующем layer0 — маркер ОТСУТСТВИЯ (F-C6) + staleness (✅O16).
*/
import { buildRouterPrompt } from './router-engine.mjs';
/** A8 (нах.F6): ДР-1 гранулярность — поведенческая инъекция в system.
* VA-1 (финревью 3/5): ЕДИНЫЙ источник — экспортируется (mentor-verdict импортирует,
* дубль-литерал снят). */
export const DR1_LINE = 'ЕДИНИЦА ПЛАНА (ДР-1): растяжка до следующей НЕИЗВЕСТНОСТИ; громкость церемонии по риску — detectHighRisk (atomic/trivial → тихо; прод/многошаг/разрушительное → громко).';
function renderDistricts(graphSection) {
const layer0 = graphSection && Array.isArray(graphSection.layer0) ? graphSection.layer0 : [];
// F-C6 (sharp-edges): слой-0 ВСЕГДА (ДР-3) + «сигнал ВСЕГДА» (§5.4) — отсутствие карты
// НЕ молчит: явный маркер видят и наставник, и владелец (зеркало staleness-философии).
if (!layer0.length) return '--- ГРАФ ПРОЕКТА: КАРТА РАЙОНОВ ОТСУТСТВУЕТ (слой-0 не передан/пуст — ДР-3 требует ВСЕГДА) ---';
const lines = layer0.map((d) => {
const top = Array.isArray(d.topNodes) ? d.topNodes.map((n) => n.id).join(', ') : '';
return `${d.district} (${d.nodeCount} узлов)${top ? ` — топ: ${top}` : ''}`;
});
return ['--- ГРАФ ПРОЕКТА: КАРТА РАЙОНОВ (layer-0, прунённая) ---', ...lines].join('\n');
}
function renderStaleness(graphSection) {
const st = graphSection && graphSection.staleness;
if (!st) return '';
return `--- СВЕЖЕСТЬ ГРАФА (staleness) --- stale=${!!st.stale} commits_behind=${st.commits_behind ?? '?'} uncommitted=${st.uncommitted ?? '?'}`;
}
/** VA-1 (финревью 3/5): ЕДИНЫЙ рендер контекста — экспортируется (mentor-verdict
* импортирует; дубль-рендер с дрейфом снят). VA-2 («сигнал-всегда», зеркало F-C6):
* пустой список НЕ молчит — наставник видит, что контекста НЕТ (VA-9 ловит на печати,
* а круги переговоров идут до неё). */
export function renderVerifiedContext(vc) {
const list = Array.isArray(vc) ? vc : [];
if (!list.length) return '--- ПРОВЕРЕННЫЙ КОНТЕКСТ ПУСТ (0 записей — печать заблокирует VA-9) ---';
const lines = list.map((e) => `[${e.kind || '?'}] ${e.claim || ''} (${e.ref || ''})`);
return ['--- ПРОВЕРЕННЫЙ КОНТЕКСТ (резолв цитат, НЕ истина — ревью владельца обязателен) ---', ...lines].join('\n');
}
/** VA-1: единый рендер переговоров (export — см. выше). Пустой лог легитимен
* (круг 1 без истории) → '' без маркера. */
export function renderNegotiation(log) {
const list = Array.isArray(log) ? log : [];
if (!list.length) return '';
const lines = list.map((e) => `круг ${e.round} [${e.side}] ${e.utterance} — обоснование: ${e.justification || ''}`);
return ['--- ПЕРЕГОВОРЫ ЗАДАЧИ (история кругов) ---', ...lines].join('\n');
}
/** Резолв ID узла → «#N — имя» через registry.indexById; нет узла/реестра → голый #N (fail-safe). */
function resolveNodeName(id, registry) {
const node = registry && registry.indexById && typeof registry.indexById.get === 'function'
? registry.indexById.get(id) : null;
const label = node && (node.name || node.slug);
return label ? `${id}${label}` : String(id);
}
/** Рендер скил-контекста для промпта наставника (мерж): объявленные в плане скилы +
* рекомендация роутера (ID резолвятся в имена через registry). recommendedChain=null →
* классификатор недоступен (не сверять). */
export function renderSkillContext({ declared = [], recommendedChain = null, registry = null } = {}) {
const dec = declared.length ? declared.join(', ') : '(не объявлены)';
const rec = recommendedChain === null
? '(рекомендация роутера недоступна — НЕ заворачивай за скилы)'
: (Array.isArray(recommendedChain) && recommendedChain.length
? recommendedChain.map((id) => resolveNodeName(id, registry)).join(', ')
: '(роутер ничего не порекомендовал)');
return `--- СКИЛЫ ---\nОбъявлены в плане: ${dec}\nРекомендация роутера: ${rec}\n`
+ 'Оцени уместность выбора скилов; неуместный/неполный выбор → decision="NO-GO" + что добавить/убрать.';
}
/**
* Построить промпт наставника = buildRouterPrompt(graph=граф-секция B; районы+staleness
* рендерит БАЗА в system — W1-канон, FR-2 финревью 2026-06-11) + проверенный контекст +
* журнал переговоров — в user (волатильное); system = router-system + ДР-1 (A8).
* Extras рендерят районы/staleness ТОЛЬКО при пустом/отсутствующем layer0 (база молчит):
* маркер ОТСУТСТВИЯ карты (F-C6 «сигнал-всегда») + staleness-строка (✅O16). {system,user}.
*/
export function buildMentorPrompt({ prompt = '', plan = null, graphSection = null, verifiedContext = [], negotiationLog = [], catalog = null, contracts = [] } = {}) {
const base = buildRouterPrompt({ prompt, plan, graph: graphSection, catalog, contracts });
const baseRendersGraph = graphSection && Array.isArray(graphSection.layer0) && graphSection.layer0.length > 0;
const extras = [
baseRendersGraph ? '' : renderDistricts(graphSection),
baseRendersGraph ? '' : renderStaleness(graphSection),
renderVerifiedContext(verifiedContext),
renderNegotiation(negotiationLog),
].filter(Boolean).join('\n');
return {
system: [base.system, DR1_LINE].join('\n'),
user: [base.user, extras].filter(Boolean).join('\n'),
};
}
// Task 5 — runMentorRound + петля: импорт здесь валиден (ESM hoisting).
import { parseRouterResponse, validateTrace, groundTrace, chosenIsCandidate } from './router-engine.mjs';
/** §6.3 SE-7: потолок кругов наставника per-ЗАДАЧЕ (×MENTOR_PROBE_CAP probe в D). */
export const MENTOR_ROUND_CAP = 3;
/** §5.3 F7 (✅O19-зеркало): дозапросов соседнего района на КРУГ — дешёвое чтение
* КАРТЫ (districtDetail из B), не файлов. Сверх cap → отказ. */
export const DISTRICT_PROBE_CAP = 2;
/**
* Один круг наставника: buildMentorPrompt → llmCall (инъект; живой транспорт — владелец) →
* пайплайн runRouter (validateTrace/groundTrace/chosenIsCandidate/abstain). wired:true только
* при реальном завершённом вызове (SE-R6-6: сбой/stub → wired:false, НЕ суд).
* A2 (SE2): groundingGraph = ПОЛНЫЙ нод-граф (заземление groundTrace, CD-R6-G «против
* полного»); graphSection = прунённая карта районов B (в промпт). НЕ путать — разные
* формы, своп ломает заземление.
* F7 (A6/Н-3): трасса с request_district:<name> → конструктивное воздержание с сигналом
* escalateDistrict (контроллер фетчит districtDetail(name) из B в graphSection следующего
* круга); districtProbesUsed ≥ DISTRICT_PROBE_CAP → отказ (бюджет круга исчерпан).
* F-C5 (sharp-edges, secure default): дефолт districtProbesUsed = DISTRICT_PROBE_CAP —
* ОМИССИЯ счётчика НЕ даёт безлимитные дозапросы (configuration cliff); реальный
* per-round счётчик ОБЯЗАН вести производитель (C2-W3), передавая фактическое значение.
*/
export async function runMentorRound({ prompt, plan = null, groundingGraph, graphSection = null, verifiedContext = [], negotiationLog = [], catalog = null, contracts = [], llmCall, confidenceThreshold = 0.5, districtProbesUsed = DISTRICT_PROBE_CAP }) {
let trace;
try {
trace = await llmCall({ buildPrompt: () => buildMentorPrompt({ prompt, plan, graphSection, verifiedContext, negotiationLog, catalog, contracts }) });
} catch {
return { ok: false, wired: false, abstain: false, escalateDistrict: null, reason: 'сбой вызова наставника', trace: null };
}
if (typeof trace === 'string') trace = parseRouterResponse(trace);
if (!trace) return { ok: false, wired: false, abstain: false, escalateDistrict: null, reason: 'пустой/неразборный ответ наставника', trace: null };
const reqDistrict = typeof trace.request_district === 'string' ? trace.request_district.trim() : '';
if (reqDistrict) {
if (Number(districtProbesUsed) >= DISTRICT_PROBE_CAP) {
return { ok: false, wired: true, abstain: false, escalateDistrict: null, reason: `дозапрос района сверх DISTRICT_PROBE_CAP=${DISTRICT_PROBE_CAP} на круг — отказ (F7)`, trace };
}
// VA-3 (финревью 3/5): при непустой карте (layer0) имя района проверяется — опечатка/
// выдумка наставника НЕ эскалируется молча к пустой детали districtDetail. Пустая/
// отсутствующая карта → валидировать нечем, старое поведение (контроллер разрулит).
const known = (graphSection && Array.isArray(graphSection.layer0)) ? graphSection.layer0 : [];
if (known.length && !known.some((d) => d && d.district === reqDistrict)) {
return { ok: false, wired: true, abstain: false, escalateDistrict: null, reason: `запрошенный район «${reqDistrict}» отсутствует в карте районов — отказ (VA-3)`, trace };
}
return { ok: true, wired: true, abstain: true, escalateDistrict: reqDistrict, reason: 'дозапрос соседнего района — конструктивное воздержание (F7, ДР-P1)', trace };
}
const v = validateTrace(trace);
if (!v.ok) return { ok: false, wired: true, abstain: false, escalateDistrict: null, reason: `невалидная трасса: [${v.missingSlots.join(', ')}]`, trace };
const g = groundTrace(trace, groundingGraph);
if (!g.grounded) return { ok: false, wired: true, abstain: false, escalateDistrict: null, reason: `выдуманные узлы: [${g.invented.join(', ')}]`, trace };
if (!chosenIsCandidate(trace)) return { ok: false, wired: true, abstain: false, escalateDistrict: null, reason: 'выбор не среди кандидатов (§6.3)', trace };
if (trace.confidence < confidenceThreshold) return { ok: true, wired: true, abstain: true, escalateDistrict: null, reason: 'низкая уверенность — воздержание (5.2): переспрос/эскалация', trace };
return { ok: true, wired: true, abstain: false, escalateDistrict: null, trace };
}
/**
* Петля кругов (§4/6.4): крутит runRoundImpl пока не согласовано ЛИБО потолок кругов
* (→ эскалация владельцу, симметрия ДР-6 в ОБА направления). resolved = ok && !abstain.
* Каждый круг — забота вызывающего записать в mentor-journal (вне чистой петли). nowRound —
* текущий счётчик кругов задачи (из roundCount). @returns {{resolved, escalate, round, last}}
*/
export async function mentorLoop({ runRoundImpl, nowRound = 0, cap = MENTOR_ROUND_CAP }) {
let round = nowRound;
let last = null;
while (round < cap) {
round += 1;
last = await runRoundImpl(round);
if (last && last.ok && !last.abstain) return { resolved: true, escalate: false, round, last };
}
return { resolved: false, escalate: true, round, last, reason: `${cap} круга без согласия → к владельцу (ДР-6)` };
}