From 9e64b717103e5a6bb65137231a4a0b8370d565ee Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 02:10:27 +0800 Subject: [PATCH] feat: orchestrate conversational rectification --- .../app/api/birth-time-conversation/route.ts | 503 ++++++++++++ .../orchestrator.ts | 769 ++++++++++++++++++ ...ational-rectification-orchestrator.test.ts | 504 ++++++++++++ ...conversational-rectification-route.test.ts | 196 +++++ 4 files changed, 1972 insertions(+) create mode 100644 frontend/src/app/api/birth-time-conversation/route.ts create mode 100644 frontend/src/lib/conversational-rectification/orchestrator.ts create mode 100644 frontend/tests/conversational-rectification-orchestrator.test.ts create mode 100644 frontend/tests/conversational-rectification-route.test.ts diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts new file mode 100644 index 00000000..0b61ebfc --- /dev/null +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -0,0 +1,503 @@ +import { randomUUID } from "node:crypto"; +import { + conversationalRectificationCommandSchema, + type ConversationalRectificationCommand, + type ConversationalRectificationTurn, +} from "../../../lib/conversational-rectification/contracts.ts"; +import { + ConversationalRectificationError, + toConversationalRectificationPublicError, +} from "../../../lib/conversational-rectification/errors.ts"; +import { + createConversationalRectificationService, + type ConversationalRectificationPacketBuildInput, + type ConversationalRectificationService, +} from "../../../lib/conversational-rectification/orchestrator.ts"; +import type { DeclaredBirthInput, 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"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type AuthenticatedRequest = Readonly<{ + userId: string; + context: unknown; +}>; + +export type BirthTimeConversationRouteService = ConversationalRectificationService; + +export type BirthTimeConversationRouteLog = Readonly<{ + requestId: string; + actionId: string | null; + caseId: string | null; + code: string; +}>; + +export type BirthTimeConversationPostDependencies = Readonly<{ + authenticate(request: Request): Promise; + createService(authenticated: AuthenticatedRequest): Promise; + createRequestId?(request: Request): string; + log?(entry: BirthTimeConversationRouteLog): void; +}>; + +type ProfileQueryResult = Readonly<{ + data: unknown; + error: unknown; +}>; + +type ProfileClient = { + from(table: string): { + select(columns: string): { + eq(column: string, value: string): { + maybeSingle(): PromiseLike; + }; + }; + }; +}; + +async function authenticateProductionRequest(): Promise { + const { createServerSupabaseClient } = await import("../../../lib/supabase/server.ts"); + const serverClient = await createServerSupabaseClient(); + const { data: { user }, error } = await serverClient.auth.getUser(); + if (error || !user) return null; + return { userId: user.id, context: serverClient }; +} + +async function requestPayload(request: Request): Promise { + try { + return await request.json(); + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +function profileRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +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; +} { + const profile = profileRecord(value); + if (!profile) throw new ConversationalRectificationError("profile_incomplete"); + const birthDate = text(profile.birth_date); + const source = text(profile.birth_time_source); + const cityCode = text(profile.city_code); + const latitude = finiteNumber(profile.latitude); + const longitude = finiteNumber(profile.longitude); + const timezoneOffset = finiteNumber(profile.timezone_offset); + if (!birthDate || !source || !cityCode || latitude === null || longitude === null + || timezoneOffset === null) { + throw new ConversationalRectificationError("profile_incomplete"); + } + const birthplace = { + ...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}), + ...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}), + cityCode, + ...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}), + latitude, + longitude, + timezoneOffset, + }; + const common = { + birthDate, + birthTimeClue: text(profile.birth_time_clue), + birthplace, + }; + const reportedTime = text(profile.reported_birth_time)?.slice(0, 5) ?? null; + const period = text(profile.birth_time_period); + const before = integer(profile.uncertainty_before_minutes); + const after = integer(profile.uncertainty_after_minutes); + let declaredBirthInput: unknown; + switch (source) { + case "hospital_record": + declaredBirthInput = { + ...common, source, reportedTime, + uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2, + }; + break; + case "family_exact": + case "approximate": + declaredBirthInput = { + ...common, source, reportedTime, + uncertaintyBeforeMinutes: before, uncertaintyAfterMinutes: after, + }; + break; + case "period_only": + declaredBirthInput = { ...common, source, reportedPeriod: period }; + break; + case "unknown": + declaredBirthInput = { ...common, source }; + break; + case "legacy_import": + declaredBirthInput = { + ...common, + source, + ...(reportedTime ? { reportedTime } : {}), + ...(period ? { reportedPeriod: period } : {}), + ...(before === null ? {} : { uncertaintyBeforeMinutes: before }), + ...(after === null ? {} : { uncertaintyAfterMinutes: after }), + }; + break; + default: + throw new ConversationalRectificationError("profile_incomplete"); + } + return { + declaredBirthInput, + revisionOfCaseId: text(profile.rectification_case_id), + }; +} + +function priceCredits(): number { + const raw = process.env.RECTIFICATION_PRICE_CREDITS?.trim() ?? "1"; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1 || value > 100) { + throw new ConversationalRectificationError("service_unavailable"); + } + return value; +} + +function minute(value: string): number { + const [hour = 0, part = 0] = value.split(":").map(Number); + return hour * 60 + part; +} + +function clock(value: number): string { + const normalized = ((value % 1_440) + 1_440) % 1_440; + return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; +} + +function declaredRange(input: DeclaredBirthInput): { readonly startTime: string; readonly endTime: string } { + if (input.source === "period_only") { + return { + early_morning: { startTime: "04:00", endTime: "07:59" }, + morning: { startTime: "08:00", endTime: "11:59" }, + afternoon: { startTime: "12:00", endTime: "17:59" }, + evening: { startTime: "18:00", endTime: "22:59" }, + late_night: { startTime: "23:00", endTime: "03:59" }, + }[input.reportedPeriod]; + } + if (input.source === "unknown") return { startTime: "00:01", 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" }; + } + const reportedTime = input.reportedTime; + if (!reportedTime) throw new ConversationalRectificationError("profile_incomplete"); + const before = input.uncertaintyBeforeMinutes ?? 2; + const after = input.uncertaintyAfterMinutes ?? 2; + return { + startTime: clock(minute(reportedTime) - before), + endTime: clock(minute(reportedTime) + after), + }; +} + +function scanCoordinates(range: { readonly startTime: string; readonly endTime: string }) { + const start = minute(range.startTime); + let end = minute(range.endTime); + if (end < start) end += 1_440; + const center = Math.round((start + end) / 2); + return { + centerTime: clock(center), + uncertaintyMinutes: Math.max(1, Math.ceil((end - start) / 2)), + }; +} + +function currentRange(input: ConversationalRectificationPacketBuildInput) { + const start = input.privateCandidate?.rangeStart; + const end = input.privateCandidate?.rangeEnd; + return start && end ? { startTime: start, endTime: end } : declaredRange(input.declaredBirthInput); +} + +function scoreableLifeEvents(evidence: readonly LifeEventEvidence[]): LifeEvent[] { + return evidence.slice(-10).flatMap((item) => { + if (item.scoreable !== true || !item.dateValue + || !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")) { + return []; + } + const domain = item.domain === "family" ? "relationship" + : item.domain === "other" ? null + : item.domain; + if (!domain) return []; + return [{ + id: item.id, + domain, + precision: item.datePrecision as "day" | "month" | "year", + date: item.dateValue, + } as LifeEvent]; + }); +} + +function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] { + const raw = profileRecord(scan.raw.candidate_scan); + const samples = Array.isArray(raw?.samples) ? raw.samples : []; + const links = samples.flatMap((item, sampleIndex) => { + const rawTime = text(profileRecord(item)?.time); + const match = rawTime?.match(/(?:^|[T\s])(([01]\d|2[0-3]):[0-5]\d)/); + return match?.[1] ? [{ sampleIndex, time: match[1] }] : []; + }); + if (links.length !== scan.samples.length) { + throw new ConversationalRectificationError("service_unavailable"); + } + return links; +} + +function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: string) { + const layers = [ + ["D1", "ascendantSign"], + ["D4", "d4Sign"], + ["D9", "d9Sign"], + ["D10", "d10Sign"], + ["D24", "d24Sign"], + ["D30", "d30Sign"], + ] as const; + const availableLayers = layers + .filter(([, key]) => scan.samples.some((sample) => typeof sample[key] === "string" && sample[key]?.trim())) + .map(([layer]) => layer); + return { + availableLayers, + layerReferences: Object.fromEntries(availableLayers.map((layer) => [ + layer, + [`server-scan-${calculationVersion}-${layer.toLowerCase()}`], + ])), + }; +} + +function boundaryDistance(range: { readonly startTime: string; readonly endTime: string }, representative: string) { + const start = minute(range.startTime); + let end = minute(range.endTime); + let value = minute(representative); + if (end < start) end += 1_440; + if (value < start) value += 1_440; + return Math.max(0, Math.min(value - start, end - value)); +} + +async function buildProductionPacket( + engine: BirthTimeJourneyEngine, + input: ConversationalRectificationPacketBuildInput, +) { + const place = input.declaredBirthInput.birthplace; + if (place.latitude === undefined || place.longitude === undefined) { + throw new ConversationalRectificationError("profile_incomplete"); + } + const baseRange = currentRange(input); + const events = scoreableLifeEvents(input.evidence as readonly LifeEventEvidence[]); + const eventScore: CandidateResult | null = events.length > 0 + ? await engine.scoreEvents({ + birthDate: input.declaredBirthInput.birthDate, + startTime: baseRange.startTime, + endTime: baseRange.endTime, + lat: place.latitude, + lon: place.longitude, + tz: place.timezoneOffset, + events, + }) + : null; + 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 candidateDifferences = await engine.buildDifferencePacket({ + caseId: input.caseId, + asOfDate: input.asOfDate, + birthDate: input.declaredBirthInput.birthDate, + startTime: selectedRange.startTime, + endTime: selectedRange.endTime, + lat: place.latitude, + lon: place.longitude, + tz: place.timezoneOffset, + evidence: [], + dismissedOpportunityIds: [], + questionFingerprints: [], + partitionFingerprints: [], + recentRanges: [], + candidateModel: null, + }); + const calculationVersion = eventScore + ? `${candidateDifferences.packet.scoringVersion}+${eventScore.algorithmVersion}` + : candidateDifferences.packet.scoringVersion; + const metadata = layerMetadata(questionnaire, calculationVersion); + const representative = eventScore?.winningSegment?.representativeTime + ?? scanPoint.centerTime; + const { buildRectificationTechnicalPacket } = await import( + "../../../lib/conversational-rectification/technical-packet.ts" + ); + return { + packet: buildRectificationTechnicalPacket({ + scan: questionnaire, + candidateDifferences, + eventScore, + consultation: { + source: "server_consultation_workflow", + calculationVersion, + availableLayers: metadata.availableLayers, + layerReferences: metadata.layerReferences, + timeLinkedScanSamples: sampleTimes(questionnaire), + boundaryDistanceMinutes: boundaryDistance(selectedRange, representative), + futureWindows: [], + }, + }), + resultId: eventScore?.resultId ?? null, + }; +} + +async function productionNarrativeGenerator(): Promise { + const [{ defaultLanguageModel }, { Agent }] = await Promise.all([ + import("../../../mastra/model.ts"), + import("@mastra/core/agent"), + ]); + const model = defaultLanguageModel(); + if (!model) { + return { + modelId: "deterministic-rectification-fallback", + async generate() { throw new Error("NarrativeModelUnavailable"); }, + }; + } + const agent = new Agent({ + id: `conversational-rectification-${model.id}`, + name: "Conversational Rectification Narrator", + model: model.model, + instructions: "Return only the exact JSON object requested by the user prompt. Use only supplied packet facts. Never invent times, layers, references, scores, dates, or confirmation state.", + }); + return { + modelId: model.id, + async generate(prompt) { + const result = await agent.generate([{ role: "user", content: prompt }]); + return { text: result.text }; + }, + }; +} + +async function createProductionService( + authenticated: AuthenticatedRequest, +): Promise { + const [ + { createAdminSupabaseClient }, + { createSupabaseConversationalRectificationStore }, + { createSupabaseConversationalRectificationBilling }, + { createJyotishBirthTimeJourneyEngine }, + narrativeGenerator, + ] = await Promise.all([ + import("../../../lib/supabase/admin.ts"), + import("../../../lib/conversational-rectification/store.ts"), + import("../../../lib/conversational-rectification/billing.ts"), + import("../../../lib/birth-time-journey-engine.ts"), + productionNarrativeGenerator(), + ]); + const admin = createAdminSupabaseClient(); + const profileClient = authenticated.context as ProfileClient; + const engine = createJyotishBirthTimeJourneyEngine(); + return createConversationalRectificationService({ + store: createSupabaseConversationalRectificationStore(admin), + 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); + }, + buildTechnicalPacket: (input) => buildProductionPacket(engine, input), + narrativeGenerator, + asOfDate: () => new Date().toISOString().slice(0, 10), + }); +} + +function stableRequestId(request: Request): string { + const supplied = request.headers.get("x-request-id"); + return supplied && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(supplied) + ? supplied.toLowerCase() + : randomUUID(); +} + +function errorResponse(error: unknown) { + const publicError = toConversationalRectificationPublicError(error); + return Response.json(publicError, { status: publicError.status }); +} + +async function dispatch( + service: BirthTimeConversationRouteService, + userId: string, + command: ConversationalRectificationCommand, +): Promise { + switch (command.type) { + case "start": return service.start(userId, command); + case "resume": return service.resume(userId, command); + case "answer": return service.answer(userId, command); + case "pause": return service.pause(userId, command); + case "abandon": return service.abandon(userId, command); + case "confirm": return service.confirm(userId, command); + } +} + +export function createBirthTimeConversationPostHandler( + dependencies: BirthTimeConversationPostDependencies, +) { + return async function handleBirthTimeConversationPost(request: Request): Promise { + const requestId = dependencies.createRequestId?.(request) ?? stableRequestId(request); + let actionId: string | null = null; + let caseId: string | null = null; + try { + const authenticated = await dependencies.authenticate(request); + if (!authenticated) return errorResponse(new ConversationalRectificationError("authentication_required")); + + const parsed = conversationalRectificationCommandSchema.safeParse(await requestPayload(request)); + if (!parsed.success) return errorResponse(new ConversationalRectificationError("invalid_command")); + actionId = parsed.data.actionId; + caseId = parsed.data.type === "start" ? parsed.data.actionId : parsed.data.caseId; + + const service = await dependencies.createService(authenticated); + return Response.json(await dispatch(service, authenticated.userId, parsed.data)); + } catch (error) { + const publicError = toConversationalRectificationPublicError(error); + dependencies.log?.({ requestId, actionId, caseId, code: publicError.code }); + return Response.json(publicError, { status: publicError.status }); + } + }; +} + +const productionPost = createBirthTimeConversationPostHandler({ + authenticate: authenticateProductionRequest, + createService: createProductionService, + createRequestId: stableRequestId, + log(entry) { + console.error( + `[birth-time-conversation] request=${entry.requestId} action=${entry.actionId ?? "none"} case=${entry.caseId ?? "none"} code=${entry.code}`, + ); + }, +}); + +export async function POST(request: Request) { + return productionPost(request); +} diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts new file mode 100644 index 00000000..d5fd9a1b --- /dev/null +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -0,0 +1,769 @@ +import { + conversationalRectificationCommandSchema, + conversationalRectificationTurnSchema, + type ConversationalRectificationCommand, + type ConversationalRectificationTurn, +} from "./contracts.ts"; +import { ConversationalRectificationError } from "./errors.ts"; +import { extractLifeEventEvidence } from "./evidence-extractor.ts"; +import { + declaredBirthInputSchema, + privateCandidateSchema, + validationReceiptSchema, + type DeclaredBirthInput, + type LifeEventEvidence, + type PrivateCandidate, + type ValidationReceipt, +} from "./persistence-contracts.ts"; +import { + generateRectificationNarrative, + type RectificationNarrativeGenerator, + type RectificationNarrativeResult, +} from "./narrative-agent.ts"; +import { + projectRectificationTechnicalPacket, + type RectificationEvidenceDomain, + type RectificationTechnicalPacket, +} from "./technical-packet.ts"; +import type { ConversationalRectificationBilling } from "./billing.ts"; +import type { + ConversationalRectificationStore, + LifeEventEvidenceInput, + LoadedConversationalRectificationCase, + PrivateCandidateInput, + StoredConversationalRectificationCase, +} from "./store.ts"; + +type CommandOf = Extract< + ConversationalRectificationCommand, + { readonly type: Type } +>; + +export type ComputedConversationalRectificationPacket = Readonly<{ + packet: RectificationTechnicalPacket; + resultId: string | null; +}>; + +export type ConversationalRectificationProfile = Readonly<{ + declaredBirthInput: unknown; + revisionOfCaseId: string | null; +}>; + +export type ConversationalRectificationPacketBuildInput = Readonly<{ + userId: string; + caseId: string; + asOfDate: string; + declaredBirthInput: DeclaredBirthInput; + privateCandidate: PrivateCandidateInput | null; + evidence: ReadonlyArray; +}>; + +export type ConversationalRectificationServicePorts = Readonly<{ + store: Pick; + billing: Pick; + rectificationPriceCredits: number; + loadDeclaredProfile(userId: string): Promise; + buildTechnicalPacket( + input: ConversationalRectificationPacketBuildInput, + ): Promise; + narrativeGenerator: RectificationNarrativeGenerator; + asOfDate(): string; +}>; + +export type ConversationalRectificationService = Readonly<{ + start(userId: string, command: CommandOf<"start">): Promise; + resume(userId: string, command: CommandOf<"resume">): Promise; + answer(userId: string, command: CommandOf<"answer">): Promise; + pause(userId: string, command: CommandOf<"pause">): Promise; + abandon(userId: string, command: CommandOf<"abandon">): Promise; + confirm(userId: string, command: CommandOf<"confirm">): Promise; +}>; + +const transitionValidatorVersion = "conversational-rectification-orchestrator-v1"; +const directionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不知道|不确定)/; + +function safeFailure(error: unknown): ConversationalRectificationError { + return error instanceof ConversationalRectificationError + ? error + : new ConversationalRectificationError("service_unavailable"); +} + +function parseCommand( + type: Type, + value: unknown, +): CommandOf { + const parsed = conversationalRectificationCommandSchema.safeParse(value); + if (!parsed.success || parsed.data.type !== type) { + throw new ConversationalRectificationError("invalid_command"); + } + return parsed.data as CommandOf; +} + +function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn { + const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn); + if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); + return parsed.data; +} + +function transitionReceipt(modelId = "deterministic-rectification-transition"): ValidationReceipt { + return validationReceiptSchema.parse({ + modelId, + schemaValidated: true, + validatorVersion: transitionValidatorVersion, + retryCount: 0, + fallbackUsed: false, + issues: [], + }); +} + +function latestReceipt(value: LoadedConversationalRectificationCase): ValidationReceipt { + const receipt = value.validationReceipts.at(-1); + const parsed = validationReceiptSchema.safeParse(receipt); + if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); + return parsed.data; +} + +function evidenceRecap(evidence: ReadonlyArray) { + return evidence.slice(-20).map((item) => ({ + id: item.id, + summary: item.eventSummary, + dateLabel: item.dateValue + ? item.scoreable === false && item.extractionStatus !== "needs_clarification" + ? `${item.dateValue}(未来,仅作背景)` + : item.dateValue + : "日期待补充", + })); +} + +function exactTechnicalReceipt(packet: RectificationTechnicalPacket) { + const projected = projectRectificationTechnicalPacket(packet); + return { + calculationVersion: projected.technicalReceipt.calculationVersion, + stableLayers: projected.technicalReceipt.stableLayers, + sensitiveLayers: projected.technicalReceipt.sensitiveLayers, + candidateDifferenceRefs: projected.technicalReceipt.candidateDifferenceRefs, + }; +} + +function actionsFor(status: "active" | "confirming") { + if (status === "confirming") { + return ["answer", "pause", "abandon", "confirm"] as const; + } + return ["answer", "pause", "abandon"] as const; +} + +function turnFromNarrative(input: { + readonly caseId: string; + readonly turnVersion: number; + readonly pendingConsultationQuestion: string | null; + readonly packet: RectificationTechnicalPacket; + readonly narrative: RectificationNarrativeResult; + readonly evidence: ReadonlyArray; +}): ConversationalRectificationTurn { + const projected = projectRectificationTechnicalPacket(input.packet); + const status = projected.candidate.status === "ready_for_confirmation" ? "confirming" : "active"; + const evidenceRequest = input.narrative.output.evidenceRequest + ? { + domains: input.narrative.output.evidenceRequest.domains, + datePrecision: input.narrative.output.evidenceRequest.datePrecision, + freeTextAllowed: true as const, + } + : null; + const candidate = { + status: projected.candidate.status, + representativeTime: projected.candidate.representativeTime, + rangeStart: projected.candidate.rangeStart, + rangeEnd: projected.candidate.rangeEnd, + }; + const parsed = conversationalRectificationTurnSchema.safeParse({ + caseId: input.caseId, + journeyProtocol: "conversational-evidence-v3", + status, + turnVersion: input.turnVersion, + narrative: input.narrative.narrative, + candidate, + technicalReceipt: exactTechnicalReceipt(input.packet), + evidenceRequest, + evidenceRecap: evidenceRecap(input.evidence), + actions: actionsFor(status), + pendingConsultationQuestion: input.pendingConsultationQuestion, + }); + if (!parsed.success) throw new ConversationalRectificationError("service_unavailable"); + return parsed.data; +} + +function privateCandidateFromPacket(input: { + readonly packet: RectificationTechnicalPacket; + readonly resultId: string | null; + readonly iteration: number; +}): PrivateCandidate { + const packet = input.packet; + const parsed = privateCandidateSchema.safeParse({ + resultId: input.resultId, + representativeTime: packet.candidate.representativeTime, + rangeStart: packet.candidate.range.startTime, + rangeEnd: packet.candidate.range.endTime, + calculationVersion: packet.calculationVersion, + candidateWeights: Object.entries(packet.candidateWeights) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, weight]) => weight), + candidateModelRefs: packet.candidateModelRefs, + d1Stability: packet.d1Stability, + boundaryDistanceMinutes: packet.boundaryDistanceMinutes, + supportedSensitiveLayers: packet.supportedSensitiveLayers, + scoredHistoricalEvidence: packet.scoredHistoricalEvidence, + suggestedDomains: packet.suggestedDomains.map((item) => item.domain), + futureWindows: packet.futureWindows, + workingState: { + phase: packet.candidate.status === "ready_for_confirmation" ? "ready" : "collecting_evidence", + iteration: input.iteration, + notes: [], + }, + }); + if (!parsed.success) throw new ConversationalRectificationError("service_unavailable"); + return parsed.data; +} + +function changedTurn(input: { + readonly current: LoadedConversationalRectificationCase; + readonly status: "paused" | "abandoned" | "completed"; + readonly narrative: string; + readonly receipt?: ValidationReceipt; +}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } { + const current = input.current.latestTurn; + const actions = input.status === "paused" + ? ["answer", "abandon"] + : input.status === "completed" && current.pendingConsultationQuestion + ? ["continue_original_question"] + : []; + const candidate = input.status === "completed" + ? { ...current.candidate, status: "confirmed" as const } + : current.candidate; + const parsed = conversationalRectificationTurnSchema.safeParse({ + ...current, + status: input.status, + turnVersion: input.current.turnVersion + 1, + narrative: input.narrative, + candidate, + evidenceRequest: input.status === "completed" ? null : current.evidenceRequest, + actions, + }); + if (!parsed.success) throw new ConversationalRectificationError("service_unavailable"); + return { turn: parsed.data, receipt: input.receipt ?? transitionReceipt() }; +} + +function boundedNarrative(previous: string, suffix: string): string { + const room = Math.max(1, 12_000 - suffix.length - 2); + return `${previous.slice(0, room)}\n\n${suffix}`.slice(0, 12_000); +} + +function domainsForClarification( + current: ConversationalRectificationTurn, + hint: RectificationEvidenceDomain | undefined, +): readonly RectificationEvidenceDomain[] { + const values = [ + hint, + ...(current.evidenceRequest?.domains ?? []), + "career" as const, + "relationship" as const, + ].filter((value): value is RectificationEvidenceDomain => Boolean(value)); + return [...new Set(values)].slice(0, 4).length >= 2 + ? [...new Set(values)].slice(0, 4) + : ["career", "relationship"]; +} + +function nonScoringTurn(input: { + readonly current: LoadedConversationalRectificationCase; + readonly newEvidence: ReadonlyArray; + readonly domain?: RectificationEvidenceDomain; + readonly directionChange: boolean; + readonly scoringFallback?: boolean; +}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } { + const allEvidence = [...input.current.eventEvidence, ...input.newEvidence]; + const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification" + && item.scoreable === false && item.dateValue !== null); + const narrative = input.scoringFallback + ? "本轮原文已安全保存,但新的专业解释未通过事实一致性校验,因此候选没有推进。请稍后重试,或继续补充一件已经发生并带有年月的事件。" + : input.directionChange + ? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。" + : hasFuture + ? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。" + : "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。"; + const status = input.current.status === "confirming" ? "confirming" : "active"; + const actions = actionsFor(status); + const evidenceRequest = status === "confirming" && input.current.latestTurn.evidenceRequest === null + ? null + : { + domains: domainsForClarification(input.current.latestTurn, input.domain), + datePrecision: "month_preferred" as const, + freeTextAllowed: true as const, + }; + const parsed = conversationalRectificationTurnSchema.safeParse({ + ...input.current.latestTurn, + status, + turnVersion: input.current.turnVersion + 1, + narrative, + evidenceRequest, + evidenceRecap: evidenceRecap(allEvidence), + actions, + }); + if (!parsed.success) throw new ConversationalRectificationError("service_unavailable"); + return { + turn: parsed.data, + receipt: transitionReceipt(input.scoringFallback + ? "deterministic-scoring-safety-fallback" + : "deterministic-evidence-clarification"), + }; +} + +function requireLoaded( + value: LoadedConversationalRectificationCase | null, +): LoadedConversationalRectificationCase { + if (!value) throw new ConversationalRectificationError("case_not_found"); + return value; +} + +function requireExactVersion(current: LoadedConversationalRectificationCase, expected: number) { + if (current.turnVersion !== expected) throw new ConversationalRectificationError("stale_turn"); +} + +function requireMutable(current: LoadedConversationalRectificationCase) { + if (!(["active", "paused", "confirming"] as const).includes( + current.status as "active" | "paused" | "confirming", + )) { + throw new ConversationalRectificationError("invalid_transition"); + } +} + +export function createConversationalRectificationService( + ports: ConversationalRectificationServicePorts, +): ConversationalRectificationService { + async function load(userId: string, caseId: string) { + try { + return requireLoaded(await ports.store.loadCase({ userId, caseId })); + } catch (error) { + throw safeFailure(error); + } + } + + function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] { + let extracted: readonly LifeEventEvidence[]; + try { + extracted = extractLifeEventEvidence({ + rawText: command.answer, + sourceTurnId: command.actionId, + asOfDate: ports.asOfDate(), + }).map((item) => ({ + ...item, + domain: item.domain === "other" && command.domain && command.domain !== "other" + ? command.domain + : item.domain, + })); + } catch { + throw new ConversationalRectificationError("invalid_command"); + } + if (extracted.length > 20) throw new ConversationalRectificationError("invalid_command"); + return extracted; + } + + return Object.freeze({ + async start(userId, rawCommand) { + const command = parseCommand("start", rawCommand); + let profile: ConversationalRectificationProfile; + try { + profile = await ports.loadDeclaredProfile(userId); + } catch (error) { + throw safeFailure(error); + } + const declared = declaredBirthInputSchema.safeParse(profile.declaredBirthInput); + if (!declared.success) throw new ConversationalRectificationError("profile_incomplete"); + + let price: number; + try { + price = ports.rectificationPriceCredits; + } catch { + throw new ConversationalRectificationError("service_unavailable"); + } + if (!Number.isSafeInteger(price) || price < 1 || price > 1_000_000) { + throw new ConversationalRectificationError("service_unavailable"); + } + + const caseId = command.actionId; + let existing: LoadedConversationalRectificationCase | null; + try { + existing = await ports.store.loadCase({ userId, caseId }); + } catch (error) { + throw safeFailure(error); + } + if (existing) { + if (existing.pendingConsultationQuestion !== (command.pendingConsultationQuestion ?? null)) { + throw new ConversationalRectificationError("action_conflict"); + } + if (existing.billingState === "reserved") { + try { + await ports.billing.complete({ + userId, + caseId, + expectedVersion: 0, + actionId: command.actionId, + }); + } catch (error) { + try { + await ports.billing.release({ + userId, + caseId, + expectedVersion: 0, + actionId: command.actionId, + price, + }); + } catch { + throw new ConversationalRectificationError("billing_failed"); + } + throw safeFailure(error); + } + } else if (existing.billingState !== "charged" + && existing.billingState !== "migration_waived") { + throw new ConversationalRectificationError("billing_failed"); + } + return publicTurn(existing); + } + + let reserved = false; + try { + const reservation = await ports.billing.reserve({ + userId, + caseId, + expectedVersion: 0, + actionId: command.actionId, + price, + }); + reserved = reservation.billingState === "reserved"; + const computed = await ports.buildTechnicalPacket({ + userId, + caseId, + asOfDate: ports.asOfDate(), + declaredBirthInput: declared.data, + privateCandidate: null, + evidence: [], + }); + 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, + turnVersion: 0, + pendingConsultationQuestion: command.pendingConsultationQuestion ?? null, + packet: computed.packet, + narrative, + evidence: [], + }); + const created = await ports.store.createCaseWithFirstTurn({ + userId, + caseId, + expectedVersion: 0, + actionId: command.actionId, + revisionOfCaseId: profile.revisionOfCaseId, + pendingConsultationQuestion: command.pendingConsultationQuestion ?? null, + declaredBirthInput: declared.data, + firstTurn, + validationReceipt: narrative.validationReceipt, + privateCandidate, + }); + await ports.billing.complete({ + userId, + caseId, + expectedVersion: 0, + actionId: command.actionId, + }); + return publicTurn(created); + } catch (error) { + if (reserved) { + try { + await ports.billing.release({ + userId, + caseId, + expectedVersion: 0, + actionId: command.actionId, + price, + }); + } catch { + throw new ConversationalRectificationError("billing_failed"); + } + } + throw safeFailure(error); + } + }, + + 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 current = await load(userId, command.caseId); + requireMutable(current); + const evidence = extractedEvidence(command); + + if (current.turnVersion === command.turnVersion + 1) { + try { + const replayed = await ports.store.saveTurn({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn: current.latestTurn, + evidence, + validationReceipt: latestReceipt(current), + privateCandidate: current.privateCandidate, + }); + return publicTurn(replayed); + } catch (error) { + throw safeFailure(error); + } + } + requireExactVersion(current, command.turnVersion); + + const scoreableEvidence = evidence.filter((item) => item.scoreable === true + && item.extractionStatus !== "needs_clarification"); + const directionChange = directionChangePattern.test(command.answer); + if (directionChange || scoreableEvidence.length === 0) { + const next = nonScoringTurn({ + current, + newEvidence: evidence, + domain: command.domain, + directionChange, + }); + try { + const saved = await ports.store.saveTurn({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn: next.turn, + evidence, + validationReceipt: next.receipt, + privateCandidate: current.privateCandidate, + }); + return publicTurn(saved); + } catch (error) { + throw safeFailure(error); + } + } + + try { + const allScoreable = [...current.eventEvidence, ...evidence] + .filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification"); + const computed = await ports.buildTechnicalPacket({ + userId, + caseId: command.caseId, + asOfDate: ports.asOfDate(), + declaredBirthInput: current.declaredBirthInput, + privateCandidate: current.privateCandidate, + evidence: allScoreable, + }); + const phase = computed.packet.candidate.status === "ready_for_confirmation" + ? "final" as const + : "intermediate" as const; + const narrative = await generateRectificationNarrative({ + phase, + packet: computed.packet, + generator: ports.narrativeGenerator, + }); + if (!narrative.allowEvidenceScoringAdvance) { + const next = nonScoringTurn({ + current, + newEvidence: evidence, + domain: command.domain, + directionChange: false, + scoringFallback: true, + }); + const saved = await ports.store.saveTurn({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn: next.turn, + evidence, + validationReceipt: narrative.validationReceipt, + privateCandidate: current.privateCandidate, + }); + return publicTurn(saved); + } + const privateCandidate = privateCandidateFromPacket({ + packet: computed.packet, + resultId: computed.resultId, + iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1, + }); + const turn = turnFromNarrative({ + caseId: command.caseId, + turnVersion: command.turnVersion + 1, + pendingConsultationQuestion: current.pendingConsultationQuestion, + packet: computed.packet, + narrative, + evidence: [...current.eventEvidence, ...evidence], + }); + const saved = await ports.store.saveTurn({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn, + evidence, + validationReceipt: narrative.validationReceipt, + privateCandidate, + }); + return publicTurn(saved); + } catch (error) { + throw safeFailure(error); + } + }, + + async pause(userId, rawCommand) { + const command = parseCommand("pause", rawCommand); + const current = await load(userId, command.caseId); + if (current.turnVersion === command.turnVersion + 1 && current.status === "paused") { + try { + const replayed = await ports.store.pause({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn: current.latestTurn, + validationReceipt: latestReceipt(current), + }); + return publicTurn(replayed); + } catch (error) { + throw safeFailure(error); + } + } + requireExactVersion(current, command.turnVersion); + if (current.status !== "active" && current.status !== "confirming") { + throw new ConversationalRectificationError("invalid_transition"); + } + const next = changedTurn({ + current, + status: "paused", + narrative: boundedNarrative(current.latestTurn.narrative, "校正已暂停,现有证据和候选已保存;继续时不会重复扣点。"), + }); + try { + return publicTurn(await ports.store.pause({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn: next.turn, + validationReceipt: next.receipt, + })); + } catch (error) { + throw safeFailure(error); + } + }, + + async abandon(userId, rawCommand) { + const command = parseCommand("abandon", rawCommand); + const current = await load(userId, command.caseId); + if (current.turnVersion === command.turnVersion + 1 && current.status === "abandoned") { + try { + return publicTurn(await ports.store.abandon({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn: current.latestTurn, + validationReceipt: latestReceipt(current), + })); + } catch (error) { + throw safeFailure(error); + } + } + requireExactVersion(current, command.turnVersion); + requireMutable(current); + const next = changedTurn({ + current, + status: "abandoned", + narrative: boundedNarrative(current.latestTurn.narrative, "本次校正已放弃;再次校正期间原有确认时间始终没有被替换。"), + }); + try { + return publicTurn(await ports.store.abandon({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + turn: next.turn, + validationReceipt: next.receipt, + })); + } catch (error) { + throw safeFailure(error); + } + }, + + async confirm(userId, rawCommand) { + const command = parseCommand("confirm", rawCommand); + const current = await load(userId, command.caseId); + if (current.turnVersion === command.turnVersion + 1 && current.status === "completed") { + const resultId = current.privateCandidate.resultId; + if (!resultId) throw new ConversationalRectificationError("candidate_changed"); + try { + return publicTurn(await ports.store.confirm({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + resultId, + time: command.time, + calculationVersion: current.privateCandidate.calculationVersion, + turn: current.latestTurn, + validationReceipt: latestReceipt(current), + })); + } catch (error) { + throw safeFailure(error); + } + } + requireExactVersion(current, command.turnVersion); + const resultId = current.privateCandidate.resultId; + if (current.status !== "confirming" + || current.latestTurn.candidate.status !== "ready_for_confirmation" + || !resultId + || current.privateCandidate.representativeTime !== command.time + || current.latestTurn.candidate.representativeTime !== command.time) { + throw new ConversationalRectificationError("candidate_changed"); + } + const next = changedTurn({ + current, + status: "completed", + narrative: boundedNarrative( + current.latestTurn.narrative, + current.pendingConsultationQuestion + ? "你已明确确认这个候选时间。现在可以使用新确认时间继续回答原问题。" + : "你已明确确认这个候选时间,账户当前排盘时间已原子更新。", + ), + }); + try { + return publicTurn(await ports.store.confirm({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + resultId, + time: command.time, + calculationVersion: current.privateCandidate.calculationVersion, + turn: next.turn, + validationReceipt: next.receipt, + })); + } catch (error) { + throw safeFailure(error); + } + }, + }); +} diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts new file mode 100644 index 00000000..8132dca5 --- /dev/null +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -0,0 +1,504 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +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, + StoredConversationalRectificationCase, +} from "../src/lib/conversational-rectification/store.ts"; + +const userId = "00000000-0000-4000-8000-000000000701"; +const startActionId = "00000000-0000-4000-8000-000000000702"; +const answerActionId = "00000000-0000-4000-8000-000000000703"; +const pauseActionId = "00000000-0000-4000-8000-000000000704"; +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 declaredBirthInput = { + source: "approximate" as const, + birthDate: "2000-01-01", + reportedTime: "05:20", + uncertaintyBeforeMinutes: 30 as const, + uncertaintyAfterMinutes: 30 as const, + birthTimeClue: null, + birthplace: { + countryCode: "CN", + provinceCode: "110000", + cityCode: "110000-city", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + }, +}; + +function packet(ready = false): RectificationTechnicalPacket { + return { + calculationVersion: "rectification-technical-v1", + candidate: { + status: ready ? "ready_for_confirmation" : "pending_validation", + representativeTime: ready ? "05:18" : "05:20", + range: ready + ? { startTime: "05:16", endTime: "05:20" } + : { startTime: "04:50", endTime: "05:50" }, + }, + useBoundary: ready + ? "该候选已达到确认门槛,但必须由用户明确确认后才能替换当前排盘时间。" + : "该时间仅是待验证候选,必须由用户明确确认后才能替换当前排盘时间。", + candidateModelRefs: ["candidate-model-v1"], + candidateDifferenceRefs: ["consult-d1", "consult-d9", "consult-d10"], + candidateWeights: { "05:16": 0.4, "05:18": 0.6 }, + partitionIds: ["private-partition"], + d1Stability: "stable", + boundaryDistanceMinutes: 2, + sensitivityScope: { + source: "time_linked_candidate_scan_samples", + rangeStart: ready ? "05:16" : "04:50", + rangeEnd: ready ? "05:20" : "05:50", + sampleTimes: ready ? ["05:16", "05:20"] : ["04:50", "05:20", "05:50"], + }, + stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["consult-d1"] }], + sensitiveLayers: [ + { layer: "D9", values: ["Aries", "Leo"], referenceIds: ["consult-d9"] }, + { layer: "D10", values: ["Taurus", "Libra"], referenceIds: ["consult-d10"] }, + ], + supportedSensitiveLayers: ["D9", "D10"], + scoredHistoricalEvidence: ready ? [{ + evidenceId: "00000000-0000-4000-8000-000000000709", + domain: "career", + candidateTime: "05:18", + score: 8, + ruleRefs: ["vim-career"], + }] : [], + suggestedDomains: [ + { domain: "relationship", layer: "D9", reason: "D9 在候选范围内呈现 Aries / Leo 差异,可用已发生的关系事件区分。" }, + { domain: "career", layer: "D10", reason: "D10 在候选范围内呈现 Taurus / Libra 差异,可用已发生的事业事件区分。" }, + ], + referenceIds: ["consult-d1", "consult-d9", "consult-d10"], + futureWindows: [{ + label: "未来事业背景窗口", + startDate: "2028-03-01", + endDate: "2028-05-31", + scoreable: false, + }], + }; +} + +function validGenerator(events: string[]) { + return { + modelId: "synthetic-rectification-model", + async generate(prompt: string) { + events.push("narrative"); + const request = JSON.parse(prompt) as { + phase: "first" | "intermediate" | "final"; + packet: Omit, "candidate"> & { + candidate: ReturnType["candidate"] & { + rangeStart: string; + rangeEnd: string; + }; + }; + }; + const value = request.packet; + const domains = value.suggestedDomains.map((item) => item.domain); + const narrative = [ + `${value.candidate.representativeTime} 是待验证候选。`, + "D1(Cancer)保持稳定。", + "D9(Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。", + "D10(Taurus / Libra)呈现分钟敏感差异,事业事件可区分 D10。", + request.phase === "final" ? "当前证据已形成候选总结。" : "请提供已经发生的真实事件,写明哪一年、哪一月以及发生了什么。", + "这仅是候选,必须由你确认后才会替换当前排盘时间。", + ].join(""); + return { text: JSON.stringify({ + narrative, + candidateStatus: value.candidate.status, + representativeTime: value.candidate.representativeTime, + rangeStart: value.candidate.rangeStart, + rangeEnd: value.candidate.rangeEnd, + useBoundary: value.useBoundary, + stableLayers: value.stableLayers.map((item) => item.layer), + sensitiveLayers: value.sensitiveLayers.map((item) => item.layer), + referenceIds: [], + domainReasons: value.suggestedDomains.map((item) => ({ ...item })), + evidenceRequest: request.phase === "final" ? null : { + domains, + datePrecision: "month_preferred", + prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。", + }, + }) }; + }, + }; +} + +type MutableCase = { + row: LoadedConversationalRectificationCase; +}; + +function harness(options: { + readonly packetFailure?: Error; + readonly completeFailures?: number; + readonly releaseFailure?: boolean; +} = {}) { + const events: string[] = []; + const mutations: string[] = []; + const cases = new Map(); + const receipts = new Map(); + let packetBuilds = 0; + let reserveCount = 0; + let releaseCount = 0; + let remainingCompleteFailures = options.completeFailures ?? 0; + let billingState: LoadedConversationalRectificationCase["billingState"] = null; + + function updateBillingState(next: LoadedConversationalRectificationCase["billingState"]) { + billingState = next; + for (const [caseId, value] of cases) { + cases.set(caseId, { row: { ...value.row, billingState: next } }); + } + } + + function stored(input: { + readonly userId: string; + readonly caseId: string; + readonly turn: ConversationalRectificationTurnInput; + readonly privateCandidate: LoadedConversationalRectificationCase["privateCandidate"]; + readonly evidence?: LoadedConversationalRectificationCase["eventEvidence"]; + readonly receipts?: LoadedConversationalRectificationCase["validationReceipts"]; + readonly revisionOfCaseId?: string | null; + }): LoadedConversationalRectificationCase { + return { + caseId: input.caseId, + userId: input.userId, + status: input.turn.status === "completed" ? "completed" : input.turn.status, + turnVersion: input.turn.turnVersion, + revisionOfCaseId: input.revisionOfCaseId ?? priorCaseId, + importedFromCaseId: null, + baselineActiveTime: "04:58", + pendingConsultationQuestion: input.turn.pendingConsultationQuestion, + billingState, + latestTurn: conversationalRectificationTurnSchema.parse(input.turn), + declaredBirthInput, + privateCandidate: input.privateCandidate, + eventEvidence: input.evidence ?? [], + validationReceipts: input.receipts ?? [], + }; + } + + function replay(actionId: string, input: unknown, make: () => LoadedConversationalRectificationCase) { + 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 }); + return response; + } + + const store: ConversationalRectificationServicePorts["store"] = { + async loadCase(input) { + const value = input.caseId + ? cases.get(input.caseId)?.row + : [...cases.values()].at(-1)?.row; + return value ?? null; + }, + async createCaseWithFirstTurn(input) { + events.push("create"); + mutations.push("create"); + return replay(input.actionId, input, () => { + const row = stored({ + userId: input.userId, + caseId: input.caseId, + turn: input.firstTurn, + privateCandidate: input.privateCandidate, + receipts: [input.validationReceipt], + revisionOfCaseId: input.revisionOfCaseId, + }); + cases.set(input.caseId, { row }); + return row; + }); + }, + async saveTurn(input) { + mutations.push("saveTurn"); + return replay(input.actionId, input, () => { + const current = cases.get(input.turn.caseId)?.row; + assert.ok(current); + if (current.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn"); + const row = stored({ + userId: input.userId, + caseId: input.turn.caseId, + turn: input.turn, + privateCandidate: input.privateCandidate, + evidence: [...current.eventEvidence, ...input.evidence], + receipts: [...current.validationReceipts, input.validationReceipt], + revisionOfCaseId: current.revisionOfCaseId, + }); + cases.set(input.turn.caseId, { row }); + return row; + }); + }, + async pause(input) { + mutations.push("pause"); + return replay(input.actionId, input, () => { + const current = cases.get(input.turn.caseId)?.row; + assert.ok(current); + if (current.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn"); + const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn, + receipts: [...current.validationReceipts, input.validationReceipt] }); + cases.set(input.turn.caseId, { row }); + return row; + }); + }, + async abandon(input) { + mutations.push("abandon"); + return replay(input.actionId, input, () => { + const current = cases.get(input.turn.caseId)?.row; + assert.ok(current); + const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn, + receipts: [...current.validationReceipts, input.validationReceipt] }); + cases.set(input.turn.caseId, { row }); + return row; + }); + }, + async confirm(input) { + mutations.push("confirm"); + return replay(input.actionId, input, () => { + const current = cases.get(input.turn.caseId)?.row; + assert.ok(current); + const row = stored({ ...current, userId: input.userId, caseId: input.turn.caseId, turn: input.turn, + receipts: [...current.validationReceipts, input.validationReceipt] }); + cases.set(input.turn.caseId, { row }); + return row; + }); + }, + }; + + const ports: ConversationalRectificationServicePorts = { + get rectificationPriceCredits() { + events.push("price"); + return 9; + }, + store, + billing: { + async reserve(input) { + reserveCount += 1; + events.push(`reserve:${input.price}`); + updateBillingState("reserved"); + return { success: true, credits: 91, billingState: "reserved" }; + }, + async complete() { + events.push("complete"); + if (remainingCompleteFailures > 0) { + remainingCompleteFailures -= 1; + throw new Error("complete response unavailable"); + } + updateBillingState("charged"); + return { success: true, credits: 91, billingState: "charged" }; + }, + async release() { + releaseCount += 1; + events.push("release"); + if (options.releaseFailure) throw new Error("release SQL detail"); + updateBillingState("released"); + return { success: true, credits: 100, billingState: "released" }; + }, + }, + async loadDeclaredProfile() { + events.push("profile"); + return { declaredBirthInput, revisionOfCaseId: priorCaseId }; + }, + async buildTechnicalPacket(input) { + packetBuilds += 1; + events.push(input.evidence.length > 0 ? "score-packet" : "packet"); + if (options.packetFailure) throw options.packetFailure; + return input.evidence.length > 0 + ? { packet: packet(true), resultId } + : { packet: packet(false), resultId: null }; + }, + narrativeGenerator: validGenerator(events), + asOfDate: () => "2026-07-21", + }; + + return { + events, + mutations, + cases, + service: createConversationalRectificationService(ports), + counts: () => ({ packetBuilds, reserveCount, releaseCount }), + }; +} + +async function start(value: ReturnType, pendingConsultationQuestion: string | null = "我的工作何时变化?") { + return value.service.start(userId, { + type: "start", + actionId: startActionId, + pendingConsultationQuestion, + }); +} + +test("start validates profile, reads server price, reserves, computes, saves, then charges", async () => { + const value = harness(); + const turn = await start(value); + + assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "narrative", "create", "complete"]); + assert.equal(turn.caseId, startActionId); + assert.equal(turn.turnVersion, 0); + assert.equal(turn.pendingConsultationQuestion, "我的工作何时变化?"); + assert.deepEqual(turn.technicalReceipt.sensitiveLayers, ["D9", "D10"]); + assert.equal(JSON.stringify(turn).includes("candidateWeights"), false); + assert.equal(value.cases.get(startActionId)?.row.revisionOfCaseId, priorCaseId); + assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58"); +}); + +test("start rejects a client price before profile, billing, or calculation", async () => { + const value = harness(); + await assert.rejects(value.service.start(userId, { + type: "start", actionId: startActionId, price: 0, + } as never), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "invalid_command"); + assert.deepEqual(value.events, []); +}); + +test("every post-reservation failure releases exactly once and never leaks its cause", async () => { + const raw = "SQL model browser secret detail"; + const value = harness({ packetFailure: new Error(raw) }); + await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "service_unavailable" && !error.message.includes(raw)); + assert.equal(value.counts().releaseCount, 1); + assert.deepEqual(value.events, ["profile", "price", "reserve:9", "packet", "release"]); +}); + +test("a start retry settles an existing reservation without reserving or computing again", async () => { + const value = harness({ completeFailures: 1, releaseFailure: true }); + await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "billing_failed"); + assert.equal(value.cases.get(startActionId)?.row.billingState, "reserved"); + + const replayed = await start(value); + + assert.equal(replayed.status, "active"); + assert.equal(value.cases.get(startActionId)?.row.billingState, "charged"); + assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 }); + assert.deepEqual(value.events.slice(-3), ["profile", "price", "complete"]); +}); + +test("a settlement failure releases its created case once and cannot replay as success", async () => { + const value = harness({ completeFailures: 1 }); + await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "service_unavailable"); + assert.equal(value.cases.get(startActionId)?.row.billingState, "released"); + assert.deepEqual(value.events.slice(-3), ["create", "complete", "release"]); + + await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "billing_failed"); + assert.deepEqual(value.counts(), { packetBuilds: 1, reserveCount: 1, releaseCount: 1 }); +}); + +test("clear historical evidence is extracted, scored, narrated, recapped, and atomically saved", async () => { + const value = harness(); + await start(value); + const turn = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2021年7月毕业,并在2022年3月去外地工作", + }); + + assert.equal(value.counts().packetBuilds, 2); + assert.equal(turn.status, "confirming"); + assert.equal(turn.candidate.status, "ready_for_confirmation"); + assert.equal(turn.turnVersion, 1); + assert.equal(turn.evidenceRecap.length, 2); + const saved = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(saved.length, 2); + assert.ok(saved.every((item) => item.rawText === "2021年7月毕业,并在2022年3月去外地工作")); + assert.ok(saved.every((item) => item.scoreable === true)); + assert.ok(value.events.includes("score-packet")); +}); + +test("vague, future, and unmatched answers stay conversational and never score", async () => { + for (const [answer, domain] of [ + ["后来换了工作", undefined], + ["2099年3月开始新工作", undefined], + ["这些都不符合,我想从家庭变化说起", "family"], + ] as const) { + const value = harness(); + await start(value, null); + const turn = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer, + ...(domain ? { domain } : {}), + }); + assert.equal(value.counts().packetBuilds, 1); + assert.equal(turn.status, "active"); + assert.match(turn.narrative, /年月|已发生|已经发生|换个方向|未来/); + assert.doesNotMatch(turn.narrative, /A[.、:]|B[.、:]|2006.?2011/); + assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.scoreable, false); + } +}); + +test("pause persists, resume reads it without another charge, and stale commands are stable", 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, + }); + assert.deepEqual(resumed, paused); + assert.equal(value.counts().reserveCount, 1); + await assert.rejects(value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "2021年7月毕业", + }), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "stale_turn"); +}); + +test("a lost-response retry replays the saved answer without rescoring or regenerating", async () => { + const value = harness(); + await start(value, null); + const command = { + type: "answer" as const, + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2021年7月毕业,并在2022年3月去外地工作", + }; + const first = await value.service.answer(userId, command); + const before = [...value.events]; + const replayed = await value.service.answer(userId, command); + assert.deepEqual(replayed, first); + assert.equal(value.counts().packetBuilds, 2); + assert.deepEqual(value.events, before); +}); + +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, "请继续回答原来的事业问题"); + const ready = await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "2021年7月毕业,并在2022年3月去外地工作", + }); + assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58"); + const before = value.mutations.length; + const confirmed = await value.service.confirm(userId, { + type: "confirm", caseId: startActionId, actionId: confirmActionId, + turnVersion: ready.turnVersion, time: "05:18", + }); + assert.deepEqual(value.mutations.slice(before), ["confirm"]); + assert.equal(confirmed.status, "completed"); + assert.equal(confirmed.candidate.status, "confirmed"); + assert.equal(confirmed.pendingConsultationQuestion, "请继续回答原来的事业问题"); + assert.deepEqual(confirmed.actions, ["continue_original_question"]); +}); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts new file mode 100644 index 00000000..c72510b7 --- /dev/null +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + createBirthTimeConversationPostHandler, + type BirthTimeConversationRouteService, +} from "../src/app/api/birth-time-conversation/route.ts"; +import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts"; + +const userId = "00000000-0000-4000-8000-000000000711"; +const actionId = "00000000-0000-4000-8000-000000000712"; +const caseId = "00000000-0000-4000-8000-000000000713"; +const requestId = "00000000-0000-4000-8000-000000000714"; + +const turn = { + caseId, + journeyProtocol: "conversational-evidence-v3" as const, + status: "active" as const, + turnVersion: 2, + narrative: "这是经过服务端验证的合成校正解释。", + candidate: { + status: "pending_validation" as const, + representativeTime: "05:20", + rangeStart: "05:10", + rangeEnd: "05:30", + }, + technicalReceipt: { + calculationVersion: "rectification-technical-v1", + stableLayers: ["D1"], + sensitiveLayers: ["D9", "D10"], + candidateDifferenceRefs: ["consult-d9", "consult-d10"], + }, + evidenceRequest: { + domains: ["relationship" as const, "career" as const], + datePrecision: "month_preferred" as const, + freeTextAllowed: true as const, + }, + evidenceRecap: [], + actions: ["answer" as const, "pause" as const, "abandon" as const], + pendingConsultationQuestion: null, +}; + +function request(body: unknown, events: string[]) { + return { + headers: new Headers({ "x-request-id": requestId }), + async json() { + events.push("body"); + return body; + }, + } as Request; +} + +function service(overrides: Partial = {}): BirthTimeConversationRouteService { + const response = async () => turn; + return { + start: response, + resume: response, + answer: response, + pause: response, + abandon: response, + confirm: response, + ...overrides, + }; +} + +test("authentication happens before body parsing and unauthenticated requests create no privileged service", async () => { + const events: string[] = []; + let creates = 0; + const handler = createBirthTimeConversationPostHandler({ + async authenticate() { + events.push("auth"); + return null; + }, + async createService() { + creates += 1; + return service(); + }, + createRequestId: () => requestId, + }); + const response = await handler(request({ type: "start", actionId }, events)); + assert.equal(response.status, 401); + assert.deepEqual(events, ["auth"]); + assert.equal(creates, 0); + assert.deepEqual(await response.json(), { + code: "authentication_required", + status: 401, + error: "请先登录", + message: "登录后才能继续生时校正。", + retryable: false, + }); +}); + +test("strict invalid commands return 400 before admin, billing, or service construction", async () => { + const events: string[] = []; + let creates = 0; + const handler = createBirthTimeConversationPostHandler({ + async authenticate() { + events.push("auth"); + return { userId, context: null }; + }, + async createService() { + creates += 1; + return service(); + }, + createRequestId: () => requestId, + }); + const response = await handler(request({ type: "start", actionId, price: 0 }, events)); + assert.equal(response.status, 400); + assert.deepEqual(events, ["auth", "body"]); + assert.equal(creates, 0); + assert.equal((await response.json() as { code: string }).code, "invalid_command"); +}); + +test("valid commands dispatch exactly one authenticated service method", async () => { + const events: string[] = []; + const calls: unknown[] = []; + const handler = createBirthTimeConversationPostHandler({ + async authenticate() { + events.push("auth"); + return { userId, context: { authenticated: true } }; + }, + async createService() { + events.push("service"); + return service({ + async answer(receivedUserId, command) { + calls.push([receivedUserId, command]); + return turn; + }, + }); + }, + createRequestId: () => requestId, + }); + const command = { type: "answer", caseId, actionId, turnVersion: 1, answer: "2021年7月毕业" }; + const response = await handler(request(command, events)); + assert.equal(response.status, 200); + assert.deepEqual(events, ["auth", "body", "service"]); + assert.deepEqual(calls, [[userId, command]]); + assert.deepEqual(await response.json(), turn); +}); + +test("known conflicts and unavailable failures use stable safe Chinese responses", async () => { + for (const [failure, status, code] of [ + [new ConversationalRectificationError("stale_turn"), 409, "stale_turn"], + [new ConversationalRectificationError("action_conflict"), 409, "action_conflict"], + [new ConversationalRectificationError("service_unavailable"), 503, "service_unavailable"], + ] as const) { + const logs: unknown[] = []; + const handler = createBirthTimeConversationPostHandler({ + async authenticate() { return { userId, context: null }; }, + async createService() { + return service({ async resume() { throw failure; } }); + }, + createRequestId: () => requestId, + log: (entry) => logs.push(entry), + }); + const response = await handler(request({ type: "resume", caseId, actionId, turnVersion: 1 }, [])); + const body = await response.json() as { code: string; error: string; message: string }; + assert.equal(response.status, status); + assert.equal(body.code, code); + assert.match(`${body.error}${body.message}`, /校正|服务|进度|稍后|重试/); + assert.deepEqual(logs, [{ requestId, actionId, caseId, code }]); + } +}); + +test("unknown SQL, model, and browser errors are never exposed or logged", async () => { + const raw = "duplicate key SQL WebKit DOMException model response with token=secret"; + const logs: unknown[] = []; + const handler = createBirthTimeConversationPostHandler({ + async authenticate() { return { userId, context: null }; }, + async createService() { + return service({ async pause() { throw new Error(raw); } }); + }, + createRequestId: () => requestId, + log: (entry) => logs.push(entry), + }); + const response = await handler(request({ type: "pause", caseId, actionId, turnVersion: 1 }, [])); + const serialized = JSON.stringify(await response.json()); + assert.equal(response.status, 503); + assert.equal(serialized.includes(raw), false); + assert.equal(JSON.stringify(logs).includes(raw), false); + 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/); +});