diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 29e07e7d..b23a0f34 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -114,7 +114,7 @@ import { publicEvidenceItemStatus, resolveEvidenceQuote, } from "@/lib/rectification-agentic/v9/evidence-quote"; -import { projectTurnDecision } from "@/lib/rectification-agentic/v9/turn-decision"; +import { modelVisibleInference, projectTurnDecision } from "@/lib/rectification-agentic/v9/turn-decision"; import { contrastPacketFromLatestResult, decideFromDossier, @@ -624,8 +624,16 @@ function agentVisibleLatestProjection( representative_time_pre_inference: _preTime, house_table_pre_inference: _preHouse, natal_recast_pre_inference: _preNatal, + // R1 (TASK-rectification-grounding-20260927): the engine's own width is + // audit-only (BUG-593); the report width is skill_verification_report. + engine_indistinguishable_width_minutes: _engineWidth, ...rest } = projection; + // R1: the verification Markdown is kept once, in skill_verification_report; + // range_delivery carried a byte-identical copy. + const rangeDelivery = rest.range_delivery && typeof rest.range_delivery === "object" && !Array.isArray(rest.range_delivery) + ? (({ verification_markdown: _markdown, ...delivery }) => delivery)(rest.range_delivery as Record) + : rest.range_delivery; const currentQuestion = extras.openQuestion ? extras.openQuestion.unrenderable === true ? { @@ -647,13 +655,15 @@ function agentVisibleLatestProjection( probe_id: extras.openQuestion.probe_id, } : null; + // BUG-1057: the same slim candidate list the read-case shows. const compactInference = inference && typeof inference === "object" && !Array.isArray(inference) - ? inference as Record + ? modelVisibleInference(inference as Record) : null; const renderableChoice = extras.openQuestion?.kind === "choice" && extras.openQuestion.unrenderable !== true; return { ...rest, + ...(rangeDelivery !== undefined ? { range_delivery: rangeDelivery } : {}), current_question: currentQuestion, current_probe: null, inference_state: renderableChoice && compactInference @@ -675,6 +685,17 @@ function agentVisibleLatestProjection( }; } +/** + * What rectification-offer-candidates hands the model (R1): the compare + * stripping applied to the offer payload. No open question rides on an offer. + */ +export function offerModelProjection(payload: Record): Record { + return agentVisibleLatestProjection(payload, { + openQuestion: null, + focusStatus: "not_applicable", + }); +} + function followupPlanForParsed( parsed: DossierForTools, latest: DossierForTools["latestResult"], @@ -2202,7 +2223,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { inputFingerprint, resultFingerprint: hashResult(payload), }); - return payload; + // R1 (BUG-593 side path): the model gets the same stripped projection + // compare returns — no *_pre_inference, contrast packet, probes, + // duplicate report Markdown or engine width. The receipt fingerprint + // above stays over the full payload. + return offerModelProjection(payload); } catch (error) { await failReceipt("rectification-offer-candidates", "candidates.updated", error, { inputFingerprint }); throw error; diff --git a/frontend/tests/rectification-grounding-projection-20260927.test.ts b/frontend/tests/rectification-grounding-projection-20260927.test.ts new file mode 100644 index 00000000..5378af76 --- /dev/null +++ b/frontend/tests/rectification-grounding-projection-20260927.test.ts @@ -0,0 +1,142 @@ +/** + * TASK-rectification-grounding-20260927 T5 (R1, BUG-593 side path): what + * compare and offer hand the model. Fixture: a real local engine response for + * a public AA chart (9 candidates), AGENTS §7.4. + * + * - offer used to return the full projection (~100 KB): `*_pre_inference`, + * the contrast packet (31 KB) and the probe lists. It now gets the compare + * stripping. + * - the verification Markdown is kept once (skill_verification_report); + * range_delivery carried a byte-identical copy. + * - `engine_indistinguishable_width_minutes` is audit-only (BUG-593) and no + * longer reaches the model. + * - batch returns the post-rescore range and whether this turn delivers. + * The full projection used by the case API and the stored receipts is + * unchanged. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + CASE_ID, + TURN_ID, + USER_ID, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; +import { + AA_EVIDENCE_ROWS, + AA_RANGE, + AA_TURNS, + buildGoldenLatest, + setFocusHandler, + stubGoldenFetch, +} from "./rectification-grounding-support.ts"; +import { + batchRangeAfterRescore, + createRectificationV9Tools, + latestResultToolProjection, + offerModelProjection, +} from "../src/mastra/rectification-v9-tools.ts"; +import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; + +const bytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value), "utf8"); + +const MODEL_HIDDEN_KEYS = [ + "candidate_contrast_packet", + "discriminating_event_probes", + "event_clarification_probes", + "evidence_collection_probes", + "candidate_contrast_opportunities", + "dasha_agreement_pre_inference", + "representative_time_pre_inference", + "house_table_pre_inference", + "natal_recast_pre_inference", + "engine_indistinguishable_width_minutes", +] as const; + +function assertModelView(view: Record) { + for (const key of MODEL_HIDDEN_KEYS) assert.equal(key in view, false, key); + const report = view.skill_verification_report as { markdown?: string } | undefined; + assert.ok(report?.markdown && report.markdown.length > 200, "report Markdown kept once"); + const delivery = view.range_delivery as Record | undefined; + if (delivery) assert.equal("verification_markdown" in delivery, false); + const needle = JSON.stringify(report.markdown.slice(0, 80)).slice(1, -1); + assert.equal(JSON.stringify(view).split(needle).length - 1, 1, "Markdown appears exactly once"); + const inference = view.inference_state as { candidates?: Array> } | null; + for (const candidate of inference?.candidates ?? []) { + assert.deepEqual(Object.keys(candidate), ["time", "score", "status", "cluster_range"]); + } +} + +test("R1: offer's model view drops pre-inference, packet, probes, duplicate Markdown and engine width", async () => { + const { latest } = await buildGoldenLatest(); + const parsed = parseV9CaseDossier(dossierFixture({ evidence: AA_EVIDENCE_ROWS, latestResult: latest, turns: AA_TURNS as never, candidateRange: AA_RANGE as never }))!; + const decision = decideFromDossier(parsed as never); + const full = latestResultToolProjection(parsed.latestResult as never, decision as never) as Record; + // The full projection (case API, receipts) keeps its audit fields. + assert.ok("candidate_contrast_packet" in full); + assert.ok("engine_indistinguishable_width_minutes" in full); + const view = offerModelProjection({ ...full, candidate_snapshot: { is_current: true } }); + assertModelView(view); + // Conclusion fields the delivery turn needs are still there. + for (const key of ["candidates", "representative_time", "credible_range", "session_outcome", "can_adopt", "event_fit_rate", "range_delivery"]) { + assert.ok(key in view, key); + } + console.log(JSON.stringify({ scope: "R1 offer model view", before_bytes: bytes(full), after_bytes: bytes(view) })); + assert.ok(bytes(view) < bytes(full) / 3); +}); + +test("R1: compare's model view (real tool, golden engine response) keeps the Markdown once and no engine width", async () => { + const { latest, compute } = await buildGoldenLatest(); + const restore = stubGoldenFetch(); + try { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture({ + evidence: AA_EVIDENCE_ROWS, + latestResult: latest, + turns: AA_TURNS as never, + candidateRange: AA_RANGE as never, + }), + get_agentic_rectification_case_compute: () => compute, + set_agentic_rectification_conversation_focus: setFocusHandler, + refresh_agentic_rectification_vedastro_validation: () => ({ result_id: latest.result_id, decision_receipt: latest.decision_receipt }), + }, { fallback: () => null }); + const tools = createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + userMessage: "2000 年拿了奖", + accounting: accounting.client as never, + }) as unknown as Record> }>; + await tools["rectification-read-case"].execute({ caseId: CASE_ID }); + const compare = await tools["rectification-compare-candidates"].execute({ caseId: CASE_ID }); + assertModelView(compare); + console.log(JSON.stringify({ scope: "R1 compare model view", after_bytes: bytes(compare) })); + } finally { + restore(); + } +}); + +test("T1/R3: batch range_after_rescore names the post-rescore range and whether this turn delivers", async () => { + const { latest } = await buildGoldenLatest(); + const parsed = parseV9CaseDossier(dossierFixture({ evidence: AA_EVIDENCE_ROWS, latestResult: latest, candidateRange: AA_RANGE as never }))!; + const decision = decideFromDossier(parsed as never); + const withQuestion = batchRangeAfterRescore(decision as never, latest.decision_receipt, { question_id: "q" }); + assert.deepEqual(withQuestion?.credible_range, decision.credibleRange); + assert.equal(withQuestion?.representative_time, decision.representativeTime ?? null); + assert.equal(withQuestion?.delivers_range_this_turn, false); + const deliverable = { ...decision, canAdopt: true, sessionOutcome: "adopt_representative" }; + assert.equal(batchRangeAfterRescore(deliverable as never, latest.decision_receipt, null)?.delivers_range_this_turn, true); + assert.equal(batchRangeAfterRescore(null, latest.decision_receipt, null), null); + const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + // The receipt keeps hashing the pre-existing shape; only the returned value grows. + const batchReceipt = tools.indexOf("executedMethods: [...rescore.executedMethods],"); + const batchReturn = tools.indexOf("range_after_rescore: rescore.rangeAfter"); + assert.ok(batchReceipt > 0 && batchReturn > batchReceipt); + assert.match(tools.slice(batchReceipt - 120, batchReceipt), /resultFingerprint: hashResult\(projection\)/); + assert.match(tools, /return offerModelProjection\(payload\);/); +});