Files
portal/tools/mentor-seam.mjs
T

172 lines
14 KiB
JavaScript
Raw Normal View History

#!/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)` };
}