diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index a6728fd3..a71cdac1 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -167,6 +167,26 @@ function declaredBirthInputFromProfile(value: unknown): DeclaredBirthInput { return parsed.data; } +export function declaredBirthInputForLegacyCase( + currentProfileValue: unknown, + legacyCaseValue: unknown, +): DeclaredBirthInput { + const currentProfile = profileRecord(currentProfileValue); + const legacyCase = profileRecord(legacyCaseValue); + if (!currentProfile || !legacyCase) { + throw new ConversationalRectificationError("profile_incomplete"); + } + return declaredBirthInputFromProfile({ + ...currentProfile, + birth_date: legacyCase.reported_date, + reported_birth_time: legacyCase.reported_time, + birth_time_source: legacyCase.source, + birth_time_period: legacyCase.reported_period, + uncertainty_before_minutes: legacyCase.uncertainty_before_minutes, + uncertainty_after_minutes: legacyCase.uncertainty_after_minutes, + }); +} + export type ProductionConversationalRectificationProfileDependencies = Readonly<{ loadProfile(userId: string): Promise; loadRectificationCase(userId: string, caseId: string): Promise; @@ -178,22 +198,42 @@ export async function loadProductionConversationalRectificationProfile( ): 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 }; + if (!priorCaseId) return { + declaredBirthInput, + revisionOfCaseId: null, + legacyCaseId: 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"); + const protocol = prior ? text(prior.journey_protocol) : null; + const status = prior ? text(prior.status) : null; + const unfinishedLegacyStatuses = new Set([ + "reported", + "assessing", + "rectifying", + "candidate", + "confirming", + ]); + const unfinishedLegacy = prior + && text(prior.id) === priorCaseId + && (protocol === "legacy-guided-v1" || protocol === "dynamic-choice-v2") + && status !== null + && unfinishedLegacyStatuses.has(status); return { declaredBirthInput, revisionOfCaseId: terminalV3Revision ? priorCaseId : null, + legacyCaseId: unfinishedLegacy ? priorCaseId : null, }; } @@ -428,7 +468,7 @@ export async function buildProductionConversationalRectificationPacket( events, }) : null; - const selectedRange = eventScore?.winningSegment + const selectedRange = !input.preserveCandidateRange && eventScore?.winningSegment ? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime } : baseRange; const questionnaires: RectificationQuestionnaire[] = []; @@ -474,7 +514,9 @@ export async function buildProductionConversationalRectificationPacket( packet: buildRectificationTechnicalPacket({ scan: questionnaire, candidateDifferences, - eventScore, + eventScore: input.preserveCandidateRange && eventScore + ? { ...eventScore, confidence: "low", canApply: false, winningSegment: null } + : eventScore, consultation: { source: "server_consultation_workflow", calculationVersion, @@ -562,6 +604,55 @@ async function createProductionService( }, }, userId); }, + async loadLegacyCase(userId, legacyCaseId) { + const { createJourneyLoadClient, loadStoredRectificationCase } = await import( + "../../../lib/birth-time-journey-case-loader.ts" + ); + const { data: identity, error } = await admin + .from("birth_time_rectification_cases") + .select("id,user_id,journey_protocol,status,reported_date,reported_time,reported_period,source,uncertainty_before_minutes,uncertainty_after_minutes") + .eq("id", legacyCaseId) + .eq("user_id", userId) + .maybeSingle(); + if (error || !identity + || (identity.journey_protocol !== "legacy-guided-v1" + && identity.journey_protocol !== "dynamic-choice-v2")) return null; + const { data: currentProfile, error: profileError } = 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", userId) + .maybeSingle(); + if (profileError || !currentProfile) { + throw new ConversationalRectificationError("store_unavailable"); + } + const declaredBirthInput = declaredBirthInputForLegacyCase(currentProfile, identity); + const loaded = await loadStoredRectificationCase( + createJourneyLoadClient(admin), + userId, + legacyCaseId, + ); + if (!loaded) return null; + const winning = loaded.candidateResult?.winningSegment; + const snapshotRange = loaded.snapshot.reportedRange; + const currentRange = loaded.journeyProtocol === "dynamic-choice-v2" + ? loaded.dynamicTurnState.progress.currentRange + : winning + ? { startTime: winning.startTime, endTime: winning.endTime } + : snapshotRange.startTime && snapshotRange.endTime + ? { startTime: snapshotRange.startTime, endTime: snapshotRange.endTime } + : null; + if (!currentRange) throw new ConversationalRectificationError("store_unavailable"); + return { + caseId: loaded.id, + userId: loaded.userId, + journeyProtocol: loaded.journeyProtocol, + status: identity.status, + turnVersion: loaded.turnVersion ?? 0, + declaredBirthInput, + currentRange, + lifeEvents: loaded.lifeEvents ?? [], + }; + }, buildTechnicalPacket: (input) => buildProductionConversationalRectificationPacket(engine, input), narrativeGenerator, asOfDate: () => new Date().toISOString().slice(0, 10), diff --git a/frontend/src/lib/conversational-rectification/legacy-import.ts b/frontend/src/lib/conversational-rectification/legacy-import.ts new file mode 100644 index 00000000..69c2a147 --- /dev/null +++ b/frontend/src/lib/conversational-rectification/legacy-import.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; +import type { LifeEvent } from "../birth-time-evidence.ts"; +import { ConversationalRectificationError } from "./errors.ts"; +import { + declaredBirthInputSchema, + lifeEventEvidenceSchema, + type DeclaredBirthInput, + type LifeEventEvidence, +} from "./persistence-contracts.ts"; + +const uuidSchema = z.string().uuid(); +const dateSchema = z.string().date(); +const timeRangeSchema = z.object({ + startTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), + endTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), +}).strict().readonly(); + +const unfinishedLegacyStatuses = new Set([ + "assessing", + "rectifying", + "candidate", + "confirming", + "starting", + "active", + "paused", +]); + +export type LegacyConversationalImportSource = Readonly<{ + caseId: string; + userId: string; + journeyProtocol: "legacy-guided-v1" | "dynamic-choice-v2"; + status: string; + turnVersion: number; + declaredBirthInput: DeclaredBirthInput; + currentRange: Readonly<{ startTime: string; endTime: string }>; + lifeEvents: readonly LifeEvent[]; + // Loaders may carry these old private fields. The projection deliberately + // never reads them: choices are not dated life-event facts. + currentChoicePrompt?: string | null; + choiceAnswers?: readonly unknown[]; +}>; + +export type ProjectedLegacyConversationalImport = Readonly<{ + legacyCaseId: string; + expectedVersion: number; + declaredBirthInput: DeclaredBirthInput; + currentRange: Readonly<{ startTime: string; endTime: string }>; + evidence: readonly LifeEventEvidence[]; +}>; + +const domainLabels = { + career: "事业", + education: "学业", + relocation: "迁居", + relationship: "关系", + family: "家庭", + other: "其他", +} as const; + +function importedDomain(domain: LifeEvent["domain"]): LifeEventEvidence["domain"] { + return domain === "finance" || domain === "health_pressure" ? "other" : domain; +} + +function eventIsWithinHistoricalWindow( + event: LifeEvent, + birthDate: string, + asOfDate: string, +): boolean { + const lowerBound = event.precision === "year" + ? birthDate.slice(0, 4) + : event.precision === "month" ? birthDate.slice(0, 7) : birthDate; + const upperBound = event.precision === "year" + ? asOfDate.slice(0, 4) + : event.precision === "month" ? asOfDate.slice(0, 7) : asOfDate; + return event.date >= lowerBound && event.date <= upperBound; +} + +function importedEvidence(event: LifeEvent): LifeEventEvidence { + const domain = importedDomain(event.domain); + const label = domainLabels[domain]; + const summary = `旧校时记录中的${label}事件`; + return lifeEventEvidenceSchema.parse({ + id: event.id, + rawText: `${summary}(${event.date})`, + domain, + eventSummary: summary, + dateValue: event.date, + datePrecision: event.precision, + extractionStatus: "clear", + scoreable: true, + correctsEvidenceIds: [], + }); +} + +export function projectLegacyCaseForConversationalImport(input: Readonly<{ + source: LegacyConversationalImportSource; + asOfDate: string; + expectedUserId?: string; +}>): ProjectedLegacyConversationalImport { + const source = input.source; + const sourceCaseId = uuidSchema.safeParse(source.caseId); + const sourceUserId = uuidSchema.safeParse(source.userId); + const expectedUserId = input.expectedUserId === undefined + ? null + : uuidSchema.safeParse(input.expectedUserId); + if (!sourceCaseId.success || !sourceUserId.success + || (expectedUserId !== null && (!expectedUserId.success + || expectedUserId.data !== sourceUserId.data))) { + throw new ConversationalRectificationError("case_not_found"); + } + if (source.journeyProtocol !== "legacy-guided-v1" + && source.journeyProtocol !== "dynamic-choice-v2") { + throw new ConversationalRectificationError("case_not_found"); + } + if (!unfinishedLegacyStatuses.has(source.status)) { + throw new ConversationalRectificationError("invalid_transition"); + } + if (!Number.isSafeInteger(source.turnVersion) || source.turnVersion < 0) { + throw new ConversationalRectificationError("case_not_found"); + } + const range = timeRangeSchema.safeParse(source.currentRange); + const declared = declaredBirthInputSchema.safeParse(source.declaredBirthInput); + const asOfDate = dateSchema.safeParse(input.asOfDate); + if (!range.success || !declared.success || !asOfDate.success) { + throw new ConversationalRectificationError("profile_incomplete"); + } + + const evidence: LifeEventEvidence[] = []; + const importedIds = new Set(); + for (const event of source.lifeEvents) { + if (importedIds.has(event.id) + || !eventIsWithinHistoricalWindow(event, declared.data.birthDate, asOfDate.data)) continue; + try { + const projected = importedEvidence(event); + importedIds.add(projected.id); + evidence.push(projected); + } catch { + // Legacy rows predate today's stricter schema. Invalid historical + // fragments remain in the read-only old row and never become v3 facts. + } + } + + return Object.freeze({ + legacyCaseId: sourceCaseId.data, + expectedVersion: source.turnVersion, + declaredBirthInput: declared.data, + currentRange: range.data, + evidence: Object.freeze(evidence.slice(-20)), + }); +} diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index d315b7b5..e1a3ba02 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -27,6 +27,10 @@ import { type RectificationTechnicalPacket, } from "./technical-packet.ts"; import type { ConversationalRectificationBilling } from "./billing.ts"; +import { + projectLegacyCaseForConversationalImport, + type LegacyConversationalImportSource, +} from "./legacy-import.ts"; import type { ConversationalRectificationStore, LifeEventEvidenceInput, @@ -48,6 +52,7 @@ export type ComputedConversationalRectificationPacket = Readonly<{ export type ConversationalRectificationProfile = Readonly<{ declaredBirthInput: unknown; revisionOfCaseId: string | null; + legacyCaseId?: string | null; }>; export type ConversationalRectificationPacketBuildInput = Readonly<{ @@ -57,14 +62,20 @@ export type ConversationalRectificationPacketBuildInput = Readonly<{ declaredBirthInput: DeclaredBirthInput; privateCandidate: PrivateCandidateInput | null; evidence: ReadonlyArray; + preserveCandidateRange?: true; }>; export type ConversationalRectificationServicePorts = Readonly<{ store: Pick; + "createCaseWithFirstTurn" | "loadCase" | "loadActionReceipt" | "saveTurn" | "pause" | "abandon" | "confirm"> + & Partial>; billing: Pick; rectificationPriceCredits: number; loadDeclaredProfile(userId: string): Promise; + loadLegacyCase?( + userId: string, + legacyCaseId: string, + ): Promise; buildTechnicalPacket( input: ConversationalRectificationPacketBuildInput, ): Promise; @@ -73,6 +84,12 @@ export type ConversationalRectificationServicePorts = Readonly<{ }>; export type ConversationalRectificationService = Readonly<{ + importLegacyCase( + userId: string, + legacyCaseId: string, + actionId: string, + pendingConsultationQuestion?: string | null, + ): Promise; start(userId: string, command: CommandOf<"start">): Promise; resume(userId: string, command: CommandOf<"resume">): Promise; answer(userId: string, command: CommandOf<"answer">): Promise; @@ -316,6 +333,21 @@ function boundedNarrative(previous: string, suffix: string): string { return `${previous.slice(0, room)}\n\n${suffix}`.slice(0, 12_000); } +function midpointOfRange(range: Readonly<{ startTime: string; endTime: string }>): string { + const minute = (value: string) => { + const [hour = 0, part = 0] = value.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 start = minute(range.startTime); + let end = minute(range.endTime); + if (end < start) end += 1_440; + return clock(Math.round((start + end) / 2)); +} + function domainsForClarification( current: ConversationalRectificationTurn, hint: RectificationEvidenceDomain | undefined, @@ -454,6 +486,114 @@ export function createConversationalRectificationService( } } + async function importLegacyCase( + userId: string, + legacyCaseId: string, + actionId: string, + pendingConsultationQuestion: string | null = null, + ): Promise { + const importer = ports.store.importLegacy; + const loadLegacy = ports.loadLegacyCase; + if (!importer || !loadLegacy) { + throw new ConversationalRectificationError("service_unavailable"); + } + + try { + const existingByAction = await ports.store.loadCase({ userId, caseId: actionId }); + if (existingByAction) { + if (existingByAction.importedFromCaseId !== legacyCaseId + || existingByAction.billingState !== "migration_waived" + || existingByAction.pendingConsultationQuestion !== pendingConsultationQuestion) { + throw new ConversationalRectificationError("action_conflict"); + } + return publicTurn(existingByAction); + } + const current = await ports.store.loadCase({ userId }); + if (current?.importedFromCaseId === legacyCaseId + && current.billingState === "migration_waived" + && current.pendingConsultationQuestion === pendingConsultationQuestion) { + return publicTurn(current); + } + if (current?.importedFromCaseId === legacyCaseId + && current.billingState === "migration_waived") { + throw new ConversationalRectificationError("action_conflict"); + } + + const legacy = await loadLegacy(userId, legacyCaseId); + if (!legacy) throw new ConversationalRectificationError("case_not_found"); + const projected = projectLegacyCaseForConversationalImport({ + source: legacy, + asOfDate: ports.asOfDate(), + expectedUserId: userId, + }); + const rangeSeed = privateCandidateSchema.parse({ + resultId: null, + representativeTime: midpointOfRange(projected.currentRange), + rangeStart: projected.currentRange.startTime, + rangeEnd: projected.currentRange.endTime, + calculationVersion: "legacy-import-range-v1", + workingState: { phase: "collecting_evidence", iteration: 0, notes: [] }, + }); + const computed = await ports.buildTechnicalPacket({ + userId, + caseId: actionId, + asOfDate: ports.asOfDate(), + declaredBirthInput: projected.declaredBirthInput, + privateCandidate: rangeSeed, + evidence: projected.evidence, + preserveCandidateRange: true, + }); + const narrative = await generateRectificationNarrative({ + phase: "first", + packet: computed.packet, + generator: ports.narrativeGenerator, + }); + const privateCandidate = privateCandidateFromPacket({ + packet: computed.packet, + resultId: computed.resultId, + iteration: 0, + }); + const firstTurn = turnFromNarrative({ + caseId: actionId, + turnVersion: 0, + pendingConsultationQuestion, + packet: computed.packet, + narrative, + evidence: projected.evidence, + }); + const imported = await importer.call(ports.store, { + userId, + caseId: actionId, + expectedVersion: projected.expectedVersion, + actionId, + legacyCaseId, + price: ports.rectificationPriceCredits, + pendingConsultationQuestion, + declaredBirthInput: projected.declaredBirthInput, + evidence: projected.evidence, + firstTurn, + validationReceipt: narrative.validationReceipt, + privateCandidate, + }); + return publicTurn(imported); + } catch (error) { + if (error instanceof ConversationalRectificationError + && error.code === "action_conflict") { + try { + const winner = await ports.store.loadCase({ userId }); + if (winner?.importedFromCaseId === legacyCaseId + && winner.billingState === "migration_waived" + && winner.pendingConsultationQuestion === pendingConsultationQuestion) { + return publicTurn(winner); + } + } catch { + // Preserve the original stable conflict below. + } + } + throw safeFailure(error); + } + } + function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] { let extracted: readonly LifeEventEvidence[]; try { @@ -480,6 +620,7 @@ export function createConversationalRectificationService( } return Object.freeze({ + importLegacyCase, async start(userId, rawCommand) { const command = parseCommand("start", rawCommand); let profile: ConversationalRectificationProfile; @@ -491,6 +632,15 @@ export function createConversationalRectificationService( const declared = declaredBirthInputSchema.safeParse(profile.declaredBirthInput); if (!declared.success) throw new ConversationalRectificationError("profile_incomplete"); + if (profile.legacyCaseId) { + return importLegacyCase( + userId, + profile.legacyCaseId, + command.actionId, + command.pendingConsultationQuestion ?? null, + ); + } + let price: number; try { price = ports.rectificationPriceCredits; diff --git a/frontend/src/lib/conversational-rectification/store.ts b/frontend/src/lib/conversational-rectification/store.ts index 3ce6b179..02cefa80 100644 --- a/frontend/src/lib/conversational-rectification/store.ts +++ b/frontend/src/lib/conversational-rectification/store.ts @@ -127,6 +127,8 @@ export type ImportLegacyConversationalRectificationInput = MutationIdentity & Re legacyCaseId: string; price: number; pendingConsultationQuestion: string | null; + declaredBirthInput: DeepReadonly; + evidence: ReadonlyArray; firstTurn: ConversationalRectificationTurnInput; validationReceipt: ValidationReceiptInput; privateCandidate: PrivateCandidateInput; @@ -414,6 +416,8 @@ export class ConversationalRectificationStore { p_legacy_case_id: input.legacyCaseId, p_price: input.price, p_pending_consultation_question: input.pendingConsultationQuestion, + p_declared_birth_input: requireDeclaredBirthInput(input.declaredBirthInput), + p_evidence: requireEvidence(input.evidence), p_first_turn: requirePublicTurn(input.firstTurn), p_validation_receipt: requireValidationReceipt(input.validationReceipt), p_private_candidate: requirePrivateCandidate(input.privateCandidate), diff --git a/frontend/supabase/migrations/20260721010000_conversational_legacy_import_projection.sql b/frontend/supabase/migrations/20260721010000_conversational_legacy_import_projection.sql new file mode 100644 index 00000000..23e9bd3c --- /dev/null +++ b/frontend/supabase/migrations/20260721010000_conversational_legacy_import_projection.sql @@ -0,0 +1,435 @@ +begin; + +create unique index if not exists birth_time_rectification_cases_one_v3_import_per_legacy + on public.birth_time_rectification_cases (imported_from_case_id) + where imported_from_case_id is not null + and journey_protocol = 'conversational-evidence-v3'; + +create or replace function public.conversational_rectification_project_legacy_event_evidence( + p_life_events jsonb, + p_birth_date date, + p_as_of_date date +) +returns jsonb +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + v_item jsonb; + v_id uuid; + v_domain text; + v_imported_domain text; + v_label text; + v_precision text; + v_date text; + v_day date; + v_seen uuid[] := '{}'::uuid[]; + v_result jsonb := '[]'::jsonb; +begin + if pg_catalog.jsonb_typeof(p_life_events) is distinct from 'array' then + return '[]'::jsonb; + end if; + + for v_item in + select item.value + from pg_catalog.jsonb_array_elements(p_life_events) with ordinality item(value, ordinality) + order by item.ordinality + loop + begin + if pg_catalog.jsonb_typeof(v_item) is distinct from 'object' + or not public.conversational_rectification_has_only_keys( + v_item, array['id', 'domain', 'precision', 'date'] + ) + or public.conversational_rectification_valid_uuid_text(v_item ->> 'id') is not true then + continue; + end if; + v_id := (v_item ->> 'id')::uuid; + if v_id = any(v_seen) then continue; end if; + v_domain := v_item ->> 'domain'; + v_precision := v_item ->> 'precision'; + v_date := v_item ->> 'date'; + if v_domain not in ( + 'career', 'education', 'relocation', 'relationship', 'finance', 'health_pressure' + ) or v_precision not in ('year', 'month', 'day') then + continue; + end if; + if v_precision = 'year' then + if v_date !~ '^(19|20)[0-9]{2}$' + or v_date < pg_catalog.to_char(p_birth_date, 'YYYY') + or v_date > pg_catalog.to_char(p_as_of_date, 'YYYY') then + continue; + end if; + elsif v_precision = 'month' then + if v_date !~ '^(19|20)[0-9]{2}-(0[1-9]|1[0-2])$' + or v_date < pg_catalog.to_char(p_birth_date, 'YYYY-MM') + or v_date > pg_catalog.to_char(p_as_of_date, 'YYYY-MM') then + continue; + end if; + else + if v_date !~ '^(19|20)[0-9]{2}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])$' then + continue; + end if; + v_day := v_date::date; + if pg_catalog.to_char(v_day, 'YYYY-MM-DD') is distinct from v_date + or v_day < p_birth_date or v_day > p_as_of_date then + continue; + end if; + end if; + + v_imported_domain := case + when v_domain in ('finance', 'health_pressure') then 'other' + else v_domain + end; + v_label := case v_imported_domain + when 'career' then '事业' + when 'education' then '学业' + when 'relocation' then '迁居' + when 'relationship' then '关系' + when 'family' then '家庭' + else '其他' + end; + v_result := v_result || pg_catalog.jsonb_build_array( + pg_catalog.jsonb_build_object( + 'id', v_id, + 'rawText', '旧校时记录中的' || v_label || '事件(' || v_date || ')', + 'domain', v_imported_domain, + 'eventSummary', '旧校时记录中的' || v_label || '事件', + 'dateValue', v_date, + 'datePrecision', v_precision, + 'extractionStatus', 'clear', + 'scoreable', true, + 'correctsEvidenceIds', '[]'::jsonb + ) + ); + v_seen := pg_catalog.array_append(v_seen, v_id); + if pg_catalog.jsonb_array_length(v_result) > 20 then + v_result := v_result - 0; + end if; + exception when others then + -- Old rows predate the strict v3 evidence contract. An invalid fragment + -- stays in the immutable source row instead of aborting or becoming fact. + continue; + end; + end loop; + return v_result; +end; +$$; + +drop function if exists public.import_legacy_conversational_rectification_case( + uuid, uuid, uuid, bigint, uuid, integer, text, jsonb, jsonb, jsonb +); + +create or replace function public.import_legacy_conversational_rectification_case( + p_user_id uuid, + p_case_id uuid, + p_legacy_case_id uuid, + p_expected_version bigint, + p_action_id uuid, + p_price integer, + p_pending_consultation_question text, + p_declared_birth_input jsonb, + p_evidence jsonb, + p_first_turn jsonb, + p_validation_receipt jsonb, + p_private_candidate jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.birth_time_rectification_cases%rowtype; + v_legacy public.birth_time_rectification_cases%rowtype; + v_profile public.profiles%rowtype; + v_billing public.birth_time_rectification_billing%rowtype; + v_receipt public.birth_time_rectification_action_receipts%rowtype; + v_expected_declared jsonb; + v_expected_evidence jsonb; + v_expected_recap jsonb; + v_expected_range_start time without time zone; + v_expected_range_end time without time zone; + v_first_turn_id uuid; + v_response jsonb; + v_fingerprint text := public.conversational_rectification_fingerprint( + pg_catalog.jsonb_build_object( + 'kind', 'import_legacy', 'userId', p_user_id, 'caseId', p_case_id, + 'legacyCaseId', p_legacy_case_id, 'expectedVersion', p_expected_version, + 'actionId', p_action_id, 'price', p_price, + 'pendingConsultationQuestion', p_pending_consultation_question, + 'declaredBirthInput', p_declared_birth_input, 'evidence', p_evidence, + 'firstTurn', p_first_turn, 'validationReceipt', p_validation_receipt, + 'privateCandidate', p_private_candidate + ) + ); +begin + if p_user_id is null or p_case_id is null or p_legacy_case_id is null + or p_action_id is null or p_case_id is distinct from p_action_id + or p_expected_version is null or p_expected_version < 0 + or p_price is null or not (p_price between 1 and 1000000) then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended( + p_user_id::text || ':conversational-rectification-case', 0 + ) + ); + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_action_id::text, 0) + ); + + select c.* into v_case + from public.birth_time_rectification_cases c + where c.id = p_case_id + for update; + if found then + if v_case.user_id is distinct from p_user_id + or v_case.journey_protocol is distinct from 'conversational-evidence-v3' + or v_case.imported_from_case_id is distinct from p_legacy_case_id then + raise exception 'conversational_case_not_found' using errcode = 'P0001'; + end if; + select r.* into v_receipt + from public.birth_time_rectification_action_receipts r + where r.case_id = p_case_id and r.action_id = p_action_id + for update; + if found + and v_receipt.user_id is not distinct from p_user_id + and v_receipt.action_kind is not distinct from 'import_legacy' + and v_receipt.expected_turn_version is not distinct from p_expected_version + and v_receipt.request_fingerprint is not distinct from v_fingerprint then + return v_receipt.response; + end if; + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + select legacy.* into v_legacy + from public.birth_time_rectification_cases legacy + where legacy.id = p_legacy_case_id and legacy.user_id = p_user_id + for update; + if not found + or v_legacy.journey_protocol not in ('legacy-guided-v1', 'dynamic-choice-v2') + or v_legacy.status in ('confirmed', 'completed', 'abandoned') then + raise exception 'conversational_case_not_found' using errcode = 'P0001'; + end if; + if v_legacy.turn_version is distinct from p_expected_version then + raise exception 'conversational_stale_turn' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.birth_time_rectification_cases imported + where imported.imported_from_case_id = p_legacy_case_id + and imported.journey_protocol = 'conversational-evidence-v3' + ) then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.birth_time_rectification_cases active_case + where active_case.user_id = p_user_id + and active_case.id <> p_case_id + and active_case.id <> p_legacy_case_id + and active_case.journey_protocol = 'conversational-evidence-v3' + and active_case.status in ('starting', 'active', 'paused', 'confirming') + ) then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + select profile.* into v_profile + from public.profiles profile + where profile.id = p_user_id + for update; + if not found then + raise exception 'conversational_case_not_found' using errcode = 'P0001'; + end if; + v_profile.credits := public.recover_conversational_rectification_orphan_reservations( + p_user_id, null::uuid + ); + v_expected_declared := pg_catalog.jsonb_build_object( + 'birthDate', pg_catalog.to_char(v_legacy.reported_date, 'YYYY-MM-DD'), + 'source', v_legacy.source, + 'birthTimeClue', v_profile.birth_time_clue, + 'birthplace', pg_catalog.jsonb_strip_nulls(pg_catalog.jsonb_build_object( + 'countryCode', v_profile.country_code, + 'provinceCode', v_profile.province_code, + 'cityCode', v_profile.city_code, + 'districtCode', v_profile.district_code, + 'latitude', v_profile.latitude, + 'longitude', v_profile.longitude, + 'timezoneOffset', v_profile.timezone_offset + )) + ); + if v_legacy.reported_time is not null then + v_expected_declared := v_expected_declared || pg_catalog.jsonb_build_object( + 'reportedTime', pg_catalog.to_char(v_legacy.reported_time, 'HH24:MI') + ); + end if; + if v_legacy.reported_period is not null then + v_expected_declared := v_expected_declared || pg_catalog.jsonb_build_object( + 'reportedPeriod', v_legacy.reported_period + ); + end if; + if v_legacy.uncertainty_before_minutes is not null + or v_legacy.uncertainty_after_minutes is not null then + v_expected_declared := v_expected_declared || pg_catalog.jsonb_build_object( + 'uncertaintyBeforeMinutes', v_legacy.uncertainty_before_minutes, + 'uncertaintyAfterMinutes', v_legacy.uncertainty_after_minutes + ); + end if; + v_expected_evidence := public.conversational_rectification_project_legacy_event_evidence( + v_legacy.life_events, v_legacy.reported_date, current_date + ); + if v_legacy.journey_protocol = 'dynamic-choice-v2' + and v_legacy.turn_state #>> '{progress,currentRange,startTime}' + ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + and v_legacy.turn_state #>> '{progress,currentRange,endTime}' + ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then + v_expected_range_start := ( + v_legacy.turn_state #>> '{progress,currentRange,startTime}' + )::time; + v_expected_range_end := ( + v_legacy.turn_state #>> '{progress,currentRange,endTime}' + )::time; + elsif v_legacy.candidate_start is not null and v_legacy.candidate_end is not null then + v_expected_range_start := v_legacy.candidate_start; + v_expected_range_end := v_legacy.candidate_end; + end if; + select coalesce(pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_object( + 'id', item.value ->> 'id', + 'summary', item.value ->> 'eventSummary', + 'dateLabel', item.value ->> 'dateValue' + ) order by item.ordinality + ), '[]'::jsonb) into v_expected_recap + from pg_catalog.jsonb_array_elements(v_expected_evidence) + with ordinality item(value, ordinality); + + if p_declared_birth_input is distinct from v_expected_declared + or p_evidence is distinct from v_expected_evidence + or public.conversational_rectification_valid_declared_birth_input( + p_declared_birth_input + ) is not true + or public.conversational_rectification_valid_life_event_evidence_array( + p_evidence + ) is not true + or public.conversational_rectification_valid_public_turn(p_first_turn) is not true + or public.conversational_rectification_valid_validation_receipt( + p_validation_receipt + ) is not true + or public.conversational_rectification_valid_private_candidate( + p_private_candidate + ) is not true + or p_first_turn ->> 'caseId' is distinct from p_case_id::text + or p_first_turn ->> 'journeyProtocol' is distinct from 'conversational-evidence-v3' + or (p_first_turn ->> 'turnVersion')::bigint is distinct from 0 + or p_first_turn ->> 'status' not in ('active', 'confirming') + or p_first_turn -> 'evidenceRecap' is distinct from v_expected_recap + or p_first_turn -> 'candidate' ->> 'rangeStart' + is distinct from p_private_candidate ->> 'rangeStart' + or p_first_turn -> 'candidate' ->> 'rangeEnd' + is distinct from p_private_candidate ->> 'rangeEnd' + or (v_expected_range_start is not null and ( + nullif(p_private_candidate ->> 'rangeStart', '')::time + is distinct from v_expected_range_start + or nullif(p_private_candidate ->> 'rangeEnd', '')::time + is distinct from v_expected_range_end + )) + or nullif(p_first_turn ->> 'pendingConsultationQuestion', '') + is distinct from nullif(p_pending_consultation_question, '') + or pg_catalog.jsonb_path_exists(p_first_turn, '$.**.candidateWeights') + or pg_catalog.jsonb_path_exists(p_first_turn, '$.**.candidateScores') + or pg_catalog.jsonb_path_exists(p_first_turn, '$.**.partitionId') + or pg_catalog.jsonb_path_exists(p_first_turn, '$.**.rawModelOutput') + or pg_catalog.jsonb_path_exists(p_first_turn, '$.**.systemPrompt') then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + if exists ( + select 1 from public.birth_time_rectification_billing b + where b.user_id = p_user_id and b.state = 'reserved' + for update + ) then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + insert into public.birth_time_rectification_cases ( + id, user_id, journey_protocol, status, reported_date, reported_time, + reported_period, source, uncertainty_before_minutes, + uncertainty_after_minutes, declared_birth_input, + questionnaire, answers, life_events, candidate_scan, + journey_snapshot, turn_version, turn_state, + candidate_result, candidate_result_id, candidate_start, candidate_end, + event_scoring_version, imported_from_case_id, baseline_active_time, + pending_consultation_question, updated_at + ) values ( + p_case_id, p_user_id, 'conversational-evidence-v3', + p_first_turn ->> 'status', v_legacy.reported_date, v_legacy.reported_time, + v_legacy.reported_period, v_legacy.source, v_legacy.uncertainty_before_minutes, + v_legacy.uncertainty_after_minutes, p_declared_birth_input, + '{}'::jsonb, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb, + p_first_turn, 0, p_first_turn, p_private_candidate, + nullif(p_private_candidate ->> 'resultId', '')::uuid, + nullif(p_private_candidate ->> 'rangeStart', '')::time, + nullif(p_private_candidate ->> 'rangeEnd', '')::time, + nullif(p_private_candidate ->> 'calculationVersion', ''), + p_legacy_case_id, v_profile.active_birth_time, + nullif(p_pending_consultation_question, ''), pg_catalog.now() + ); + insert into public.birth_time_rectification_turns ( + case_id, turn_version, narrative, candidate, technical_receipt, + evidence_request, evidence_recap, actions, output_validation_receipt + ) values ( + p_case_id, 0, p_first_turn ->> 'narrative', p_first_turn -> 'candidate', + p_first_turn -> 'technicalReceipt', + nullif(p_first_turn -> 'evidenceRequest', 'null'::jsonb), + p_first_turn -> 'evidenceRecap', p_first_turn -> 'actions', p_validation_receipt + ) returning id into v_first_turn_id; + insert into public.birth_time_rectification_event_evidence ( + id, case_id, source_turn_id, raw_text, domain, event_summary, + date_value, date_precision, extraction_status, scoreable, + corrects_evidence_ids + ) + select (item ->> 'id')::uuid, p_case_id, v_first_turn_id, + item ->> 'rawText', item ->> 'domain', item ->> 'eventSummary', + nullif(item ->> 'dateValue', ''), item ->> 'datePrecision', + item ->> 'extractionStatus', (item ->> 'scoreable')::boolean, + array(select value::uuid from pg_catalog.jsonb_array_elements_text( + coalesce(item -> 'correctsEvidenceIds', '[]'::jsonb) + ) correction(value)) + from pg_catalog.jsonb_array_elements(p_evidence) evidence(item); + insert into public.birth_time_rectification_billing ( + case_id, user_id, price, state, billing_receipt_id, + complete_action_id, balance_after + ) values ( + p_case_id, p_user_id, p_price, 'migration_waived', pg_catalog.gen_random_uuid(), + p_action_id, v_profile.credits + ); + + v_response := public.conversational_rectification_case_projection(p_user_id, p_case_id); + insert into public.birth_time_rectification_action_receipts ( + case_id, action_id, user_id, action_kind, expected_turn_version, + result_turn_version, request_fingerprint, request, response + ) values ( + p_case_id, p_action_id, p_user_id, 'import_legacy', p_expected_version, + 0, v_fingerprint, + public.conversational_rectification_action_request( + 'import_legacy', p_user_id, p_case_id, p_expected_version, + p_action_id, v_fingerprint + ), + v_response + ); + return v_response; +end; +$$; + +revoke all on function public.conversational_rectification_project_legacy_event_evidence( + jsonb, date, date +) from public, anon, authenticated, service_role; +revoke all on function public.import_legacy_conversational_rectification_case( + uuid, uuid, uuid, bigint, uuid, integer, text, jsonb, jsonb, jsonb, jsonb, jsonb +) from public, anon, authenticated; +grant execute on function public.import_legacy_conversational_rectification_case( + uuid, uuid, uuid, bigint, uuid, integer, text, jsonb, jsonb, jsonb, jsonb, jsonb +) to service_role; + +commit; diff --git a/frontend/tests/conversational-legacy-import.test.ts b/frontend/tests/conversational-legacy-import.test.ts new file mode 100644 index 00000000..3f771d08 --- /dev/null +++ b/frontend/tests/conversational-legacy-import.test.ts @@ -0,0 +1,329 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + projectLegacyCaseForConversationalImport, + type LegacyConversationalImportSource, +} from "../src/lib/conversational-rectification/legacy-import.ts"; +import { + createConversationalRectificationService, + type ConversationalRectificationServicePorts, +} from "../src/lib/conversational-rectification/orchestrator.ts"; +import { conversationalRectificationTurnSchema } from "../src/lib/conversational-rectification/contracts.ts"; +import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts"; +import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts"; +import type { + ConversationalRectificationTurnInput, + LoadedConversationalRectificationCase, +} from "../src/lib/conversational-rectification/store.ts"; + +const userId = "00000000-0000-4000-8000-000000001101"; +const legacyCaseId = "00000000-0000-4000-8000-000000001102"; +const actionId = "00000000-0000-4000-8000-000000001103"; +const competingActionId = "00000000-0000-4000-8000-000000001104"; +const lifeEventId = "00000000-0000-4000-8000-000000001105"; +const futureEventId = "00000000-0000-4000-8000-000000001106"; + +const declaredBirthInput = { + source: "approximate" as const, + birthDate: "1990-01-01", + reportedTime: "05:30", + uncertaintyBeforeMinutes: 30 as const, + uncertaintyAfterMinutes: 30 as const, + birthTimeClue: "家人只记得天刚亮", + birthplace: { + countryCode: "CN", + provinceCode: "130000", + cityCode: "130400", + districtCode: "130406", + latitude: 36.420487, + longitude: 114.209936, + timezoneOffset: 8, + }, +}; + +function source(protocol: "legacy-guided-v1" | "dynamic-choice-v2"): LegacyConversationalImportSource { + return { + caseId: legacyCaseId, + userId, + journeyProtocol: protocol, + status: "rectifying", + turnVersion: protocol === "dynamic-choice-v2" ? 4 : 2, + declaredBirthInput, + currentRange: protocol === "dynamic-choice-v2" + ? { startTime: "05:18", endTime: "05:42" } + : { startTime: "05:10", endTime: "05:50" }, + lifeEvents: [ + { id: lifeEventId, domain: "career", precision: "month", date: "2021-07" }, + { id: futureEventId, domain: "relationship", precision: "month", date: "2099-01" }, + ], + // These fields deliberately contain the legacy UX material that must not + // cross the protocol boundary. + currentChoicePrompt: "哪一个时间段更接近一次持续的健康压力变化?", + choiceAnswers: [{ optionId: "2006-2011", label: "2006-2011年" }], + }; +} + +function packet(range = { startTime: "05:18", endTime: "05:42" }): RectificationTechnicalPacket { + return { + calculationVersion: "legacy-import-technical-v1", + candidate: { + status: "pending_validation", + representativeTime: "05:30", + range, + }, + useBoundary: "这是继承的待验证候选范围,不是已经确认的出生分钟。", + candidateModelRefs: ["legacy-import-range-v1"], + candidateDifferenceRefs: ["d9-boundary", "d10-boundary"], + candidateWeights: { "05:18": 0.5, "05:42": 0.5 }, + partitionIds: [], + d1Stability: "stable", + boundaryDistanceMinutes: 12, + sensitivityScope: { + source: "time_linked_candidate_scan_samples", + rangeStart: range.startTime, + rangeEnd: range.endTime, + sampleTimes: [range.startTime, range.endTime], + }, + stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["d1"] }], + sensitiveLayers: [ + { layer: "D9", values: ["Aries", "Taurus"], referenceIds: ["d9-boundary"] }, + { layer: "D10", values: ["Virgo", "Libra"], referenceIds: ["d10-boundary"] }, + ], + supportedSensitiveLayers: ["D9", "D10"], + scoredHistoricalEvidence: [{ + evidenceId: lifeEventId, + domain: "career", + candidateTime: "05:30", + score: 2, + ruleRefs: ["career-rule"], + }], + suggestedDomains: [ + { domain: "relationship", layer: "D9", reason: "D9 区分候选" }, + { domain: "career", layer: "D10", reason: "D10 区分候选" }, + ], + referenceIds: ["d9-boundary", "d10-boundary", "career-rule"], + futureWindows: [], + }; +} + +test("v1 and v2 projection preserves only declared facts, latest range, and scoreable past events", () => { + for (const protocol of ["legacy-guided-v1", "dynamic-choice-v2"] as const) { + const legacy = source(protocol); + const projected = projectLegacyCaseForConversationalImport({ + source: legacy, + asOfDate: "2026-07-21", + }); + + assert.equal(projected.legacyCaseId, legacyCaseId); + assert.equal(projected.expectedVersion, legacy.turnVersion); + assert.deepEqual(projected.declaredBirthInput, declaredBirthInput); + assert.deepEqual(projected.currentRange, legacy.currentRange); + assert.deepEqual(projected.evidence.map((item) => ({ + id: item.id, + domain: item.domain, + dateValue: item.dateValue, + datePrecision: item.datePrecision, + scoreable: item.scoreable, + })), [{ + id: lifeEventId, + domain: "career", + dateValue: "2021-07", + datePrecision: "month", + scoreable: true, + }]); + const serialized = JSON.stringify(projected); + assert.equal(serialized.includes("哪一个时间段"), false); + assert.equal(serialized.includes("2006-2011"), false); + assert.equal(serialized.includes(futureEventId), false); + } +}); + +test("projection rejects terminal, foreign-owner, and unsupported protocol sources", () => { + for (const candidate of [ + { ...source("legacy-guided-v1"), status: "completed" }, + { ...source("dynamic-choice-v2"), status: "abandoned" }, + { ...source("dynamic-choice-v2"), status: "confirmed" }, + { ...source("dynamic-choice-v2"), userId: competingActionId }, + { ...source("dynamic-choice-v2"), journeyProtocol: "conversational-evidence-v3" }, + ]) { + assert.throws(() => projectLegacyCaseForConversationalImport({ + source: candidate as LegacyConversationalImportSource, + asOfDate: "2026-07-21", + expectedUserId: userId, + }), (error: unknown) => error instanceof ConversationalRectificationError + && ["case_not_found", "invalid_transition"].includes(error.code)); + } +}); + +function importedRow(input: { + readonly firstTurn: ConversationalRectificationTurnInput; + readonly privateCandidate: NonNullable; + readonly evidence: LoadedConversationalRectificationCase["eventEvidence"]; + readonly pendingConsultationQuestion: string | null; +}): LoadedConversationalRectificationCase { + return { + caseId: actionId, + userId, + status: "active", + turnVersion: 0, + revisionOfCaseId: null, + importedFromCaseId: legacyCaseId, + baselineActiveTime: "04:58", + pendingConsultationQuestion: input.pendingConsultationQuestion, + billingState: "migration_waived", + latestTurn: conversationalRectificationTurnSchema.parse(input.firstTurn), + declaredBirthInput, + privateCandidate: input.privateCandidate, + eventEvidence: input.evidence, + validationReceipts: [{ modelId: "synthetic-narrator", schemaValidated: true }], + }; +} + +function harness() { + const cases = new Map(); + const events: string[] = []; + let importCount = 0; + let reserved = 0; + let loadLegacyCount = 0; + const ports: ConversationalRectificationServicePorts = { + rectificationPriceCredits: 9, + store: { + async loadCase(input) { + if (input.caseId) return cases.get(input.caseId) ?? null; + return [...cases.values()].at(-1) ?? null; + }, + async loadActionReceipt() { return null; }, + async createCaseWithFirstTurn() { throw new Error("paid create must not run"); }, + async saveTurn() { throw new Error("not used"); }, + async pause() { throw new Error("not used"); }, + async abandon() { throw new Error("not used"); }, + async confirm() { throw new Error("not used"); }, + async importLegacy(input) { + importCount += 1; + events.push("import"); + assert.deepEqual(input.declaredBirthInput, declaredBirthInput); + assert.deepEqual(input.evidence.map((item) => item.id), [lifeEventId]); + assert.equal(input.firstTurn.evidenceRecap[0]?.id, lifeEventId); + assert.equal(input.firstTurn.narrative.includes("候选"), true); + assert.equal(input.firstTurn.narrative.includes("哪一个时间段"), false); + const row = importedRow({ + firstTurn: input.firstTurn, + privateCandidate: input.privateCandidate, + evidence: input.evidence, + pendingConsultationQuestion: input.pendingConsultationQuestion, + }); + cases.set(input.caseId, row); + return row; + }, + }, + billing: { + async reserve() { reserved += 1; throw new Error("must not reserve"); }, + async complete() { throw new Error("must not complete"); }, + async release() { throw new Error("must not release"); }, + }, + async loadDeclaredProfile() { + return { + declaredBirthInput, + revisionOfCaseId: null, + legacyCaseId, + }; + }, + async loadLegacyCase(receivedUserId, receivedLegacyCaseId) { + loadLegacyCount += 1; + assert.equal(receivedUserId, userId); + assert.equal(receivedLegacyCaseId, legacyCaseId); + return source("dynamic-choice-v2"); + }, + async buildTechnicalPacket(input) { + events.push("packet"); + assert.deepEqual({ + rangeStart: input.privateCandidate?.rangeStart, + rangeEnd: input.privateCandidate?.rangeEnd, + }, { rangeStart: "05:18", rangeEnd: "05:42" }); + assert.deepEqual(input.evidence.map((item) => item.id), [lifeEventId]); + assert.equal(input.preserveCandidateRange, true); + return { packet: packet(), resultId: null }; + }, + narrativeGenerator: { + modelId: "synthetic-narrator", + async generate() { + events.push("narrative"); + return { text: JSON.stringify({ + narrative: "05:18—05:42 是继承的待验证候选范围。D1 稳定,D9、D10 对分钟敏感;请补充一件带年月的真实事业或关系事件。", + evidenceRequest: { domains: ["career", "relationship"], datePrecision: "month_preferred" }, + facts: { + calculationVersion: "legacy-import-technical-v1", + candidateStatus: "pending_validation", + representativeTime: "05:30", + rangeStart: "05:18", + rangeEnd: "05:42", + stableLayers: ["D1"], + sensitiveLayers: ["D9", "D10"], + candidateDifferenceRefs: ["d9-boundary", "d10-boundary", "career-rule"], + }, + }) }; + }, + }, + asOfDate: () => "2026-07-21", + }; + return { + service: createConversationalRectificationService(ports), + cases, + events, + counts: () => ({ importCount, reserved, loadLegacyCount }), + }; +} + +test("start imports an owner-bound unfinished case once, waives billing, and returns a fresh rich turn", async () => { + const value = harness(); + const first = await value.service.start(userId, { + type: "start", + actionId, + pendingConsultationQuestion: "我的事业什么时候变化?", + }); + assert.equal(first.caseId, actionId); + assert.equal(first.evidenceRecap[0]?.id, lifeEventId); + assert.deepEqual(value.events, ["packet", "narrative", "narrative", "import"]); + assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 }); + const stored = value.cases.get(actionId); + assert.equal(stored?.importedFromCaseId, legacyCaseId); + assert.equal(stored?.billingState, "migration_waived"); + assert.equal(stored?.baselineActiveTime, "04:58"); + + const repeated = await value.service.start(userId, { + type: "start", + actionId, + pendingConsultationQuestion: "我的事业什么时候变化?", + }); + assert.deepEqual(repeated, first); + assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 }); +}); + +test("a second action for the same imported legacy case reuses the existing v3 case", async () => { + const value = harness(); + const first = await value.service.importLegacyCase(userId, legacyCaseId, actionId, null); + const repeated = await value.service.importLegacyCase( + userId, + legacyCaseId, + competingActionId, + null, + ); + assert.deepEqual(repeated, first); + assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 }); +}); + +test("a second import action cannot silently replace the pending consultation question", async () => { + const value = harness(); + await value.service.importLegacyCase(userId, legacyCaseId, actionId, null); + await assert.rejects( + value.service.importLegacyCase( + userId, + legacyCaseId, + competingActionId, + "请先看事业变化", + ), + (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "action_conflict", + ); + assert.deepEqual(value.counts(), { importCount: 1, reserved: 0, loadLegacyCount: 1 }); +}); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index c8f86699..10e2444a 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildProductionConversationalRectificationPacket, createBirthTimeConversationPostHandler, + declaredBirthInputForLegacyCase, loadProductionConversationalRectificationProfile, type BirthTimeConversationRouteService, } from "../src/app/api/birth-time-conversation/route.ts"; @@ -57,6 +58,7 @@ function request(body: unknown, events: string[]) { function service(overrides: Partial = {}): BirthTimeConversationRouteService { const response = async () => turn; return { + importLegacyCase: response, start: response, resume: response, answer: response, @@ -305,7 +307,7 @@ test("unknown SQL, model, and browser errors are never exposed or logged", async assert.deepEqual(logs, [{ requestId, actionId, caseId, code: "service_unavailable" }]); }); -test("production profile conversion only links terminal v3 revisions and leaves pre-v3 baselines unlinked", async () => { +test("production profile conversion links terminal v3 revisions and owner-bound unfinished legacy imports", async () => { const priorId = "00000000-0000-4000-8000-000000000715"; const profile = { birth_date: "1990-01-01", @@ -325,12 +327,17 @@ test("production profile conversion only links terminal v3 revisions and leaves 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], + for (const [prior, expectedRevision, expectedLegacy] of [ + [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "completed" }, priorId, null], + [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "abandoned" }, priorId, null], + [{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "active" }, null, null], + [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "rectifying" }, null, priorId], + [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "candidate" }, null, priorId], + [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "confirmed" }, null, null], + [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "abandoned" }, null, null], + [{ id: priorId, journey_protocol: "dynamic-choice-v2", status: null }, null, null], + [{ id: priorId, journey_protocol: "legacy-guided-v1", status: "unexpected" }, null, null], + [{ id: actionId, journey_protocol: "dynamic-choice-v2", status: "rectifying" }, null, null], ] as const) { const caseLoads: unknown[] = []; const loaded = await loadProductionConversationalRectificationProfile({ @@ -345,6 +352,7 @@ test("production profile conversion only links terminal v3 revisions and leaves }, userId); assert.equal(loaded.revisionOfCaseId, expectedRevision); + assert.equal(loaded.legacyCaseId, expectedLegacy); assert.deepEqual(caseLoads, [[userId, priorId]]); assert.equal(loaded.declaredBirthInput.source, "legacy_import"); assert.equal("reportedTime" in loaded.declaredBirthInput @@ -353,6 +361,50 @@ test("production profile conversion only links terminal v3 revisions and leaves } }); +test("legacy import declaration uses the immutable old case time while preserving current place and clue", () => { + const declared = declaredBirthInputForLegacyCase({ + birth_date: "1990-01-01", + reported_birth_time: "06:40:00", + birth_time_source: "family_exact", + birth_time_period: null, + birth_time_clue: "现存账户线索", + uncertainty_before_minutes: 15, + uncertainty_after_minutes: 15, + country_code: "TW", + province_code: "TPE", + city_code: "TPE-CITY", + district_code: "DAAN", + latitude: 25.0268, + longitude: 121.5434, + timezone_offset: 8, + }, { + reported_date: "1990-01-01", + reported_time: "05:20:00", + source: "approximate", + reported_period: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + }); + + assert.deepEqual(declared, { + source: "approximate", + birthDate: "1990-01-01", + reportedTime: "05:20", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthTimeClue: "现存账户线索", + birthplace: { + countryCode: "TW", + provinceCode: "TPE", + cityCode: "TPE-CITY", + districtCode: "DAAN", + latitude: 25.0268, + longitude: 121.5434, + timezoneOffset: 8, + }, + }); +}); + 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) => { @@ -486,6 +538,73 @@ test("production packet waits for three supported events and then scores the acc assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id)); }); +test("legacy import scores inherited events without silently replacing the inherited candidate range", async () => { + const scoreCalls: LifeEvent[][] = []; + const inherited = { startTime: "05:10", endTime: "05:50" }; + const inheritedEvidence = [ + syntheticEvidence(1, "career"), + syntheticEvidence(2, "education"), + syntheticEvidence(3, "relocation"), + ]; + const scored: CandidateResult = { + resultId: "00000000-0000-4000-8000-000000000898", + confidence: "low", + canApply: false, + winningSegment: { + startTime: "05:20", + endTime: "05:24", + representativeTime: "05:22", + widthMinutes: 5, + }, + eventCount: 3, + domainCount: 3, + topScore: 4, + secondScore: 3, + marginPercent: 10, + reasons: ["synthetic narrower scored segment"], + evidence: inheritedEvidence.map((item) => ({ + eventId: item.id, + domain: item.domain as "career" | "education" | "relocation", + candidateTime: "05:22", + ruleIds: ["synthetic-rule"], + points: 1, + })), + algorithmVersion: "synthetic-event-score-v1", + }; + const result = await buildProductionConversationalRectificationPacket( + packetEngine({ scoreCalls, scoreResults: [scored] }), + { + 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: { + calculationVersion: "legacy-import-range-v1", + rangeStart: inherited.startTime, + rangeEnd: inherited.endTime, + }, + evidence: inheritedEvidence, + preserveCandidateRange: true, + }, + ); + + assert.equal(scoreCalls.length, 1, "trusted inherited facts still contribute technical evidence"); + assert.deepEqual(result.packet.candidate.range, inherited); + assert.equal(result.packet.candidate.status, "pending_validation"); + assert.deepEqual( + result.packet.scoredHistoricalEvidence.map((item) => item.evidenceId), + scored.evidence.map((item) => item.eventId), + ); +}); + test("production rescans the declared range after correction while ordinary evidence stays incremental", async () => { const scanCalls: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }> = []; const narrowResult: CandidateResult = { diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index f4e36526..252f7c49 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -332,6 +332,8 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard legacyCaseId: "00000000-0000-4000-8000-000000000106", price: 3, pendingConsultationQuestion: null, + declaredBirthInput: declaredBirthInputSchema.parse(storedRow.declared_birth_input), + evidence: [], firstTurn: { ...firstTurn, caseId: importCaseId }, validationReceipt, privateCandidate: { resultId, calculationVersion: "rectification-v3.1" }, diff --git a/tests/test_conversational_rectification_contract.py b/tests/test_conversational_rectification_contract.py index fd5592b6..24c80e56 100644 --- a/tests/test_conversational_rectification_contract.py +++ b/tests/test_conversational_rectification_contract.py @@ -6,6 +6,7 @@ MIGRATIONS = ROOT / "frontend" / "supabase" / "migrations" SCHEMA = MIGRATIONS / "20260720010000_conversational_rectification_schema.sql" BILLING = MIGRATIONS / "20260720020000_conversational_rectification_billing.sql" TRANSITIONS = MIGRATIONS / "20260720030000_conversational_rectification_transitions.sql" +LEGACY_IMPORT = MIGRATIONS / "20260721010000_conversational_legacy_import_projection.sql" def _normalized(path: Path) -> str: @@ -210,6 +211,49 @@ def test_legacy_import_is_waived_without_changing_credits() -> None: assert f"v_profile.{preserved_profile_field}" in body +def test_forward_legacy_import_projects_only_trusted_facts_into_v3() -> None: + sql = _normalized(LEGACY_IMPORT) + body = _function(sql, "import_legacy_conversational_rectification_case") + projection = _function( + sql, "conversational_rectification_project_legacy_event_evidence" + ) + + assert "drop function if exists public.import_legacy_conversational_rectification_case" in sql + assert "birth_time_rectification_cases_one_v3_import_per_legacy" in sql + assert "p_declared_birth_input jsonb" in body + assert "p_evidence jsonb" in body + assert "p_declared_birth_input is distinct from v_expected_declared" in body + assert "p_evidence is distinct from v_expected_evidence" in body + assert "conversational_rectification_valid_life_event_evidence_array" in body + assert "insert into public.birth_time_rectification_event_evidence" in body + assert "'{}'::jsonb, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb" in body + assert "v_legacy.questionnaire" not in body + assert "v_legacy.answers" not in body + assert "v_legacy.candidate_scan" not in body + assert "update public.birth_time_rectification_cases" not in body + assert "'migration_waived'" in body + assert "set credits =" not in body + assert "for update" in body + assert "pg_advisory_xact_lock" in body + assert "v_legacy.status in ('confirmed', 'completed', 'abandoned')" in body + assert "v_legacy.turn_version is distinct from p_expected_version" in body + assert "current_date" in body + + assert "v_date < pg_catalog.to_char(p_birth_date, 'yyyy')" in projection + assert "v_date > pg_catalog.to_char(p_as_of_date, 'yyyy')" in projection + assert "when v_domain in ('finance', 'health_pressure') then 'other'" in projection + assert "current_choice_question" not in projection + assert "choice_answers" not in projection + + +def test_forward_legacy_import_rpc_is_service_role_only() -> None: + sql = _normalized(LEGACY_IMPORT) + assert "revoke all on function public.import_legacy_conversational_rectification_case" in sql + assert "from public, anon, authenticated" in sql + assert "grant execute on function public.import_legacy_conversational_rectification_case" in sql + assert "to service_role" in sql + + def test_imported_legacy_sources_are_immutable_history() -> None: sql = _normalized(TRANSITIONS) assert "create or replace function public.guard_imported_rectification_history" in sql diff --git a/tests/test_conversational_rectification_postgres_runtime.py b/tests/test_conversational_rectification_postgres_runtime.py index b8a424a9..cca433e1 100644 --- a/tests/test_conversational_rectification_postgres_runtime.py +++ b/tests/test_conversational_rectification_postgres_runtime.py @@ -1825,10 +1825,28 @@ def test_crash_then_legacy_import_refunds_orphan_without_an_unrelated_paid_start _create_legacy_case(pg14_database, user_id, legacy_case_id) assert _reserve(pg14_database, user_id, lost_action)["credits"] == 7 + declared = { + "birthDate": "1990-01-01", + "reportedTime": "05:20", + "source": "legacy_import", + "birthTimeClue": None, + "uncertaintyBeforeMinutes": 0, + "uncertaintyAfterMinutes": 0, + "birthplace": { + "countryCode": "TW", + "provinceCode": "TPE", + "cityCode": "TPE-CITY", + "districtCode": "DAAN", + "latitude": 25.0268, + "longitude": 121.5434, + "timezoneOffset": 8, + }, + } statement = f""" select public.import_legacy_conversational_rectification_case( '{user_id}'::uuid, '{import_action}'::uuid, '{legacy_case_id}'::uuid, 0, '{import_action}'::uuid, 3, null, + {_jsonb(declared)}, '[]'::jsonb, {_jsonb(_valid_turn(import_action))}, {_jsonb({'modelId': 'synthetic-model', 'schemaValidated': True})}, {_jsonb(_valid_private_candidate())} @@ -1889,3 +1907,165 @@ def test_crash_then_legacy_import_refunds_orphan_without_an_unrelated_paid_start assert pg14_database.sql( f"select count(*) from public.credit_transactions where user_id = '{user_id}'::uuid" ) == "2" + + +def test_concurrent_legacy_import_projects_events_once_and_keeps_old_row_read_only( + pg14_database: PgDatabase, +) -> None: + user_id = "00000000-0000-4000-8000-000000002701" + legacy_case_id = "00000000-0000-4000-8000-000000002702" + action_a = "00000000-0000-4000-8000-000000002703" + action_b = "00000000-0000-4000-8000-000000002704" + drift_action = "00000000-0000-4000-8000-000000002709" + career_id = "00000000-0000-4000-8000-000000002705" + finance_id = "00000000-0000-4000-8000-000000002706" + _create_user(pg14_database, user_id, credits=10) + _create_legacy_case(pg14_database, user_id, legacy_case_id) + old_life_events = [ + {"id": career_id, "domain": "career", "precision": "month", "date": "2021-07"}, + {"id": finance_id, "domain": "finance", "precision": "year", "date": "2020"}, + {"id": "00000000-0000-4000-8000-000000002707", "domain": "relationship", "precision": "month", "date": "2099-01"}, + {"id": "00000000-0000-4000-8000-000000002708", "domain": "education", "precision": "year", "date": "1980"}, + ] + pg14_database.sql( + f""" + update public.birth_time_rectification_cases + set questionnaire = {_jsonb({'questions': [{'prompt': '哪一个时间段更符合?'}]})}, + answers = {_jsonb({'generic-question': 'A'})}, + life_events = {_jsonb(old_life_events)}, + candidate_scan = {_jsonb({'genericRanges': ['2006-2011', '2011-2016']})}, + candidate_start = '05:10', + candidate_end = '05:30' + where id = '{legacy_case_id}'::uuid; + """ + ) + declared = { + "birthDate": "1990-01-01", + "reportedTime": "05:20", + "source": "legacy_import", + "birthTimeClue": None, + "uncertaintyBeforeMinutes": 0, + "uncertaintyAfterMinutes": 0, + "birthplace": { + "countryCode": "TW", + "provinceCode": "TPE", + "cityCode": "TPE-CITY", + "districtCode": "DAAN", + "latitude": 25.0268, + "longitude": 121.5434, + "timezoneOffset": 8, + }, + } + evidence = [ + { + "id": career_id, + "rawText": "旧校时记录中的事业事件(2021-07)", + "domain": "career", + "eventSummary": "旧校时记录中的事业事件", + "dateValue": "2021-07", + "datePrecision": "month", + "extractionStatus": "clear", + "scoreable": True, + "correctsEvidenceIds": [], + }, + { + "id": finance_id, + "rawText": "旧校时记录中的其他事件(2020)", + "domain": "other", + "eventSummary": "旧校时记录中的其他事件", + "dateValue": "2020", + "datePrecision": "year", + "extractionStatus": "clear", + "scoreable": True, + "correctsEvidenceIds": [], + }, + ] + + def statement(action: str) -> str: + turn = { + **_valid_turn(action), + "evidenceRecap": [ + {"id": item["id"], "summary": item["eventSummary"], "dateLabel": item["dateValue"]} + for item in evidence + ], + } + return f""" + select public.import_legacy_conversational_rectification_case( + '{user_id}'::uuid, '{action}'::uuid, '{legacy_case_id}'::uuid, + 0, '{action}'::uuid, 3, null, + {_jsonb(declared)}, {_jsonb(evidence)}, {_jsonb(turn)}, + {_jsonb({'modelId': 'synthetic-model', 'schemaValidated': True})}, + {_jsonb(_valid_private_candidate())} + )::text; + """ + + drifted_declared = {**declared, "reportedTime": "06:40"} + drifted_turn = { + **_valid_turn(drift_action), + "evidenceRecap": [ + {"id": item["id"], "summary": item["eventSummary"], "dateLabel": item["dateValue"]} + for item in evidence + ], + } + assert pg14_database.rejects( + f""" + select public.import_legacy_conversational_rectification_case( + '{user_id}'::uuid, '{drift_action}'::uuid, '{legacy_case_id}'::uuid, + 0, '{drift_action}'::uuid, 3, null, + {_jsonb(drifted_declared)}, {_jsonb(evidence)}, {_jsonb(drifted_turn)}, + {_jsonb({'modelId': 'synthetic-model', 'schemaValidated': True})}, + {_jsonb(_valid_private_candidate())} + )::text; + """ + ) + assert pg14_database.sql( + f"select count(*) from public.birth_time_rectification_cases where imported_from_case_id = '{legacy_case_id}'::uuid" + ) == "0" + + processes = [subprocess.Popen( + pg14_database.command("-A", "-t", "-q", "-c", statement(action)), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) for action in (action_a, action_b)] + results = [process.communicate(timeout=20) for process in processes] + return_codes = [process.returncode for process in processes] + assert return_codes.count(0) == 1, results + assert sum(code != 0 for code in return_codes) == 1, results + + durable = json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_build_object( + 'credits', profile.credits, + 'activeTime', pg_catalog.to_char(profile.active_birth_time, 'HH24:MI'), + 'importCount', (select pg_catalog.count(*) from public.birth_time_rectification_cases imported where imported.imported_from_case_id = '{legacy_case_id}'::uuid), + 'billingStates', (select pg_catalog.jsonb_agg(billing.state) from public.birth_time_rectification_billing billing join public.birth_time_rectification_cases imported on imported.id = billing.case_id where imported.imported_from_case_id = '{legacy_case_id}'::uuid), + 'eventIds', (select pg_catalog.jsonb_agg(event.id order by event.created_at) from public.birth_time_rectification_event_evidence event join public.birth_time_rectification_cases imported on imported.id = event.case_id where imported.imported_from_case_id = '{legacy_case_id}'::uuid), + 'importedGenericState', (select pg_catalog.jsonb_build_object('questionnaire', imported.questionnaire, 'answers', imported.answers, 'lifeEvents', imported.life_events, 'candidateScan', imported.candidate_scan, 'baseline', pg_catalog.to_char(imported.baseline_active_time, 'HH24:MI')) from public.birth_time_rectification_cases imported where imported.imported_from_case_id = '{legacy_case_id}'::uuid), + 'legacyGenericState', (select pg_catalog.jsonb_build_object('questionnaire', legacy.questionnaire, 'answers', legacy.answers, 'candidateScan', legacy.candidate_scan) from public.birth_time_rectification_cases legacy where legacy.id = '{legacy_case_id}'::uuid) + )::text + from public.profiles profile where profile.id = '{user_id}'::uuid; + """ + )) + assert durable == { + "credits": 10, + "activeTime": "04:58", + "importCount": 1, + "billingStates": ["migration_waived"], + "eventIds": [career_id, finance_id], + "importedGenericState": { + "questionnaire": {}, + "answers": {}, + "lifeEvents": [], + "candidateScan": {}, + "baseline": "04:58", + }, + "legacyGenericState": { + "questionnaire": {"questions": [{"prompt": "哪一个时间段更符合?"}]}, + "answers": {"generic-question": "A"}, + "candidateScan": {"genericRanges": ["2006-2011", "2011-2016"]}, + }, + } + assert pg14_database.rejects( + f"update public.birth_time_rectification_cases set answers = '{{}}'::jsonb where id = '{legacy_case_id}'::uuid" + )