fix(rectification): reject pre-birth scoring evidence

This commit is contained in:
Jesse_Chen
2026-07-21 03:26:57 +08:00
parent 1684278304
commit 9f9c249de8
4 changed files with 166 additions and 7 deletions
@@ -10,6 +10,7 @@ import {
} from "../../../lib/conversational-rectification/errors.ts";
import {
createConversationalRectificationService,
evidencePredatesBirthDate,
type ConversationalRectificationPacketBuildInput,
type ConversationalRectificationService,
} from "../../../lib/conversational-rectification/orchestrator.ts";
@@ -281,10 +282,14 @@ function currentRange(input: ConversationalRectificationPacketBuildInput) {
return start && end ? { startTime: start, endTime: end } : declaredRange(input.declaredBirthInput);
}
function scoreableLifeEvents(evidence: readonly LifeEventEvidence[]): LifeEvent[] {
function scoreableLifeEvents(
evidence: readonly LifeEventEvidence[],
birthDate: string,
): LifeEvent[] {
return evidence.flatMap((item) => {
if (item.scoreable !== true || !item.dateValue
|| !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")) {
|| !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")
|| evidencePredatesBirthDate(item, birthDate)) {
return [];
}
if (item.domain === "family" || item.domain === "other") return [];
@@ -408,7 +413,10 @@ export async function buildProductionConversationalRectificationPacket(
throw new ConversationalRectificationError("profile_incomplete");
}
const baseRange = currentRange(input);
const events = scoreableLifeEvents(input.evidence as readonly LifeEventEvidence[]);
const events = scoreableLifeEvents(
input.evidence as readonly LifeEventEvidence[],
input.declaredBirthInput.birthDate,
);
const eventScore: CandidateResult | null = events.length >= 3
? await engine.scoreEvents({
birthDate: input.declaredBirthInput.birthDate,
@@ -85,6 +85,31 @@ const transitionValidatorVersion = "conversational-rectification-orchestrator-v1
const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/;
const genericUncertaintyPattern = /(?:不知道|不确定)/;
export function evidencePredatesBirthDate(
evidence: Pick<LifeEventEvidence, "dateValue" | "datePrecision">,
birthDate: string,
): boolean {
if (!evidence.dateValue) return false;
const boundary = evidence.datePrecision === "year"
? birthDate.slice(0, 4)
: evidence.datePrecision === "month"
? birthDate.slice(0, 7)
: evidence.datePrecision === "day"
? birthDate
: null;
return boundary !== null && evidence.dateValue < boundary;
}
function evidenceForDeclaredBirthDate(
evidence: readonly LifeEventEvidence[],
birthDate: string,
): readonly LifeEventEvidence[] {
return evidence.map((item) => item.scoreable === true
&& evidencePredatesBirthDate(item, birthDate)
? { ...item, extractionStatus: "needs_clarification" as const, scoreable: false }
: item);
}
function safeFailure(error: unknown): ConversationalRectificationError {
return error instanceof ConversationalRectificationError
? error
@@ -552,7 +577,10 @@ export function createConversationalRectificationService(
if (receipt) return receipt;
const current = await load(userId, command.caseId);
requireMutable(current);
const evidence = extractedEvidence(command);
const evidence = evidenceForDeclaredBirthDate(
extractedEvidence(command),
current.declaredBirthInput.birthDate,
);
if (current.turnVersion === command.turnVersion + 1) {
try {
@@ -606,7 +634,9 @@ export function createConversationalRectificationService(
try {
const allScoreable = [...current.eventEvidence, ...evidence]
.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification");
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
const computed = await ports.buildTechnicalPacket({
userId,
caseId: command.caseId,
@@ -24,6 +24,7 @@ const resultId = "00000000-0000-4000-8000-000000000708";
const laterActionId = "00000000-0000-4000-8000-000000000710";
const secondAnswerActionId = "00000000-0000-4000-8000-000000000711";
const thirdAnswerActionId = "00000000-0000-4000-8000-000000000712";
const fourthAnswerActionId = "00000000-0000-4000-8000-000000000713";
const declaredBirthInput = {
source: "approximate" as const,
@@ -163,6 +164,7 @@ function harness(options: {
expectedVersion?: number;
commandFingerprint?: string;
}>();
const packetEvidenceCounts: number[] = [];
let packetBuilds = 0;
let reserveCount = 0;
let releaseCount = 0;
@@ -355,6 +357,7 @@ function harness(options: {
},
async buildTechnicalPacket(input) {
packetBuilds += 1;
packetEvidenceCounts.push(input.evidence.length);
events.push(input.evidence.length > 0 ? "score-packet" : "packet");
if (options.packetFailure) throw options.packetFailure;
return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1)
@@ -368,6 +371,7 @@ function harness(options: {
return {
events,
mutations,
packetEvidenceCounts,
cases,
service: createConversationalRectificationService(ports),
counts: () => ({ packetBuilds, reserveCount, releaseCount }),
@@ -553,6 +557,37 @@ test("family evidence remains stored and public without changing its domain", as
assert.equal(turn.status, "active");
});
test("two valid events plus pre-birth evidence wait until a later valid third event scores", async () => {
const value = harness({ readyAfterEvidenceCount: 3 });
await start(value, null);
const answers = [
[answerActionId, "2019年7月毕业"],
[secondAnswerActionId, "2020年8月搬家"],
[thirdAnswerActionId, "1999年12月开始工作"],
[fourthAnswerActionId, "2021年9月换工作"],
] as const;
let latest = value.cases.get(startActionId)?.row.latestTurn;
for (const [index, [receivedActionId, answer]] of answers.entries()) {
latest = await value.service.answer(userId, {
type: "answer",
caseId: startActionId,
actionId: receivedActionId,
turnVersion: index,
answer,
});
assert.equal(latest.status, index < 3 ? "active" : "confirming");
}
const stored = value.cases.get(startActionId)?.row;
const preBirth = stored?.eventEvidence.find((item) => item.dateValue === "1999-12");
assert.equal(preBirth?.scoreable, false);
assert.equal(preBirth?.extractionStatus, "needs_clarification");
assert.equal(stored?.eventEvidence.length, 4);
assert.equal(latest?.evidenceRecap.length, 4);
assert.deepEqual(value.packetEvidenceCounts, [0, 1, 2, 3]);
});
test("vague, future, and unmatched answers stay conversational and never score", async () => {
for (const [answer, domain] of [
["后来换了工作", undefined],
@@ -70,14 +70,16 @@ function service(overrides: Partial<BirthTimeConversationRouteService> = {}): Bi
function syntheticEvidence(
index: number,
domain: LifeEventEvidence["domain"],
dateValue = `${2010 + index}-07`,
datePrecision: LifeEventEvidence["datePrecision"] = "month",
): LifeEventEvidence {
return {
id: `00000000-0000-4000-8000-${String(800 + index).padStart(12, "0")}`,
rawText: `synthetic event ${index}`,
domain,
eventSummary: `synthetic summary ${index}`,
dateValue: `${2010 + index}-07`,
datePrecision: "month",
dateValue,
datePrecision,
extractionStatus: "clear",
scoreable: true,
};
@@ -124,6 +126,15 @@ function packetEngine(options: {
},
async score() { throw new Error("unexpected questionnaire score"); },
async scoreEvents(input) {
assert.ok(input.events.length >= 3 && input.events.length <= 6);
for (const event of input.events) {
const birthBoundary = event.precision === "year"
? input.birthDate.slice(0, 4)
: event.precision === "month"
? input.birthDate.slice(0, 7)
: input.birthDate;
assert.ok(event.date >= birthBoundary, "synthetic scorer rejected pre-birth evidence");
}
options.scoreCalls?.push([...input.events]);
return {
resultId: "00000000-0000-4000-8000-000000000899",
@@ -553,3 +564,78 @@ test("a single period-only scan filters duplicate and out-of-range samples from
assert.deepEqual(built.packet.candidate.range, { startTime: "08:00", endTime: "11:59" });
assert.deepEqual(built.packet.sensitivityScope.sampleTimes, ["08:00", "10:00"]);
});
test("year-precision evidence before birth waits while the birth year can become the valid third event", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const valid = [
syntheticEvidence(20, "education", "2018", "year"),
syntheticEvidence(21, "relocation", "2019", "year"),
];
const beforeBirth = syntheticEvidence(22, "career", "1999", "year");
const birthYear = syntheticEvidence(23, "relationship", "2000", "year");
const input = {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "approximate" as const,
birthDate: "2000-06-15",
reportedTime: "05:20",
uncertaintyBeforeMinutes: 30 as const,
uncertaintyAfterMinutes: 30 as const,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
};
const waiting = await buildProductionConversationalRectificationPacket(engine, {
...input,
evidence: [...valid, beforeBirth],
});
assert.equal(waiting.resultId, null);
assert.equal(scoreCalls.length, 0);
await buildProductionConversationalRectificationPacket(engine, {
...input,
evidence: [...valid, beforeBirth, birthYear],
});
assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [[
...valid.map((item) => item.id),
birthYear.id,
]]);
});
test("month-precision evidence excludes the month before birth and accepts the birth month", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const valid = [
syntheticEvidence(30, "education", "2018-01", "month"),
syntheticEvidence(31, "relocation", "2019-02", "month"),
];
const monthBeforeBirth = syntheticEvidence(32, "career", "2000-05", "month");
const birthMonth = syntheticEvidence(33, "relationship", "2000-06", "month");
await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "approximate",
birthDate: "2000-06-15",
reportedTime: "05:20",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence: [...valid, monthBeforeBirth, birthMonth],
});
assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [[
...valid.map((item) => item.id),
birthMonth.id,
]]);
});