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([
|
||||
|
||||
+40
-2
@@ -293,7 +293,7 @@ as $$
|
||||
where pg_catalog.jsonb_typeof(item) <> 'object'
|
||||
or pg_catalog.octet_length(item::text) > 4096
|
||||
or not public.conversational_rectification_has_only_keys(
|
||||
item, array['id', 'summary', 'dateLabel']::text[]
|
||||
item, array['id', 'summary', 'dateLabel', 'isCorrection']::text[]
|
||||
)
|
||||
or not (item ?& array['id', 'summary', 'dateLabel']::text[])
|
||||
or pg_catalog.jsonb_typeof(item -> 'id') <> 'string'
|
||||
@@ -312,6 +312,10 @@ as $$
|
||||
or public.conversational_rectification_text_is_nonblank(
|
||||
item ->> 'dateLabel'
|
||||
) is not true
|
||||
or (
|
||||
item ? 'isCorrection'
|
||||
and pg_catalog.jsonb_typeof(item -> 'isCorrection') <> 'boolean'
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
@@ -423,7 +427,7 @@ begin
|
||||
p_value,
|
||||
array[
|
||||
'id', 'rawText', 'domain', 'eventSummary', 'dateValue',
|
||||
'datePrecision', 'extractionStatus', 'scoreable'
|
||||
'datePrecision', 'extractionStatus', 'scoreable', 'correctsEvidenceIds'
|
||||
]::text[]
|
||||
)
|
||||
or not (p_value ?& array[
|
||||
@@ -473,6 +477,32 @@ begin
|
||||
or (
|
||||
p_value ? 'scoreable'
|
||||
and pg_catalog.jsonb_typeof(p_value -> 'scoreable') is distinct from 'boolean'
|
||||
)
|
||||
or (
|
||||
p_value ? 'correctsEvidenceIds'
|
||||
and (
|
||||
pg_catalog.jsonb_typeof(p_value -> 'correctsEvidenceIds') is distinct from 'array'
|
||||
or pg_catalog.octet_length((p_value -> 'correctsEvidenceIds')::text) > 64
|
||||
or pg_catalog.jsonb_array_length(p_value -> 'correctsEvidenceIds') > 1
|
||||
or exists (
|
||||
select 1
|
||||
from pg_catalog.jsonb_array_elements(p_value -> 'correctsEvidenceIds') target
|
||||
where pg_catalog.jsonb_typeof(target) is distinct from 'string'
|
||||
or public.conversational_rectification_valid_uuid_text(target #>> '{}') is not true
|
||||
)
|
||||
)
|
||||
)
|
||||
or (
|
||||
p_value ->> 'extractionStatus' = 'corrected'
|
||||
and (
|
||||
not (p_value ? 'correctsEvidenceIds')
|
||||
or pg_catalog.jsonb_array_length(p_value -> 'correctsEvidenceIds') <> 1
|
||||
)
|
||||
)
|
||||
or (
|
||||
p_value ->> 'extractionStatus' = 'clear'
|
||||
and p_value ? 'correctsEvidenceIds'
|
||||
and pg_catalog.jsonb_array_length(p_value -> 'correctsEvidenceIds') <> 0
|
||||
) then
|
||||
return false;
|
||||
end if;
|
||||
@@ -1209,6 +1239,14 @@ create table if not exists public.birth_time_rectification_event_evidence (
|
||||
extraction_status text not null check (
|
||||
extraction_status in ('clear', 'needs_clarification', 'corrected')
|
||||
),
|
||||
corrects_evidence_ids uuid[] not null default '{}'::uuid[] check (
|
||||
pg_catalog.cardinality(corrects_evidence_ids) <= 1
|
||||
and (
|
||||
(extraction_status = 'corrected' and pg_catalog.cardinality(corrects_evidence_ids) = 1)
|
||||
or (extraction_status = 'clear' and pg_catalog.cardinality(corrects_evidence_ids) = 0)
|
||||
or extraction_status = 'needs_clarification'
|
||||
)
|
||||
),
|
||||
scoreable boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
foreign key (case_id, source_turn_id)
|
||||
|
||||
+41
-4
@@ -152,7 +152,8 @@ begin
|
||||
'dateValue', evidence.date_value,
|
||||
'datePrecision', evidence.date_precision,
|
||||
'extractionStatus', evidence.extraction_status,
|
||||
'scoreable', evidence.scoreable
|
||||
'scoreable', evidence.scoreable,
|
||||
'correctsEvidenceIds', pg_catalog.to_jsonb(evidence.corrects_evidence_ids)
|
||||
) order by evidence.created_at, evidence.id
|
||||
)
|
||||
from public.birth_time_rectification_event_evidence evidence
|
||||
@@ -273,7 +274,8 @@ begin
|
||||
'dateValue', evidence.date_value,
|
||||
'datePrecision', evidence.date_precision,
|
||||
'extractionStatus', evidence.extraction_status,
|
||||
'scoreable', evidence.scoreable
|
||||
'scoreable', evidence.scoreable,
|
||||
'correctsEvidenceIds', pg_catalog.to_jsonb(evidence.corrects_evidence_ids)
|
||||
) order by evidence.created_at, evidence.id
|
||||
), '[]'::jsonb) into v_existing_evidence
|
||||
from public.birth_time_rectification_event_evidence evidence
|
||||
@@ -294,7 +296,10 @@ begin
|
||||
'scoreable', case
|
||||
when item.value ? 'scoreable' then (item.value ->> 'scoreable')::boolean
|
||||
else false
|
||||
end
|
||||
end,
|
||||
'correctsEvidenceIds', coalesce(
|
||||
item.value -> 'correctsEvidenceIds', '[]'::jsonb
|
||||
)
|
||||
) order by item.ordinality
|
||||
), '[]'::jsonb) into v_new_evidence
|
||||
from pg_catalog.jsonb_array_elements(p_new_evidence) with ordinality item(value, ordinality);
|
||||
@@ -634,6 +639,32 @@ begin
|
||||
or pg_catalog.jsonb_path_exists(p_turn, '$.**.systemPrompt') then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
-- Correction targets are account-case evidence tips. Under the locked case version,
|
||||
-- reject missing, cross-case, or already-retired targets before writing a turn.
|
||||
if exists (
|
||||
select 1
|
||||
from pg_catalog.jsonb_array_elements(p_evidence) item(value)
|
||||
cross join lateral pg_catalog.jsonb_array_elements_text(
|
||||
coalesce(item.value -> 'correctsEvidenceIds', '[]'::jsonb)
|
||||
) correction(value)
|
||||
where not exists (
|
||||
select 1
|
||||
from public.birth_time_rectification_event_evidence target
|
||||
where target.case_id = p_case_id
|
||||
and target.id = correction.value::uuid
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from public.birth_time_rectification_event_evidence evidence
|
||||
cross join lateral pg_catalog.unnest(
|
||||
evidence.corrects_evidence_ids
|
||||
) retired(target_id)
|
||||
where evidence.case_id = p_case_id
|
||||
and retired.target_id = correction.value::uuid
|
||||
)
|
||||
) then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
if public.conversational_rectification_case_fits_load_limits(
|
||||
p_user_id, p_case_id, p_turn ->> 'status', p_expected_version + 1,
|
||||
p_turn, p_private_candidate, p_evidence, p_validation_receipt
|
||||
@@ -655,7 +686,7 @@ begin
|
||||
|
||||
insert into public.birth_time_rectification_event_evidence (
|
||||
id, case_id, source_turn_id, raw_text, domain, event_summary,
|
||||
date_value, date_precision, extraction_status, scoreable
|
||||
date_value, date_precision, extraction_status, corrects_evidence_ids, scoreable
|
||||
)
|
||||
select
|
||||
(item ->> 'id')::uuid, p_case_id, v_turn_id, item ->> 'rawText',
|
||||
@@ -664,6 +695,12 @@ begin
|
||||
else item ->> 'dateValue' end,
|
||||
item ->> 'datePrecision',
|
||||
item ->> 'extractionStatus',
|
||||
case when item ? 'correctsEvidenceIds' then array(
|
||||
select correction.value::uuid
|
||||
from pg_catalog.jsonb_array_elements_text(
|
||||
item -> 'correctsEvidenceIds'
|
||||
) correction(value)
|
||||
) else '{}'::uuid[] end,
|
||||
case when item ? 'scoreable' then (item ->> 'scoreable')::boolean
|
||||
else false end
|
||||
from pg_catalog.jsonb_array_elements(p_evidence) item;
|
||||
|
||||
@@ -112,14 +112,32 @@ test("marks a future event as context-only and non-scoreable", () => {
|
||||
});
|
||||
|
||||
test("clear replacement evidence is explicitly marked corrected", () => {
|
||||
const targetId = "00000000-0000-4000-8000-000000000611";
|
||||
const [evidence] = extractLifeEventEvidence({
|
||||
rawText: "更正:2020年11月离职",
|
||||
sourceTurnId,
|
||||
asOfDate: "2026-07-20",
|
||||
correctionOfEvidenceIds: ["00000000-0000-4000-8000-000000000611"],
|
||||
correctsEvidenceId: targetId,
|
||||
});
|
||||
|
||||
assert.equal(evidence?.eventSummary, "离职");
|
||||
assert.equal(evidence?.extractionStatus, "corrected");
|
||||
assert.equal(evidence?.scoreable, true);
|
||||
assert.deepEqual(evidence?.correctsEvidenceIds, [targetId]);
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
|
||||
});
|
||||
|
||||
test("an unclear correction keeps durable lineage while withholding both old and new scoring", () => {
|
||||
const targetId = "00000000-0000-4000-8000-000000000611";
|
||||
const [evidence] = extractLifeEventEvidence({
|
||||
rawText: "更正:具体年月记不清",
|
||||
sourceTurnId,
|
||||
asOfDate: "2026-07-20",
|
||||
correctsEvidenceId: targetId,
|
||||
});
|
||||
|
||||
assert.equal(evidence?.extractionStatus, "needs_clarification");
|
||||
assert.equal(evidence?.scoreable, false);
|
||||
assert.deepEqual(evidence?.correctsEvidenceIds, [targetId]);
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ const turn: ConversationalRectificationTurn = {
|
||||
id: "00000000-0000-4000-8000-000000000822",
|
||||
summary: "开始第一份长期工作",
|
||||
dateLabel: "2021 年 7 月",
|
||||
isCorrection: false,
|
||||
}],
|
||||
actions: ["answer", "pause", "abandon", "confirm"],
|
||||
pendingConsultationQuestion: "我适合什么时候换工作?",
|
||||
@@ -61,13 +62,18 @@ function controller(overrides: Partial<ConversationalRectificationController> =
|
||||
turn,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
error: "",
|
||||
getSnapshot: () => ({ turn, draft: "", selectedDomain: null, pending: false, error: "" }),
|
||||
getSnapshot: () => ({
|
||||
turn, draft: "", selectedDomain: null, correctionTarget: null, pending: false, error: "",
|
||||
}),
|
||||
subscribe: () => () => undefined,
|
||||
synchronizeInitialTurn: () => undefined,
|
||||
setDraft: () => undefined,
|
||||
selectDomain: () => undefined,
|
||||
beginEvidenceCorrection: () => undefined,
|
||||
cancelEvidenceCorrection: () => undefined,
|
||||
start: async () => turn,
|
||||
resume: async () => turn,
|
||||
answer: async () => turn,
|
||||
@@ -112,6 +118,35 @@ test("evidence is correctable, technical receipts stay visible, and confirmation
|
||||
assert.match(markup, /放弃本次校正/);
|
||||
});
|
||||
|
||||
test("correction mode identifies its durable target, can be cancelled, and marks revised recaps", () => {
|
||||
const revisedTurn = {
|
||||
...turn,
|
||||
evidenceRecap: [{ ...turn.evidenceRecap[0]!, isCorrection: true }],
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
const correctionTarget = revisedTurn.evidenceRecap[0]!;
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
{ controller: controller({
|
||||
turn: revisedTurn,
|
||||
draft: "更正:其实是 2020 年 11 月离职",
|
||||
correctionTarget,
|
||||
getSnapshot: () => ({
|
||||
turn: revisedTurn,
|
||||
draft: "更正:其实是 2020 年 11 月离职",
|
||||
selectedDomain: null,
|
||||
correctionTarget,
|
||||
pending: false,
|
||||
error: "",
|
||||
}),
|
||||
}) },
|
||||
));
|
||||
|
||||
assert.match(markup, /正在更正/);
|
||||
assert.match(markup, /开始第一份长期工作/);
|
||||
assert.match(markup, /取消更正/);
|
||||
assert.match(markup, /已修订/);
|
||||
});
|
||||
|
||||
test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
const pendingController = controller({
|
||||
pending: true,
|
||||
@@ -120,6 +155,7 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
turn,
|
||||
draft: "保留中的文字",
|
||||
selectedDomain: "career",
|
||||
correctionTarget: null,
|
||||
pending: true,
|
||||
error: "",
|
||||
}),
|
||||
@@ -559,7 +595,12 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
|
||||
datePrecision: "month_preferred",
|
||||
freeTextAllowed: true,
|
||||
},
|
||||
evidenceRecap: [],
|
||||
evidenceRecap: status === "abandoned" ? [] : [{
|
||||
id: "00000000-0000-4000-8000-000000000822",
|
||||
summary: "开始第一份长期工作",
|
||||
dateLabel: "2021-07",
|
||||
isCorrection: false,
|
||||
}],
|
||||
actions: status === "abandoned" ? [] : status === "paused"
|
||||
? ["answer", "abandon"]
|
||||
: ["answer", "pause", "abandon"],
|
||||
@@ -662,6 +703,22 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
|
||||
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
|
||||
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
|
||||
|
||||
await cdp.evaluate("document.querySelector('[aria-label^=\"更正这条经历\"]').click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`(() => {
|
||||
const textarea = document.getElementById('conversational-rectification-answer');
|
||||
return document.body.textContent.includes('正在更正')
|
||||
&& document.body.textContent.includes('开始第一份长期工作')
|
||||
&& textarea?.value === '';
|
||||
})()`) ?? Promise.resolve(false),
|
||||
"durable correction target selection without polluting the new answer",
|
||||
);
|
||||
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('取消更正')).click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("!document.body.textContent.includes('正在更正') && document.getElementById('conversational-rectification-answer').value === ''") ?? Promise.resolve(false),
|
||||
"correction cancellation",
|
||||
);
|
||||
|
||||
await cdp.evaluate("document.querySelector('[data-evidence-domain=career]').click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'conversational-rectification-answer'") ?? Promise.resolve(false),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
const actionId = "a9890e09-d535-46f0-9a36-86017515a5a1";
|
||||
const caseId = "77b29d28-c576-429e-9e3d-d0a90348e3cb";
|
||||
const evidenceId = "37e0e35e-cfdc-4c7a-8375-84310ee6bd42";
|
||||
|
||||
function parseCommand(value: unknown) {
|
||||
return conversationalRectificationCommandSchema.safeParse(value).success;
|
||||
@@ -46,6 +47,33 @@ test("bounds free-text answers and requires a strict HH:mm confirmation time", (
|
||||
assert.equal(parseCommand({ type: "confirm", caseId, actionId, turnVersion: 1, time: "24:00" }), false);
|
||||
});
|
||||
|
||||
test("accepts one explicit UUID correction target and rejects ambiguous target shapes", () => {
|
||||
assert.equal(parseCommand({
|
||||
type: "answer",
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion: 1,
|
||||
answer: "更正:2020 年 11 月离职",
|
||||
correctsEvidenceId: evidenceId,
|
||||
}), true);
|
||||
assert.equal(parseCommand({
|
||||
type: "answer",
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion: 1,
|
||||
answer: "更正:2020 年 11 月离职",
|
||||
correctsEvidenceId: "not-a-uuid",
|
||||
}), false);
|
||||
assert.equal(parseCommand({
|
||||
type: "answer",
|
||||
caseId,
|
||||
actionId,
|
||||
turnVersion: 1,
|
||||
answer: "更正两条",
|
||||
correctsEvidenceIds: [evidenceId, caseId],
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("rejects client candidate scores and technical receipts", () => {
|
||||
assert.equal(parseCommand({
|
||||
type: "answer", caseId, actionId, turnVersion: 1, answer: "2019 年 7 月换了工作",
|
||||
@@ -82,9 +110,10 @@ test("accepts only the exact public turn shape", () => {
|
||||
freeTextAllowed: true,
|
||||
},
|
||||
evidenceRecap: [{
|
||||
id: "37e0e35e-cfdc-4c7a-8375-84310ee6bd42",
|
||||
id: evidenceId,
|
||||
summary: "2019 年换工作",
|
||||
dateLabel: "2019-07",
|
||||
isCorrection: true,
|
||||
}],
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
pendingConsultationQuestion: null,
|
||||
@@ -94,6 +123,10 @@ test("accepts only the exact public turn shape", () => {
|
||||
assert.equal(conversationalRectificationTurnSchema.safeParse({ ...turn, candidateScores: [0.99] }).success, false);
|
||||
assert.equal(conversationalRectificationTurnSchema.safeParse({ ...turn, candidate: { ...turn.candidate, score: 0.99 } }).success, false);
|
||||
assert.equal(conversationalRectificationTurnSchema.safeParse({ ...turn, technicalReceipt: { ...turn.technicalReceipt, rawModelOutput: "secret" } }).success, false);
|
||||
assert.equal(conversationalRectificationTurnSchema.safeParse({
|
||||
...turn,
|
||||
evidenceRecap: [{ ...turn.evidenceRecap[0], isCorrection: "yes" }],
|
||||
}).success, false);
|
||||
});
|
||||
|
||||
function assertNoReachableText(value: unknown, forbidden: string, seen = new Set<unknown>()) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
|
||||
const caseId = "00000000-0000-4000-8000-000000000811";
|
||||
const otherCaseId = "00000000-0000-4000-8000-000000000819";
|
||||
const evidenceId = "00000000-0000-4000-8000-000000000818";
|
||||
const actionIds = [
|
||||
"00000000-0000-4000-8000-000000000812",
|
||||
"00000000-0000-4000-8000-000000000813",
|
||||
@@ -51,6 +52,18 @@ function activeTurn(turnVersion = 2): ConversationalRectificationTurn {
|
||||
};
|
||||
}
|
||||
|
||||
function correctableTurn(turnVersion = 2): ConversationalRectificationTurn {
|
||||
return {
|
||||
...activeTurn(turnVersion),
|
||||
evidenceRecap: [{
|
||||
id: evidenceId,
|
||||
summary: "开始第一份长期工作",
|
||||
dateLabel: "2021-07",
|
||||
isCorrection: false,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function idFactory() {
|
||||
const ids = [...actionIds];
|
||||
return () => ids.shift() ?? assert.fail("unexpected action id allocation");
|
||||
@@ -124,6 +137,70 @@ test("controller preserves the exact draft and stable action id across failures"
|
||||
assert.equal(commands[0]?.actionId, commands[1]?.actionId);
|
||||
});
|
||||
|
||||
test("a correction target is explicit in the command identity, survives failure, and clears on success", async () => {
|
||||
const commands: ConversationalRectificationCommand[] = [];
|
||||
let fail = true;
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: correctableTurn(),
|
||||
createActionId: idFactory(),
|
||||
send: async (command) => {
|
||||
commands.push(command);
|
||||
if (fail) throw new Error("offline");
|
||||
return { ...correctableTurn(3), evidenceRecap: [] };
|
||||
},
|
||||
});
|
||||
|
||||
controller.beginEvidenceCorrection(evidenceId);
|
||||
assert.equal(controller.getSnapshot().correctionTarget?.id, evidenceId);
|
||||
assert.equal(
|
||||
controller.getSnapshot().draft,
|
||||
"",
|
||||
"the old summary/date must stay outside the submitted correction text",
|
||||
);
|
||||
controller.setDraft("更正:其实是 2020 年 11 月离职");
|
||||
await assert.rejects(controller.answer());
|
||||
assert.equal(controller.getSnapshot().correctionTarget?.id, evidenceId);
|
||||
assert.equal(commands[0]?.type, "answer");
|
||||
assert.equal(commands[0]?.type === "answer" ? commands[0].correctsEvidenceId : null, evidenceId);
|
||||
|
||||
fail = false;
|
||||
await controller.answer();
|
||||
assert.equal(commands[0]?.actionId, commands[1]?.actionId);
|
||||
assert.equal(controller.getSnapshot().correctionTarget, null);
|
||||
assert.equal(controller.getSnapshot().draft, "");
|
||||
});
|
||||
|
||||
test("canceling correction removes the target and changing targets changes the replay identity", async () => {
|
||||
const secondEvidenceId = "00000000-0000-4000-8000-000000000817";
|
||||
const commands: ConversationalRectificationCommand[] = [];
|
||||
const initial = {
|
||||
...correctableTurn(),
|
||||
evidenceRecap: [
|
||||
...correctableTurn().evidenceRecap,
|
||||
{ id: secondEvidenceId, summary: "搬到另一座城市", dateLabel: "2022-03" },
|
||||
],
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: initial,
|
||||
createActionId: idFactory(),
|
||||
send: async (command) => {
|
||||
commands.push(command);
|
||||
throw new Error("offline");
|
||||
},
|
||||
});
|
||||
|
||||
controller.beginEvidenceCorrection(evidenceId);
|
||||
controller.setDraft("更正:其实是 2020 年 11 月离职");
|
||||
await assert.rejects(controller.answer());
|
||||
controller.beginEvidenceCorrection(secondEvidenceId);
|
||||
controller.setDraft("更正:其实是 2022 年 3 月搬家");
|
||||
await assert.rejects(controller.answer());
|
||||
assert.notEqual(commands[0]?.actionId, commands[1]?.actionId);
|
||||
controller.cancelEvidenceCorrection();
|
||||
assert.equal(controller.getSnapshot().correctionTarget, null);
|
||||
assert.equal(controller.getSnapshot().draft, "");
|
||||
});
|
||||
|
||||
test("a stale answer resumes the latest turn and returns recovered state without clearing text", async () => {
|
||||
const commands: ConversationalRectificationCommand[] = [];
|
||||
const recovered = activeTurn(5);
|
||||
@@ -173,6 +250,32 @@ test("a stale recovery retains the selected domain only while the recovered turn
|
||||
assert.equal(controller.getSnapshot().selectedDomain, "career");
|
||||
});
|
||||
|
||||
test("stale recovery retains a correction only when the latest effective recap still contains it", async () => {
|
||||
const recoveryCases: Array<[ConversationalRectificationTurn, string | null]> = [
|
||||
[correctableTurn(5), evidenceId],
|
||||
[{ ...correctableTurn(5), evidenceRecap: [] }, null],
|
||||
];
|
||||
for (const [recovered, expected] of recoveryCases) {
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: correctableTurn(2),
|
||||
createActionId: idFactory(),
|
||||
send: async (command) => {
|
||||
if (command.type === "answer") {
|
||||
throw new ConversationalRectificationRequestError(409, "stale_turn", "请加载最新进度后再试。");
|
||||
}
|
||||
return recovered;
|
||||
},
|
||||
});
|
||||
controller.beginEvidenceCorrection(evidenceId);
|
||||
controller.setDraft("更正:其实是 2020 年 11 月离职");
|
||||
|
||||
await controller.answer();
|
||||
|
||||
assert.equal(controller.getSnapshot().correctionTarget?.id ?? null, expected);
|
||||
assert.equal(controller.getSnapshot().draft, "更正:其实是 2020 年 11 月离职");
|
||||
}
|
||||
});
|
||||
|
||||
test("a changed payload receives a different action id after a failed send", async () => {
|
||||
const commands: ConversationalRectificationCommand[] = [];
|
||||
const controller = createConversationalRectificationController({
|
||||
@@ -289,6 +392,7 @@ test("a case switch detaches the old mutation so the new case can mutate indepen
|
||||
turn: caseBTurn,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
error: "",
|
||||
});
|
||||
@@ -332,6 +436,7 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
|
||||
turn: null,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
error: "",
|
||||
});
|
||||
@@ -342,6 +447,7 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
|
||||
turn: null,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
@@ -487,6 +487,137 @@ test("clear historical evidence is extracted, scored, narrated, recapped, and at
|
||||
assert.ok(value.events.includes("score-packet"));
|
||||
});
|
||||
|
||||
test("evidence corrections are append-only while recap and scoring use only the effective lineage tip", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(firstId);
|
||||
|
||||
const corrected = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: secondAnswerActionId,
|
||||
turnVersion: 1,
|
||||
answer: "更正:其实是2020年11月离职",
|
||||
correctsEvidenceId: firstId,
|
||||
});
|
||||
const secondId = value.cases.get(startActionId)?.row.eventEvidence[1]?.id;
|
||||
assert.ok(secondId);
|
||||
assert.deepEqual(corrected.evidenceRecap, [{
|
||||
id: secondId,
|
||||
summary: "其实是离职",
|
||||
dateLabel: "2020-11",
|
||||
isCorrection: true,
|
||||
}]);
|
||||
|
||||
const twiceCorrected = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: thirdAnswerActionId,
|
||||
turnVersion: 2,
|
||||
answer: "更正:准确的是2021年2月入职",
|
||||
correctsEvidenceId: secondId,
|
||||
});
|
||||
|
||||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||||
assert.equal(stored.length, 3, "the audit lineage must remain append-only");
|
||||
assert.deepEqual(stored[0]?.correctsEvidenceIds ?? [], []);
|
||||
assert.deepEqual(stored[1]?.correctsEvidenceIds, [firstId]);
|
||||
assert.deepEqual(stored[2]?.correctsEvidenceIds, [secondId]);
|
||||
assert.deepEqual(twiceCorrected.evidenceRecap, [{
|
||||
id: stored[2]?.id,
|
||||
summary: "准确的是入职",
|
||||
dateLabel: "2021-02",
|
||||
isCorrection: true,
|
||||
}]);
|
||||
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1, 1]);
|
||||
});
|
||||
|
||||
test("an unclear correction immediately retires the wrong fact and stays retired after later turns", async () => {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(wrongId);
|
||||
|
||||
const clarification = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1, answer: "更正:具体年月记不清", correctsEvidenceId: wrongId,
|
||||
});
|
||||
const unclear = value.cases.get(startActionId)?.row.eventEvidence[1];
|
||||
assert.equal(unclear?.extractionStatus, "needs_clarification");
|
||||
assert.equal(unclear?.scoreable, false);
|
||||
assert.deepEqual(unclear?.correctsEvidenceIds, [wrongId]);
|
||||
assert.deepEqual(clarification.evidenceRecap, [{
|
||||
id: unclear?.id,
|
||||
summary: "具体年月记不清",
|
||||
dateLabel: "日期待补充",
|
||||
isCorrection: true,
|
||||
}]);
|
||||
assert.equal(clarification.status, "active");
|
||||
assert.equal(clarification.candidate.status, "pending_validation");
|
||||
assert.equal(clarification.actions.includes("confirm"), false);
|
||||
assert.equal(value.cases.get(startActionId)?.row.privateCandidate.resultId, null);
|
||||
assert.deepEqual(
|
||||
value.cases.get(startActionId)?.row.privateCandidate.scoredHistoricalEvidence ?? [],
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 0]);
|
||||
|
||||
const later = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: thirdAnswerActionId,
|
||||
turnVersion: 2, answer: "2022年3月搬家",
|
||||
});
|
||||
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 0, 1]);
|
||||
assert.equal(later.evidenceRecap.some((item) => item.id === wrongId), false);
|
||||
assert.equal(later.evidenceRecap.some((item) => item.id === unclear?.id), true);
|
||||
});
|
||||
|
||||
test("missing or already-retired correction targets fail closed without advancing or persisting", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
const before = [...value.mutations];
|
||||
await assert.rejects(value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "更正:2020年11月离职",
|
||||
correctsEvidenceId: "00000000-0000-4000-8000-000000000799",
|
||||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "action_conflict");
|
||||
assert.deepEqual(value.mutations, before);
|
||||
assert.equal(value.cases.get(startActionId)?.row.turnVersion, 0);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 0);
|
||||
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(firstId);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: thirdAnswerActionId,
|
||||
turnVersion: 1, answer: "更正:2020年11月离职", correctsEvidenceId: firstId,
|
||||
});
|
||||
const versionBeforeConflict = value.cases.get(startActionId)?.row.turnVersion;
|
||||
const evidenceBeforeConflict = value.cases.get(startActionId)?.row.eventEvidence.length;
|
||||
await assert.rejects(value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: fourthAnswerActionId,
|
||||
turnVersion: 2, answer: "再次覆盖旧错误:2021年2月入职", correctsEvidenceId: firstId,
|
||||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "action_conflict");
|
||||
assert.equal(value.cases.get(startActionId)?.row.turnVersion, versionBeforeConflict);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, evidenceBeforeConflict);
|
||||
});
|
||||
|
||||
test("generic date uncertainty does not suppress clear historical evidence", async () => {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
|
||||
@@ -288,7 +288,8 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard
|
||||
eventSummary: "换工作",
|
||||
dateValue: "2019-07",
|
||||
datePrecision: "month" as const,
|
||||
extractionStatus: "clear" as const,
|
||||
extractionStatus: "corrected" as const,
|
||||
correctsEvidenceIds: ["00000000-0000-4000-8000-000000000109"],
|
||||
}];
|
||||
|
||||
await store.saveTurn({
|
||||
@@ -731,12 +732,33 @@ test("life-event evidence rejects unknown, blank, and non-boolean durable values
|
||||
scoreable: true,
|
||||
};
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse({
|
||||
...evidence,
|
||||
extractionStatus: "corrected",
|
||||
correctsEvidenceIds: ["00000000-0000-4000-8000-000000000109"],
|
||||
}).success, true);
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse({
|
||||
...evidence,
|
||||
extractionStatus: "needs_clarification",
|
||||
scoreable: false,
|
||||
correctsEvidenceIds: ["00000000-0000-4000-8000-000000000109"],
|
||||
}).success, true);
|
||||
for (const invalid of [
|
||||
{ ...evidence, extra: "discard me" },
|
||||
{ ...evidence, eventSummary: " \t " },
|
||||
{ ...evidence, dateValue: " \t " },
|
||||
{ ...evidence, scoreable: null },
|
||||
{ ...evidence, scoreable: "true" },
|
||||
{ ...evidence, extractionStatus: "corrected", correctsEvidenceIds: [] },
|
||||
{ ...evidence, correctsEvidenceIds: ["not-a-uuid"] },
|
||||
{
|
||||
...evidence,
|
||||
extractionStatus: "corrected",
|
||||
correctsEvidenceIds: [
|
||||
"00000000-0000-4000-8000-000000000109",
|
||||
"00000000-0000-4000-8000-000000000110",
|
||||
],
|
||||
},
|
||||
]) {
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse(invalid).success, false);
|
||||
}
|
||||
|
||||
@@ -489,6 +489,30 @@ def test_life_event_evidence_is_strictly_validated_before_insert() -> None:
|
||||
assert "nullif(item ->> 'datevalue', '')" not in save
|
||||
|
||||
|
||||
def test_evidence_correction_lineage_is_bounded_projected_and_validated_atomically() -> None:
|
||||
schema = _normalized(SCHEMA)
|
||||
transitions = _normalized(TRANSITIONS)
|
||||
validator = _function(schema, "conversational_rectification_valid_life_event_evidence")
|
||||
load = _function(transitions, "load_conversational_rectification_case")
|
||||
limits = _function(transitions, "conversational_rectification_case_fits_load_limits")
|
||||
save = _function(transitions, "save_conversational_rectification_turn")
|
||||
|
||||
assert "corrects_evidence_ids uuid[] not null default '{}'::uuid[]" in schema
|
||||
assert "'correctsevidenceids'" in validator
|
||||
assert "jsonb_array_length(p_value -> 'correctsevidenceids') > 1" in validator
|
||||
assert "p_value ->> 'extractionstatus' = 'corrected'" in validator
|
||||
assert "p_value ->> 'extractionstatus' = 'clear'" in validator
|
||||
assert "'correctsevidenceids', pg_catalog.to_jsonb(evidence.corrects_evidence_ids)" in load
|
||||
assert "'correctsevidenceids', pg_catalog.to_jsonb(evidence.corrects_evidence_ids)" in limits
|
||||
assert "corrects_evidence_ids" in save
|
||||
assert "item -> 'correctsevidenceids'" in save
|
||||
assert "pg_catalog.unnest( evidence.corrects_evidence_ids )" in save
|
||||
assert save.index("pg_catalog.unnest( evidence.corrects_evidence_ids )") < save.index(
|
||||
"insert into public.birth_time_rectification_event_evidence"
|
||||
)
|
||||
assert "where evidence.case_id = p_case_id" in save
|
||||
|
||||
|
||||
def test_case_mutations_enforce_cumulative_load_limits_under_the_case_lock() -> None:
|
||||
transitions = _normalized(TRANSITIONS)
|
||||
helper = _function(transitions, "conversational_rectification_case_fits_load_limits")
|
||||
|
||||
@@ -1018,6 +1018,144 @@ def test_save_rejects_invalid_evidence_without_discarding_or_coercing_fields(
|
||||
) == "false"
|
||||
|
||||
|
||||
def test_correction_lineage_is_append_only_owner_scoped_and_rejects_retired_targets(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000001201"
|
||||
case_id = "00000000-0000-4000-8000-000000001202"
|
||||
first_action = "00000000-0000-4000-8000-000000001203"
|
||||
second_action = "00000000-0000-4000-8000-000000001204"
|
||||
third_action = "00000000-0000-4000-8000-000000001205"
|
||||
rejected_action = "00000000-0000-4000-8000-000000001206"
|
||||
missing_id = "00000000-0000-4000-8000-000000001207"
|
||||
first_id = "00000000-0000-4000-8000-000000001211"
|
||||
second_id = "00000000-0000-4000-8000-000000001212"
|
||||
third_id = "00000000-0000-4000-8000-000000001213"
|
||||
_create_user(pg14_database, user_id)
|
||||
_reserve(pg14_database, user_id, case_id)
|
||||
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
|
||||
_complete(pg14_database, user_id, case_id)
|
||||
|
||||
first = {
|
||||
"id": first_id,
|
||||
"rawText": "2019 年 7 月开始第一份工作",
|
||||
"domain": "career",
|
||||
"eventSummary": "开始第一份工作",
|
||||
"dateValue": "2019-07",
|
||||
"datePrecision": "month",
|
||||
"extractionStatus": "clear",
|
||||
"scoreable": True,
|
||||
}
|
||||
pg14_database.sql(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
0,
|
||||
first_action,
|
||||
[first],
|
||||
turn={
|
||||
**_valid_turn(case_id),
|
||||
"turnVersion": 1,
|
||||
"evidenceRecap": [{
|
||||
"id": first_id,
|
||||
"summary": "开始第一份工作",
|
||||
"dateLabel": "2019-07",
|
||||
"isCorrection": False,
|
||||
}],
|
||||
},
|
||||
))
|
||||
loaded_first = json.loads(pg14_database.sql(
|
||||
f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text"
|
||||
))
|
||||
assert loaded_first["event_evidence"][0]["correctsEvidenceIds"] == []
|
||||
|
||||
second = {
|
||||
"id": second_id,
|
||||
"rawText": "更正:2020 年 11 月离职",
|
||||
"domain": "career",
|
||||
"eventSummary": "离职",
|
||||
"dateValue": "2020-11",
|
||||
"datePrecision": "month",
|
||||
"extractionStatus": "corrected",
|
||||
"scoreable": True,
|
||||
"correctsEvidenceIds": [first_id],
|
||||
}
|
||||
pg14_database.sql(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
1,
|
||||
second_action,
|
||||
[second],
|
||||
turn={
|
||||
**_valid_turn(case_id),
|
||||
"turnVersion": 2,
|
||||
"evidenceRecap": [{
|
||||
"id": second_id,
|
||||
"summary": "离职",
|
||||
"dateLabel": "2020-11",
|
||||
"isCorrection": True,
|
||||
}],
|
||||
},
|
||||
))
|
||||
|
||||
for invalid_target in (missing_id, first_id):
|
||||
rejected = {
|
||||
**second,
|
||||
"id": third_id,
|
||||
"correctsEvidenceIds": [invalid_target],
|
||||
}
|
||||
assert pg14_database.rejects(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
2,
|
||||
rejected_action,
|
||||
[rejected],
|
||||
))
|
||||
assert pg14_database.sql(
|
||||
f"select turn_version::text from public.birth_time_rectification_cases where id = '{case_id}'::uuid"
|
||||
) == "2"
|
||||
assert pg14_database.sql(
|
||||
f"select count(*)::text from public.birth_time_rectification_event_evidence where case_id = '{case_id}'::uuid"
|
||||
) == "2"
|
||||
|
||||
third = {
|
||||
**second,
|
||||
"id": third_id,
|
||||
"rawText": "更正:2021 年 2 月入职",
|
||||
"eventSummary": "入职",
|
||||
"dateValue": "2021-02",
|
||||
"correctsEvidenceIds": [second_id],
|
||||
}
|
||||
pg14_database.sql(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
2,
|
||||
third_action,
|
||||
[third],
|
||||
turn={
|
||||
**_valid_turn(case_id),
|
||||
"turnVersion": 3,
|
||||
"evidenceRecap": [{
|
||||
"id": third_id,
|
||||
"summary": "入职",
|
||||
"dateLabel": "2021-02",
|
||||
"isCorrection": True,
|
||||
}],
|
||||
},
|
||||
))
|
||||
loaded = json.loads(pg14_database.sql(
|
||||
f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text"
|
||||
))
|
||||
assert len(loaded["event_evidence"]) == 3
|
||||
assert loaded["event_evidence"][1]["correctsEvidenceIds"] == [first_id]
|
||||
assert loaded["event_evidence"][2]["correctsEvidenceIds"] == [second_id]
|
||||
assert loaded["latest_turn"]["evidenceRecap"] == [{
|
||||
"id": third_id,
|
||||
"summary": "入职",
|
||||
"dateLabel": "2021-02",
|
||||
"isCorrection": True,
|
||||
}]
|
||||
|
||||
|
||||
def test_save_rejects_cumulative_evidence_count_before_inserting(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user