fix: persist rectification evidence corrections

This commit is contained in:
Jesse_Chen
2026-07-21 05:23:51 +08:00
parent c68f4e5f4f
commit 887b40df43
16 changed files with 801 additions and 27 deletions
@@ -243,14 +243,33 @@ export function ConversationalRectificationSurface({
submit();
}}
>
{controller.correctionTarget && (
<div className="conversational-correction-target" role="status">
<p>
<strong>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</strong>
</p>
<span>使</span>
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => controller.cancelEvidenceCorrection()}
>
</button>
</div>
)}
<label htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "填写更正后的真实经历" : "补充真实经历"}
<span></span>
</label>
<textarea
id="conversational-rectification-answer"
disabled={controller.pending || !canAnswer}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:其实是 2020 年 11 月离职"
: "请描述一件已经发生的事,并尽量写明年份或月份"}
ref={composer}
rows={4}
value={controller.draft}
@@ -283,13 +302,17 @@ export function ConversationalRectificationSurface({
<ul>
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<div><time>{entry.dateLabel}</time><p>{entry.summary}</p></div>
<div>
<time>{entry.dateLabel}</time>
<p>{entry.summary}</p>
{entry.isCorrection && <span className="conversational-correction-badge"></span>}
</div>
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending || !canAnswer}
type="button"
onClick={() => {
controller.setDraft(`更正「${entry.summary}」(${entry.dateLabel}):`);
controller.beginEvidenceCorrection(entry.id);
focusComposer();
}}
>
@@ -16,11 +16,13 @@ import type {
type EvidenceDomain = NonNullable<
ConversationalRectificationTurn["evidenceRequest"]
>["domains"][number];
type EvidenceRecapEntry = ConversationalRectificationTurn["evidenceRecap"][number];
export type ConversationalRectificationControllerSnapshot = Readonly<{
turn: ConversationalRectificationTurn | null;
draft: string;
selectedDomain: EvidenceDomain | null;
correctionTarget: EvidenceRecapEntry | null;
pending: boolean;
error: string;
}>;
@@ -33,6 +35,8 @@ export type ConversationalRectificationController = ConversationalRectificationC
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void;
setDraft(value: string): void;
selectDomain(domain: EvidenceDomain | null): void;
beginEvidenceCorrection(evidenceId: string): void;
cancelEvidenceCorrection(): void;
start(pendingConsultationQuestion?: string | null): MutationResult;
resume(): MutationResult;
answer(domain?: EvidenceDomain): MutationResult;
@@ -97,6 +101,7 @@ export function createConversationalRectificationController(
turn: input.initialTurn ?? null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
};
@@ -123,10 +128,16 @@ export function createConversationalRectificationController(
: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
? snapshot.selectedDomain
: null;
const correctionTarget = clearDraft
? null
: snapshot.correctionTarget
? turn.evidenceRecap.find((entry) => entry.id === snapshot.correctionTarget?.id) ?? null
: null;
patch({
turn,
error: "",
selectedDomain,
correctionTarget,
...(clearDraft ? { draft: "" } : {}),
});
try {
@@ -216,6 +227,7 @@ export function createConversationalRectificationController(
get turn() { return snapshot.turn; },
get draft() { return snapshot.draft; },
get selectedDomain() { return snapshot.selectedDomain; },
get correctionTarget() { return snapshot.correctionTarget; },
get pending() { return snapshot.pending; },
get error() { return snapshot.error; },
getSnapshot: () => snapshot,
@@ -229,13 +241,27 @@ export function createConversationalRectificationController(
if (current === null) return;
caseContext += 1;
activeMutation = null;
patch({ turn: null, draft: "", selectedDomain: null, pending: false, error: "" });
patch({
turn: null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
});
return;
}
if (current === null || current.caseId !== turn.caseId) {
caseContext += 1;
activeMutation = null;
patch({ turn, draft: "", selectedDomain: null, pending: false, error: "" });
patch({
turn,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
});
return;
}
if (turn.turnVersion <= current.turnVersion) return;
@@ -246,6 +272,9 @@ export function createConversationalRectificationController(
&& turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
? snapshot.selectedDomain
: null,
correctionTarget: snapshot.correctionTarget
? turn.evidenceRecap.find((entry) => entry.id === snapshot.correctionTarget?.id) ?? null
: null,
});
},
setDraft(value: string) {
@@ -254,6 +283,19 @@ export function createConversationalRectificationController(
selectDomain(domain: EvidenceDomain | null) {
patch({ selectedDomain: domain });
},
beginEvidenceCorrection(evidenceId: string) {
const target = snapshot.turn?.evidenceRecap.find((entry) => entry.id === evidenceId);
if (!target || !snapshot.turn?.actions.includes("answer")) return;
patch({
correctionTarget: target,
// Keep the old fact visible in the correction banner, but out of the new raw evidence.
// Otherwise its old date can make an appended replacement date look ambiguous.
draft: "",
});
},
cancelEvidenceCorrection() {
patch({ correctionTarget: null, draft: "" });
},
start(pendingConsultationQuestion: string | null = null) {
return run({
identity: {
@@ -277,7 +319,12 @@ export function createConversationalRectificationController(
const turn = currentTurn();
const answer = snapshot.draft.trim();
if (!turn || !answer || !turn.actions.includes("answer")) return Promise.resolve(turn);
const payload = { answer, ...(domain ? { domain } : {}) };
const correctsEvidenceId = snapshot.correctionTarget?.id;
const payload = {
answer,
...(domain ? { domain } : {}),
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
};
return currentMutation("answer", payload, (current, actionId) => ({
type: "answer",
caseId: current.caseId,
@@ -285,6 +332,7 @@ export function createConversationalRectificationController(
turnVersion: current.turnVersion,
answer,
...(domain ? { domain } : {}),
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
}), true);
},
pause() {
@@ -42,6 +42,7 @@ export const conversationalRectificationCommandSchema = z.discriminatedUnion("ty
type: z.literal("answer"),
domain: evidenceDomainSchema.optional(),
answer: z.string().trim().min(1).max(4_000),
correctsEvidenceId: z.string().uuid().optional(),
}).strict(),
actionCommandSchema.extend({
type: z.literal("pause"),
@@ -81,6 +82,8 @@ const evidenceRecapEntrySchema = boundedJson(z.object({
id: z.string().uuid(),
summary: boundedNonblankText(1_000),
dateLabel: boundedNonblankText(80),
// Optional so turns written before correction lineage was introduced still resume safely.
isCorrection: z.boolean().optional(),
}).strict(), 4_096);
const evidenceRecapSchema = boundedJson(
z.array(evidenceRecapEntrySchema).max(20),
@@ -10,13 +10,14 @@ export type ExtractedLifeEventEvidence = {
readonly datePrecision: "day" | "month" | "year" | "unknown";
readonly extractionStatus: "clear" | "needs_clarification" | "corrected";
readonly scoreable: boolean;
readonly correctsEvidenceIds: readonly string[];
};
export type ExtractLifeEventEvidenceInput = {
readonly rawText: string;
readonly sourceTurnId: string;
readonly asOfDate: string;
readonly correctionOfEvidenceIds?: readonly string[];
readonly correctsEvidenceId?: string;
};
type ParsedDate = {
@@ -139,7 +140,7 @@ export function extractLifeEventEvidence(
if (!input.rawText.trim()) throw new TypeError("life-event raw text is required");
if (!input.sourceTurnId.trim()) throw new TypeError("source turn id is required");
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.asOfDate)) throw new TypeError("asOfDate must be YYYY-MM-DD");
const corrections = [...(input.correctionOfEvidenceIds ?? [])];
const correctionTargets = input.correctsEvidenceId ? [input.correctsEvidenceId] : [];
const events: ExtractedLifeEventEvidence[] = [];
for (const fragments of splitSentences(input.rawText.normalize("NFKC"))) {
@@ -155,7 +156,7 @@ export function extractLifeEventEvidence(
const complete = summary !== missingEventSummary && date !== null && !unresolvedRelativeTime;
const extractionStatus = !complete
? "needs_clarification"
: corrections.length > 0 ? "corrected" : "clear";
: correctionTargets.length > 0 ? "corrected" : "clear";
events.push({
id: evidenceId(input, events.length, summary),
rawText: input.rawText,
@@ -165,6 +166,7 @@ export function extractLifeEventEvidence(
datePrecision: date?.precision ?? "unknown",
extractionStatus,
scoreable: complete && !dateIsFuture(date, input.asOfDate),
correctsEvidenceIds: correctionTargets,
});
}
}
@@ -134,7 +134,7 @@ type MutableCommand = Extract<ConversationalRectificationCommand, {
function commandFingerprint(command: MutableCommand): string {
const identity = command.type === "answer"
? [command.type, command.caseId, command.actionId, command.turnVersion,
command.domain ?? null, command.answer]
command.domain ?? null, command.answer, command.correctsEvidenceId ?? null]
: command.type === "confirm"
? [command.type, command.caseId, command.actionId, command.turnVersion, command.time]
: [command.type, command.caseId, command.actionId, command.turnVersion];
@@ -165,8 +165,21 @@ function latestReceipt(value: LoadedConversationalRectificationCase): Validation
return parsed.data;
}
export function effectiveLifeEventEvidence<
Evidence extends Readonly<{
id: string;
correctsEvidenceIds?: readonly string[];
}>,
>(evidence: ReadonlyArray<Evidence>): ReadonlyArray<Evidence> {
const correctedIds = new Set<string>();
for (const item of evidence) {
for (const correctedId of item.correctsEvidenceIds ?? []) correctedIds.add(correctedId);
}
return evidence.filter((item) => !correctedIds.has(item.id));
}
function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
return evidence.slice(-20).map((item) => ({
return effectiveLifeEventEvidence(evidence).slice(-20).map((item) => ({
id: item.id,
summary: item.eventSummary,
dateLabel: item.dateValue
@@ -174,6 +187,7 @@ function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
? `${item.dateValue}(未来,仅作背景)`
: item.dateValue
: "日期待补充",
...((item.correctsEvidenceIds?.length ?? 0) > 0 ? { isCorrection: true } : {}),
}));
}
@@ -238,6 +252,7 @@ function privateCandidateFromPacket(input: {
readonly packet: RectificationTechnicalPacket;
readonly resultId: string | null;
readonly iteration: number;
readonly forceCollecting?: boolean;
}): PrivateCandidate {
const packet = input.packet;
const parsed = privateCandidateSchema.safeParse({
@@ -257,7 +272,9 @@ function privateCandidateFromPacket(input: {
suggestedDomains: packet.suggestedDomains.map((item) => item.domain),
futureWindows: packet.futureWindows,
workingState: {
phase: packet.candidate.status === "ready_for_confirmation" ? "ready" : "collecting_evidence",
phase: input.forceCollecting
? "collecting_evidence"
: packet.candidate.status === "ready_for_confirmation" ? "ready" : "collecting_evidence",
iteration: input.iteration,
notes: [],
},
@@ -320,18 +337,23 @@ function nonScoringTurn(input: {
readonly domain?: RectificationEvidenceDomain;
readonly directionChange: boolean;
readonly scoringFallback?: boolean;
readonly correctionClarificationPacket?: RectificationTechnicalPacket;
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification"
&& item.scoreable === false && item.dateValue !== null);
const narrative = input.scoringFallback
const narrative = input.correctionClarificationPacket
? "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,请补充大约年份、月份和发生了什么;在补清之前不会沿用旧证据推进确认。"
: input.scoringFallback
? "本轮原文已安全保存,但新的专业解释未通过事实一致性校验,因此候选没有推进。请稍后重试,或继续补充一件已经发生并带有年月的事件。"
: input.directionChange
? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。"
: hasFuture
? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。"
: "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
const status = input.current.status === "confirming" ? "confirming" : "active";
const status = input.correctionClarificationPacket
? "active" as const
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
const actions = actionsFor(status);
const evidenceRequest = status === "confirming" && input.current.latestTurn.evidenceRequest === null
? null
@@ -348,6 +370,13 @@ function nonScoringTurn(input: {
evidenceRequest,
evidenceRecap: evidenceRecap(allEvidence),
actions,
...(input.correctionClarificationPacket ? {
candidate: {
...projectRectificationTechnicalPacket(input.correctionClarificationPacket).candidate,
status: "pending_validation" as const,
},
technicalReceipt: exactTechnicalReceipt(input.correctionClarificationPacket),
} : {}),
});
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
return {
@@ -416,8 +445,10 @@ export function createConversationalRectificationService(
rawText: command.answer,
sourceTurnId: command.actionId,
asOfDate: ports.asOfDate(),
correctsEvidenceId: command.correctsEvidenceId,
}).map((item) => ({
...item,
correctsEvidenceIds: [...item.correctsEvidenceIds],
domain: item.domain === "other" && command.domain && command.domain !== "other"
? command.domain
: item.domain,
@@ -602,11 +633,62 @@ export function createConversationalRectificationService(
}
requireExactVersion(current, command.turnVersion);
if (command.correctsEvidenceId
&& !effectiveLifeEventEvidence(current.eventEvidence)
.some((item) => item.id === command.correctsEvidenceId)) {
throw new ConversationalRectificationError("action_conflict");
}
const scoreableEvidence = evidence.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification");
const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer);
const directionChange = explicitDirectionChange
|| (scoreableEvidence.length === 0 && genericUncertaintyPattern.test(command.answer));
const unclearCorrection = Boolean(command.correctsEvidenceId)
&& evidence.some((item) => item.extractionStatus === "needs_clarification");
if (unclearCorrection) {
try {
const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence])
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
const computed = await ports.buildTechnicalPacket({
userId,
caseId: command.caseId,
asOfDate: ports.asOfDate(),
declaredBirthInput: current.declaredBirthInput,
privateCandidate: current.privateCandidate,
evidence: allScoreable,
});
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange: false,
correctionClarificationPacket: computed.packet,
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: true,
});
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
evidence,
validationReceipt: next.receipt,
privateCandidate,
});
return publicTurn(saved);
} catch (error) {
throw safeFailure(error);
}
}
if (directionChange || scoreableEvidence.length === 0) {
const next = nonScoringTurn({
current,
@@ -633,7 +715,7 @@ export function createConversationalRectificationService(
}
try {
const allScoreable = [...current.eventEvidence, ...evidence]
const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence])
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
@@ -181,6 +181,8 @@ export const validationReceiptSchema = boundedJson(z.object({
}).strict(), 8_192);
export type ValidationReceipt = z.infer<typeof validationReceiptSchema>;
const correctionEvidenceIdsSchema = boundedJson(z.array(uuidSchema).max(1), 64);
export const lifeEventEvidenceSchema = boundedJson(z.object({
id: uuidSchema,
rawText: boundedText(4_000),
@@ -190,7 +192,17 @@ export const lifeEventEvidenceSchema = boundedJson(z.object({
datePrecision: z.enum(["day", "month", "year", "range", "unknown"]),
extractionStatus: z.enum(["clear", "needs_clarification", "corrected"]),
scoreable: z.boolean().optional(),
}).strict(), 16_384);
// Optional only for rows written before durable correction lineage existed.
correctsEvidenceIds: correctionEvidenceIdsSchema.optional(),
}).strict().superRefine((value, context) => {
const correctionCount = value.correctsEvidenceIds?.length ?? 0;
if (value.extractionStatus === "corrected" && correctionCount !== 1) {
context.addIssue({ code: "custom", message: "corrected evidence requires one target" });
}
if (value.extractionStatus === "clear" && correctionCount !== 0) {
context.addIssue({ code: "custom", message: "clear evidence cannot correct another row" });
}
}), 16_384);
export type LifeEventEvidence = z.infer<typeof lifeEventEvidenceSchema>;
const mutationKindSchema = z.enum([