fix: reset rectification corrections safely
This commit is contained in:
@@ -248,7 +248,7 @@ export function ConversationalRectificationSurface({
|
||||
<p>
|
||||
正在更正:<strong>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</strong>
|
||||
</p>
|
||||
<span>提交后会保留原记录用于审计,但候选评分只使用更正后的有效证据。</span>
|
||||
<span>一次只更正一条事件。提交后会保留原记录用于审计,但候选评分只使用更正后的有效证据。</span>
|
||||
<button
|
||||
className="button-secondary"
|
||||
disabled={controller.pending}
|
||||
|
||||
@@ -331,19 +331,35 @@ function domainsForClarification(
|
||||
: ["career", "relationship"];
|
||||
}
|
||||
|
||||
type CorrectionResetReason =
|
||||
| "needs_clarification"
|
||||
| "non_scoreable"
|
||||
| "direction_change"
|
||||
| "validation_fallback";
|
||||
|
||||
function nonScoringTurn(input: {
|
||||
readonly current: LoadedConversationalRectificationCase;
|
||||
readonly newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
readonly domain?: RectificationEvidenceDomain;
|
||||
readonly directionChange: boolean;
|
||||
readonly scoringFallback?: boolean;
|
||||
readonly correctionClarificationPacket?: RectificationTechnicalPacket;
|
||||
readonly correctionReset?: Readonly<{
|
||||
packet: RectificationTechnicalPacket;
|
||||
reason: CorrectionResetReason;
|
||||
}>;
|
||||
}): { 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.correctionClarificationPacket
|
||||
? "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,请补充大约年份、月份和发生了什么;在补清之前不会沿用旧证据推进确认。"
|
||||
const correctionNarrative = input.correctionReset?.reason === "validation_fallback"
|
||||
? "这条更正已保存,原记录已经停止参与候选评分。候选已从声明范围重新计算,但新的专业解释未通过事实一致性校验;本轮不会保留旧候选的确认资格,请稍后重试或继续补充真实事件。"
|
||||
: input.correctionReset?.reason === "direction_change"
|
||||
? "这条更正已保存,原记录已经停止参与候选评分。我们会从声明范围重新开始核对,你可以换一个真实事件方向并尽量写明年月;本轮不会沿用旧候选推进确认。"
|
||||
: input.correctionReset?.reason === "non_scoreable"
|
||||
? "这条更正已保存,原记录已经停止参与候选评分。更正后的内容目前不能作为已经发生的评分证据,候选已从声明范围重新计算;请再补充一件已发生并带有年月的事件。"
|
||||
: "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,候选已从声明范围重新计算;请补充大约年份、月份和发生了什么。";
|
||||
const narrative = input.correctionReset
|
||||
? correctionNarrative
|
||||
: input.scoringFallback
|
||||
? "本轮原文已安全保存,但新的专业解释未通过事实一致性校验,因此候选没有推进。请稍后重试,或继续补充一件已经发生并带有年月的事件。"
|
||||
: input.directionChange
|
||||
@@ -351,7 +367,7 @@ function nonScoringTurn(input: {
|
||||
: hasFuture
|
||||
? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。"
|
||||
: "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
|
||||
const status = input.correctionClarificationPacket
|
||||
const status = input.correctionReset
|
||||
? "active" as const
|
||||
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
|
||||
const actions = actionsFor(status);
|
||||
@@ -370,12 +386,12 @@ function nonScoringTurn(input: {
|
||||
evidenceRequest,
|
||||
evidenceRecap: evidenceRecap(allEvidence),
|
||||
actions,
|
||||
...(input.correctionClarificationPacket ? {
|
||||
...(input.correctionReset ? {
|
||||
candidate: {
|
||||
...projectRectificationTechnicalPacket(input.correctionClarificationPacket).candidate,
|
||||
...projectRectificationTechnicalPacket(input.correctionReset.packet).candidate,
|
||||
status: "pending_validation" as const,
|
||||
},
|
||||
technicalReceipt: exactTechnicalReceipt(input.correctionClarificationPacket),
|
||||
technicalReceipt: exactTechnicalReceipt(input.correctionReset.packet),
|
||||
} : {}),
|
||||
});
|
||||
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
|
||||
@@ -457,6 +473,9 @@ export function createConversationalRectificationService(
|
||||
throw new ConversationalRectificationError("invalid_command");
|
||||
}
|
||||
if (extracted.length > 20) throw new ConversationalRectificationError("invalid_command");
|
||||
if (command.correctsEvidenceId && extracted.length !== 1) {
|
||||
throw new ConversationalRectificationError("invalid_command");
|
||||
}
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -644,34 +663,110 @@ export function createConversationalRectificationService(
|
||||
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) {
|
||||
const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence])
|
||||
.filter((item) => item.scoreable === true
|
||||
&& item.extractionStatus !== "needs_clarification"
|
||||
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
|
||||
|
||||
if (command.correctsEvidenceId) {
|
||||
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,
|
||||
// A correction invalidates any range narrowed by the retired fact.
|
||||
// Rebuild from the user's declared/baseline range so eliminated
|
||||
// minutes can re-enter the deterministic scan.
|
||||
privateCandidate: null,
|
||||
evidence: allScoreable,
|
||||
});
|
||||
const next = nonScoringTurn({
|
||||
current,
|
||||
newEvidence: evidence,
|
||||
domain: command.domain,
|
||||
directionChange: false,
|
||||
correctionClarificationPacket: computed.packet,
|
||||
const replacement = evidence[0];
|
||||
if (!replacement) throw new ConversationalRectificationError("invalid_command");
|
||||
const resetReason: CorrectionResetReason | null = directionChange
|
||||
? "direction_change"
|
||||
: replacement.extractionStatus === "needs_clarification"
|
||||
? "needs_clarification"
|
||||
: replacement.scoreable !== true ? "non_scoreable" : null;
|
||||
if (resetReason) {
|
||||
const next = nonScoringTurn({
|
||||
current,
|
||||
newEvidence: evidence,
|
||||
domain: command.domain,
|
||||
directionChange: false,
|
||||
correctionReset: { packet: computed.packet, reason: resetReason },
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
const phase = computed.packet.candidate.status === "ready_for_confirmation"
|
||||
? "final" as const
|
||||
: "intermediate" as const;
|
||||
const narrative = await generateRectificationNarrative({
|
||||
phase,
|
||||
packet: computed.packet,
|
||||
generator: ports.narrativeGenerator,
|
||||
});
|
||||
if (!narrative.allowEvidenceScoringAdvance) {
|
||||
const next = nonScoringTurn({
|
||||
current,
|
||||
newEvidence: evidence,
|
||||
domain: command.domain,
|
||||
directionChange: false,
|
||||
correctionReset: {
|
||||
packet: computed.packet,
|
||||
reason: "validation_fallback",
|
||||
},
|
||||
});
|
||||
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: narrative.validationReceipt,
|
||||
privateCandidate,
|
||||
});
|
||||
return publicTurn(saved);
|
||||
}
|
||||
|
||||
const privateCandidate = privateCandidateFromPacket({
|
||||
packet: computed.packet,
|
||||
resultId: null,
|
||||
resultId: computed.resultId,
|
||||
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
|
||||
forceCollecting: true,
|
||||
});
|
||||
const turn = turnFromNarrative({
|
||||
caseId: command.caseId,
|
||||
turnVersion: command.turnVersion + 1,
|
||||
pendingConsultationQuestion: current.pendingConsultationQuestion,
|
||||
packet: computed.packet,
|
||||
narrative,
|
||||
evidence: [...current.eventEvidence, ...evidence],
|
||||
});
|
||||
const saved = await ports.store.saveTurn({
|
||||
userId,
|
||||
@@ -679,9 +774,9 @@ export function createConversationalRectificationService(
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
commandFingerprint: fingerprint,
|
||||
turn: next.turn,
|
||||
turn,
|
||||
evidence,
|
||||
validationReceipt: next.receipt,
|
||||
validationReceipt: narrative.validationReceipt,
|
||||
privateCandidate,
|
||||
});
|
||||
return publicTurn(saved);
|
||||
@@ -715,10 +810,6 @@ export function createConversationalRectificationService(
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+16
-1
@@ -640,7 +640,22 @@ begin
|
||||
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.
|
||||
-- reject one-to-many replacement attempts, missing/cross-case targets, or
|
||||
-- already-retired targets before writing a turn.
|
||||
if exists (
|
||||
select 1
|
||||
from (
|
||||
select correction.value::uuid as target_id
|
||||
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)
|
||||
group by correction.value::uuid
|
||||
having pg_catalog.count(*) > 1
|
||||
) duplicate_correction_target
|
||||
) then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
if exists (
|
||||
select 1
|
||||
from pg_catalog.jsonb_array_elements(p_evidence) item(value)
|
||||
|
||||
@@ -143,6 +143,7 @@ test("correction mode identifies its durable target, can be cancelled, and marks
|
||||
|
||||
assert.match(markup, /正在更正/);
|
||||
assert.match(markup, /开始第一份长期工作/);
|
||||
assert.match(markup, /一次只更正一条事件/);
|
||||
assert.match(markup, /取消更正/);
|
||||
assert.match(markup, /已修订/);
|
||||
});
|
||||
|
||||
@@ -95,13 +95,21 @@ function packet(ready = false): RectificationTechnicalPacket {
|
||||
};
|
||||
}
|
||||
|
||||
function validGenerator(events: string[], varyNarrative = false) {
|
||||
function validGenerator(
|
||||
events: string[],
|
||||
varyNarrative = false,
|
||||
invalidNarrativeFromGeneration?: number,
|
||||
) {
|
||||
let generation = 0;
|
||||
return {
|
||||
modelId: "synthetic-rectification-model",
|
||||
async generate(prompt: string) {
|
||||
generation += 1;
|
||||
events.push("narrative");
|
||||
if (invalidNarrativeFromGeneration !== undefined
|
||||
&& generation >= invalidNarrativeFromGeneration) {
|
||||
return { text: "not a grounded narrative result" };
|
||||
}
|
||||
const request = JSON.parse(prompt) as {
|
||||
phase: "first" | "intermediate" | "final";
|
||||
packet: Omit<ReturnType<typeof packet>, "candidate"> & {
|
||||
@@ -153,6 +161,7 @@ function harness(options: {
|
||||
readonly releaseFailure?: boolean;
|
||||
readonly readyAfterEvidenceCount?: number;
|
||||
readonly varyNarrative?: boolean;
|
||||
readonly invalidNarrativeFromGeneration?: number;
|
||||
} = {}) {
|
||||
const events: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
@@ -165,6 +174,12 @@ function harness(options: {
|
||||
commandFingerprint?: string;
|
||||
}>();
|
||||
const packetEvidenceCounts: number[] = [];
|
||||
const packetEvidenceIds: string[][] = [];
|
||||
const packetPrivateCandidates: Array<Readonly<{
|
||||
rangeStart?: string | null;
|
||||
rangeEnd?: string | null;
|
||||
resultId?: string | null;
|
||||
}> | null> = [];
|
||||
let packetBuilds = 0;
|
||||
let reserveCount = 0;
|
||||
let releaseCount = 0;
|
||||
@@ -358,13 +373,25 @@ function harness(options: {
|
||||
async buildTechnicalPacket(input) {
|
||||
packetBuilds += 1;
|
||||
packetEvidenceCounts.push(input.evidence.length);
|
||||
packetEvidenceIds.push(input.evidence.map((item) => item.id));
|
||||
packetPrivateCandidates.push(input.privateCandidate
|
||||
? {
|
||||
rangeStart: input.privateCandidate.rangeStart,
|
||||
rangeEnd: input.privateCandidate.rangeEnd,
|
||||
resultId: input.privateCandidate.resultId,
|
||||
}
|
||||
: null);
|
||||
events.push(input.evidence.length > 0 ? "score-packet" : "packet");
|
||||
if (options.packetFailure) throw options.packetFailure;
|
||||
return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1)
|
||||
? { packet: packet(true), resultId }
|
||||
: { packet: packet(false), resultId: null };
|
||||
},
|
||||
narrativeGenerator: validGenerator(events, options.varyNarrative),
|
||||
narrativeGenerator: validGenerator(
|
||||
events,
|
||||
options.varyNarrative,
|
||||
options.invalidNarrativeFromGeneration,
|
||||
),
|
||||
asOfDate: () => "2026-07-21",
|
||||
};
|
||||
|
||||
@@ -372,6 +399,8 @@ function harness(options: {
|
||||
events,
|
||||
mutations,
|
||||
packetEvidenceCounts,
|
||||
packetEvidenceIds,
|
||||
packetPrivateCandidates,
|
||||
cases,
|
||||
service: createConversationalRectificationService(ports),
|
||||
counts: () => ({ packetBuilds, reserveCount, releaseCount }),
|
||||
@@ -583,6 +612,148 @@ test("an unclear correction immediately retires the wrong fact and stays retired
|
||||
assert.equal(later.evidenceRecap.some((item) => item.id === unclear?.id), true);
|
||||
});
|
||||
|
||||
test("every non-confirmable correction rescans the declared range and withdraws the old candidate", async () => {
|
||||
const scenarios = [
|
||||
{ name: "unclear", answer: "更正:具体年月记不清" },
|
||||
{ name: "future", answer: "更正:2099年3月开始新工作" },
|
||||
{ name: "direction change", answer: "更正:这些都不符合" },
|
||||
{
|
||||
name: "narrative validation fallback",
|
||||
answer: "更正:其实是2020年11月离职",
|
||||
invalidNarrativeFromGeneration: 3,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const invalidNarrativeFromGeneration = "invalidNarrativeFromGeneration" in scenario
|
||||
? scenario.invalidNarrativeFromGeneration
|
||||
: undefined;
|
||||
const value = harness({
|
||||
...(invalidNarrativeFromGeneration === undefined
|
||||
? {}
|
||||
: { invalidNarrativeFromGeneration }),
|
||||
});
|
||||
await start(value, null);
|
||||
const prior = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
assert.equal(prior.status, "confirming", scenario.name);
|
||||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(wrongId, scenario.name);
|
||||
const buildsBeforeCorrection = value.counts().packetBuilds;
|
||||
|
||||
const corrected = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: secondAnswerActionId,
|
||||
turnVersion: 1,
|
||||
answer: scenario.answer,
|
||||
correctsEvidenceId: wrongId,
|
||||
});
|
||||
|
||||
const stored = value.cases.get(startActionId)?.row;
|
||||
const replacement = stored?.eventEvidence.at(-1);
|
||||
assert.ok(stored && replacement, scenario.name);
|
||||
assert.equal(value.counts().packetBuilds, buildsBeforeCorrection + 1, scenario.name);
|
||||
assert.equal(value.packetPrivateCandidates.at(-1), null, scenario.name);
|
||||
assert.equal(value.packetEvidenceIds.at(-1)?.includes(wrongId), false, scenario.name);
|
||||
assert.equal(corrected.status, "active", scenario.name);
|
||||
assert.equal(corrected.candidate.status, "pending_validation", scenario.name);
|
||||
assert.equal(corrected.actions.includes("confirm"), false, scenario.name);
|
||||
assert.equal(stored.privateCandidate.resultId ?? null, null, scenario.name);
|
||||
assert.equal(stored.privateCandidate.workingState?.phase, "collecting_evidence", scenario.name);
|
||||
assert.equal(corrected.evidenceRecap.some((item) => item.id === wrongId), false, scenario.name);
|
||||
assert.equal(corrected.evidenceRecap.some((item) => item.id === replacement.id), true, scenario.name);
|
||||
assert.equal(value.counts().reserveCount, 1, scenario.name);
|
||||
|
||||
if (scenario.name === "narrative validation fallback") {
|
||||
assert.deepEqual(value.packetEvidenceIds.at(-1), [replacement.id]);
|
||||
} else {
|
||||
assert.deepEqual(value.packetEvidenceIds.at(-1), []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a clear one-to-one correction can form a new confirmation candidate only after a declared-range rescan", 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 corrected = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1, answer: "更正:其实是2020年11月离职",
|
||||
correctsEvidenceId: wrongId,
|
||||
});
|
||||
const stored = value.cases.get(startActionId)?.row;
|
||||
const replacementId = stored?.eventEvidence.at(-1)?.id;
|
||||
assert.ok(stored && replacementId);
|
||||
|
||||
assert.equal(value.packetPrivateCandidates.at(-1), null);
|
||||
assert.deepEqual(value.packetEvidenceIds.at(-1), [replacementId]);
|
||||
assert.equal(corrected.status, "confirming");
|
||||
assert.equal(corrected.candidate.status, "ready_for_confirmation");
|
||||
assert.equal(corrected.actions.includes("confirm"), true);
|
||||
assert.equal(stored.privateCandidate.resultId, resultId);
|
||||
});
|
||||
|
||||
test("a targeted correction must extract exactly one replacement before scoring or persistence", 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 wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(wrongId);
|
||||
const before = {
|
||||
builds: value.counts().packetBuilds,
|
||||
mutations: [...value.mutations],
|
||||
version: value.cases.get(startActionId)?.row.turnVersion,
|
||||
evidenceCount: value.cases.get(startActionId)?.row.eventEvidence.length,
|
||||
};
|
||||
|
||||
await assert.rejects(value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1,
|
||||
answer: "更正:2020年11月离职,并在2021年2月入职",
|
||||
correctsEvidenceId: wrongId,
|
||||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "invalid_command");
|
||||
|
||||
assert.equal(value.counts().packetBuilds, before.builds);
|
||||
assert.deepEqual(value.mutations, before.mutations);
|
||||
assert.equal(value.cases.get(startActionId)?.row.turnVersion, before.version);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, before.evidenceCount);
|
||||
});
|
||||
|
||||
test("ordinary new evidence continues incrementally from the current candidate range", async () => {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1, answer: "2020年11月搬家",
|
||||
});
|
||||
|
||||
assert.deepEqual(value.packetPrivateCandidates, [
|
||||
null,
|
||||
{ rangeStart: "04:50", rangeEnd: "05:50", resultId: null },
|
||||
{ rangeStart: "05:16", rangeEnd: "05:20", resultId },
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing or already-retired correction targets fail closed without advancing or persisting", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||||
import type { BirthTimeJourneyEngine } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { LifeEventEvidence } from "../src/lib/conversational-rectification/persistence-contracts.ts";
|
||||
import type { LifeEvent } from "../src/lib/birth-time-evidence.ts";
|
||||
import type { CandidateResult, LifeEvent } from "../src/lib/birth-time-evidence.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000711";
|
||||
const actionId = "00000000-0000-4000-8000-000000000712";
|
||||
@@ -88,7 +88,10 @@ function syntheticEvidence(
|
||||
function packetEngine(options: {
|
||||
readonly scoreCalls?: LifeEvent[][];
|
||||
readonly scanTimes?: readonly string[];
|
||||
readonly scanCalls?: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }>;
|
||||
readonly scoreResults?: readonly CandidateResult[];
|
||||
} = {}): BirthTimeJourneyEngine {
|
||||
let scoreResultIndex = 0;
|
||||
const minute = (value: string) => {
|
||||
const [hour = 0, part = 0] = value.slice(-5).split(":").map(Number);
|
||||
return hour * 60 + part;
|
||||
@@ -99,6 +102,10 @@ function packetEngine(options: {
|
||||
};
|
||||
return {
|
||||
async scan(input) {
|
||||
options.scanCalls?.push({
|
||||
birthTime: input.birthTime,
|
||||
uncertaintyMinutes: input.uncertaintyMinutes,
|
||||
});
|
||||
const center = minute(input.birthTime);
|
||||
const times = options.scanTimes ?? [
|
||||
clock(center - input.uncertaintyMinutes),
|
||||
@@ -136,6 +143,9 @@ function packetEngine(options: {
|
||||
assert.ok(event.date >= birthBoundary, "synthetic scorer rejected pre-birth evidence");
|
||||
}
|
||||
options.scoreCalls?.push([...input.events]);
|
||||
const configured = options.scoreResults?.[scoreResultIndex];
|
||||
scoreResultIndex += 1;
|
||||
if (configured) return configured;
|
||||
return {
|
||||
resultId: "00000000-0000-4000-8000-000000000899",
|
||||
confidence: "low",
|
||||
@@ -476,6 +486,107 @@ test("production packet waits for three supported events and then scores the acc
|
||||
assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id));
|
||||
});
|
||||
|
||||
test("production rescans the declared range after correction while ordinary evidence stays incremental", async () => {
|
||||
const scanCalls: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }> = [];
|
||||
const narrowResult: CandidateResult = {
|
||||
resultId: "00000000-0000-4000-8000-000000001301",
|
||||
confidence: "low",
|
||||
canApply: false,
|
||||
winningSegment: {
|
||||
startTime: "05:16",
|
||||
endTime: "05:20",
|
||||
representativeTime: "05:18",
|
||||
widthMinutes: 5,
|
||||
},
|
||||
eventCount: 3,
|
||||
domainCount: 3,
|
||||
topScore: 4,
|
||||
secondScore: 3,
|
||||
marginPercent: 10,
|
||||
reasons: ["synthetic narrowed range"],
|
||||
evidence: [],
|
||||
algorithmVersion: "synthetic-event-score-v1",
|
||||
};
|
||||
const broadResult: CandidateResult = {
|
||||
...narrowResult,
|
||||
resultId: "00000000-0000-4000-8000-000000001302",
|
||||
winningSegment: null,
|
||||
reasons: ["synthetic evidence no longer narrows the range"],
|
||||
};
|
||||
const engine = packetEngine({
|
||||
scanCalls,
|
||||
scoreResults: [narrowResult, broadResult, broadResult],
|
||||
});
|
||||
const declaredBirthInput = {
|
||||
source: "approximate" as const,
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: "05:20",
|
||||
uncertaintyBeforeMinutes: 30 as const,
|
||||
uncertaintyAfterMinutes: 30 as const,
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
};
|
||||
const oldEvidence = [
|
||||
syntheticEvidence(41, "education"),
|
||||
syntheticEvidence(42, "relocation"),
|
||||
syntheticEvidence(43, "career"),
|
||||
];
|
||||
|
||||
const narrowed = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput,
|
||||
privateCandidate: null,
|
||||
evidence: oldEvidence,
|
||||
});
|
||||
assert.deepEqual(narrowed.packet.candidate.range, { startTime: "05:16", endTime: "05:20" });
|
||||
assert.deepEqual(scanCalls.at(-1), {
|
||||
birthTime: "1990-01-01 05:18",
|
||||
uncertaintyMinutes: 2,
|
||||
});
|
||||
|
||||
const currentCandidate = {
|
||||
calculationVersion: narrowed.packet.calculationVersion,
|
||||
rangeStart: narrowed.packet.candidate.range.startTime,
|
||||
rangeEnd: narrowed.packet.candidate.range.endTime,
|
||||
representativeTime: narrowed.packet.candidate.representativeTime,
|
||||
};
|
||||
const ordinary = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput,
|
||||
privateCandidate: currentCandidate,
|
||||
evidence: [...oldEvidence, syntheticEvidence(44, "relationship")],
|
||||
});
|
||||
assert.deepEqual(ordinary.packet.candidate.range, { startTime: "05:16", endTime: "05:20" });
|
||||
assert.deepEqual(scanCalls.at(-1), {
|
||||
birthTime: "1990-01-01 05:18",
|
||||
uncertaintyMinutes: 2,
|
||||
});
|
||||
|
||||
const corrected = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput,
|
||||
privateCandidate: null,
|
||||
evidence: [
|
||||
syntheticEvidence(45, "education"),
|
||||
syntheticEvidence(46, "relocation"),
|
||||
syntheticEvidence(47, "career"),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(corrected.packet.candidate.range, { startTime: "04:50", endTime: "05:50" });
|
||||
assert.deepEqual(scanCalls.at(-1), {
|
||||
birthTime: "1990-01-01 05:20",
|
||||
uncertaintyMinutes: 30,
|
||||
});
|
||||
assert.ok(corrected.packet.sensitivityScope.sampleTimes.includes("04:50"));
|
||||
assert.equal(ordinary.packet.sensitivityScope.sampleTimes.includes("04:50"), false);
|
||||
});
|
||||
|
||||
test("production packet deterministically sends only the latest six supported events", async () => {
|
||||
const scoreCalls: LifeEvent[][] = [];
|
||||
const engine = packetEngine({ scoreCalls });
|
||||
|
||||
@@ -511,6 +511,10 @@ def test_evidence_correction_lineage_is_bounded_projected_and_validated_atomical
|
||||
"insert into public.birth_time_rectification_event_evidence"
|
||||
)
|
||||
assert "where evidence.case_id = p_case_id" in save
|
||||
assert "group by correction.value::uuid having pg_catalog.count(*) > 1" in save
|
||||
assert save.index("group by correction.value::uuid having pg_catalog.count(*) > 1") < save.index(
|
||||
"insert into public.birth_time_rectification_turns"
|
||||
)
|
||||
|
||||
|
||||
def test_case_mutations_enforce_cumulative_load_limits_under_the_case_lock() -> None:
|
||||
|
||||
@@ -1156,6 +1156,163 @@ def test_correction_lineage_is_append_only_owner_scoped_and_rejects_retired_targ
|
||||
}]
|
||||
|
||||
|
||||
def test_duplicate_correction_target_fails_atomically_without_advancing_any_durable_state(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000001401"
|
||||
case_id = "00000000-0000-4000-8000-000000001402"
|
||||
first_action = "00000000-0000-4000-8000-000000001403"
|
||||
rejected_action = "00000000-0000-4000-8000-000000001404"
|
||||
first_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaa1411"
|
||||
replacement_a = "00000000-0000-4000-8000-000000001412"
|
||||
replacement_b = "00000000-0000-4000-8000-000000001413"
|
||||
_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]))
|
||||
|
||||
def durable_counts() -> dict[str, int]:
|
||||
return json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'version', rectification.turn_version,
|
||||
'turns', (select pg_catalog.count(*) from public.birth_time_rectification_turns turn where turn.case_id = rectification.id),
|
||||
'evidence', (select pg_catalog.count(*) from public.birth_time_rectification_event_evidence evidence where evidence.case_id = rectification.id),
|
||||
'receipts', (select pg_catalog.count(*) from public.birth_time_rectification_action_receipts receipt where receipt.case_id = rectification.id)
|
||||
)::text
|
||||
from public.birth_time_rectification_cases rectification
|
||||
where rectification.id = '{case_id}'::uuid;
|
||||
"""
|
||||
))
|
||||
|
||||
before = durable_counts()
|
||||
duplicate_replacements = [
|
||||
{
|
||||
"id": replacement_a,
|
||||
"rawText": "更正:2020 年 11 月离职",
|
||||
"domain": "career",
|
||||
"eventSummary": "离职",
|
||||
"dateValue": "2020-11",
|
||||
"datePrecision": "month",
|
||||
"extractionStatus": "corrected",
|
||||
"scoreable": True,
|
||||
"correctsEvidenceIds": [first_id.upper()],
|
||||
},
|
||||
{
|
||||
"id": replacement_b,
|
||||
"rawText": "更正:2021 年 2 月入职",
|
||||
"domain": "career",
|
||||
"eventSummary": "入职",
|
||||
"dateValue": "2021-02",
|
||||
"datePrecision": "month",
|
||||
"extractionStatus": "corrected",
|
||||
"scoreable": True,
|
||||
"correctsEvidenceIds": [first_id],
|
||||
},
|
||||
]
|
||||
assert pg14_database.rejects(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
1,
|
||||
rejected_action,
|
||||
duplicate_replacements,
|
||||
))
|
||||
assert durable_counts() == before
|
||||
|
||||
|
||||
def test_concurrent_corrections_of_one_tip_leave_exactly_one_winner(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000001421"
|
||||
case_id = "00000000-0000-4000-8000-000000001422"
|
||||
first_action = "00000000-0000-4000-8000-000000001423"
|
||||
action_a = "00000000-0000-4000-8000-000000001424"
|
||||
action_b = "00000000-0000-4000-8000-000000001425"
|
||||
first_id = "00000000-0000-4000-8000-000000001431"
|
||||
replacement_a = "00000000-0000-4000-8000-000000001432"
|
||||
replacement_b = "00000000-0000-4000-8000-000000001433"
|
||||
_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]))
|
||||
|
||||
statements = []
|
||||
for action_id, evidence_id, date_value, summary in (
|
||||
(action_a, replacement_a, "2020-11", "离职"),
|
||||
(action_b, replacement_b, "2021-02", "入职"),
|
||||
):
|
||||
replacement = {
|
||||
"id": evidence_id,
|
||||
"rawText": f"更正:{date_value} {summary}",
|
||||
"domain": "career",
|
||||
"eventSummary": summary,
|
||||
"dateValue": date_value,
|
||||
"datePrecision": "month",
|
||||
"extractionStatus": "corrected",
|
||||
"scoreable": True,
|
||||
"correctsEvidenceIds": [first_id],
|
||||
}
|
||||
statements.append(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
1,
|
||||
action_id,
|
||||
[replacement],
|
||||
))
|
||||
|
||||
processes = [subprocess.Popen(
|
||||
pg14_database.command("-A", "-t", "-q", "-c", statement),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
) for statement in statements]
|
||||
results = [process.communicate(timeout=20) for process in processes]
|
||||
return_codes = [process.returncode for process in processes]
|
||||
assert return_codes.count(0) == 1, results
|
||||
assert sum(code != 0 for code in return_codes) == 1, results
|
||||
|
||||
durable = json.loads(pg14_database.sql(
|
||||
f"""
|
||||
select pg_catalog.jsonb_build_object(
|
||||
'version', rectification.turn_version,
|
||||
'turns', (select pg_catalog.count(*) from public.birth_time_rectification_turns turn where turn.case_id = rectification.id),
|
||||
'evidence', (select pg_catalog.count(*) from public.birth_time_rectification_event_evidence evidence where evidence.case_id = rectification.id),
|
||||
'winningReceipts', (select pg_catalog.count(*) from public.birth_time_rectification_action_receipts receipt where receipt.case_id = rectification.id and receipt.action_id in ('{action_a}'::uuid, '{action_b}'::uuid))
|
||||
)::text
|
||||
from public.birth_time_rectification_cases rectification
|
||||
where rectification.id = '{case_id}'::uuid;
|
||||
"""
|
||||
))
|
||||
assert durable == {
|
||||
"version": 2,
|
||||
"turns": 3,
|
||||
"evidence": 2,
|
||||
"winningReceipts": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_save_rejects_cumulative_evidence_count_before_inserting(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user