import { createHash } from "node:crypto"; 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 explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/; const genericUncertaintyPattern = /(?:不知道|不确定)/; 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; } type MutableCommand = Extract; function commandFingerprint(command: MutableCommand): string { const identity = command.type === "answer" ? [command.type, command.caseId, command.actionId, command.turnVersion, command.domain ?? null, command.answer] : command.type === "confirm" ? [command.type, command.caseId, command.actionId, command.turnVersion, command.time] : [command.type, command.caseId, command.actionId, command.turnVersion]; return createHash("sha256").update(JSON.stringify(identity), "utf8").digest("hex"); } function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn { const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn); if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); 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); } } async function replayMutation( userId: string, command: MutableCommand, actionKind: "save_turn" | "pause" | "abandon" | "confirm", fingerprint: string, ): Promise { try { const receipt = await ports.store.loadActionReceipt({ userId, caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, actionKind, commandFingerprint: fingerprint, }); return receipt ? publicTurn(receipt) : null; } catch (error) { throw safeFailure(error); } } function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] { let extracted: readonly LifeEventEvidence[]; try { 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); return publicTurn(current); }, async answer(userId, rawCommand) { const command = parseCommand("answer", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "save_turn", fingerprint); if (receipt) return receipt; const current = await load(userId, command.caseId); requireMutable(current); const evidence = extractedEvidence(command); if (current.turnVersion === command.turnVersion + 1) { try { const replayed = await ports.store.saveTurn({ userId, caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, 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 explicitDirectionChange = explicitDirectionChangePattern.test(command.answer); const directionChange = explicitDirectionChange || (scoreableEvidence.length === 0 && genericUncertaintyPattern.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, commandFingerprint: fingerprint, 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, commandFingerprint: fingerprint, 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, commandFingerprint: fingerprint, turn, evidence, validationReceipt: narrative.validationReceipt, privateCandidate, }); return publicTurn(saved); } catch (error) { throw safeFailure(error); } }, async pause(userId, rawCommand) { const command = parseCommand("pause", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "pause", fingerprint); if (receipt) return receipt; const current = await load(userId, command.caseId); if (current.turnVersion === command.turnVersion + 1 && current.status === "paused") { try { const replayed = await ports.store.pause({ userId, caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, 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, commandFingerprint: fingerprint, turn: next.turn, validationReceipt: next.receipt, })); } catch (error) { throw safeFailure(error); } }, async abandon(userId, rawCommand) { const command = parseCommand("abandon", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "abandon", fingerprint); if (receipt) return receipt; const current = await load(userId, command.caseId); if (current.turnVersion === command.turnVersion + 1 && current.status === "abandoned") { try { return publicTurn(await ports.store.abandon({ userId, caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, 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, commandFingerprint: fingerprint, turn: next.turn, validationReceipt: next.receipt, })); } catch (error) { throw safeFailure(error); } }, async confirm(userId, rawCommand) { const command = parseCommand("confirm", rawCommand); const fingerprint = commandFingerprint(command); const receipt = await replayMutation(userId, command, "confirm", fingerprint); if (receipt) return receipt; const current = await load(userId, command.caseId); if (current.turnVersion === command.turnVersion + 1 && current.status === "completed") { const resultId = current.privateCandidate.resultId; if (!resultId) throw new ConversationalRectificationError("candidate_changed"); try { return publicTurn(await ports.store.confirm({ userId, caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, 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, commandFingerprint: fingerprint, resultId, time: command.time, calculationVersion: current.privateCandidate.calculationVersion, turn: next.turn, validationReceipt: next.receipt, })); } catch (error) { throw safeFailure(error); } }, }); }