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([
@@ -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)
@@ -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: