feat(rectification): the server writes the evidence recap — 「记下了 N 件事」 with the ledger list collapsed under it (BUG-1136)

- Evidence turns whose batch accepted items: the body is the server recap
  built from those items (two or fewer listed inline, more as a count);
  the model no longer restates them (both prompt sets updated).
- The case snapshot carries each assistant turn's recorded evidence from
  the ledger (rejected/superseded excluded); the message shows it in a
  collapsed <details> list when more than two.
- Chinese date labels read straight into the event phrase (2016年入学);
  ISO labels keep their space.
- Assertions updated with original/new/reason notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-01 10:40:29 +08:00
co-authored by Claude Opus 5.5
parent 408b22749e
commit 9d302e515c
21 changed files with 244 additions and 27 deletions
@@ -783,3 +783,15 @@ export function accidentCaseHardcodedTurns(input: {
deliveryAdopt: deliveryAdoptNarration(shared),
};
}
/**
* BUG-1136 (D2): the evidence turn's recap is the server's, built from the
* batch's accepted items. Two or fewer are listed inline; more read as a count
* and the list sits in the message, collapsed.
*/
export function evidenceTurnRecap(lines: readonly string[]): string | null {
const items = lines.map((line) => line.trim()).filter(Boolean);
if (!items.length) return null;
if (items.length <= 2) return `记下了:${items.join("、")}。`;
return `记下了 ${items.length} 件事。`;
}
@@ -47,6 +47,7 @@ import { currentEngineCallTimings, reportTurnProgress } from "./turn-instrumenta
import {
batchResultFromToolChunk,
batchRescoreFailed,
acceptedRecapLines,
composeHostFallbackNarration,
lastCompletedPublicTool,
publicWriteToolCompleted,
@@ -455,6 +456,9 @@ export async function streamV9Attempt(
hostRecap: toolTerminalStatus.get("rectification-record-evidence-batch") === "completed"
? composeHostFallbackNarration(batchToolResult ?? {})
: null,
hostRecapLines: toolTerminalStatus.get("rectification-record-evidence-batch") === "completed"
? acceptedRecapLines(batchToolResult ?? {})
: null,
answerDeltas,
phases,
toolsUsed: [...toolsUsed],
@@ -21,7 +21,7 @@ import {
v9TurnReceipts,
} from "./agent-run-support";
import { dropUngroundedFactSentences } from "./spoken-grounding";
import { RECTIFICATION_USER_COPY } from "../user-copy";
import { evidenceTurnRecap, RECTIFICATION_USER_COPY } from "../user-copy";
import { moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
import { recordModerationEvent } from "@/lib/moderation/log";
import type { V9AgentRunResult } from "./agent-run";
@@ -190,6 +190,11 @@ export async function finishV9AgentTurn(
if (action === "evidence") {
// BUG-606 / BUG-615: the trim applies to the model body only.
spokenAnswer = trimSpokenTurnForInterview(answerText, interviewIdle?.terminalNote === true);
// BUG-1136 (D2): when the batch accepted items, the recap is the server's
// — 「记下了 N 件事」 (two or fewer listed) — not the model's item-by-item
// restatement. The list itself hangs on the message from the ledger.
const recap = evidenceTurnRecap(outcome.hostRecapLines ?? []);
if (recap) spokenAnswer = recap;
}
// BUG-1055: server fact sentences join after the trim, so they are never
// cut and never streamed before this final text.
@@ -39,6 +39,8 @@ export type AttemptOutcome = Readonly<{
spokenFacts?: SpokenFactWhitelist | null;
/** Server recap of the batch write, used when the whitelist leaves no model body. */
hostRecap?: string | null;
/** BUG-1136: accepted batch items this turn, for the server-written recap. */
hostRecapLines?: readonly string[] | null;
}>;
/**
@@ -11,7 +11,7 @@ import {
} from "@/lib/rectification-agentic/v9/tool-service";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision, nextUserActionFromDossier, stepStateFromCaseDossier } from "@/lib/rectification-agentic/v9/interview-state";
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
import { attachQuestionsToTurns, attachOfferResultToTurns } from "@/lib/rectification-agentic/v9/turn-question";
import { attachQuestionsToTurns, attachOfferResultToTurns, recordedEvidenceByTurn } from "@/lib/rectification-agentic/v9/turn-question";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision";
import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/evidence-model";
@@ -66,6 +66,7 @@ export function dossierResponse(
const segmentChecks = parseCaseSegmentChecks(dossier.case.segmentChecks, dossier.case.rectificationDomain);
const consistencySummary = segmentChecks?.delivered_turn_id && segmentChecks.summary?.no_rectification_needed
? segmentChecks.summary : null;
const recorded = recordedEvidenceByTurn(dossier.evidence);
const response = {
case: {
result_identity: identity,
@@ -90,6 +91,8 @@ export function dossierResponse(
case_revision: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.revision ?? 0,
},
turns: turns.map((turn) => ({
...(turn.role === "assistant" && recorded.get(turn.id)?.length
? { recorded_evidence: recorded.get(turn.id) } : {}),
id: turn.id,
role: turn.role,
text: turn.text,
@@ -71,13 +71,17 @@ export function lastCompletedPublicTool(
return last;
}
function recapLine(item: HostFallbackRecap): string {
export function evidenceRecapLine(item: HostFallbackRecap): string {
const label = typeof item.display_date_label === "string" ? item.display_date_label.trim() : "";
const phrase = typeof item.event_phrase === "string" ? item.event_phrase.trim() : "";
if (!phrase) return label;
if (/\d{4}年/.test(phrase) || (label.length > 0 && phrase.startsWith(label))) {
return phrase;
}
// BUG-1136: the recap is now the server's on every evidence turn; a
// Chinese date label reads straight into the phrase (「2016年入学」), an ISO
// label keeps its space (「2016-09 入学」).
if (label && /[\u4e00-\u9fff]$/u.test(label)) return `${label}${phrase}`;
return [label, phrase].filter(Boolean).join(" ").trim();
}
@@ -121,12 +125,16 @@ export function batchRescoreFailed(batchResult: unknown): boolean {
return rescore?.status === "failed";
}
/** One display line per accepted item of a record-evidence batch (server data). */
export function acceptedRecapLines(batchResult: unknown): string[] {
if (batchResult == null || isToolInputRejection(batchResult)) return [];
return recapsFromBatchResult(batchResult).map(evidenceRecapLine).filter(Boolean);
}
export function composeHostFallbackNarration(batchResult: unknown): string | null {
if (batchResult == null) return null;
if (isToolInputRejection(batchResult)) return null;
const lines = recapsFromBatchResult(batchResult)
.map(recapLine)
.filter(Boolean);
const lines = acceptedRecapLines(batchResult);
if (lines.length > 0) return `记下了:${lines.join("、")}。`;
return "记下了。";
}
@@ -1,3 +1,5 @@
import { evidenceRecapLine } from "./host-fallback.ts";
import { displayDateLabel, eventPhraseFromSummary } from "./evidence-model.ts";
import { stripQuestionSentences } from "./collect-prompt";
import { parseAgentChoiceCopy, type ChoiceKey, type RectificationChoiceCard } from "./choice-card";
import type { ConversationFocus } from "./tool-service";
@@ -350,3 +352,37 @@ export function copyTextForMessage(body: string, question: TurnQuestion | null |
}
return lines.join("\n").trim();
}
/**
* BUG-1136 (D2): what an evidence turn recorded, read from the ledger (not the
* model's text). Evidence carries the turn it was said in; the assistant reply
* of that turn carries the list. Rejected and superseded rows are left out.
*/
export function recordedEvidenceByTurn(
evidence: readonly Readonly<{
sourceTurnId: string;
status: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
summary: string;
}>[],
): Map<string, string[]> {
const byTurn = new Map<string, string[]>();
for (const row of evidence) {
if (!row.sourceTurnId || row.status === "rejected" || row.status === "superseded") continue;
const line = evidenceRecapLine({
display_date_label: displayDateLabel(row.datePrecision, row.occurredFrom, row.occurredTo),
event_phrase: eventPhraseFromSummary(row.summary),
});
if (!line) continue;
byTurn.set(row.sourceTurnId, [...(byTurn.get(row.sourceTurnId) ?? []), line]);
}
return byTurn;
}
export function parseRecordedEvidence(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const lines = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
return lines.length ? lines : undefined;
}
@@ -18,7 +18,7 @@ import {
import { isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import { isStructuredChoiceUserText } from "@/lib/rectification-agentic/v9/choice-action";
import type { ChoiceKey } from "@/lib/rectification-agentic/v9/choice-card";
import { parseTurnQuestion, questionIsAnswered } from "@/lib/rectification-agentic/v9/turn-question";
import { parseRecordedEvidence, parseTurnQuestion, questionIsAnswered } from "@/lib/rectification-agentic/v9/turn-question";
import type { RenderMessage } from "@/components/rectification-message-entry";
import { parseSegmentSummary } from "./rectification-agentic/core/segment-summary.ts";
@@ -30,6 +30,7 @@ export type PersistedTurn = Readonly<{
question?: unknown;
offer_result_id?: string | null;
segment_consistency?: unknown;
recorded_evidence?: unknown;
receipt?: Readonly<{
status: string;
phases: readonly string[];
@@ -149,6 +150,7 @@ export function messagesFromTurns(initialTurns: readonly PersistedTurn[]): Rende
turnId: turn.id,
question: parseTurnQuestion(turn.question) ?? undefined,
segmentConsistency: parseSegmentSummary(turn.segment_consistency) ?? undefined,
recordedEvidence: parseRecordedEvidence(turn.recorded_evidence),
candidateOffer: typeof turn.offer_result_id === "string"
? { resultId: turn.offer_result_id }
: undefined,
@@ -1,6 +1,6 @@
import { parseSegmentSummary, type SegmentSummary } from "./rectification-agentic/core/segment-summary.ts";
import { stripQuestionSentences } from "./rectification-agentic/v9/collect-prompt.ts";
import { parseTurnQuestion, persistedOfferFromTurn, questionIsDeadUnanswered, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
import { parseRecordedEvidence, parseTurnQuestion, persistedOfferFromTurn, questionIsDeadUnanswered, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
export type SnapshotTurnMessage = {
role: "assistant" | "user";
@@ -10,20 +10,22 @@ export type SnapshotTurnMessage = {
question?: TurnQuestion;
candidateOffer?: { resultId: string };
segmentConsistency?: SegmentSummary;
recordedEvidence?: readonly string[];
};
export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
current: T[],
turns: readonly unknown[],
): T[] {
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null; consistency: SegmentSummary | null }>();
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null; consistency: SegmentSummary | null; recorded: string[] | undefined }>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown; segment_consistency?: unknown };
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown; segment_consistency?: unknown; recorded_evidence?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, {
question: parseTurnQuestion(turn.question),
consistency: parseSegmentSummary(turn.segment_consistency),
recorded: parseRecordedEvidence(turn.recorded_evidence),
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
});
}
@@ -44,6 +46,7 @@ export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
text,
question: question ?? undefined,
segmentConsistency: next?.consistency ?? undefined,
recordedEvidence: next?.recorded ?? message.recordedEvidence,
candidateOffer: persistedOfferFromTurn(
next?.offerResultId,
message.candidateOffer,