diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 3894c1b5..d0b3f675 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -282,22 +282,19 @@ function currentRange(input: ConversationalRectificationPacketBuildInput) { } function scoreableLifeEvents(evidence: readonly LifeEventEvidence[]): LifeEvent[] { - return evidence.slice(-10).flatMap((item) => { + return evidence.flatMap((item) => { if (item.scoreable !== true || !item.dateValue || !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")) { return []; } - const domain = item.domain === "family" ? "relationship" - : item.domain === "other" ? null - : item.domain; - if (!domain) return []; + if (item.domain === "family" || item.domain === "other") return []; return [{ id: item.id, - domain, + domain: item.domain, precision: item.datePrecision as "day" | "month" | "year", date: item.dateValue, } as LifeEvent]; - }); + }).slice(-6); } function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] { @@ -336,7 +333,6 @@ function mergeQuestionnaireScans( ): RectificationQuestionnaire { const first = scans[0]; if (!first) throw new ConversationalRectificationError("service_unavailable"); - if (scans.length === 1) return first; const byTime = new Map 0 + const eventScore: CandidateResult | null = events.length >= 3 ? await engine.scoreEvents({ birthDate: input.declaredBirthInput.birthDate, startTime: baseRange.startTime, diff --git a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql index 28687df7..b91999b3 100644 --- a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql +++ b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql @@ -590,10 +590,10 @@ begin if v_receipt.user_id is distinct from p_user_id or v_receipt.action_kind is distinct from 'save_turn' or v_receipt.expected_turn_version is distinct from p_expected_version - or (v_receipt.request ? 'commandFingerprint' - and v_receipt.request ->> 'commandFingerprint' - is distinct from p_command_fingerprint) - or v_receipt.request_fingerprint is distinct from v_fingerprint then + or (case when v_receipt.request ? 'commandFingerprint' then + v_receipt.request ->> 'commandFingerprint' + is distinct from p_command_fingerprint + else v_receipt.request_fingerprint is distinct from v_fingerprint end) then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; return v_receipt.response; @@ -756,10 +756,10 @@ begin if v_receipt.user_id is distinct from p_user_id or v_receipt.action_kind is distinct from 'pause' or v_receipt.expected_turn_version is distinct from p_expected_version - or (v_receipt.request ? 'commandFingerprint' - and v_receipt.request ->> 'commandFingerprint' - is distinct from p_command_fingerprint) - or v_receipt.request_fingerprint is distinct from v_fingerprint then + or (case when v_receipt.request ? 'commandFingerprint' then + v_receipt.request ->> 'commandFingerprint' + is distinct from p_command_fingerprint + else v_receipt.request_fingerprint is distinct from v_fingerprint end) then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; return v_receipt.response; @@ -890,10 +890,10 @@ begin if v_receipt.user_id is distinct from p_user_id or v_receipt.action_kind is distinct from 'abandon' or v_receipt.expected_turn_version is distinct from p_expected_version - or (v_receipt.request ? 'commandFingerprint' - and v_receipt.request ->> 'commandFingerprint' - is distinct from p_command_fingerprint) - or v_receipt.request_fingerprint is distinct from v_fingerprint then + or (case when v_receipt.request ? 'commandFingerprint' then + v_receipt.request ->> 'commandFingerprint' + is distinct from p_command_fingerprint + else v_receipt.request_fingerprint is distinct from v_fingerprint end) then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; return v_receipt.response; @@ -1037,10 +1037,10 @@ begin if v_receipt.user_id is distinct from p_user_id or v_receipt.action_kind is distinct from 'confirm' or v_receipt.expected_turn_version is distinct from p_expected_version - or (v_receipt.request ? 'commandFingerprint' - and v_receipt.request ->> 'commandFingerprint' - is distinct from p_command_fingerprint) - or v_receipt.request_fingerprint is distinct from v_fingerprint then + or (case when v_receipt.request ? 'commandFingerprint' then + v_receipt.request ->> 'commandFingerprint' + is distinct from p_command_fingerprint + else v_receipt.request_fingerprint is distinct from v_fingerprint end) then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; return v_receipt.response; diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 13ba8406..6805e3b1 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -22,6 +22,8 @@ const confirmActionId = "00000000-0000-4000-8000-000000000706"; const priorCaseId = "00000000-0000-4000-8000-000000000707"; 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 declaredBirthInput = { source: "approximate" as const, @@ -92,10 +94,12 @@ function packet(ready = false): RectificationTechnicalPacket { }; } -function validGenerator(events: string[]) { +function validGenerator(events: string[], varyNarrative = false) { + let generation = 0; return { modelId: "synthetic-rectification-model", async generate(prompt: string) { + generation += 1; events.push("narrative"); const request = JSON.parse(prompt) as { phase: "first" | "intermediate" | "final"; @@ -113,6 +117,7 @@ function validGenerator(events: string[]) { "D1(Cancer)保持稳定。", "D9(Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。", "D10(Taurus / Libra)呈现分钟敏感差异,事业事件可区分 D10。", + varyNarrative ? `这是第 ${generation} 次合成措辞。` : "", request.phase === "final" ? "当前证据已形成候选总结。" : "请提供已经发生的真实事件,写明哪一年、哪一月以及发生了什么。", "这仅是候选,必须由你确认后才会替换当前排盘时间。", ].join(""); @@ -145,6 +150,8 @@ function harness(options: { readonly packetFailure?: Error; readonly completeFailures?: number; readonly releaseFailure?: boolean; + readonly readyAfterEvidenceCount?: number; + readonly varyNarrative?: boolean; } = {}) { const events: string[] = []; const mutations: string[] = []; @@ -204,7 +211,13 @@ function harness(options: { ) { const prior = receipts.get(actionId); if (prior) { - assert.deepEqual(input, prior.input); + if (prior.actionKind && prior.commandFingerprint) { + assert.equal(actionKind, prior.actionKind); + assert.equal(input.expectedVersion, prior.expectedVersion); + assert.equal(input.commandFingerprint, prior.commandFingerprint); + } else { + assert.deepEqual(input, prior.input); + } return prior.response; } const response = make(); @@ -344,11 +357,11 @@ function harness(options: { packetBuilds += 1; events.push(input.evidence.length > 0 ? "score-packet" : "packet"); if (options.packetFailure) throw options.packetFailure; - return input.evidence.length > 0 + return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1) ? { packet: packet(true), resultId } : { packet: packet(false), resultId: null }; }, - narrativeGenerator: validGenerator(events), + narrativeGenerator: validGenerator(events, options.varyNarrative), asOfDate: () => "2026-07-21", }; @@ -489,6 +502,57 @@ test("generic date uncertainty does not suppress clear historical evidence", asy .some((item) => item.eventSummary.includes("毕业") && item.scoreable === true)); }); +test("one and two supported events save and narrate before the third accumulated event ranks", async () => { + const value = harness({ readyAfterEvidenceCount: 3 }); + await start(value, null); + const answers = [ + [answerActionId, "2019年7月毕业"], + [secondAnswerActionId, "2020年8月搬家"], + [thirdAnswerActionId, "2021年9月换工作"], + ] as const; + + for (const [index, [receivedActionId, answer]] of answers.entries()) { + const turn = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: receivedActionId, + turnVersion: index, + answer, + }); + const stored = value.cases.get(startActionId)?.row; + assert.equal(stored?.eventEvidence.length, index + 1); + assert.equal(turn.evidenceRecap.length, index + 1); + assert.equal(turn.status, index < 2 ? "active" : "confirming"); + } + + assert.equal(value.counts().packetBuilds, 4); + assert.equal(value.events.filter((event) => event === "narrative").length, 4); +}); + +test("family evidence remains stored and public without changing its domain", async () => { + const value = harness({ readyAfterEvidenceCount: 3 }); + await start(value, null); + + const turn = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2020年7月父亲生病", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 1); + assert.equal(stored[0]?.domain, "family"); + assert.match(stored[0]?.eventSummary ?? "", /父亲/); + assert.deepEqual(turn.evidenceRecap, [{ + id: stored[0]?.id, + summary: stored[0]?.eventSummary, + dateLabel: "2020-07", + }]); + assert.equal(turn.status, "active"); +}); + test("vague, future, and unmatched answers stay conversational and never score", async () => { for (const [answer, domain] of [ ["后来换了工作", undefined], @@ -556,6 +620,28 @@ test("a lost-response retry replays the saved answer without rescoring or regene assert.deepEqual(value.events, before); }); +test("overlapping identical answers converge on the first receipt despite different derived narratives", async () => { + const value = harness({ varyNarrative: true }); + await start(value, null); + const command = { + type: "answer" as const, + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2021年7月毕业,并在2022年3月去外地工作", + }; + + const [first, second] = await Promise.all([ + value.service.answer(userId, command), + value.service.answer(userId, command), + ]); + + assert.deepEqual(second, first); + assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 2); + assert.equal(value.events.filter((event) => event === "narrative").length, 3); + assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2); +}); + test("receipt-first delayed retries replay the original answer, pause, abandon, and confirm after later turns", async () => { const scenarios = [ { diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index 7d6eeda5..95b987fd 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -8,6 +8,8 @@ import { } from "../src/app/api/birth-time-conversation/route.ts"; 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"; const userId = "00000000-0000-4000-8000-000000000711"; const actionId = "00000000-0000-4000-8000-000000000712"; @@ -65,6 +67,105 @@ function service(overrides: Partial = {}): Bi }; } +function syntheticEvidence( + index: number, + domain: LifeEventEvidence["domain"], +): 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", + extractionStatus: "clear", + scoreable: true, + }; +} + +function packetEngine(options: { + readonly scoreCalls?: LifeEvent[][]; + readonly scanTimes?: readonly string[]; +} = {}): BirthTimeJourneyEngine { + const minute = (value: string) => { + const [hour = 0, part = 0] = value.slice(-5).split(":").map(Number); + return hour * 60 + part; + }; + const clock = (value: number) => { + const normalized = ((value % 1_440) + 1_440) % 1_440; + return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; + }; + return { + async scan(input) { + const center = minute(input.birthTime); + const times = options.scanTimes ?? [ + clock(center - input.uncertaintyMinutes), + clock(center), + clock(center + input.uncertaintyMinutes), + ]; + return { + questionnaire: { + questions: [], + samples: times.map((time, index) => ({ + ascendantSign: "Cancer", + d4Sign: index % 2 === 0 ? "Aries" : "Taurus", + d9Sign: index % 2 === 0 ? "Gemini" : "Virgo", + d10Sign: index % 2 === 0 ? "Leo" : "Libra", + d24Sign: "Sagittarius", + d30Sign: "Pisces", + })), + raw: { + candidate_scan: { + samples: times.map((time) => ({ time: `1990-01-01 ${time}` })), + }, + }, + }, + }; + }, + async score() { throw new Error("unexpected questionnaire score"); }, + async scoreEvents(input) { + options.scoreCalls?.push([...input.events]); + return { + resultId: "00000000-0000-4000-8000-000000000899", + confidence: "low", + canApply: false, + winningSegment: null, + eventCount: input.events.length, + domainCount: new Set(input.events.map((event) => event.domain)).size, + topScore: 1, + secondScore: 1, + marginPercent: 0, + reasons: ["synthetic low result"], + evidence: [], + algorithmVersion: "synthetic-event-score-v1", + }; + }, + async buildDifferencePacket(input) { + return { + packet: { + caseId: input.caseId, + scoringVersion: "birth-time-choice-scoring-v2", + currentRange: { startTime: input.startTime, endTime: input.endTime }, + opportunities: [], + askedQuestionFingerprints: [], + candidatePartitionFingerprints: [], + recentRangeHistory: [], + }, + candidateModel: { version: "birth-time-choice-scoring-v2" }, + scoringPartitions: {}, + }; + }, + async scoreChoices() { throw new Error("unexpected choice score"); }, + }; +} + +const packetBirthplace = { + cityCode: "TPE-CITY", + latitude: 25.0268, + longitude: 121.5434, + timezoneOffset: 8, +}; + test("authentication happens before body parsing and unauthenticated requests create no privileged service", async () => { const events: string[] = []; let creates = 0; @@ -326,3 +427,129 @@ test("production unknown-time adapter covers the declared full day with bounded assert.equal(new Set(sampleTimes).size, sampleTimes.length); assert.deepEqual(sampleTimes, [...sampleTimes].sort((left, right) => minute(left) - minute(right))); }); + +test("production packet waits for three supported events and then scores the accumulated evidence", async () => { + const scoreCalls: LifeEvent[][] = []; + const engine = packetEngine({ scoreCalls }); + const evidence = [ + syntheticEvidence(1, "education"), + syntheticEvidence(2, "relocation"), + syntheticEvidence(3, "career"), + ]; + + for (let count = 1; count <= 3; count += 1) { + const built = await buildProductionConversationalRectificationPacket(engine, { + userId, + caseId, + asOfDate: "2026-07-21", + declaredBirthInput: { + source: "approximate", + birthDate: "1990-01-01", + reportedTime: "05:20", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthTimeClue: null, + birthplace: packetBirthplace, + }, + privateCandidate: null, + evidence: evidence.slice(0, count), + }); + assert.equal(built.packet.candidate.status, "pending_validation"); + assert.equal( + built.resultId, + count < 3 ? null : "00000000-0000-4000-8000-000000000899", + ); + } + + assert.equal(scoreCalls.length, 1); + assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id)); +}); + +test("production packet deterministically sends only the latest six supported events", async () => { + const scoreCalls: LifeEvent[][] = []; + const engine = packetEngine({ scoreCalls }); + const domains = ["education", "relocation", "career", "relationship"] as const; + const evidence = Array.from({ length: 8 }, (_, index) => + syntheticEvidence(index + 1, domains[index % domains.length] ?? "career")); + + await buildProductionConversationalRectificationPacket(engine, { + userId, + caseId, + asOfDate: "2026-07-21", + declaredBirthInput: { + source: "approximate", + birthDate: "1990-01-01", + reportedTime: "05:20", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthTimeClue: null, + birthplace: packetBirthplace, + }, + privateCandidate: null, + evidence, + }); + + assert.equal(scoreCalls.length, 1); + assert.deepEqual( + scoreCalls[0]?.map((event) => event.id), + evidence.slice(-6).map((item) => item.id), + ); +}); + +test("family evidence stays out of relationship scoring when three real scorer domains exist", async () => { + const scoreCalls: LifeEvent[][] = []; + const engine = packetEngine({ scoreCalls }); + const family = syntheticEvidence(1, "family"); + const supported = [ + syntheticEvidence(2, "education"), + syntheticEvidence(3, "relocation"), + syntheticEvidence(4, "career"), + ]; + + await buildProductionConversationalRectificationPacket(engine, { + userId, + caseId, + asOfDate: "2026-07-21", + declaredBirthInput: { + source: "approximate", + birthDate: "1990-01-01", + reportedTime: "05:20", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthTimeClue: null, + birthplace: packetBirthplace, + }, + privateCandidate: null, + evidence: [family, ...supported], + }); + + assert.equal(scoreCalls.length, 1); + assert.deepEqual(scoreCalls[0]?.map((event) => event.domain), [ + "education", + "relocation", + "career", + ]); + assert.equal(scoreCalls[0]?.some((event) => event.id === family.id), false); + assert.equal(scoreCalls[0]?.some((event) => event.domain === "relationship"), false); +}); + +test("a single period-only scan filters duplicate and out-of-range samples from the exact :59 range", async () => { + const engine = packetEngine({ scanTimes: ["08:00", "10:00", "10:00", "12:00"] }); + const built = await buildProductionConversationalRectificationPacket(engine, { + userId, + caseId, + asOfDate: "2026-07-21", + declaredBirthInput: { + source: "period_only", + birthDate: "1990-01-01", + reportedPeriod: "morning", + birthTimeClue: null, + birthplace: packetBirthplace, + }, + privateCandidate: null, + evidence: [], + }); + + assert.deepEqual(built.packet.candidate.range, { startTime: "08:00", endTime: "11:59" }); + assert.deepEqual(built.packet.sensitivityScope.sampleTimes, ["08:00", "10:00"]); +}); diff --git a/tests/test_conversational_rectification_contract.py b/tests/test_conversational_rectification_contract.py index 5320c9e5..5bd370f6 100644 --- a/tests/test_conversational_rectification_contract.py +++ b/tests/test_conversational_rectification_contract.py @@ -296,6 +296,12 @@ def test_historical_action_replay_is_owner_scoped_exact_bounded_and_read_only() assert "p_command_fingerprint text" in body assert "p_command_fingerprint !~ '^[0-9a-f]{64}$'" in body assert "'commandfingerprint', p_command_fingerprint" in body + assert ( + "case when v_receipt.request ? 'commandfingerprint' then " + "v_receipt.request ->> 'commandfingerprint' is distinct from " + "p_command_fingerprint else v_receipt.request_fingerprint is " + "distinct from v_fingerprint end" + ) in body def test_start_identity_and_account_concurrency_are_server_guarded() -> None: diff --git a/tests/test_conversational_rectification_postgres_runtime.py b/tests/test_conversational_rectification_postgres_runtime.py index a523c583..d3fc23f7 100644 --- a/tests/test_conversational_rectification_postgres_runtime.py +++ b/tests/test_conversational_rectification_postgres_runtime.py @@ -563,6 +563,87 @@ def test_historical_receipt_replays_exact_public_response_after_later_turns( assert privileges == {"anon": False, "authenticated": False, "serviceRole": True} +def test_overlapping_mutation_uses_command_identity_while_legacy_uses_full_request( + pg14_database: PgDatabase, +) -> None: + user_id = "00000000-0000-4000-8000-000000001041" + case_id = "00000000-0000-4000-8000-000000001042" + action_id = "00000000-0000-4000-8000-000000001043" + fingerprint = "c" * 64 + _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) + + original = json.loads(pg14_database.sql(_save_statement( + user_id, + case_id, + 0, + action_id, + [], + command_fingerprint=fingerprint, + ))) + alternate_turn = { + **_valid_turn(case_id), + "turnVersion": 1, + "narrative": "A different valid narrative derived by an overlapping request.", + } + replayed = json.loads(pg14_database.sql(_save_statement( + user_id, + case_id, + 0, + action_id, + [], + turn=alternate_turn, + validation_receipt={"modelId": "alternate-model", "schemaValidated": True}, + command_fingerprint=fingerprint, + ))) + assert replayed == original + assert pg14_database.rejects(_save_statement( + user_id, + case_id, + 0, + action_id, + [], + turn=alternate_turn, + command_fingerprint="d" * 64, + )) + + legacy_user_id = "00000000-0000-4000-8000-000000001051" + legacy_case_id = "00000000-0000-4000-8000-000000001052" + legacy_action_id = "00000000-0000-4000-8000-000000001053" + _create_user(pg14_database, legacy_user_id) + _reserve(pg14_database, legacy_user_id, legacy_case_id) + _create_case( + pg14_database, + legacy_user_id, + legacy_case_id, + _valid_declared_birth_input(), + ) + _complete(pg14_database, legacy_user_id, legacy_case_id) + legacy_statement = _save_statement( + legacy_user_id, + legacy_case_id, + 0, + legacy_action_id, + [], + ) + legacy_original = json.loads(pg14_database.sql(legacy_statement)) + assert json.loads(pg14_database.sql(legacy_statement)) == legacy_original + assert pg14_database.rejects(_save_statement( + legacy_user_id, + legacy_case_id, + 0, + legacy_action_id, + [], + turn={ + **_valid_turn(legacy_case_id), + "turnVersion": 1, + "narrative": "A different legacy-derived narrative must remain a conflict.", + }, + )) + + def test_database_rejects_oversize_or_unknown_durable_json(pg14_database: PgDatabase) -> None: user_id = "00000000-0000-4000-8000-000000000951" action_id = "00000000-0000-4000-8000-000000000952"