fix: persist rectification evidence corrections
This commit is contained in:
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user