Files
Jyotisha/frontend/src/lib/conversational-rectification/orchestrator.ts
T

830 lines
31 KiB
TypeScript

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<Type extends ConversationalRectificationCommand["type"]> = 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<LifeEventEvidenceInput>;
}>;
export type ConversationalRectificationServicePorts = Readonly<{
store: Pick<ConversationalRectificationStore,
"createCaseWithFirstTurn" | "loadCase" | "loadActionReceipt" | "saveTurn" | "pause" | "abandon" | "confirm">;
billing: Pick<ConversationalRectificationBilling, "reserve" | "complete" | "release">;
rectificationPriceCredits: number;
loadDeclaredProfile(userId: string): Promise<ConversationalRectificationProfile>;
buildTechnicalPacket(
input: ConversationalRectificationPacketBuildInput,
): Promise<ComputedConversationalRectificationPacket>;
narrativeGenerator: RectificationNarrativeGenerator;
asOfDate(): string;
}>;
export type ConversationalRectificationService = Readonly<{
start(userId: string, command: CommandOf<"start">): Promise<ConversationalRectificationTurn>;
resume(userId: string, command: CommandOf<"resume">): Promise<ConversationalRectificationTurn>;
answer(userId: string, command: CommandOf<"answer">): Promise<ConversationalRectificationTurn>;
pause(userId: string, command: CommandOf<"pause">): Promise<ConversationalRectificationTurn>;
abandon(userId: string, command: CommandOf<"abandon">): Promise<ConversationalRectificationTurn>;
confirm(userId: string, command: CommandOf<"confirm">): Promise<ConversationalRectificationTurn>;
}>;
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 extends ConversationalRectificationCommand["type"]>(
type: Type,
value: unknown,
): CommandOf<Type> {
const parsed = conversationalRectificationCommandSchema.safeParse(value);
if (!parsed.success || parsed.data.type !== type) {
throw new ConversationalRectificationError("invalid_command");
}
return parsed.data as CommandOf<Type>;
}
type MutableCommand = Extract<ConversationalRectificationCommand, {
readonly type: "answer" | "pause" | "abandon" | "confirm";
}>;
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<LifeEventEvidenceInput>) {
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<LifeEventEvidenceInput>;
}): 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<LifeEventEvidenceInput>;
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<ConversationalRectificationTurn | null> {
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);
}
},
});
}