diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index d0b3f675..a6728fd3 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -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, diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 2225b651..9e9f6f9f 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -85,6 +85,31 @@ const transitionValidatorVersion = "conversational-rectification-orchestrator-v1 const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/; const genericUncertaintyPattern = /(?:不知道|不确定)/; +export function evidencePredatesBirthDate( + evidence: Pick, + 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, diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 6805e3b1..491377bb 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -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], diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index 95b987fd..f607aa35 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -70,14 +70,16 @@ function service(overrides: Partial = {}): 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, + ]]); +});