Files
portal/tools/brain-retro-opus-reviewer.mjs
T
Дмитрий a0bb11a6fb perf(brain-retro): prompt-caching split on reviewer-agent
Add buildReviewPromptStructured() returning { system, user } and route
reviewViaDirectApi through callAnthropicAPI's structured branch — same
pattern the classifier already uses (router-classifier.mjs L456-484), so
infrastructure is reused, no new transport code.

system block: static instructions + 8-dim cues + schema-version notes
(byte-identical across episodes of the same schema_version → cache key
stable within a 5-min TTL).
user block: per-episode JSON (volatile).

Effect on Opus 4.7: ~zero until system grows past 4096-token cache-
minimum or model switches to Sonnet (2048 min). Anthropic silently
no-ops cache_control when prefix is below the minimum — no error,
cache_creation_input_tokens just stays at 0. Architecturally correct
and future-proof; activates the moment either condition flips.

buildReviewPrompt() kept as backward-compat wrapper.

Tests: +5 invariants for the split + cache-prerequisite check
(system identical across two v4 episodes with different bodies).
14/14 GREEN.

ремонт: фикс инфраструктуры стоимости — split prompt для активации
prompt caching на reviewer-agent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:48:20 +03:00

154 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* brain-retro reviewer — direct Opus API fallback handler (Phase 3 Task 18).
*
* Spec §4.6: the primary reviewer is a Claude Code subagent
* (`.claude/agents/reviewer-agent.md`) spawned via Task() from /brain-retro.
* THIS module is the FALLBACK handler invoked by the controller when the
* subagent crashes / times out: direct Opus API call with the same adaptive
* review prompt (but no cross-episode reading, no skill invocations).
*
* Pure layer: buildReviewPrompt + parseReview (this file's tests). Network
* layer: reviewViaDirectApi (zero-cost wrapper around router-classifier's
* callAnthropicAPI; the controller decides when to call it).
*
* G16 — file did not exist before Phase 3 Task 18; created here.
*/
import { REVIEWER_MODEL } from './router-config.mjs';
const REQUIRED_REVIEW_FIELDS = [
'node_quality',
'chain_quality',
'gap_assessment',
'agent_self_assessment_accuracy',
'error_root_cause',
'outcome_reviewed',
'reasoning',
];
/**
* Build the adaptive review prompt as { system, user } blocks for Anthropic
* prompt caching (ephemeral 5m TTL). The `system` block contains static
* instructions + 8-dim cues + schema-version-conditional notes; it is
* byte-identical across all episodes with the same schema_version and gets
* billed at ~10% rate after the first call within a 5-minute window. The
* `user` block carries the per-episode JSON (volatile).
*
* Cache-eligibility caveat: Anthropic's minimum cacheable prefix is
* model-dependent — 4096 tokens on Opus 4.7 / 4.6, 2048 on Sonnet 4.6. The
* static block here is ~300-400 tokens, so on Opus 4.7 cache writes silently
* no-op until either the static block grows or the model switches to Sonnet.
* The split is still applied so the moment either condition flips, caching
* activates with zero further code changes.
*/
export function buildReviewPromptStructured(episode) {
const v = Number(episode?.schema_version) || 0;
const cues = [
'node_quality: correct | wrong_node | overkill | underkill | disputable',
'chain_quality: correct | missing_step | extra_step | wrong_order | n/a',
'gap_assessment: acceptable | mistake_should_complete | mistake_should_not_start | n/a',
'agent_self_assessment_accuracy: accurate | over_confident | under_confident | no_self_assessment',
'error_root_cause: wrong_skill | wrong_tool | wrong_chain_order | external_failure | n/a',
'alternative_better: <node_id> | null',
'outcome_reviewed: success | soft_success | rework | blocked',
'reasoning: 1-3 sentences',
];
const adaptiveNotes = [];
if (v >= 3) {
adaptiveNotes.push('Episode is v3+: primary_rationale carries triggers/candidates/boundaries.');
}
if (v >= 4) {
adaptiveNotes.push('Episode is v4: classifier_output.alternatives_considered tells you what the classifier weighed.');
adaptiveNotes.push('self_assessment (if present and not pending) is the agent\'s post-hoc judgement — compare honesty.');
adaptiveNotes.push('execution_trace.chain_gaps shows whether the recommended chain ran in full.');
}
const system = [
'You are the independent reviewer of routing decisions for the Лидерра brain-governance experiment.',
'Return ONLY a JSON object with the 8 fields below. No prose, no code fences.',
'',
'Fields:',
...cues.map((c) => ' - ' + c),
'',
adaptiveNotes.length ? 'Notes for this schema version:' : '',
...adaptiveNotes.map((n) => ' - ' + n),
].filter(Boolean).join('\n');
const user = [
'Episode (JSON):',
JSON.stringify(episode, null, 2),
'',
'Output JSON only.',
].join('\n');
return { system, user };
}
/**
* Backward-compat wrapper — returns the concatenated single-string prompt for
* tests and any caller that hasn't switched to the structured form.
*
* Adaptive prompt template (spec §4.6):
* - v4 → full prompt including alternatives_considered, self_assessment,
* chain_gaps cues.
* - v3 → omits alternatives_considered.
* - v2 → omits both alternatives_considered and self_assessment.
* - v1 → skipped upstream (caller filters them out).
*/
export function buildReviewPrompt(episode) {
const { system, user } = buildReviewPromptStructured(episode);
return `${system}\n\n${user}`;
}
/**
* Parse the Opus reviewer response. Pure. Returns null on malformed JSON or
* when a required 8-dim field is missing. Passes through `reviewer_error`
* escalations from the subagent.
*/
export function parseReview(text) {
if (!text) return null;
const stripped = String(text).trim()
.replace(/^```(?:json)?\s*\n?/, '')
.replace(/\n?```$/, '')
.trim();
let parsed;
try { parsed = JSON.parse(stripped); }
catch { return null; }
if (!parsed || typeof parsed !== 'object') return null;
// Reviewer-agent escalation: pass through verbatim.
if (typeof parsed.reviewer_error === 'string') return parsed;
for (const f of REQUIRED_REVIEW_FIELDS) {
if (parsed[f] === undefined) return null;
}
return parsed;
}
/**
* Direct Opus API call. Wraps callAnthropicAPI from router-classifier with
* the reviewer model. Caller (controller inside /brain-retro) is responsible
* for decision (subagent first, this on failure).
*
* Returns the parsed review object or null on transport / parse failure.
*/
export async function reviewViaDirectApi(episode, options = {}) {
const { callAnthropicAPI } = await import('./router-classifier.mjs');
const apiKey = options.apiKey ?? process.env.ROUTER_LLM_KEY;
if (!apiKey) return null;
const structured = buildReviewPromptStructured(episode);
try {
const text = await callAnthropicAPI(structured, {
apiKey,
baseUrl: options.baseUrl ?? process.env.ROUTER_LLM_BASE_URL ?? undefined,
model: options.model ?? REVIEWER_MODEL,
onUsage: options.onUsage,
});
return parseReview(text);
} catch {
return null;
}
}