feat: import unfinished birth-time cases

This commit is contained in:
Jesse_Chen
2026-07-21 13:53:03 +08:00
parent c12d36e804
commit d4ad0637fe
10 changed files with 1515 additions and 11 deletions
@@ -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<unknown>;
loadRectificationCase(userId: string, caseId: string): Promise<unknown>;
@@ -178,22 +198,42 @@ export async function loadProductionConversationalRectificationProfile(
): Promise<Readonly<{
declaredBirthInput: DeclaredBirthInput;
revisionOfCaseId: string | null;
legacyCaseId: string | null;
}>> {
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),
@@ -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<string>();
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)),
});
}
@@ -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<LifeEventEvidenceInput>;
preserveCandidateRange?: true;
}>;
export type ConversationalRectificationServicePorts = Readonly<{
store: Pick<ConversationalRectificationStore,
"createCaseWithFirstTurn" | "loadCase" | "loadActionReceipt" | "saveTurn" | "pause" | "abandon" | "confirm">;
"createCaseWithFirstTurn" | "loadCase" | "loadActionReceipt" | "saveTurn" | "pause" | "abandon" | "confirm">
& Partial<Pick<ConversationalRectificationStore, "importLegacy">>;
billing: Pick<ConversationalRectificationBilling, "reserve" | "complete" | "release">;
rectificationPriceCredits: number;
loadDeclaredProfile(userId: string): Promise<ConversationalRectificationProfile>;
loadLegacyCase?(
userId: string,
legacyCaseId: string,
): Promise<LegacyConversationalImportSource | null>;
buildTechnicalPacket(
input: ConversationalRectificationPacketBuildInput,
): Promise<ComputedConversationalRectificationPacket>;
@@ -73,6 +84,12 @@ export type ConversationalRectificationServicePorts = Readonly<{
}>;
export type ConversationalRectificationService = Readonly<{
importLegacyCase(
userId: string,
legacyCaseId: string,
actionId: string,
pendingConsultationQuestion?: string | null,
): Promise<ConversationalRectificationTurn>;
start(userId: string, command: CommandOf<"start">): Promise<ConversationalRectificationTurn>;
resume(userId: string, command: CommandOf<"resume">): Promise<ConversationalRectificationTurn>;
answer(userId: string, command: CommandOf<"answer">): Promise<ConversationalRectificationTurn>;
@@ -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<ConversationalRectificationTurn> {
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;
@@ -127,6 +127,8 @@ export type ImportLegacyConversationalRectificationInput = MutationIdentity & Re
legacyCaseId: string;
price: number;
pendingConsultationQuestion: string | null;
declaredBirthInput: DeepReadonly<DeclaredBirthInput>;
evidence: ReadonlyArray<LifeEventEvidenceInput>;
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),