397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
14 KiB
JavaScript
172 lines
14 KiB
JavaScript
#!/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)` };
|
||
}
|