From 1112d8460b2cbd6a2c022e0ff818e832a629310a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 02:53:40 +0800 Subject: [PATCH] fix(rectification): address task 7 review findings --- .../app/api/birth-time-conversation/route.ts | 184 ++++++++++++++--- .../orchestrator.ts | 68 +++++- .../persistence-contracts.ts | 1 + .../lib/conversational-rectification/store.ts | 56 ++++- ...00_conversational_rectification_schema.sql | 12 +- ...nversational_rectification_transitions.sql | 129 ++++++++++-- ...ational-rectification-orchestrator.test.ts | 195 ++++++++++++++++-- ...conversational-rectification-route.test.ts | 158 ++++++++++++-- ...conversational-rectification-store.test.ts | 43 +++- ...t_conversational_rectification_contract.py | 52 +++++ ...sational_rectification_postgres_runtime.py | 82 +++++++- 11 files changed, 894 insertions(+), 86 deletions(-) diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 0b61ebfc..3894c1b5 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -13,7 +13,11 @@ import { type ConversationalRectificationPacketBuildInput, type ConversationalRectificationService, } from "../../../lib/conversational-rectification/orchestrator.ts"; -import type { DeclaredBirthInput, LifeEventEvidence } from "../../../lib/conversational-rectification/persistence-contracts.ts"; +import { + declaredBirthInputSchema, + type DeclaredBirthInput, + type LifeEventEvidence, +} from "../../../lib/conversational-rectification/persistence-contracts.ts"; import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts"; import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts"; import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts"; @@ -92,10 +96,7 @@ function integer(value: unknown): number | null { return typeof value === "number" && Number.isInteger(value) ? value : null; } -function declaredBirthInputFromProfile(value: unknown): { - readonly declaredBirthInput: unknown; - readonly revisionOfCaseId: string | null; -} { +function declaredBirthInputFromProfile(value: unknown): DeclaredBirthInput { const profile = profileRecord(value); if (!profile) throw new ConversationalRectificationError("profile_incomplete"); const birthDate = text(profile.birth_date); @@ -160,9 +161,38 @@ function declaredBirthInputFromProfile(value: unknown): { default: throw new ConversationalRectificationError("profile_incomplete"); } + const parsed = declaredBirthInputSchema.safeParse(declaredBirthInput); + if (!parsed.success) throw new ConversationalRectificationError("profile_incomplete"); + return parsed.data; +} + +export type ProductionConversationalRectificationProfileDependencies = Readonly<{ + loadProfile(userId: string): Promise; + loadRectificationCase(userId: string, caseId: string): Promise; +}>; + +export async function loadProductionConversationalRectificationProfile( + dependencies: ProductionConversationalRectificationProfileDependencies, + userId: string, +): Promise> { + const profileValue = await dependencies.loadProfile(userId); + const profile = profileRecord(profileValue); + if (!profile) throw new ConversationalRectificationError("profile_incomplete"); + const declaredBirthInput = declaredBirthInputFromProfile(profile); + const priorCaseId = text(profile.rectification_case_id); + if (!priorCaseId) return { declaredBirthInput, revisionOfCaseId: null }; + + const prior = profileRecord(await dependencies.loadRectificationCase(userId, priorCaseId)); + const terminalV3Revision = prior + && text(prior.id) === priorCaseId + && text(prior.journey_protocol) === "conversational-evidence-v3" + && (text(prior.status) === "completed" || text(prior.status) === "abandoned"); return { declaredBirthInput, - revisionOfCaseId: text(profile.rectification_case_id), + revisionOfCaseId: terminalV3Revision ? priorCaseId : null, }; } @@ -195,12 +225,12 @@ function declaredRange(input: DeclaredBirthInput): { readonly startTime: string; late_night: { startTime: "23:00", endTime: "03:59" }, }[input.reportedPeriod]; } - if (input.source === "unknown") return { startTime: "00:01", endTime: "23:59" }; + if (input.source === "unknown") return { startTime: "00:00", endTime: "23:59" }; if (input.source === "legacy_import" && !input.reportedTime) { if (input.reportedPeriod) { return declaredRange({ ...input, source: "period_only", reportedPeriod: input.reportedPeriod }); } - return { startTime: "00:01", endTime: "23:59" }; + return { startTime: "00:00", endTime: "23:59" }; } const reportedTime = input.reportedTime; if (!reportedTime) throw new ConversationalRectificationError("profile_incomplete"); @@ -223,6 +253,28 @@ function scanCoordinates(range: { readonly startTime: string; readonly endTime: }; } +function boundedScanRanges(range: { readonly startTime: string; readonly endTime: string }) { + const start = minute(range.startTime); + let end = minute(range.endTime); + if (end < start) end += 1_440; + if (end - start <= 360) return [range]; + + const ranges: Array<{ readonly startTime: string; readonly endTime: string }> = []; + let cursor = start; + while (end - cursor > 360) { + ranges.push({ startTime: clock(cursor), endTime: clock(cursor + 360) }); + cursor += 360; + } + if (cursor < end) { + // A symmetric integer-minute scan needs an even endpoint span. Pull an + // odd final span back by one minute, overlapping rather than inventing a + // minute outside the user's declared range. + const finalStart = (end - cursor) % 2 === 0 ? cursor : cursor - 1; + ranges.push({ startTime: clock(finalStart), endTime: clock(end) }); + } + return ranges; +} + function currentRange(input: ConversationalRectificationPacketBuildInput) { const start = input.privateCandidate?.rangeStart; const end = input.privateCandidate?.rangeEnd; @@ -262,6 +314,65 @@ function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly samp return links; } +function timeOffsetFromRangeStart(time: string, rangeStart: string): number { + const start = minute(rangeStart); + let value = minute(time); + if (value < start) value += 1_440; + return value - start; +} + +function timeIsInsideRange( + time: string, + range: { readonly startTime: string; readonly endTime: string }, +): boolean { + const offset = timeOffsetFromRangeStart(time, range.startTime); + const endOffset = timeOffsetFromRangeStart(range.endTime, range.startTime); + return offset <= endOffset; +} + +function mergeQuestionnaireScans( + scans: readonly RectificationQuestionnaire[], + range: { readonly startTime: string; readonly endTime: string }, +): RectificationQuestionnaire { + const first = scans[0]; + if (!first) throw new ConversationalRectificationError("service_unavailable"); + if (scans.length === 1) return first; + + const byTime = new Map(); + const questions = new Map(); + for (const scan of scans) { + for (const question of scan.questions) { + if (!questions.has(question.id)) questions.set(question.id, question); + } + const rawCandidateScan = profileRecord(scan.raw.candidate_scan); + const rawSamples = Array.isArray(rawCandidateScan?.samples) ? rawCandidateScan.samples : []; + for (const link of sampleTimes(scan)) { + const sample = scan.samples[link.sampleIndex]; + const rawSample = rawSamples[link.sampleIndex]; + if (!sample || rawSample === undefined || !timeIsInsideRange(link.time, range)) continue; + if (!byTime.has(link.time)) byTime.set(link.time, { sample, rawSample }); + } + } + const merged = [...byTime.entries()].sort(([left], [right]) => + timeOffsetFromRangeStart(left, range.startTime) + - timeOffsetFromRangeStart(right, range.startTime)); + const firstCandidateScan = profileRecord(first.raw.candidate_scan) ?? {}; + return { + questions: [...questions.values()], + samples: merged.map(([, item]) => item.sample), + raw: { + ...first.raw, + candidate_scan: { + ...firstCandidateScan, + samples: merged.map(([, item]) => item.rawSample), + }, + }, + }; +} + function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: string) { const layers = [ ["D1", "ascendantSign"], @@ -292,7 +403,7 @@ function boundaryDistance(range: { readonly startTime: string; readonly endTime: return Math.max(0, Math.min(value - start, end - value)); } -async function buildProductionPacket( +export async function buildProductionConversationalRectificationPacket( engine: BirthTimeJourneyEngine, input: ConversationalRectificationPacketBuildInput, ) { @@ -316,15 +427,20 @@ async function buildProductionPacket( const selectedRange = eventScore?.winningSegment ? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime } : baseRange; - const scanPoint = scanCoordinates(selectedRange); - const { questionnaire } = await engine.scan({ - birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`, - uncertaintyMinutes: scanPoint.uncertaintyMinutes, - lat: place.latitude, - lon: place.longitude, - tz: place.timezoneOffset, - ayanamsa: "lahiri", - }); + const questionnaires: RectificationQuestionnaire[] = []; + for (const scanRange of boundedScanRanges(selectedRange)) { + const scanPoint = scanCoordinates(scanRange); + const { questionnaire } = await engine.scan({ + birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`, + uncertaintyMinutes: scanPoint.uncertaintyMinutes, + lat: place.latitude, + lon: place.longitude, + tz: place.timezoneOffset, + ayanamsa: "lahiri", + }); + questionnaires.push(questionnaire); + } + const questionnaire = mergeQuestionnaireScans(questionnaires, selectedRange); const candidateDifferences = await engine.buildDifferencePacket({ caseId: input.caseId, asOfDate: input.asOfDate, @@ -346,7 +462,7 @@ async function buildProductionPacket( : candidateDifferences.packet.scoringVersion; const metadata = layerMetadata(questionnaire, calculationVersion); const representative = eventScore?.winningSegment?.representativeTime - ?? scanPoint.centerTime; + ?? scanCoordinates(selectedRange).centerTime; const { buildRectificationTechnicalPacket } = await import( "../../../lib/conversational-rectification/technical-packet.ts" ); @@ -420,15 +536,29 @@ async function createProductionService( billing: createSupabaseConversationalRectificationBilling(admin), get rectificationPriceCredits() { return priceCredits(); }, async loadDeclaredProfile(userId) { - const { data, error } = await profileClient - .from("profiles") - .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id") - .eq("id", userId) - .maybeSingle(); - if (error) throw new ConversationalRectificationError("store_unavailable"); - return declaredBirthInputFromProfile(data); + return loadProductionConversationalRectificationProfile({ + async loadProfile(receivedUserId) { + const { data, error } = await profileClient + .from("profiles") + .select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id") + .eq("id", receivedUserId) + .maybeSingle(); + if (error) throw new ConversationalRectificationError("store_unavailable"); + return data; + }, + async loadRectificationCase(receivedUserId, receivedCaseId) { + const { data, error } = await admin + .from("birth_time_rectification_cases") + .select("id,journey_protocol,status") + .eq("id", receivedCaseId) + .eq("user_id", receivedUserId) + .maybeSingle(); + if (error) throw new ConversationalRectificationError("store_unavailable"); + return data; + }, + }, userId); }, - buildTechnicalPacket: (input) => buildProductionPacket(engine, input), + buildTechnicalPacket: (input) => buildProductionConversationalRectificationPacket(engine, input), narrativeGenerator, asOfDate: () => new Date().toISOString().slice(0, 10), }); diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index d5fd9a1b..2225b651 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { conversationalRectificationCommandSchema, conversationalRectificationTurnSchema, @@ -60,7 +61,7 @@ export type ConversationalRectificationPacketBuildInput = Readonly<{ export type ConversationalRectificationServicePorts = Readonly<{ store: Pick; + "createCaseWithFirstTurn" | "loadCase" | "loadActionReceipt" | "saveTurn" | "pause" | "abandon" | "confirm">; billing: Pick; rectificationPriceCredits: number; loadDeclaredProfile(userId: string): Promise; @@ -81,7 +82,8 @@ export type ConversationalRectificationService = Readonly<{ }>; const transitionValidatorVersion = "conversational-rectification-orchestrator-v1"; -const directionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不知道|不确定)/; +const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/; +const genericUncertaintyPattern = /(?:不知道|不确定)/; function safeFailure(error: unknown): ConversationalRectificationError { return error instanceof ConversationalRectificationError @@ -100,6 +102,20 @@ function parseCommand( return parsed.data as CommandOf; } +type MutableCommand = Extract; + +function commandFingerprint(command: MutableCommand): string { + const identity = command.type === "answer" + ? [command.type, command.caseId, command.actionId, command.turnVersion, + command.domain ?? null, command.answer] + : command.type === "confirm" + ? [command.type, command.caseId, command.actionId, command.turnVersion, command.time] + : [command.type, command.caseId, command.actionId, command.turnVersion]; + return createHash("sha256").update(JSON.stringify(identity), "utf8").digest("hex"); +} + function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn { const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn); if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); @@ -347,6 +363,27 @@ export function createConversationalRectificationService( } } + async function replayMutation( + userId: string, + command: MutableCommand, + actionKind: "save_turn" | "pause" | "abandon" | "confirm", + fingerprint: string, + ): Promise { + try { + const receipt = await ports.store.loadActionReceipt({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + actionKind, + commandFingerprint: fingerprint, + }); + return receipt ? publicTurn(receipt) : null; + } catch (error) { + throw safeFailure(error); + } + } + function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] { let extracted: readonly LifeEventEvidence[]; try { @@ -505,12 +542,14 @@ export function createConversationalRectificationService( async resume(userId, rawCommand) { const command = parseCommand("resume", rawCommand); const current = await load(userId, command.caseId); - requireExactVersion(current, command.turnVersion); return publicTurn(current); }, async answer(userId, rawCommand) { const command = parseCommand("answer", rawCommand); + const fingerprint = commandFingerprint(command); + const receipt = await replayMutation(userId, command, "save_turn", fingerprint); + if (receipt) return receipt; const current = await load(userId, command.caseId); requireMutable(current); const evidence = extractedEvidence(command); @@ -522,6 +561,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn: current.latestTurn, evidence, validationReceipt: latestReceipt(current), @@ -536,7 +576,9 @@ export function createConversationalRectificationService( const scoreableEvidence = evidence.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification"); - const directionChange = directionChangePattern.test(command.answer); + const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer); + const directionChange = explicitDirectionChange + || (scoreableEvidence.length === 0 && genericUncertaintyPattern.test(command.answer)); if (directionChange || scoreableEvidence.length === 0) { const next = nonScoringTurn({ current, @@ -550,6 +592,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn: next.turn, evidence, validationReceipt: next.receipt, @@ -593,6 +636,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn: next.turn, evidence, validationReceipt: narrative.validationReceipt, @@ -618,6 +662,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn, evidence, validationReceipt: narrative.validationReceipt, @@ -631,6 +676,9 @@ export function createConversationalRectificationService( async pause(userId, rawCommand) { const command = parseCommand("pause", rawCommand); + const fingerprint = commandFingerprint(command); + const receipt = await replayMutation(userId, command, "pause", fingerprint); + if (receipt) return receipt; const current = await load(userId, command.caseId); if (current.turnVersion === command.turnVersion + 1 && current.status === "paused") { try { @@ -639,6 +687,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn: current.latestTurn, validationReceipt: latestReceipt(current), }); @@ -662,6 +711,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn: next.turn, validationReceipt: next.receipt, })); @@ -672,6 +722,9 @@ export function createConversationalRectificationService( async abandon(userId, rawCommand) { const command = parseCommand("abandon", rawCommand); + const fingerprint = commandFingerprint(command); + const receipt = await replayMutation(userId, command, "abandon", fingerprint); + if (receipt) return receipt; const current = await load(userId, command.caseId); if (current.turnVersion === command.turnVersion + 1 && current.status === "abandoned") { try { @@ -680,6 +733,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn: current.latestTurn, validationReceipt: latestReceipt(current), })); @@ -700,6 +754,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, turn: next.turn, validationReceipt: next.receipt, })); @@ -710,6 +765,9 @@ export function createConversationalRectificationService( async confirm(userId, rawCommand) { const command = parseCommand("confirm", rawCommand); + const fingerprint = commandFingerprint(command); + const receipt = await replayMutation(userId, command, "confirm", fingerprint); + if (receipt) return receipt; const current = await load(userId, command.caseId); if (current.turnVersion === command.turnVersion + 1 && current.status === "completed") { const resultId = current.privateCandidate.resultId; @@ -720,6 +778,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, resultId, time: command.time, calculationVersion: current.privateCandidate.calculationVersion, @@ -755,6 +814,7 @@ export function createConversationalRectificationService( caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, + commandFingerprint: fingerprint, resultId, time: command.time, calculationVersion: current.privateCandidate.calculationVersion, diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts index d939a28f..38577637 100644 --- a/frontend/src/lib/conversational-rectification/persistence-contracts.ts +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -212,6 +212,7 @@ export const conversationalRectificationActionReceiptRequestSchema = boundedJson expectedVersion: z.number().int().nonnegative(), actionId: uuidSchema, requestFingerprint: z.string().regex(/^[0-9a-f]{64}$/), + commandFingerprint: z.string().regex(/^[0-9a-f]{64}$/).optional(), }).strict(), 2_048); export const billingReceiptResponseSchema = boundedJson(z.object({ diff --git a/frontend/src/lib/conversational-rectification/store.ts b/frontend/src/lib/conversational-rectification/store.ts index 6571f4fd..3ce6b179 100644 --- a/frontend/src/lib/conversational-rectification/store.ts +++ b/frontend/src/lib/conversational-rectification/store.ts @@ -78,6 +78,20 @@ type MutationIdentity = Readonly<{ actionId: string; }>; +type CommandMutationIdentity = MutationIdentity & Readonly<{ + commandFingerprint: string; +}>; + +export type ConversationalRectificationActionKind = + | "save_turn" + | "pause" + | "abandon" + | "confirm"; + +export type LoadConversationalRectificationActionReceiptInput = CommandMutationIdentity & Readonly<{ + actionKind: ConversationalRectificationActionKind; +}>; + export type CreateConversationalRectificationCaseInput = MutationIdentity & Readonly<{ revisionOfCaseId: string | null; pendingConsultationQuestion: string | null; @@ -89,19 +103,19 @@ export type CreateConversationalRectificationCaseInput = MutationIdentity & Read export type LifeEventEvidenceInput = DeepReadonly; -export type SaveConversationalRectificationTurnInput = MutationIdentity & Readonly<{ +export type SaveConversationalRectificationTurnInput = CommandMutationIdentity & Readonly<{ turn: ConversationalRectificationTurnInput; evidence: ReadonlyArray; validationReceipt: ValidationReceiptInput; privateCandidate: PrivateCandidateInput; }>; -export type ConversationalRectificationTransitionInput = MutationIdentity & Readonly<{ +export type ConversationalRectificationTransitionInput = CommandMutationIdentity & Readonly<{ turn: ConversationalRectificationTurnInput; validationReceipt: ValidationReceiptInput; }>; -export type ConfirmConversationalRectificationInput = MutationIdentity & Readonly<{ +export type ConfirmConversationalRectificationInput = CommandMutationIdentity & Readonly<{ resultId: string; time: string; calculationVersion: string; @@ -174,10 +188,14 @@ function parseStoredCase(data: unknown, allowNull = false): StoredConversational pendingConsultationQuestion: value.pending_consultation_question, billingState: value.billing_state, latestTurn: value.latest_turn, - declaredBirthInput: value.declared_birth_input, - privateCandidate: value.private_candidate, - eventEvidence: value.event_evidence, - validationReceipts: value.validation_receipts, + ...(value.declared_birth_input === undefined + ? {} : { declaredBirthInput: value.declared_birth_input }), + ...(value.private_candidate === undefined + ? {} : { privateCandidate: value.private_candidate }), + ...(value.event_evidence === undefined + ? {} : { eventEvidence: value.event_evidence }), + ...(value.validation_receipts === undefined + ? {} : { validationReceipts: value.validation_receipts }), }); } @@ -222,6 +240,9 @@ const mutationIdentitySchema = z.object({ actionId: z.string().uuid(), }).strict(); +const commandFingerprintSchema = z.string().regex(/^[0-9a-f]{64}$/); +const actionKindSchema = z.enum(["save_turn", "pause", "abandon", "confirm"]); + function mutationArgs(input: MutationIdentity): Readonly> { const parsed = mutationIdentitySchema.safeParse({ userId: input.userId, @@ -238,6 +259,12 @@ function mutationArgs(input: MutationIdentity): Readonly }; } +function commandFingerprint(input: CommandMutationIdentity): string { + const parsed = commandFingerprintSchema.safeParse(input.commandFingerprint); + if (!parsed.success) return invalidDurableInput(); + return parsed.data; +} + /** * A public start action is also its durable case identity. This makes a retry * recoverable even when the first response was lost before the caller learned @@ -304,11 +331,24 @@ export class ConversationalRectificationStore { return loaded as LoadedConversationalRectificationCase; } + async loadActionReceipt( + input: LoadConversationalRectificationActionReceiptInput, + ): Promise { + const actionKind = actionKindSchema.safeParse(input.actionKind); + if (!actionKind.success) return invalidDurableInput(); + return this.callCaseRpc("replay_conversational_rectification_action", { + ...mutationArgs(input), + p_action_kind: actionKind.data, + p_command_fingerprint: commandFingerprint(input), + }, true); + } + async saveTurn( input: SaveConversationalRectificationTurnInput, ): Promise { const result = await this.callCaseRpc("save_conversational_rectification_turn", { ...mutationArgs(input), + p_command_fingerprint: commandFingerprint(input), p_turn: requirePublicTurn(input.turn), p_evidence: requireEvidence(input.evidence), p_validation_receipt: requireValidationReceipt(input.validationReceipt), @@ -336,6 +376,7 @@ export class ConversationalRectificationStore { ): Promise { const result = await this.callCaseRpc(functionName, { ...mutationArgs(input), + p_command_fingerprint: commandFingerprint(input), p_turn: requirePublicTurn(input.turn), p_validation_receipt: requireValidationReceipt(input.validationReceipt), }); @@ -348,6 +389,7 @@ export class ConversationalRectificationStore { ): Promise { const result = await this.callCaseRpc("confirm_conversational_rectification_candidate", { ...mutationArgs(input), + p_command_fingerprint: commandFingerprint(input), p_result_id: input.resultId, p_time: input.time, p_calculation_version: input.calculationVersion, diff --git a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql index 0a109cbb..2f20ef68 100644 --- a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql +++ b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql @@ -883,7 +883,8 @@ as $$ and public.conversational_rectification_has_only_keys( p_value, array[ - 'kind', 'userId', 'caseId', 'expectedVersion', 'actionId', 'requestFingerprint' + 'kind', 'userId', 'caseId', 'expectedVersion', 'actionId', + 'requestFingerprint', 'commandFingerprint' ]::text[] ) and p_value ?& array[ @@ -903,7 +904,14 @@ as $$ and pg_catalog.jsonb_typeof(p_value -> 'expectedVersion') = 'number' and p_value ->> 'expectedVersion' ~ '^[0-9]+$' and pg_catalog.jsonb_typeof(p_value -> 'requestFingerprint') = 'string' - and p_value ->> 'requestFingerprint' ~ '^[0-9a-f]{64}$'; + and p_value ->> 'requestFingerprint' ~ '^[0-9a-f]{64}$' + and ( + not (p_value ? 'commandFingerprint') + or ( + pg_catalog.jsonb_typeof(p_value -> 'commandFingerprint') = 'string' + and p_value ->> 'commandFingerprint' ~ '^[0-9a-f]{64}$' + ) + ); $$; create or replace function public.conversational_rectification_action_request( diff --git a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql index 3ae055d6..28687df7 100644 --- a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql +++ b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql @@ -169,6 +169,53 @@ begin end; $$; +create or replace function public.replay_conversational_rectification_action( + p_user_id uuid, + p_case_id uuid, + p_expected_version bigint, + p_action_id uuid, + p_action_kind text, + p_command_fingerprint text +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_receipt public.birth_time_rectification_action_receipts%rowtype; +begin + if p_user_id is null or p_case_id is null or p_action_id is null + or p_expected_version is null or p_expected_version < 0 + or p_action_kind not in ('save_turn', 'pause', 'abandon', 'confirm') + or p_command_fingerprint !~ '^[0-9a-f]{64}$' then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + select r.* into v_receipt + from public.birth_time_rectification_action_receipts r + where r.user_id = p_user_id + and r.case_id = p_case_id + and r.action_id = p_action_id; + if not found then + return null; + end if; + -- Receipts created before command identities were introduced still use the + -- mutation RPC's complete request fingerprint for their one-step replay. + if not (v_receipt.request ? 'commandFingerprint') then + return null; + end if; + if v_receipt.action_kind is distinct from p_action_kind + or v_receipt.expected_turn_version is distinct from p_expected_version + or v_receipt.request ->> 'commandFingerprint' + is distinct from p_command_fingerprint then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + return v_receipt.response; +end; +$$; + create or replace function public.conversational_rectification_case_fits_load_limits( p_user_id uuid, p_case_id uuid, @@ -494,7 +541,8 @@ create or replace function public.save_conversational_rectification_turn( p_turn jsonb, p_evidence jsonb, p_validation_receipt jsonb, - p_private_candidate jsonb + p_private_candidate jsonb, + p_command_fingerprint text default null ) returns jsonb language plpgsql @@ -518,7 +566,9 @@ declare ); begin if p_user_id is null or p_case_id is null or p_action_id is null - or p_expected_version is null or p_expected_version < 0 then + or p_expected_version is null or p_expected_version < 0 + or (p_command_fingerprint is not null + and p_command_fingerprint !~ '^[0-9a-f]{64}$') then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; perform pg_catalog.pg_advisory_xact_lock( @@ -540,6 +590,9 @@ 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 raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; @@ -642,7 +695,10 @@ begin public.conversational_rectification_action_request( 'save_turn', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint - ), + ) || case when p_command_fingerprint is null then '{}'::jsonb + else pg_catalog.jsonb_build_object( + 'commandFingerprint', p_command_fingerprint + ) end, v_response ); return v_response; @@ -655,7 +711,8 @@ create or replace function public.pause_conversational_rectification_case( p_expected_version bigint, p_action_id uuid, p_turn jsonb, - p_validation_receipt jsonb + p_validation_receipt jsonb, + p_command_fingerprint text default null ) returns jsonb language plpgsql @@ -676,7 +733,9 @@ declare ); begin if p_user_id is null or p_case_id is null or p_action_id is null - or p_expected_version is null or p_expected_version < 0 then + or p_expected_version is null or p_expected_version < 0 + or (p_command_fingerprint is not null + and p_command_fingerprint !~ '^[0-9a-f]{64}$') then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; perform pg_catalog.pg_advisory_xact_lock( @@ -697,6 +756,9 @@ 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 raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; @@ -767,7 +829,10 @@ begin p_expected_version + 1, v_fingerprint, public.conversational_rectification_action_request( 'pause', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint - ), + ) || case when p_command_fingerprint is null then '{}'::jsonb + else pg_catalog.jsonb_build_object( + 'commandFingerprint', p_command_fingerprint + ) end, v_response ); return v_response; @@ -780,7 +845,8 @@ create or replace function public.abandon_conversational_rectification_case( p_expected_version bigint, p_action_id uuid, p_turn jsonb, - p_validation_receipt jsonb + p_validation_receipt jsonb, + p_command_fingerprint text default null ) returns jsonb language plpgsql @@ -801,7 +867,9 @@ declare ); begin if p_user_id is null or p_case_id is null or p_action_id is null - or p_expected_version is null or p_expected_version < 0 then + or p_expected_version is null or p_expected_version < 0 + or (p_command_fingerprint is not null + and p_command_fingerprint !~ '^[0-9a-f]{64}$') then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; perform pg_catalog.pg_advisory_xact_lock( @@ -822,6 +890,9 @@ 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 raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; @@ -892,7 +963,10 @@ begin p_expected_version + 1, v_fingerprint, public.conversational_rectification_action_request( 'abandon', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint - ), + ) || case when p_command_fingerprint is null then '{}'::jsonb + else pg_catalog.jsonb_build_object( + 'commandFingerprint', p_command_fingerprint + ) end, v_response ); return v_response; @@ -908,7 +982,8 @@ create or replace function public.confirm_conversational_rectification_candidate p_time time without time zone, p_calculation_version text, p_turn jsonb, - p_validation_receipt jsonb + p_validation_receipt jsonb, + p_command_fingerprint text default null ) returns jsonb language plpgsql @@ -933,6 +1008,10 @@ declare ) ); begin + if p_command_fingerprint is not null + and p_command_fingerprint !~ '^[0-9a-f]{64}$' then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; if p_user_id is null or p_case_id is null or p_action_id is null or p_result_id is null or p_time is null or extract(second from p_time) is distinct from 0 @@ -958,6 +1037,9 @@ 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 raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; @@ -1075,7 +1157,10 @@ begin p_expected_version + 1, v_fingerprint, public.conversational_rectification_action_request( 'confirm', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint - ), + ) || case when p_command_fingerprint is null then '{}'::jsonb + else pg_catalog.jsonb_build_object( + 'commandFingerprint', p_command_fingerprint + ) end, v_response ); return v_response; @@ -1340,20 +1425,23 @@ revoke all on function public.conversational_rectification_case_fits_load_limits revoke all on function public.load_conversational_rectification_case(uuid, uuid) from public, anon, authenticated; +revoke all on function public.replay_conversational_rectification_action( + uuid, uuid, bigint, uuid, text, text +) from public, anon, authenticated; revoke all on function public.create_conversational_rectification_case( uuid, uuid, bigint, uuid, uuid, text, jsonb, jsonb, jsonb, jsonb ) from public, anon, authenticated; revoke all on function public.save_conversational_rectification_turn( - uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text ) from public, anon, authenticated; revoke all on function public.pause_conversational_rectification_case( - uuid, uuid, bigint, uuid, jsonb, jsonb + uuid, uuid, bigint, uuid, jsonb, jsonb, text ) from public, anon, authenticated; revoke all on function public.abandon_conversational_rectification_case( - uuid, uuid, bigint, uuid, jsonb, jsonb + uuid, uuid, bigint, uuid, jsonb, jsonb, text ) from public, anon, authenticated; revoke all on function public.confirm_conversational_rectification_candidate( - uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb + uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb, text ) from public, anon, authenticated; revoke all on function public.import_legacy_conversational_rectification_case( uuid, uuid, uuid, bigint, uuid, integer, text, jsonb, jsonb, jsonb @@ -1361,20 +1449,23 @@ revoke all on function public.import_legacy_conversational_rectification_case( grant execute on function public.load_conversational_rectification_case(uuid, uuid) to service_role; +grant execute on function public.replay_conversational_rectification_action( + uuid, uuid, bigint, uuid, text, text +) to service_role; grant execute on function public.create_conversational_rectification_case( uuid, uuid, bigint, uuid, uuid, text, jsonb, jsonb, jsonb, jsonb ) to service_role; grant execute on function public.save_conversational_rectification_turn( - uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text ) to service_role; grant execute on function public.pause_conversational_rectification_case( - uuid, uuid, bigint, uuid, jsonb, jsonb + uuid, uuid, bigint, uuid, jsonb, jsonb, text ) to service_role; grant execute on function public.abandon_conversational_rectification_case( - uuid, uuid, bigint, uuid, jsonb, jsonb + uuid, uuid, bigint, uuid, jsonb, jsonb, text ) to service_role; grant execute on function public.confirm_conversational_rectification_candidate( - uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb + uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb, text ) to service_role; grant execute on function public.import_legacy_conversational_rectification_case( uuid, uuid, uuid, bigint, uuid, integer, text, jsonb, jsonb, jsonb diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 8132dca5..13ba8406 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -21,6 +21,7 @@ const resumeActionId = "00000000-0000-4000-8000-000000000705"; 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 declaredBirthInput = { source: "approximate" as const, @@ -148,7 +149,13 @@ function harness(options: { const events: string[] = []; const mutations: string[] = []; const cases = new Map(); - const receipts = new Map(); + const receipts = new Map(); let packetBuilds = 0; let reserveCount = 0; let releaseCount = 0; @@ -189,18 +196,39 @@ function harness(options: { }; } - function replay(actionId: string, input: unknown, make: () => LoadedConversationalRectificationCase) { + function replay( + actionId: string, + input: { readonly expectedVersion?: number; readonly commandFingerprint?: string }, + make: () => LoadedConversationalRectificationCase, + actionKind?: "save_turn" | "pause" | "abandon" | "confirm", + ) { const prior = receipts.get(actionId); if (prior) { assert.deepEqual(input, prior.input); return prior.response; } const response = make(); - receipts.set(actionId, { input: structuredClone(input), response }); + receipts.set(actionId, { + input: structuredClone(input), + response, + actionKind, + expectedVersion: input.expectedVersion, + commandFingerprint: input.commandFingerprint, + }); return response; } const store: ConversationalRectificationServicePorts["store"] = { + async loadActionReceipt(input) { + const prior = receipts.get(input.actionId); + if (!prior?.actionKind) return null; + if (prior.actionKind !== input.actionKind + || prior.expectedVersion !== input.expectedVersion + || prior.commandFingerprint !== input.commandFingerprint) { + throw new ConversationalRectificationError("action_conflict"); + } + return prior.response; + }, async loadCase(input) { const value = input.caseId ? cases.get(input.caseId)?.row @@ -240,7 +268,7 @@ function harness(options: { }); cases.set(input.turn.caseId, { row }); return row; - }); + }, "save_turn"); }, async pause(input) { mutations.push("pause"); @@ -252,7 +280,7 @@ function harness(options: { receipts: [...current.validationReceipts, input.validationReceipt] }); cases.set(input.turn.caseId, { row }); return row; - }); + }, "pause"); }, async abandon(input) { mutations.push("abandon"); @@ -263,7 +291,7 @@ function harness(options: { receipts: [...current.validationReceipts, input.validationReceipt] }); cases.set(input.turn.caseId, { row }); return row; - }); + }, "abandon"); }, async confirm(input) { mutations.push("confirm"); @@ -274,7 +302,7 @@ function harness(options: { receipts: [...current.validationReceipts, input.validationReceipt] }); cases.set(input.turn.caseId, { row }); return row; - }); + }, "confirm"); }, }; @@ -330,6 +358,26 @@ function harness(options: { cases, service: createConversationalRectificationService(ports), counts: () => ({ packetBuilds, reserveCount, releaseCount }), + forceLaterVersion(caseId: string, turnVersion: number) { + const current = cases.get(caseId)?.row; + assert.ok(current); + cases.set(caseId, { + row: { + ...current, + status: "active", + turnVersion, + latestTurn: conversationalRectificationTurnSchema.parse({ + ...current.latestTurn, + status: "active", + turnVersion, + candidate: current.latestTurn.candidate.status === "confirmed" + ? { ...current.latestTurn.candidate, status: "pending_validation" } + : current.latestTurn.candidate, + actions: ["answer", "pause", "abandon"], + }), + }, + }); + }, }; } @@ -422,6 +470,25 @@ test("clear historical evidence is extracted, scored, narrated, recapped, and at assert.ok(value.events.includes("score-packet")); }); +test("generic date uncertainty does not suppress clear historical evidence", async () => { + const value = harness(); + await start(value, null); + + const turn = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2021年7月毕业,具体日期不确定", + }); + + assert.equal(turn.status, "confirming"); + assert.equal(value.counts().packetBuilds, 2); + assert.ok(value.events.includes("score-packet")); + assert.ok((value.cases.get(startActionId)?.row.eventEvidence ?? []) + .some((item) => item.eventSummary.includes("毕业") && item.scoreable === true)); +}); + test("vague, future, and unmatched answers stay conversational and never score", async () => { for (const [answer, domain] of [ ["后来换了工作", undefined], @@ -446,20 +513,26 @@ test("vague, future, and unmatched answers stay conversational and never score", } }); -test("pause persists, resume reads it without another charge, and stale commands are stable", async () => { +test("resume returns the latest owned turn on a stale new-device version without mutation or charge", async () => { const value = harness(); await start(value, null); const paused = await value.service.pause(userId, { type: "pause", caseId: startActionId, actionId: pauseActionId, turnVersion: 0, }); assert.equal(paused.status, "paused"); - const resumed = await value.service.resume(userId, { - type: "resume", caseId: startActionId, actionId: resumeActionId, turnVersion: 1, + const latest = await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 1, answer: "2021年7月毕业,并在2022年3月去外地工作", }); - assert.deepEqual(resumed, paused); + const mutationsBeforeResume = [...value.mutations]; + const resumed = await value.service.resume(userId, { + type: "resume", caseId: startActionId, actionId: resumeActionId, turnVersion: 0, + }); + assert.deepEqual(resumed, latest); + assert.deepEqual(value.mutations, mutationsBeforeResume); assert.equal(value.counts().reserveCount, 1); await assert.rejects(value.service.answer(userId, { - type: "answer", caseId: startActionId, actionId: answerActionId, + type: "answer", caseId: startActionId, actionId: laterActionId, turnVersion: 0, answer: "2021年7月毕业", }), (error: unknown) => error instanceof ConversationalRectificationError && error.code === "stale_turn"); @@ -483,6 +556,104 @@ test("a lost-response retry replays the saved answer without rescoring or regene assert.deepEqual(value.events, before); }); +test("receipt-first delayed retries replay the original answer, pause, abandon, and confirm after later turns", async () => { + const scenarios = [ + { + name: "answer", + async perform(value: ReturnType) { + const command = { + type: "answer" as const, + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2021年7月毕业,并在2022年3月去外地工作", + }; + return { command, first: await value.service.answer(userId, command) }; + }, + }, + { + name: "pause", + async perform(value: ReturnType) { + const command = { + type: "pause" as const, + caseId: startActionId, + actionId: pauseActionId, + turnVersion: 0, + }; + return { command, first: await value.service.pause(userId, command) }; + }, + }, + { + name: "abandon", + async perform(value: ReturnType) { + const command = { + type: "abandon" as const, + caseId: startActionId, + actionId: laterActionId, + turnVersion: 0, + }; + return { command, first: await value.service.abandon(userId, command) }; + }, + }, + { + name: "confirm", + async perform(value: ReturnType) { + const ready = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2021年7月毕业,并在2022年3月去外地工作", + }); + const command = { + type: "confirm" as const, + caseId: startActionId, + actionId: confirmActionId, + turnVersion: ready.turnVersion, + time: "05:18", + }; + return { command, first: await value.service.confirm(userId, command) }; + }, + }, + ] as const; + + for (const scenario of scenarios) { + const value = harness(); + await start(value, null); + const { command, first } = await scenario.perform(value); + value.forceLaterVersion(startActionId, first.turnVersion + 2); + const mutationsBeforeReplay = [...value.mutations]; + const eventsBeforeReplay = [...value.events]; + + const replayed = await value.service[scenario.name](userId, command as never); + + assert.deepEqual(replayed, first, scenario.name); + assert.deepEqual(value.mutations, mutationsBeforeReplay, scenario.name); + assert.deepEqual(value.events, eventsBeforeReplay, scenario.name); + } +}); + +test("receipt-first delayed replay rejects the same action id with a different command payload", async () => { + const value = harness(); + await start(value, null); + const command = { + type: "answer" as const, + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + domain: "education" as const, + answer: "2021年7月毕业", + }; + const first = await value.service.answer(userId, command); + value.forceLaterVersion(startActionId, first.turnVersion + 2); + + await assert.rejects(value.service.answer(userId, { + ...command, + domain: "career", + }), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "action_conflict"); +}); + test("confirm delegates to the atomic store call, preserves the old baseline until then, and returns the saved question", async () => { const value = harness(); await start(value, "请继续回答原来的事业问题"); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index c72510b7..7d6eeda5 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import test from "node:test"; import { + buildProductionConversationalRectificationPacket, createBirthTimeConversationPostHandler, + loadProductionConversationalRectificationProfile, type BirthTimeConversationRouteService, } 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"; const userId = "00000000-0000-4000-8000-000000000711"; const actionId = "00000000-0000-4000-8000-000000000712"; @@ -181,16 +183,146 @@ test("unknown SQL, model, and browser errors are never exposed or logged", async assert.deepEqual(logs, [{ requestId, actionId, caseId, code: "service_unavailable" }]); }); -test("production route lazily creates privileged clients only after authentication and strict parsing", () => { - const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8"); - assert.doesNotMatch(source, /^import .*supabase\/admin/m); - const authenticateCall = source.indexOf("dependencies.authenticate(request)"); - const parseCall = source.indexOf("safeParse(await requestPayload(request))"); - const serviceCall = source.indexOf("dependencies.createService(authenticated)"); - assert.ok(authenticateCall < parseCall); - assert.ok(parseCall < serviceCall); - assert.match(source, /import\(["'].*supabase\/admin(?:\.ts)?["']\)/); - assert.match(source, /process\.env\.RECTIFICATION_PRICE_CREDITS/); - assert.doesNotMatch(source, /BIRTH_TIME_RECTIFICATION_PRICE_CREDITS/); - assert.doesNotMatch(source, /command\.price|parsed\.data\.price/); +test("production profile conversion only links terminal v3 revisions and leaves pre-v3 baselines unlinked", async () => { + const priorId = "00000000-0000-4000-8000-000000000715"; + const profile = { + birth_date: "1990-01-01", + reported_birth_time: "04:58:00", + active_birth_time: "05:21:00", + birth_time_source: "legacy_import", + birth_time_period: null, + birth_time_clue: "synthetic dawn clue", + uncertainty_before_minutes: 0, + uncertainty_after_minutes: 0, + country_code: "TW", + province_code: "TPE", + city_code: "TPE-CITY", + district_code: "DAAN", + latitude: 25.0268, + longitude: 121.5434, + timezone_offset: 8, + rectification_case_id: priorId, + }; + for (const [prior, expectedRevision] of [ + [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "completed" }, priorId], + [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "abandoned" }, priorId], + [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "active" }, null], + [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "confirmed" }, null], + [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "confirmed" }, null], + ] as const) { + const caseLoads: unknown[] = []; + const loaded = await loadProductionConversationalRectificationProfile({ + async loadProfile(receivedUserId) { + assert.equal(receivedUserId, userId); + return profile; + }, + async loadRectificationCase(receivedUserId, receivedCaseId) { + caseLoads.push([receivedUserId, receivedCaseId]); + return prior; + }, + }, userId); + + assert.equal(loaded.revisionOfCaseId, expectedRevision); + assert.deepEqual(caseLoads, [[userId, priorId]]); + assert.equal(loaded.declaredBirthInput.source, "legacy_import"); + assert.equal("reportedTime" in loaded.declaredBirthInput + ? loaded.declaredBirthInput.reportedTime + : null, "04:58"); + } +}); + +test("production unknown-time adapter covers the declared full day with bounded deduplicated scans", async () => { + const scanCalls: Array<{ birthTime: string; uncertaintyMinutes: number }> = []; + 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")}`; + }; + const engine: BirthTimeJourneyEngine = { + async scan(input) { + scanCalls.push({ birthTime: input.birthTime, uncertaintyMinutes: input.uncertaintyMinutes }); + const center = minute(input.birthTime); + const times = [center - input.uncertaintyMinutes, center, center + input.uncertaintyMinutes] + .map(clock); + return { + questionnaire: { + questions: [], + samples: times.map((time) => { + const value = minute(time); + return { + ascendantSign: "Cancer", + d4Sign: value < 720 ? "Aries" : "Taurus", + d9Sign: value < 720 ? "Gemini" : "Virgo", + d10Sign: value < 720 ? "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() { throw new Error("unexpected event score"); }, + 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 result = await buildProductionConversationalRectificationPacket(engine, { + userId, + caseId, + asOfDate: "2026-07-21", + declaredBirthInput: { + source: "unknown", + birthDate: "1990-01-01", + birthTimeClue: null, + birthplace: { + cityCode: "TPE-CITY", + latitude: 25.0268, + longitude: 121.5434, + timezoneOffset: 8, + }, + }, + privateCandidate: null, + evidence: [], + }); + + assert.equal(scanCalls.length, 4); + assert.ok(scanCalls.every((call) => call.uncertaintyMinutes >= 1 + && call.uncertaintyMinutes <= 180)); + const covered = new Set(); + for (const call of scanCalls) { + const center = minute(call.birthTime); + for (let value = center - call.uncertaintyMinutes; + value <= center + call.uncertaintyMinutes; value += 1) { + assert.ok(value >= 0 && value <= 1_439, `scan invented minute ${value}`); + covered.add(value); + } + } + assert.equal(covered.size, 1_440); + assert.deepEqual(result.packet.candidate.range, { startTime: "00:00", endTime: "23:59" }); + const sampleTimes = result.packet.sensitivityScope.sampleTimes; + assert.equal(new Set(sampleTimes).size, sampleTimes.length); + assert.deepEqual(sampleTimes, [...sampleTimes].sort((left, right) => minute(left) - minute(right))); }); diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index fd90d1ab..971edc34 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -26,6 +26,7 @@ const caseId = "00000000-0000-4000-8000-000000000102"; const actionId = "00000000-0000-4000-8000-000000000103"; const resultId = "00000000-0000-4000-8000-000000000104"; const importCaseId = "00000000-0000-4000-8000-000000000107"; +const commandFingerprint = "c".repeat(64); const firstTurn = { caseId, @@ -206,6 +207,40 @@ test("loads the latest unfinished case by account without a chat identifier", as }]]); }); +test("loads an exact owner-scoped historical mutation receipt before current case state", async () => { + const calls: unknown[] = []; + const publicReceiptRow: Partial = { ...storedRow }; + delete publicReceiptRow.declared_birth_input; + delete publicReceiptRow.private_candidate; + delete publicReceiptRow.event_evidence; + delete publicReceiptRow.validation_receipts; + const store = new ConversationalRectificationStore(rpcClient((name, args) => { + calls.push([name, args]); + return publicReceiptRow; + })); + + const replayed = await store.loadActionReceipt({ + userId, + caseId, + actionId, + actionKind: "save_turn", + expectedVersion: 0, + commandFingerprint, + }); + + assert.equal(replayed?.caseId, caseId); + assert.deepEqual(replayed?.latestTurn, firstTurn); + assert.equal(replayed && "privateCandidate" in replayed, false); + assert.deepEqual(calls, [["replay_conversational_rectification_action", { + p_user_id: userId, + p_case_id: caseId, + p_action_id: actionId, + p_action_kind: "save_turn", + p_expected_version: 0, + p_command_fingerprint: commandFingerprint, + }]]); +}); + test("binds every paid start to the public action as its recoverable case id", async () => { let calls = 0; const client = rpcClient(() => { @@ -245,7 +280,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard calls.push([name, args]); return storedRow; })); - const common = { userId, caseId, actionId, expectedVersion: 0 }; + const common = { userId, caseId, actionId, expectedVersion: 0, commandFingerprint }; const evidence = [{ id: "00000000-0000-4000-8000-000000000105", rawText: "2019 年 7 月换工作", @@ -313,6 +348,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard assert.equal(args.p_case_id, caseId); assert.equal(args.p_action_id, actionId); assert.equal(args.p_expected_version, 0); + assert.equal(args.p_command_fingerprint, commandFingerprint); assert.deepEqual(args.p_validation_receipt, validationReceipt); } assert.equal(calls[4]?.[1].p_case_id, importCaseId); @@ -756,6 +792,7 @@ test("durable private and receipt schemas accept boundaries and reject oversize expectedVersion: 0, actionId, requestFingerprint: "a".repeat(64), + commandFingerprint, }; assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse(request).success, true); assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({ @@ -766,6 +803,10 @@ test("durable private and receipt schemas accept boundaries and reject oversize ...request, requestFingerprint: "a".repeat(65), }).success, false); + assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({ + ...request, + commandFingerprint: "c".repeat(63), + }).success, false); assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ success: false, credits: 7, diff --git a/tests/test_conversational_rectification_contract.py b/tests/test_conversational_rectification_contract.py index 1fa68960..5320c9e5 100644 --- a/tests/test_conversational_rectification_contract.py +++ b/tests/test_conversational_rectification_contract.py @@ -246,6 +246,58 @@ def test_account_resume_projection_contains_private_working_state_only_for_servi assert "private_candidate" not in public_projection +def test_historical_action_replay_is_owner_scoped_exact_bounded_and_read_only() -> None: + schema = _normalized(SCHEMA) + transitions = _normalized(TRANSITIONS) + request = _function(schema, "conversational_rectification_valid_action_request") + replay = _function( + transitions, "replay_conversational_rectification_action" + ) + + assert "'commandfingerprint'" in request + assert "requestfingerprint" in request + assert "^[0-9a-f]{64}$" in request + for invariant in ( + "r.user_id = p_user_id", + "r.case_id = p_case_id", + "r.action_id = p_action_id", + "v_receipt.action_kind is distinct from p_action_kind", + "v_receipt.expected_turn_version is distinct from p_expected_version", + "not (v_receipt.request ? 'commandfingerprint')", + "v_receipt.request ->> 'commandfingerprint' is distinct from p_command_fingerprint", + "return v_receipt.response", + "raise exception 'conversational_action_conflict'", + ): + assert invariant in replay + assert "insert into" not in replay + assert "update public." not in replay + assert "delete from" not in replay + assert ( + "revoke all on function public.replay_conversational_rectification_action" + in transitions + ) + assert ( + "grant execute on function public.replay_conversational_rectification_action" + in transitions + ) + assert not re.search( + r"grant execute on function public\.replay_conversational_rectification_action" + r".+?to (?:anon|authenticated)", + transitions, + ) + + for name in ( + "save_conversational_rectification_turn", + "pause_conversational_rectification_case", + "abandon_conversational_rectification_case", + "confirm_conversational_rectification_candidate", + ): + body = _function(transitions, name) + assert "p_command_fingerprint text" in body + assert "p_command_fingerprint !~ '^[0-9a-f]{64}$'" in body + assert "'commandfingerprint', p_command_fingerprint" in body + + def test_start_identity_and_account_concurrency_are_server_guarded() -> None: schema = _normalized(SCHEMA) transitions = _normalized(TRANSITIONS) diff --git a/tests/test_conversational_rectification_postgres_runtime.py b/tests/test_conversational_rectification_postgres_runtime.py index fa519a63..a523c583 100644 --- a/tests/test_conversational_rectification_postgres_runtime.py +++ b/tests/test_conversational_rectification_postgres_runtime.py @@ -328,6 +328,7 @@ def _save_statement( turn: dict[str, object] | None = None, validation_receipt: dict[str, object] | None = None, private_candidate: dict[str, object] | None = None, + command_fingerprint: str | None = None, ) -> str: next_turn = turn or { **_valid_turn(case_id), @@ -342,7 +343,8 @@ def _save_statement( {_jsonb(next_turn)}, {_jsonb(evidence)}, {_jsonb(validation_receipt or {"modelId": "synthetic-model", "schemaValidated": True})}, - {_jsonb(private_candidate or _valid_private_candidate())} + {_jsonb(private_candidate or _valid_private_candidate())}, + {"null" if command_fingerprint is None else _text(command_fingerprint)} )::text; """ @@ -483,6 +485,84 @@ def test_valid_declared_birth_input_round_trips_across_account_load(pg14_databas assert loaded["declared_birth_input"] == declared +def test_historical_receipt_replays_exact_public_response_after_later_turns( + pg14_database: PgDatabase, +) -> None: + user_id = "00000000-0000-4000-8000-000000000943" + case_id = "00000000-0000-4000-8000-000000000944" + first_action = "00000000-0000-4000-8000-000000000945" + later_actions = ( + "00000000-0000-4000-8000-000000000946", + "00000000-0000-4000-8000-000000000947", + ) + first_fingerprint = "a" * 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, + first_action, + [], + command_fingerprint=first_fingerprint, + ))) + for version, action_id in enumerate(later_actions, start=1): + pg14_database.sql(_save_statement( + user_id, + case_id, + version, + action_id, + [], + command_fingerprint=str(version) * 64, + )) + + replayed = json.loads(pg14_database.sql( + f""" + select public.replay_conversational_rectification_action( + '{user_id}'::uuid, '{case_id}'::uuid, 0, '{first_action}'::uuid, + 'save_turn', '{first_fingerprint}' + )::text; + """ + )) + assert replayed == original + assert replayed["turn_version"] == 1 + assert replayed["latest_turn"]["turnVersion"] == 1 + assert pg14_database.rejects( + f""" + select public.replay_conversational_rectification_action( + '{user_id}'::uuid, '{case_id}'::uuid, 0, '{first_action}'::uuid, + 'save_turn', '{'b' * 64}' + ); + """ + ) + + privileges = json.loads(pg14_database.sql( + """ + select pg_catalog.jsonb_build_object( + 'anon', pg_catalog.has_function_privilege( + 'anon', + 'public.replay_conversational_rectification_action(uuid,uuid,bigint,uuid,text,text)', + 'EXECUTE' + ), + 'authenticated', pg_catalog.has_function_privilege( + 'authenticated', + 'public.replay_conversational_rectification_action(uuid,uuid,bigint,uuid,text,text)', + 'EXECUTE' + ), + 'serviceRole', pg_catalog.has_function_privilege( + 'service_role', + 'public.replay_conversational_rectification_action(uuid,uuid,bigint,uuid,text,text)', + 'EXECUTE' + ) + )::text; + """ + )) + assert privileges == {"anon": False, "authenticated": False, "serviceRole": True} + + 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"