|
27 | 27 | // Exit codes: 0 = replayed, same verdict ("here is the clause"); 1 = DIVERGENCE — a bug by definition, |
28 | 28 | // file it with the printed stage diff; 2 = unusable input. Replay mode cannot re-query the model or touch |
29 | 29 | // any network/DB by construction: replayDecision is a pure function of the two JSON values. |
| 30 | +// |
| 31 | +// #9028: `--requery <n>` is the ONE deliberately-networked mode, for the one stage pure replay cannot cover. |
| 32 | +// It re-runs the model n times against the EXACT persisted prompt and reports an ACTION-MATCH RATE — never |
| 33 | +// "reproducibility": hosted inference is not bit-deterministic even at temperature 0 (batching, kernel |
| 34 | +// scheduling, silent model revisions), so bit-comparing outputs would measure the provider's scheduler, not |
| 35 | +// the decision. What CAN be honestly measured is whether fresh runs land in the same verdict CLASS the |
| 36 | +// recorded decision acted on (defect vs clean, through the same parseModelReview the live pipeline uses). |
| 37 | +// |
| 38 | +// The bundle gains an optional `prompt` for this mode. EXTRACT (joins the private prompts sibling by the |
| 39 | +// BASE record id, so a supersession's `:rev<N>` row still finds its head's prompt): |
| 40 | +// SELECT json_build_object( |
| 41 | +// 'record', to_jsonb(dr) || dr.record_json::jsonb, |
| 42 | +// 'replayInput', dri.replay_json::jsonb, |
| 43 | +// 'prompt', drp.prompt_json::jsonb |
| 44 | +// ) |
| 45 | +// FROM decision_records dr |
| 46 | +// JOIN decision_replay_inputs dri ON dri.record_id = dr.id |
| 47 | +// LEFT JOIN decision_replay_prompts drp |
| 48 | +// ON drp.record_id = 'record:' || dr.repo_full_name || '#' || dr.pull_number || '@' || dr.head_sha |
| 49 | +// WHERE dr.id = 'record:<owner/repo>#<pr>@<head sha>'; |
| 50 | +// |
| 51 | +// Provider config (explicit env, no defaults — this is a debugging tool, not a service): |
| 52 | +// REPLAY_AI_BASE_URL + REPLAY_AI_MODEL [+ REPLAY_AI_API_KEY] -> any OpenAI-compatible endpoint (Ollama etc.) |
| 53 | +// ANTHROPIC_API_KEY + REPLAY_AI_MODEL -> Anthropic |
| 54 | +// |
| 55 | +// Requery exit codes: 0 = report produced (a low rate is a FINDING, not a failure); 2 = unusable input or |
| 56 | +// missing provider config. Prompts above the persistence cap were skipped at capture time, never truncated — |
| 57 | +// a truncated prompt re-queried would report a rate for a prompt that was never sent. |
30 | 58 | import { readFileSync } from "node:fs"; |
31 | 59 | import { replayDecision, type DecisionReplayInput, type ReplayableRecord } from "../src/review/decision-replay"; |
| 60 | +import { recordedJudgmentClass, runRequery } from "../src/review/decision-requery"; |
| 61 | +import { createAnthropicAi, createOpenAiCompatibleAi, type SelfHostAi } from "../src/selfhost/ai"; |
32 | 62 |
|
33 | 63 | /** Parse + normalize a bundle (snake_case SQL rows accepted) and replay it. Exported for tests. |
34 | 64 | * |
@@ -61,9 +91,86 @@ export function runReplayBundle(raw: string, atMs?: number): { outcome: ReturnTy |
61 | 91 | return { outcome: replayDecision(record, replayInput, atMs === undefined ? {} : { nowMs: atMs }) }; |
62 | 92 | } |
63 | 93 |
|
| 94 | +/** Parse the bundle's optional prompt + replay input for requery. Exported for tests. */ |
| 95 | +export function parseRequeryBundle(raw: string): { systemPrompt: string; userPrompt: string; recordedClass: "defect" | "clean" } | { error: string } { |
| 96 | + let bundle: { replayInput?: DecisionReplayInput; prompt?: { systemPrompt?: unknown; userPrompt?: unknown } }; |
| 97 | + try { |
| 98 | + bundle = JSON.parse(raw) as never; |
| 99 | + } catch (error) { |
| 100 | + return { error: `unparseable bundle JSON: ${error instanceof Error ? error.message : String(error)}` }; |
| 101 | + } |
| 102 | + const systemPrompt = bundle.prompt?.systemPrompt; |
| 103 | + const userPrompt = bundle.prompt?.userPrompt; |
| 104 | + if (typeof systemPrompt !== "string" || systemPrompt.length === 0 || typeof userPrompt !== "string" || userPrompt.length === 0) { |
| 105 | + return { error: "requery needs bundle.prompt.{systemPrompt,userPrompt} — extract with the prompts LEFT JOIN in this file's header (rows age out after 30 days; older decisions cannot be re-queried, only replayed)" }; |
| 106 | + } |
| 107 | + if (!bundle.replayInput || !Array.isArray(bundle.replayInput.findings)) { |
| 108 | + return { error: "requery needs bundle.replayInput.findings to derive the recorded verdict class" }; |
| 109 | + } |
| 110 | + return { systemPrompt, userPrompt, recordedClass: recordedJudgmentClass(bundle.replayInput) }; |
| 111 | +} |
| 112 | + |
| 113 | +/** Build the provider client from explicit env. Exported for tests; returns an error string when unconfigured. */ |
| 114 | +export function requeryClientFromEnv(env: Record<string, string | undefined>): { ai: SelfHostAi; model: string } | { error: string } { |
| 115 | + const model = env.REPLAY_AI_MODEL; |
| 116 | + if (!model) return { error: "requery needs REPLAY_AI_MODEL (explicit — this tool never guesses which model to spend against)" }; |
| 117 | + if (env.REPLAY_AI_BASE_URL) { |
| 118 | + return { ai: createOpenAiCompatibleAi({ baseUrl: env.REPLAY_AI_BASE_URL, apiKey: env.REPLAY_AI_API_KEY, model }), model }; |
| 119 | + } |
| 120 | + if (env.ANTHROPIC_API_KEY) { |
| 121 | + return { ai: createAnthropicAi({ apiKey: env.ANTHROPIC_API_KEY, model }), model }; |
| 122 | + } |
| 123 | + return { error: "requery needs REPLAY_AI_BASE_URL (OpenAI-compatible) or ANTHROPIC_API_KEY" }; |
| 124 | +} |
| 125 | + |
64 | 126 | const invokedDirectly = process.argv[1]?.endsWith("replay-decision.ts") === true; |
65 | 127 | if (invokedDirectly) { |
66 | 128 | const argv = process.argv.slice(2); |
| 129 | + const requeryIndex = argv.indexOf("--requery"); |
| 130 | + if (requeryIndex !== -1) { |
| 131 | + const runsRaw = argv[requeryIndex + 1]; |
| 132 | + const runs = Number(runsRaw); |
| 133 | + if (!Number.isInteger(runs) || runs < 1 || runs > 25) { |
| 134 | + console.error("replay-decision: --requery requires a run count between 1 and 25"); |
| 135 | + process.exit(2); |
| 136 | + } |
| 137 | + const requerySource = argv.filter((_arg, index) => index !== requeryIndex && index !== requeryIndex + 1)[0]; |
| 138 | + if (!requerySource) { |
| 139 | + console.error("usage: replay-decision.ts <bundle.json | -> --requery <n>"); |
| 140 | + process.exit(2); |
| 141 | + } |
| 142 | + const rawBundle = requerySource === "-" ? readFileSync(0, "utf8") : readFileSync(requerySource, "utf8"); |
| 143 | + const parsed = parseRequeryBundle(rawBundle); |
| 144 | + if ("error" in parsed) { |
| 145 | + console.error(`replay-decision: ${parsed.error}`); |
| 146 | + process.exit(2); |
| 147 | + } |
| 148 | + const client = requeryClientFromEnv(process.env); |
| 149 | + if ("error" in client) { |
| 150 | + console.error(`replay-decision: ${client.error}`); |
| 151 | + process.exit(2); |
| 152 | + } |
| 153 | + const report = await runRequery({ |
| 154 | + systemPrompt: parsed.systemPrompt, |
| 155 | + userPrompt: parsed.userPrompt, |
| 156 | + runs, |
| 157 | + recordedClass: parsed.recordedClass, |
| 158 | + // The live call's own shape (ai-review.ts): a system turn plus a user turn, temperature 0. Matching it |
| 159 | + // is what makes the rate a statement about the DECISION and not about a different way of asking. |
| 160 | + callModel: async (systemPrompt, userPrompt) => { |
| 161 | + const result = await client.ai.run(client.model, { |
| 162 | + temperature: 0, |
| 163 | + messages: [ |
| 164 | + { role: "system", content: systemPrompt }, |
| 165 | + { role: "user", content: userPrompt }, |
| 166 | + ], |
| 167 | + }); |
| 168 | + return result.response ?? ""; |
| 169 | + }, |
| 170 | + }); |
| 171 | + console.log(JSON.stringify(report, null, 2)); |
| 172 | + process.exit(0); |
| 173 | + } |
67 | 174 | const atIndex = argv.indexOf("--at"); |
68 | 175 | const atRaw = atIndex === -1 ? undefined : argv[atIndex + 1]; |
69 | 176 | if (atIndex !== -1 && (atRaw === undefined || !Number.isFinite(Number(atRaw)))) { |
|
0 commit comments