1836 lines
70 KiB
TypeScript
1836 lines
70 KiB
TypeScript
import { createHash } from "node:crypto";
|
||
import {
|
||
conversationalRectificationCommandSchema,
|
||
conversationalRectificationTurnSchema,
|
||
type ConversationalRectificationCommand,
|
||
type RectificationFollowUp,
|
||
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 RectificationConversationMessage,
|
||
type RectificationNarrativeGenerator,
|
||
type RectificationNarrativeResult,
|
||
} from "./narrative-agent.ts";
|
||
import {
|
||
projectRectificationTechnicalPacket,
|
||
type RectificationEvidenceDomain,
|
||
type RectificationTechnicalPacket,
|
||
} from "./technical-packet.ts";
|
||
import {
|
||
convergenceNotes,
|
||
nextPlateauCount,
|
||
shouldCompleteBoundedResult,
|
||
} from "./convergence.ts";
|
||
import { RECTIFICATION_POLICY } from "../rectification-policy.ts";
|
||
import type { ConversationalRectificationBilling } from "./billing.ts";
|
||
import {
|
||
projectLegacyCaseForConversationalImport,
|
||
type LegacyConversationalImportSource,
|
||
} from "./legacy-import.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;
|
||
legacyCaseId?: string | null;
|
||
}>;
|
||
|
||
export type ConversationalRectificationPacketBuildInput = Readonly<{
|
||
userId: string;
|
||
caseId: string;
|
||
asOfDate: string;
|
||
declaredBirthInput: DeclaredBirthInput;
|
||
privateCandidate: PrivateCandidateInput | null;
|
||
evidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||
preserveCandidateRange?: true;
|
||
}>;
|
||
|
||
export type ConversationalRectificationServicePorts = Readonly<{
|
||
store: Pick<ConversationalRectificationStore,
|
||
"createCaseWithFirstTurn" | "loadCase" | "loadActionReceipt" | "saveTurn" | "pause" | "abandon" | "confirm">
|
||
& Partial<Pick<ConversationalRectificationStore, "importLegacy">>;
|
||
billing: Pick<ConversationalRectificationBilling, "reserve" | "complete" | "release">;
|
||
rectificationPriceCredits: number;
|
||
allowNewCaseCreation?: boolean;
|
||
loadDeclaredProfile(userId: string): Promise<ConversationalRectificationProfile>;
|
||
loadLegacyCase?(
|
||
userId: string,
|
||
legacyCaseId: string,
|
||
): Promise<LegacyConversationalImportSource | null>;
|
||
buildTechnicalPacket(
|
||
input: ConversationalRectificationPacketBuildInput,
|
||
): Promise<ComputedConversationalRectificationPacket>;
|
||
loadConversationMessages?(
|
||
userId: string,
|
||
caseId: string,
|
||
): Promise<ReadonlyArray<RectificationConversationMessage>>;
|
||
narrativeGenerator: RectificationNarrativeGenerator;
|
||
asOfDate(): string;
|
||
}>;
|
||
|
||
export type ConversationalRectificationService = Readonly<{
|
||
importLegacyCase(
|
||
userId: string,
|
||
legacyCaseId: string,
|
||
actionId: string,
|
||
pendingConsultationQuestion?: string | null,
|
||
): Promise<ConversationalRectificationTurn>;
|
||
start(userId: string, command: CommandOf<"start">): Promise<ConversationalRectificationTurn>;
|
||
resume(userId: string, command: CommandOf<"resume">): Promise<ConversationalRectificationTurn>;
|
||
answer(userId: string, command: CommandOf<"answer">): Promise<ConversationalRectificationTurn>;
|
||
regenerate(userId: string, command: CommandOf<"regenerate">): 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>;
|
||
}>;
|
||
|
||
export type ConversationalRectificationTelemetryOutcome = Readonly<{
|
||
billingState: "not_applicable" | "charged" | "released" | "migration_waived" | "unchanged" | "unknown";
|
||
caseStatus: ConversationalRectificationTurn["status"] | null;
|
||
}>;
|
||
|
||
const telemetryOutcomes = new WeakMap<
|
||
ConversationalRectificationService,
|
||
() => ConversationalRectificationTelemetryOutcome
|
||
>();
|
||
|
||
export function conversationalRectificationTelemetryOutcome(
|
||
service: ConversationalRectificationService,
|
||
): ConversationalRectificationTelemetryOutcome | null {
|
||
return telemetryOutcomes.get(service)?.() ?? null;
|
||
}
|
||
|
||
const transitionValidatorVersion = "conversational-rectification-orchestrator-v1";
|
||
const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/;
|
||
const genericUncertaintyPattern = /(?:不知道|不确定)/;
|
||
const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/;
|
||
const contextualBareMonthDayPattern = /^\s*(\d{1,2})\s*月\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/;
|
||
const contextualBareDayPattern = /^\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/;
|
||
const affirmativeAnswerPattern = /^\s*(?:是(?:的)?|对(?:的)?|没错|正确|确认|就是|嗯+|没问题)\s*[。.!!,,]?\s*$/u;
|
||
const negativeAnswerPattern = /^\s*(?:不是|不对|错了|并不是|否)\s*[。.!!,,]?\s*$/u;
|
||
|
||
export function evidencePredatesBirthDate(
|
||
evidence: Pick<LifeEventEvidence, "dateValue" | "datePrecision">,
|
||
birthDate: string,
|
||
): boolean {
|
||
if (!evidence.dateValue) return false;
|
||
const boundary = evidence.datePrecision === "year"
|
||
? birthDate.slice(0, 4)
|
||
: evidence.datePrecision === "month"
|
||
? birthDate.slice(0, 7)
|
||
: evidence.datePrecision === "day"
|
||
? birthDate
|
||
: null;
|
||
return boundary !== null && evidence.dateValue < boundary;
|
||
}
|
||
|
||
function evidencePostdatesAsOfDate(
|
||
evidence: Pick<LifeEventEvidence, "dateValue" | "datePrecision">,
|
||
asOfDate: string,
|
||
): boolean {
|
||
if (!evidence.dateValue) return false;
|
||
const boundary = evidence.datePrecision === "year"
|
||
? asOfDate.slice(0, 4)
|
||
: evidence.datePrecision === "month"
|
||
? asOfDate.slice(0, 7)
|
||
: evidence.datePrecision === "day"
|
||
? asOfDate
|
||
: null;
|
||
return boundary !== null && evidence.dateValue > boundary;
|
||
}
|
||
|
||
function evidenceForDeclaredBirthDate(
|
||
evidence: readonly LifeEventEvidence[],
|
||
birthDate: string,
|
||
): readonly LifeEventEvidence[] {
|
||
return evidence.map((item) => item.scoreable === true
|
||
&& evidencePredatesBirthDate(item, birthDate)
|
||
? { ...item, extractionStatus: "needs_clarification" as const, scoreable: false }
|
||
: item);
|
||
}
|
||
|
||
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" | "regenerate" | "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.correctsEvidenceId ?? null]
|
||
: 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 sameDeclaredBirthInput(
|
||
left: unknown,
|
||
right: unknown,
|
||
): boolean {
|
||
const leftParsed = declaredBirthInputSchema.safeParse(left);
|
||
const rightParsed = declaredBirthInputSchema.safeParse(right);
|
||
return leftParsed.success
|
||
&& rightParsed.success
|
||
&& stableJson(leftParsed.data) === stableJson(rightParsed.data);
|
||
}
|
||
|
||
function stableJson(value: unknown): string {
|
||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||
if (value !== null && typeof value === "object") {
|
||
return `{${Object.entries(value as Record<string, unknown>)
|
||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||
.map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`)
|
||
.join(",")}}`;
|
||
}
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
function visibleEvidenceSummary(value: string): string {
|
||
const cleaned = value.replace(/(?:发生时间|事件详情)\s*[::]\s*/g, "").trim();
|
||
return cleaned || value;
|
||
}
|
||
|
||
function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn {
|
||
const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn);
|
||
if (!parsed.success) throw new ConversationalRectificationError("store_unavailable");
|
||
const evidenceDomains = new Map(
|
||
effectiveLifeEventEvidence(value.eventEvidence ?? []).map((item) => [item.id, item.domain]),
|
||
);
|
||
return {
|
||
...parsed.data,
|
||
evidenceRecap: parsed.data.evidenceRecap.map((item) => ({
|
||
...item,
|
||
summary: visibleEvidenceSummary(item.summary),
|
||
...(item.domain ? {} : evidenceDomains.get(item.id)
|
||
? { domain: evidenceDomains.get(item.id) }
|
||
: {}),
|
||
})),
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export function effectiveLifeEventEvidence<
|
||
Evidence extends Readonly<{
|
||
id: string;
|
||
correctsEvidenceIds?: readonly string[];
|
||
}>,
|
||
>(evidence: ReadonlyArray<Evidence>): ReadonlyArray<Evidence> {
|
||
const correctedIds = new Set<string>();
|
||
for (const item of evidence) {
|
||
for (const correctedId of item.correctsEvidenceIds ?? []) correctedIds.add(correctedId);
|
||
}
|
||
return evidence.filter((item) => !correctedIds.has(item.id));
|
||
}
|
||
|
||
function normalizedEventSemantics(value: string): string {
|
||
return value
|
||
.normalize("NFKC")
|
||
.toLocaleLowerCase("zh-CN")
|
||
.replace(/^(?:我|本人|其实|准确(?:的是)?|更正(?:为|成|:|:)*)+/u, "")
|
||
.replace(/[\p{P}\p{S}\s]+/gu, "");
|
||
}
|
||
|
||
const scoringEvidenceDomains = new Set<RectificationEvidenceDomain>([
|
||
"career",
|
||
"education",
|
||
"finance",
|
||
"health_pressure",
|
||
"relocation",
|
||
"relationship",
|
||
]);
|
||
|
||
function hasScoringEvidenceDomain(item: Pick<LifeEventEvidenceInput, "domain">): boolean {
|
||
return scoringEvidenceDomains.has(item.domain);
|
||
}
|
||
|
||
function uniqueScoreableLifeEventEvidence(
|
||
evidence: ReadonlyArray<LifeEventEvidenceInput>,
|
||
birthDate: string,
|
||
): ReadonlyArray<LifeEventEvidenceInput> {
|
||
const seen = new Set<string>();
|
||
return effectiveLifeEventEvidence(evidence)
|
||
.filter((item) => item.scoreable === true
|
||
&& hasScoringEvidenceDomain(item)
|
||
&& item.extractionStatus !== "needs_clarification"
|
||
&& !evidencePredatesBirthDate(item, birthDate))
|
||
.filter((item) => {
|
||
const identity = [
|
||
item.dateValue ?? "",
|
||
item.domain,
|
||
normalizedEventSemantics(item.eventSummary),
|
||
].join("|");
|
||
if (seen.has(identity)) return false;
|
||
seen.add(identity);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
|
||
return effectiveLifeEventEvidence(evidence).slice(-20).map((item) => ({
|
||
id: item.id,
|
||
summary: visibleEvidenceSummary(item.eventSummary),
|
||
dateLabel: item.dateValue
|
||
? item.scoreable === false && item.extractionStatus !== "needs_clarification"
|
||
? `${item.dateValue}(未来,仅作背景)`
|
||
: item.dateValue
|
||
: "日期待补充",
|
||
domain: item.domain,
|
||
...((item.correctsEvidenceIds?.length ?? 0) > 0 ? { isCorrection: true } : {}),
|
||
}));
|
||
}
|
||
|
||
function narrativeConversationContext(input: Readonly<{
|
||
recentConversation?: ReadonlyArray<RectificationConversationMessage>;
|
||
latestUserText: string;
|
||
previousAssistantNarrative?: string;
|
||
previousEvidencePrompt?: string;
|
||
previousFollowUp?: RectificationFollowUp;
|
||
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||
}>) {
|
||
const activeEvidence = effectiveLifeEventEvidence(input.allEvidence);
|
||
const activeIds = new Set(activeEvidence.map((item) => item.id));
|
||
return {
|
||
recentConversation: input.recentConversation,
|
||
latestUserText: input.latestUserText.trim().slice(0, 4_000),
|
||
previousAssistantNarrative: input.previousAssistantNarrative,
|
||
previousEvidencePrompt: input.previousEvidencePrompt,
|
||
previousFollowUp: input.previousFollowUp,
|
||
latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({
|
||
id: item.id,
|
||
dateLabel: item.dateLabel,
|
||
summary: item.summary,
|
||
domain: item.domain,
|
||
})),
|
||
eventLedger: input.allEvidence.slice(-40).map((item) => ({
|
||
id: item.id,
|
||
rawText: item.rawText,
|
||
dateLabel: item.dateValue ?? "日期待补充",
|
||
summary: visibleEvidenceSummary(item.eventSummary),
|
||
domain: item.domain,
|
||
extractionStatus: item.extractionStatus,
|
||
active: activeIds.has(item.id),
|
||
correctsEvidenceIds: [...(item.correctsEvidenceIds ?? [])],
|
||
})),
|
||
unresolvedEvidence: activeEvidence
|
||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||
.slice(-20)
|
||
.map((item) => ({
|
||
id: item.id,
|
||
rawText: item.rawText,
|
||
summary: visibleEvidenceSummary(item.eventSummary),
|
||
domain: item.domain,
|
||
dateLabel: 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 confirmationGatedPacket(
|
||
packet: RectificationTechnicalPacket,
|
||
scoreableEventCount: number,
|
||
scoreableDomainCount: number,
|
||
): RectificationTechnicalPacket {
|
||
if (packet.candidate.status !== "ready_for_confirmation"
|
||
|| (scoreableEventCount >= RECTIFICATION_POLICY.minConfirmationEvents
|
||
&& scoreableDomainCount >= RECTIFICATION_POLICY.minConfirmationDomains)) return packet;
|
||
return {
|
||
...packet,
|
||
candidate: { ...packet.candidate, status: "pending_validation" },
|
||
useBoundary: `当前候选仍需至少 ${RECTIFICATION_POLICY.minConfirmationEvents} 条时间明确、覆盖 ${RECTIFICATION_POLICY.minConfirmationDomains} 个领域的真实经历验证,不能作为已经校正完成的出生分钟。`,
|
||
};
|
||
}
|
||
|
||
function scoreableDomains(
|
||
evidence: ReadonlyArray<LifeEventEvidenceInput>,
|
||
): Set<RectificationEvidenceDomain> {
|
||
return new Set(evidence.map((item) => item.domain));
|
||
}
|
||
|
||
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,
|
||
prompt: input.narrative.output.evidenceRequest.prompt,
|
||
followUp: input.narrative.output.evidenceRequest.followUp,
|
||
}
|
||
: 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 boundedResultTurn(
|
||
input: Parameters<typeof turnFromNarrative>[0],
|
||
): ConversationalRectificationTurn {
|
||
const turn = turnFromNarrative(input);
|
||
return conversationalRectificationTurnSchema.parse({
|
||
...turn,
|
||
status: "completed",
|
||
candidate: { ...turn.candidate, status: "pending_validation" },
|
||
evidenceRequest: null,
|
||
actions: turn.pendingConsultationQuestion ? ["continue_original_question"] : [],
|
||
});
|
||
}
|
||
|
||
function privateCandidateFromPacket(input: {
|
||
readonly packet: RectificationTechnicalPacket;
|
||
readonly resultId: string | null;
|
||
readonly iteration: number;
|
||
readonly forceCollecting?: boolean;
|
||
readonly notes?: readonly string[];
|
||
}): 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: input.forceCollecting
|
||
? "collecting_evidence"
|
||
: packet.candidate.status === "ready_for_confirmation" ? "ready" : "collecting_evidence",
|
||
iteration: input.iteration,
|
||
notes: [...(input.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 midpointOfRange(range: Readonly<{ startTime: string; endTime: string }>): string {
|
||
const minute = (value: string) => {
|
||
const [hour = 0, part = 0] = value.split(":").map(Number);
|
||
return hour * 60 + part;
|
||
};
|
||
const clock = (value: number) => {
|
||
const normalized = ((value % 1_440) + 1_440) % 1_440;
|
||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||
};
|
||
const start = minute(range.startTime);
|
||
let end = minute(range.endTime);
|
||
if (end < start) end += 1_440;
|
||
return clock(Math.round((start + end) / 2));
|
||
}
|
||
|
||
type CorrectionResetReason =
|
||
| "needs_clarification"
|
||
| "non_scoreable"
|
||
| "direction_change";
|
||
|
||
function nonScoringTurn(input: {
|
||
readonly current: LoadedConversationalRectificationCase;
|
||
readonly newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||
readonly latestUserText: string;
|
||
readonly authoredNarrative?: RectificationNarrativeResult | null;
|
||
readonly followUpOverride?: RectificationFollowUp;
|
||
readonly correctionReset?: Readonly<{
|
||
packet: RectificationTechnicalPacket;
|
||
reason: CorrectionResetReason;
|
||
}>;
|
||
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
|
||
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
|
||
const latestIncomplete = input.newEvidence
|
||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||
.at(-1);
|
||
const authoredNarrative = input.authoredNarrative;
|
||
const latestSummary = input.newEvidence.at(-1)?.eventSummary;
|
||
const fallbackSubject = latestSummary && latestSummary !== "事件内容待补充"
|
||
? latestSummary
|
||
: input.latestUserText.trim().slice(0, 80);
|
||
const narrative = authoredNarrative?.narrative
|
||
?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,但这次分析暂时没有完成。内容会保留,你可以继续补充它的时间和经过,或直接说下一件已经发生的经历。`;
|
||
const status = input.correctionReset
|
||
? "active" as const
|
||
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
|
||
const actions = actionsFor(status);
|
||
const clarificationFollowUp = latestIncomplete?.dateValue === null
|
||
&& latestIncomplete.eventSummary !== "事件内容待补充"
|
||
? { kind: "event_date" as const, evidenceId: latestIncomplete.id }
|
||
: latestIncomplete?.dateValue
|
||
&& latestIncomplete.eventSummary === "事件内容待补充"
|
||
? { kind: "event_detail" as const, evidenceId: latestIncomplete.id }
|
||
: null;
|
||
const authoredRequest = authoredNarrative?.output.evidenceRequest;
|
||
const priorRequest = input.current.latestTurn.evidenceRequest;
|
||
const evidenceRequest = status === "confirming" && priorRequest === null
|
||
? null
|
||
: authoredRequest
|
||
? {
|
||
domains: authoredRequest.domains,
|
||
datePrecision: authoredRequest.datePrecision,
|
||
freeTextAllowed: true as const,
|
||
prompt: authoredRequest.prompt,
|
||
followUp: input.followUpOverride ?? authoredRequest.followUp,
|
||
}
|
||
: priorRequest
|
||
? {
|
||
...priorRequest,
|
||
followUp: input.followUpOverride ?? clarificationFollowUp ?? priorRequest.followUp,
|
||
}
|
||
: null;
|
||
const parsed = conversationalRectificationTurnSchema.safeParse({
|
||
...input.current.latestTurn,
|
||
status,
|
||
turnVersion: input.current.turnVersion + 1,
|
||
narrative,
|
||
evidenceRequest,
|
||
evidenceRecap: evidenceRecap(allEvidence),
|
||
actions,
|
||
...(input.correctionReset ? {
|
||
candidate: {
|
||
...projectRectificationTechnicalPacket(input.correctionReset.packet).candidate,
|
||
status: "pending_validation" as const,
|
||
},
|
||
technicalReceipt: exactTechnicalReceipt(input.correctionReset.packet),
|
||
} : {}),
|
||
});
|
||
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
|
||
return {
|
||
turn: parsed.data,
|
||
receipt: authoredNarrative
|
||
? validationReceiptSchema.parse(authoredNarrative.validationReceipt)
|
||
: validationReceiptSchema.parse({
|
||
modelId: "deterministic-evidence-failure-continuation",
|
||
schemaValidated: false,
|
||
validatorVersion: transitionValidatorVersion,
|
||
retryCount: 0,
|
||
fallbackUsed: true,
|
||
issues: ["technical_or_narrative_generation_failed"],
|
||
}),
|
||
};
|
||
}
|
||
|
||
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 {
|
||
let lastTelemetryOutcome: ConversationalRectificationTelemetryOutcome = {
|
||
billingState: "not_applicable",
|
||
caseStatus: null,
|
||
};
|
||
function resetTelemetryOutcome() {
|
||
lastTelemetryOutcome = { billingState: "not_applicable", caseStatus: null };
|
||
}
|
||
function observeCase(
|
||
value: LoadedConversationalRectificationCase | StoredConversationalRectificationCase,
|
||
billingState: ConversationalRectificationTelemetryOutcome["billingState"] = "unchanged",
|
||
) {
|
||
lastTelemetryOutcome = { billingState, caseStatus: publicTurn(value).status };
|
||
}
|
||
|
||
async function load(userId: string, caseId: string) {
|
||
try {
|
||
const current = requireLoaded(await ports.store.loadCase({ userId, caseId }));
|
||
observeCase(current);
|
||
return current;
|
||
} 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,
|
||
});
|
||
if (!receipt) return null;
|
||
observeCase(receipt);
|
||
return publicTurn(receipt);
|
||
} catch (error) {
|
||
throw safeFailure(error);
|
||
}
|
||
}
|
||
|
||
async function importLegacyCase(
|
||
userId: string,
|
||
legacyCaseId: string,
|
||
actionId: string,
|
||
pendingConsultationQuestion: string | null = null,
|
||
): Promise<ConversationalRectificationTurn> {
|
||
resetTelemetryOutcome();
|
||
const importer = ports.store.importLegacy;
|
||
const loadLegacy = ports.loadLegacyCase;
|
||
if (!importer || !loadLegacy) {
|
||
throw new ConversationalRectificationError("service_unavailable");
|
||
}
|
||
|
||
try {
|
||
const existingByAction = await ports.store.loadCase({ userId, caseId: actionId });
|
||
if (existingByAction) {
|
||
observeCase(existingByAction);
|
||
if (existingByAction.importedFromCaseId !== legacyCaseId
|
||
|| existingByAction.billingState !== "migration_waived"
|
||
|| existingByAction.pendingConsultationQuestion !== pendingConsultationQuestion) {
|
||
throw new ConversationalRectificationError("action_conflict");
|
||
}
|
||
return publicTurn(existingByAction);
|
||
}
|
||
const current = await ports.store.loadCase({ userId });
|
||
if (current?.importedFromCaseId === legacyCaseId
|
||
&& current.billingState === "migration_waived"
|
||
&& current.pendingConsultationQuestion === pendingConsultationQuestion) {
|
||
observeCase(current);
|
||
return publicTurn(current);
|
||
}
|
||
if (current?.importedFromCaseId === legacyCaseId
|
||
&& current.billingState === "migration_waived") {
|
||
throw new ConversationalRectificationError("action_conflict");
|
||
}
|
||
if (ports.allowNewCaseCreation === false) {
|
||
throw new ConversationalRectificationError("service_unavailable");
|
||
}
|
||
|
||
const legacy = await loadLegacy(userId, legacyCaseId);
|
||
if (!legacy) throw new ConversationalRectificationError("case_not_found");
|
||
const projected = projectLegacyCaseForConversationalImport({
|
||
source: legacy,
|
||
asOfDate: ports.asOfDate(),
|
||
expectedUserId: userId,
|
||
});
|
||
const rangeSeed = privateCandidateSchema.parse({
|
||
resultId: null,
|
||
representativeTime: midpointOfRange(projected.currentRange),
|
||
rangeStart: projected.currentRange.startTime,
|
||
rangeEnd: projected.currentRange.endTime,
|
||
calculationVersion: "legacy-import-range-v1",
|
||
workingState: { phase: "collecting_evidence", iteration: 0, notes: [] },
|
||
});
|
||
const computed = await ports.buildTechnicalPacket({
|
||
userId,
|
||
caseId: actionId,
|
||
asOfDate: ports.asOfDate(),
|
||
declaredBirthInput: projected.declaredBirthInput,
|
||
privateCandidate: rangeSeed,
|
||
evidence: projected.evidence,
|
||
preserveCandidateRange: true,
|
||
});
|
||
const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0);
|
||
const narrative = await generateRectificationNarrative({
|
||
phase: "first",
|
||
packet: gatedPacket,
|
||
generator: ports.narrativeGenerator,
|
||
});
|
||
const privateCandidate = privateCandidateFromPacket({
|
||
packet: gatedPacket,
|
||
resultId: null,
|
||
iteration: 0,
|
||
forceCollecting: true,
|
||
});
|
||
const firstTurn = turnFromNarrative({
|
||
caseId: actionId,
|
||
turnVersion: 0,
|
||
pendingConsultationQuestion,
|
||
packet: gatedPacket,
|
||
narrative,
|
||
evidence: projected.evidence,
|
||
});
|
||
const imported = await importer.call(ports.store, {
|
||
userId,
|
||
caseId: actionId,
|
||
expectedVersion: projected.expectedVersion,
|
||
actionId,
|
||
legacyCaseId,
|
||
price: ports.rectificationPriceCredits,
|
||
pendingConsultationQuestion,
|
||
declaredBirthInput: projected.declaredBirthInput,
|
||
evidence: projected.evidence,
|
||
firstTurn,
|
||
validationReceipt: narrative.validationReceipt,
|
||
privateCandidate,
|
||
});
|
||
observeCase(imported, "migration_waived");
|
||
return publicTurn(imported);
|
||
} catch (error) {
|
||
if (error instanceof ConversationalRectificationError
|
||
&& error.code === "action_conflict") {
|
||
try {
|
||
const winner = await ports.store.loadCase({ userId });
|
||
if (winner?.importedFromCaseId === legacyCaseId
|
||
&& winner.billingState === "migration_waived"
|
||
&& winner.pendingConsultationQuestion === pendingConsultationQuestion) {
|
||
observeCase(winner);
|
||
return publicTurn(winner);
|
||
}
|
||
} catch {
|
||
// Preserve the original stable conflict below.
|
||
}
|
||
}
|
||
throw safeFailure(error);
|
||
}
|
||
}
|
||
|
||
function contextualizedAnswer(
|
||
command: CommandOf<"answer">,
|
||
current: LoadedConversationalRectificationCase,
|
||
): string {
|
||
const followUp = current.latestTurn.evidenceRequest?.followUp;
|
||
if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") {
|
||
return command.answer;
|
||
}
|
||
const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence);
|
||
const target = followUp.evidenceId
|
||
? activeEvidence.find((item) => item.id === followUp.evidenceId)
|
||
: null;
|
||
const anchor = target?.dateValue
|
||
? target
|
||
: activeEvidence.filter((item) => item.dateValue !== null).at(-1);
|
||
const anchorYear = Number(anchor?.dateValue?.slice(0, 4));
|
||
if (!Number.isInteger(anchorYear)) return command.answer;
|
||
|
||
const bareMonthDay = followUp.kind === "event_date"
|
||
? command.answer.match(contextualBareMonthDayPattern)
|
||
: null;
|
||
if (bareMonthDay) {
|
||
return `${anchorYear}年${Number(bareMonthDay[1])}月${Number(bareMonthDay[2])}号`;
|
||
}
|
||
const bareDay = followUp.kind === "event_date"
|
||
? command.answer.match(contextualBareDayPattern)
|
||
: null;
|
||
const anchorMonth = Number(anchor?.dateValue?.slice(5, 7));
|
||
if (bareDay && Number.isInteger(anchorMonth)) {
|
||
return `${anchorYear}年${anchorMonth}月${Number(bareDay[1])}号`;
|
||
}
|
||
|
||
const match = command.answer.match(contextualRelativeMonthPattern);
|
||
if (!match) return command.answer;
|
||
const month = Number(match[1]);
|
||
if (month < 1 || month > 12) return command.answer;
|
||
const sameYear = /(?:同年|当年|那年)/.test(match[0]);
|
||
return command.answer.replace(match[0], `${sameYear ? anchorYear : anchorYear + 1}年${month}月`);
|
||
}
|
||
|
||
async function conversationContext(input: Readonly<{
|
||
userId: string;
|
||
current: LoadedConversationalRectificationCase;
|
||
latestUserText: string;
|
||
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||
}>) {
|
||
let recentConversation: ReadonlyArray<RectificationConversationMessage> = [{
|
||
role: "assistant",
|
||
text: input.current.latestTurn.narrative,
|
||
}];
|
||
if (ports.loadConversationMessages) {
|
||
try {
|
||
const loaded = await ports.loadConversationMessages(input.userId, input.current.caseId);
|
||
if (loaded.length > 0) recentConversation = loaded;
|
||
} catch {
|
||
// Conversation history improves continuity but must not make a turn unavailable.
|
||
}
|
||
}
|
||
return narrativeConversationContext({
|
||
recentConversation: [...recentConversation, { role: "user", text: input.latestUserText }],
|
||
latestUserText: input.latestUserText,
|
||
previousAssistantNarrative: input.current.latestTurn.narrative,
|
||
previousEvidencePrompt: input.current.latestTurn.evidenceRequest?.prompt,
|
||
previousFollowUp: input.current.latestTurn.evidenceRequest?.followUp,
|
||
allEvidence: input.allEvidence,
|
||
newEvidence: input.newEvidence,
|
||
});
|
||
}
|
||
|
||
type StructuredFollowUpResolution =
|
||
| Readonly<{ kind: "confirmed"; evidence: readonly LifeEventEvidence[] }>
|
||
| Readonly<{ kind: "rejected"; evidence: readonly []; followUp: RectificationFollowUp }>
|
||
| null;
|
||
|
||
function resolveStructuredFollowUp(
|
||
command: CommandOf<"answer">,
|
||
current: LoadedConversationalRectificationCase,
|
||
): StructuredFollowUpResolution {
|
||
const followUp = current.latestTurn.evidenceRequest?.followUp;
|
||
if (followUp?.kind !== "event_date"
|
||
|| followUp.answerMode !== "yes_no"
|
||
|| !followUp.evidenceId
|
||
|| !followUp.proposedDate) {
|
||
return null;
|
||
}
|
||
const target = effectiveLifeEventEvidence(current.eventEvidence)
|
||
.find((item) => item.id === followUp.evidenceId);
|
||
if (!target) return null;
|
||
|
||
if (negativeAnswerPattern.test(command.answer)) {
|
||
return {
|
||
kind: "rejected",
|
||
evidence: [],
|
||
followUp: {
|
||
kind: "event_date",
|
||
evidenceId: target.id,
|
||
answerMode: "free_text",
|
||
proposedDate: null,
|
||
},
|
||
};
|
||
}
|
||
if (!affirmativeAnswerPattern.test(command.answer)) return null;
|
||
|
||
const merged = extractLifeEventEvidence({
|
||
rawText: `${followUp.proposedDate.value} ${target.eventSummary}`,
|
||
sourceTurnId: command.actionId,
|
||
asOfDate: ports.asOfDate(),
|
||
correctsEvidenceId: target.id,
|
||
});
|
||
if (merged.length !== 1) return null;
|
||
return {
|
||
kind: "confirmed",
|
||
evidence: merged.map((item) => ({
|
||
...item,
|
||
rawText: `${target.rawText}\n确认:${command.answer}`,
|
||
eventSummary: target.eventSummary,
|
||
domain: target.domain,
|
||
correctsEvidenceIds: [target.id],
|
||
})),
|
||
};
|
||
}
|
||
|
||
async function extractedEvidence(
|
||
command: CommandOf<"answer">,
|
||
current: LoadedConversationalRectificationCase,
|
||
): Promise<readonly LifeEventEvidence[]> {
|
||
let extracted: readonly LifeEventEvidence[];
|
||
try {
|
||
const answerForExtraction = contextualizedAnswer(command, current);
|
||
if ((affirmativeAnswerPattern.test(command.answer) || negativeAnswerPattern.test(command.answer))
|
||
&& answerForExtraction === command.answer) {
|
||
return [];
|
||
}
|
||
extracted = extractLifeEventEvidence({
|
||
rawText: answerForExtraction,
|
||
sourceTurnId: command.actionId,
|
||
asOfDate: ports.asOfDate(),
|
||
correctsEvidenceId: command.correctsEvidenceId,
|
||
}).map((item) => ({
|
||
...item,
|
||
rawText: command.answer,
|
||
correctsEvidenceIds: [...item.correctsEvidenceIds],
|
||
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");
|
||
if (command.correctsEvidenceId && extracted.length !== 1) {
|
||
throw new ConversationalRectificationError("invalid_command");
|
||
}
|
||
const ambiguous = extracted.length === 1 && extracted[0]?.domain === "other"
|
||
? extracted[0]
|
||
: null;
|
||
if (ambiguous && ports.narrativeGenerator.classifyEvidenceDomain) {
|
||
try {
|
||
const domain = await ports.narrativeGenerator.classifyEvidenceDomain({
|
||
text: command.answer,
|
||
recentEvidence: effectiveLifeEventEvidence(current.eventEvidence).slice(-6).map((item) => ({
|
||
summary: item.eventSummary,
|
||
domain: item.domain,
|
||
})),
|
||
}, { signal: AbortSignal.timeout(8_000) });
|
||
if (domain && domain !== "other") {
|
||
return [{ ...ambiguous, domain }];
|
||
}
|
||
} catch {
|
||
// Semantic classification is advisory. Keep the deterministic fallback
|
||
// instead of blocking the user's event when the model is unavailable.
|
||
}
|
||
}
|
||
return extracted;
|
||
}
|
||
|
||
function completeLatestClarification(input: Readonly<{
|
||
command: CommandOf<"answer">;
|
||
current: LoadedConversationalRectificationCase;
|
||
extracted: readonly LifeEventEvidence[];
|
||
}>): readonly LifeEventEvidence[] {
|
||
if (input.command.correctsEvidenceId || input.extracted.length !== 1) return input.extracted;
|
||
const activeEvidence = effectiveLifeEventEvidence(input.current.eventEvidence);
|
||
const declaredFollowUp = input.current.latestTurn.evidenceRequest?.followUp;
|
||
const supplied = input.extracted[0];
|
||
if (declaredFollowUp?.kind === "new_event") return input.extracted;
|
||
const followUp = declaredFollowUp;
|
||
const pending = followUp?.evidenceId
|
||
? activeEvidence.find((item) => item.id === followUp.evidenceId)
|
||
: activeEvidence.filter((item) => item.extractionStatus === "needs_clarification").at(-1);
|
||
if (!pending || !supplied) return input.extracted;
|
||
|
||
const pendingHasSummary = pending.eventSummary !== "事件内容待补充";
|
||
const suppliedHasSummary = supplied.eventSummary !== "事件内容待补充";
|
||
const suppliedMatchesPending = !suppliedHasSummary
|
||
|| supplied.domain === "other"
|
||
|| supplied.domain === pending.domain;
|
||
const fillsMissingDate = followUp?.kind !== "event_detail"
|
||
&& pending.dateValue === null
|
||
&& supplied.dateValue !== null
|
||
&& !evidencePostdatesAsOfDate(supplied, ports.asOfDate())
|
||
&& pendingHasSummary
|
||
&& suppliedMatchesPending;
|
||
const refinesKnownDate = followUp?.kind === "event_date"
|
||
&& pending.dateValue !== null
|
||
&& supplied.dateValue !== null
|
||
&& supplied.dateValue.startsWith(`${pending.dateValue}-`)
|
||
&& !evidencePostdatesAsOfDate(supplied, ports.asOfDate())
|
||
&& pendingHasSummary
|
||
&& suppliedMatchesPending;
|
||
const fillsMissingSummary = followUp?.kind !== "event_date"
|
||
&& pending.dateValue !== null
|
||
&& supplied.dateValue === null
|
||
&& !pendingHasSummary
|
||
&& suppliedHasSummary;
|
||
const addsEventDetail = followUp?.kind === "event_detail"
|
||
&& pending.dateValue !== null
|
||
&& suppliedHasSummary
|
||
&& (supplied.dateValue === null || supplied.dateValue === pending.dateValue);
|
||
if (!fillsMissingDate && !refinesKnownDate && !fillsMissingSummary && !addsEventDetail) {
|
||
return input.extracted;
|
||
}
|
||
|
||
const dateValue = supplied.dateValue ?? pending.dateValue;
|
||
const summary = fillsMissingDate || refinesKnownDate
|
||
? pending.eventSummary
|
||
: addsEventDetail
|
||
? [...new Set([pending.eventSummary, supplied.eventSummary])].join(";")
|
||
: suppliedHasSummary ? supplied.eventSummary : pending.eventSummary;
|
||
if (!dateValue || summary === "事件内容待补充") return input.extracted;
|
||
// Re-parse the original event as one dated sentence. Detail replies may
|
||
// contain punctuation that the extractor treats as separate boundaries;
|
||
// apply the composed summary after deriving the single event metadata.
|
||
const merged = extractLifeEventEvidence({
|
||
rawText: `${dateValue} ${pending.eventSummary}`,
|
||
sourceTurnId: input.command.actionId,
|
||
asOfDate: ports.asOfDate(),
|
||
correctsEvidenceId: pending.id,
|
||
});
|
||
if (merged.length !== 1) return input.extracted;
|
||
return merged.map((item) => ({
|
||
...item,
|
||
rawText: `${pending.rawText}\n补充:${input.command.answer}`,
|
||
eventSummary: summary,
|
||
domain: pending.domain === "other" ? item.domain : pending.domain,
|
||
correctsEvidenceIds: [...item.correctsEvidenceIds],
|
||
}));
|
||
}
|
||
|
||
const service: ConversationalRectificationService = Object.freeze({
|
||
importLegacyCase,
|
||
async start(userId, rawCommand) {
|
||
resetTelemetryOutcome();
|
||
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");
|
||
|
||
if (profile.legacyCaseId) {
|
||
return importLegacyCase(
|
||
userId,
|
||
profile.legacyCaseId,
|
||
command.actionId,
|
||
command.pendingConsultationQuestion ?? null,
|
||
);
|
||
}
|
||
|
||
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) {
|
||
observeCase(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,
|
||
});
|
||
observeCase(existing, "charged");
|
||
} catch (error) {
|
||
try {
|
||
await ports.billing.release({
|
||
userId,
|
||
caseId,
|
||
expectedVersion: 0,
|
||
actionId: command.actionId,
|
||
price,
|
||
});
|
||
observeCase(existing, "released");
|
||
} 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);
|
||
}
|
||
|
||
// A new action id is not a new rectification session. The durable
|
||
// reserve RPC rejects a second unfinished case for the same account,
|
||
// so resolve that invariant before spending time or credits. This is
|
||
// intentionally limited to the same declared birth input; a changed
|
||
// profile must not silently continue an old calculation.
|
||
let existingForUser: LoadedConversationalRectificationCase | null;
|
||
try {
|
||
existingForUser = await ports.store.loadCase({ userId });
|
||
} catch (error) {
|
||
throw safeFailure(error);
|
||
}
|
||
if (existingForUser && existingForUser.caseId !== caseId) {
|
||
observeCase(existingForUser);
|
||
if (sameDeclaredBirthInput(existingForUser.declaredBirthInput, declared.data)) {
|
||
return publicTurn(existingForUser);
|
||
}
|
||
throw new ConversationalRectificationError("action_conflict");
|
||
}
|
||
if (ports.allowNewCaseCreation === false) {
|
||
throw new ConversationalRectificationError("service_unavailable");
|
||
}
|
||
|
||
let reserved = false;
|
||
try {
|
||
const reservation = await ports.billing.reserve({
|
||
userId,
|
||
caseId,
|
||
expectedVersion: 0,
|
||
actionId: command.actionId,
|
||
price,
|
||
});
|
||
reserved = reservation.billingState === "reserved";
|
||
if (reserved) lastTelemetryOutcome = { billingState: "unknown", caseStatus: null };
|
||
const computed = await ports.buildTechnicalPacket({
|
||
userId,
|
||
caseId,
|
||
asOfDate: ports.asOfDate(),
|
||
declaredBirthInput: declared.data,
|
||
privateCandidate: null,
|
||
evidence: [],
|
||
});
|
||
const gatedPacket = confirmationGatedPacket(computed.packet, 0, 0);
|
||
const narrative = await generateRectificationNarrative({
|
||
phase: "first",
|
||
packet: gatedPacket,
|
||
generator: ports.narrativeGenerator,
|
||
});
|
||
const privateCandidate = privateCandidateFromPacket({
|
||
packet: gatedPacket,
|
||
resultId: null,
|
||
iteration: 0,
|
||
forceCollecting: true,
|
||
});
|
||
const firstTurn = turnFromNarrative({
|
||
caseId,
|
||
turnVersion: 0,
|
||
pendingConsultationQuestion: command.pendingConsultationQuestion ?? null,
|
||
packet: gatedPacket,
|
||
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,
|
||
});
|
||
observeCase(created, "unknown");
|
||
await ports.billing.complete({
|
||
userId,
|
||
caseId,
|
||
expectedVersion: 0,
|
||
actionId: command.actionId,
|
||
});
|
||
observeCase(created, "charged");
|
||
return publicTurn(created);
|
||
} catch (error) {
|
||
const failure = safeFailure(error);
|
||
if (reserved) {
|
||
try {
|
||
await ports.billing.release({
|
||
userId,
|
||
caseId,
|
||
expectedVersion: 0,
|
||
actionId: command.actionId,
|
||
price,
|
||
});
|
||
lastTelemetryOutcome = {
|
||
billingState: "released",
|
||
caseStatus: lastTelemetryOutcome.caseStatus,
|
||
};
|
||
} catch {
|
||
throw new ConversationalRectificationError("billing_failed");
|
||
}
|
||
}
|
||
// A concurrent start can win between the account-level read above
|
||
// and the reserve/create RPC. Re-read the account after releasing our
|
||
// reservation and return the winner when it uses the same profile.
|
||
if (failure.code === "action_conflict") {
|
||
try {
|
||
const winner = await ports.store.loadCase({ userId });
|
||
if (winner && sameDeclaredBirthInput(winner.declaredBirthInput, declared.data)) {
|
||
observeCase(winner);
|
||
return publicTurn(winner);
|
||
}
|
||
} catch {
|
||
// Preserve the original stable conflict below.
|
||
}
|
||
}
|
||
throw failure;
|
||
}
|
||
},
|
||
|
||
async resume(userId, rawCommand) {
|
||
resetTelemetryOutcome();
|
||
const command = parseCommand("resume", rawCommand);
|
||
const current = await load(userId, command.caseId);
|
||
return publicTurn(current);
|
||
},
|
||
|
||
async answer(userId, rawCommand) {
|
||
resetTelemetryOutcome();
|
||
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 structuredFollowUp = resolveStructuredFollowUp(command, current);
|
||
const extracted = structuredFollowUp?.kind === "confirmed"
|
||
? structuredFollowUp.evidence
|
||
: structuredFollowUp?.kind === "rejected"
|
||
? structuredFollowUp.evidence
|
||
: await extractedEvidence(command, current);
|
||
const evidence = evidenceForDeclaredBirthDate(
|
||
structuredFollowUp?.kind === "confirmed"
|
||
? extracted
|
||
: completeLatestClarification({ command, current, extracted }),
|
||
current.declaredBirthInput.birthDate,
|
||
);
|
||
|
||
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,
|
||
userMessage: command.answer,
|
||
turn: current.latestTurn,
|
||
evidence,
|
||
validationReceipt: latestReceipt(current),
|
||
privateCandidate: current.privateCandidate,
|
||
});
|
||
return publicTurn(replayed);
|
||
} catch (error) {
|
||
throw safeFailure(error);
|
||
}
|
||
}
|
||
requireExactVersion(current, command.turnVersion);
|
||
|
||
if (command.correctsEvidenceId
|
||
&& !effectiveLifeEventEvidence(current.eventEvidence)
|
||
.some((item) => item.id === command.correctsEvidenceId)) {
|
||
throw new ConversationalRectificationError("action_conflict");
|
||
}
|
||
|
||
const scoreableEvidence = evidence.filter((item) => item.scoreable === true
|
||
&& hasScoringEvidenceDomain(item)
|
||
&& item.extractionStatus !== "needs_clarification");
|
||
const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer);
|
||
const directionChange = explicitDirectionChange
|
||
|| (scoreableEvidence.length === 0 && genericUncertaintyPattern.test(command.answer));
|
||
const allScoreable = uniqueScoreableLifeEventEvidence(
|
||
[...current.eventEvidence, ...evidence],
|
||
current.declaredBirthInput.birthDate,
|
||
);
|
||
|
||
if (command.correctsEvidenceId) {
|
||
try {
|
||
const computed = await ports.buildTechnicalPacket({
|
||
userId,
|
||
caseId: command.caseId,
|
||
asOfDate: ports.asOfDate(),
|
||
declaredBirthInput: current.declaredBirthInput,
|
||
// A correction invalidates any range narrowed by the retired fact.
|
||
// Rebuild from the user's declared/baseline range so eliminated
|
||
// minutes can re-enter the deterministic scan.
|
||
privateCandidate: null,
|
||
evidence: allScoreable,
|
||
});
|
||
const gatedPacket = confirmationGatedPacket(
|
||
computed.packet,
|
||
allScoreable.length,
|
||
scoreableDomains(allScoreable).size,
|
||
);
|
||
const replacement = evidence[0];
|
||
if (!replacement) throw new ConversationalRectificationError("invalid_command");
|
||
const resetReason: CorrectionResetReason | null = directionChange
|
||
? "direction_change"
|
||
: replacement.extractionStatus === "needs_clarification"
|
||
? "needs_clarification"
|
||
: replacement.scoreable !== true ? "non_scoreable" : null;
|
||
if (resetReason) {
|
||
const authoredNarrative = await generateRectificationNarrative({
|
||
phase: "intermediate",
|
||
packet: gatedPacket,
|
||
generator: ports.narrativeGenerator,
|
||
context: await conversationContext({
|
||
userId,
|
||
current,
|
||
latestUserText: command.answer,
|
||
allEvidence: [...current.eventEvidence, ...evidence],
|
||
newEvidence: evidence,
|
||
}),
|
||
});
|
||
const next = nonScoringTurn({
|
||
current,
|
||
newEvidence: evidence,
|
||
latestUserText: command.answer,
|
||
authoredNarrative,
|
||
correctionReset: { packet: computed.packet, reason: resetReason },
|
||
});
|
||
const privateCandidate = privateCandidateFromPacket({
|
||
packet: gatedPacket,
|
||
resultId: null,
|
||
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
|
||
forceCollecting: true,
|
||
});
|
||
const saved = await ports.store.saveTurn({
|
||
userId,
|
||
caseId: command.caseId,
|
||
expectedVersion: command.turnVersion,
|
||
actionId: command.actionId,
|
||
commandFingerprint: fingerprint,
|
||
userMessage: command.answer,
|
||
turn: next.turn,
|
||
evidence,
|
||
validationReceipt: next.receipt,
|
||
privateCandidate,
|
||
});
|
||
return publicTurn(saved);
|
||
}
|
||
|
||
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
|
||
const answeredDomains = scoreableDomains(allScoreable);
|
||
const boundedResult = shouldCompleteBoundedResult({
|
||
packet: gatedPacket,
|
||
scoreableEventCount: allScoreable.length,
|
||
scoreableDomainCount: answeredDomains.size,
|
||
answeredDomains,
|
||
plateauCount,
|
||
});
|
||
const phase = boundedResult || gatedPacket.candidate.status === "ready_for_confirmation"
|
||
? "final" as const
|
||
: "intermediate" as const;
|
||
const narrative = await generateRectificationNarrative({
|
||
phase,
|
||
packet: gatedPacket,
|
||
generator: ports.narrativeGenerator,
|
||
context: await conversationContext({
|
||
userId,
|
||
current,
|
||
latestUserText: command.answer,
|
||
allEvidence: [...current.eventEvidence, ...evidence],
|
||
newEvidence: evidence,
|
||
}),
|
||
});
|
||
const privateCandidate = privateCandidateFromPacket({
|
||
packet: gatedPacket,
|
||
resultId: gatedPacket.candidate.status === "ready_for_confirmation"
|
||
? computed.resultId
|
||
: null,
|
||
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
|
||
notes: convergenceNotes(current.privateCandidate, plateauCount),
|
||
forceCollecting: boundedResult || gatedPacket.candidate.status !== "ready_for_confirmation",
|
||
});
|
||
const turnInput = {
|
||
caseId: command.caseId,
|
||
turnVersion: command.turnVersion + 1,
|
||
pendingConsultationQuestion: current.pendingConsultationQuestion,
|
||
packet: gatedPacket,
|
||
narrative,
|
||
evidence: [...current.eventEvidence, ...evidence],
|
||
};
|
||
const turn = boundedResult ? boundedResultTurn(turnInput) : turnFromNarrative(turnInput);
|
||
const saved = await ports.store.saveTurn({
|
||
userId,
|
||
caseId: command.caseId,
|
||
expectedVersion: command.turnVersion,
|
||
actionId: command.actionId,
|
||
commandFingerprint: fingerprint,
|
||
userMessage: command.answer,
|
||
turn,
|
||
evidence,
|
||
validationReceipt: narrative.validationReceipt,
|
||
privateCandidate,
|
||
});
|
||
return publicTurn(saved);
|
||
} catch (error) {
|
||
throw safeFailure(error);
|
||
}
|
||
}
|
||
if (directionChange || scoreableEvidence.length === 0) {
|
||
let authoredNarrative: RectificationNarrativeResult | null = null;
|
||
try {
|
||
const computed = await ports.buildTechnicalPacket({
|
||
userId,
|
||
caseId: command.caseId,
|
||
asOfDate: ports.asOfDate(),
|
||
declaredBirthInput: current.declaredBirthInput,
|
||
privateCandidate: current.privateCandidate,
|
||
evidence: allScoreable,
|
||
});
|
||
const gatedPacket = confirmationGatedPacket(
|
||
computed.packet,
|
||
allScoreable.length,
|
||
scoreableDomains(allScoreable).size,
|
||
);
|
||
authoredNarrative = await generateRectificationNarrative({
|
||
phase: "intermediate",
|
||
packet: gatedPacket,
|
||
generator: ports.narrativeGenerator,
|
||
context: await conversationContext({
|
||
userId,
|
||
current,
|
||
latestUserText: command.answer,
|
||
allEvidence: [...current.eventEvidence, ...evidence],
|
||
newEvidence: evidence,
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.warn("[rectification-non-scoring-narrative-fallback]", JSON.stringify({
|
||
error: error instanceof Error ? error.name : "UnknownError",
|
||
}));
|
||
}
|
||
const next = nonScoringTurn({
|
||
current,
|
||
newEvidence: evidence,
|
||
latestUserText: command.answer,
|
||
authoredNarrative,
|
||
followUpOverride: structuredFollowUp?.kind === "rejected"
|
||
? structuredFollowUp.followUp
|
||
: undefined,
|
||
});
|
||
try {
|
||
const saved = await ports.store.saveTurn({
|
||
userId,
|
||
caseId: command.caseId,
|
||
expectedVersion: command.turnVersion,
|
||
actionId: command.actionId,
|
||
commandFingerprint: fingerprint,
|
||
userMessage: command.answer,
|
||
turn: next.turn,
|
||
evidence,
|
||
validationReceipt: next.receipt,
|
||
privateCandidate: current.privateCandidate,
|
||
});
|
||
return publicTurn(saved);
|
||
} catch (error) {
|
||
throw safeFailure(error);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const computed = await ports.buildTechnicalPacket({
|
||
userId,
|
||
caseId: command.caseId,
|
||
asOfDate: ports.asOfDate(),
|
||
declaredBirthInput: current.declaredBirthInput,
|
||
privateCandidate: current.privateCandidate,
|
||
evidence: allScoreable,
|
||
});
|
||
const gatedPacket = confirmationGatedPacket(
|
||
computed.packet,
|
||
allScoreable.length,
|
||
scoreableDomains(allScoreable).size,
|
||
);
|
||
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
|
||
const answeredDomains = scoreableDomains(allScoreable);
|
||
const boundedResult = shouldCompleteBoundedResult({
|
||
packet: gatedPacket,
|
||
scoreableEventCount: allScoreable.length,
|
||
scoreableDomainCount: answeredDomains.size,
|
||
answeredDomains,
|
||
plateauCount,
|
||
});
|
||
const phase = boundedResult || gatedPacket.candidate.status === "ready_for_confirmation"
|
||
? "final" as const
|
||
: "intermediate" as const;
|
||
const narrative = await generateRectificationNarrative({
|
||
phase,
|
||
packet: gatedPacket,
|
||
generator: ports.narrativeGenerator,
|
||
context: await conversationContext({
|
||
userId,
|
||
current,
|
||
latestUserText: command.answer,
|
||
allEvidence: [...current.eventEvidence, ...evidence],
|
||
newEvidence: evidence,
|
||
}),
|
||
});
|
||
const privateCandidate = privateCandidateFromPacket({
|
||
packet: gatedPacket,
|
||
resultId: gatedPacket.candidate.status === "ready_for_confirmation"
|
||
? computed.resultId
|
||
: null,
|
||
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
|
||
notes: convergenceNotes(current.privateCandidate, plateauCount),
|
||
forceCollecting: boundedResult || gatedPacket.candidate.status !== "ready_for_confirmation",
|
||
});
|
||
const turnInput = {
|
||
caseId: command.caseId,
|
||
turnVersion: command.turnVersion + 1,
|
||
pendingConsultationQuestion: current.pendingConsultationQuestion,
|
||
packet: gatedPacket,
|
||
narrative,
|
||
evidence: [...current.eventEvidence, ...evidence],
|
||
};
|
||
const narratedTurn = boundedResult ? boundedResultTurn(turnInput) : turnFromNarrative(turnInput);
|
||
const saved = await ports.store.saveTurn({
|
||
userId,
|
||
caseId: command.caseId,
|
||
expectedVersion: command.turnVersion,
|
||
actionId: command.actionId,
|
||
commandFingerprint: fingerprint,
|
||
userMessage: command.answer,
|
||
turn: narratedTurn,
|
||
evidence,
|
||
validationReceipt: narrative.validationReceipt,
|
||
privateCandidate,
|
||
});
|
||
return publicTurn(saved);
|
||
} catch (error) {
|
||
throw safeFailure(error);
|
||
}
|
||
},
|
||
|
||
async regenerate(userId, rawCommand) {
|
||
resetTelemetryOutcome();
|
||
const command = parseCommand("regenerate", 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);
|
||
requireExactVersion(current, command.turnVersion);
|
||
if (!current.latestTurn.actions.includes("answer")) {
|
||
throw new ConversationalRectificationError("invalid_transition");
|
||
}
|
||
|
||
try {
|
||
const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence);
|
||
const latestEvidence = activeEvidence.at(-1);
|
||
const scoreableEvidence = uniqueScoreableLifeEventEvidence(
|
||
current.eventEvidence,
|
||
current.declaredBirthInput.birthDate,
|
||
);
|
||
const computed = await ports.buildTechnicalPacket({
|
||
userId,
|
||
caseId: command.caseId,
|
||
asOfDate: ports.asOfDate(),
|
||
declaredBirthInput: current.declaredBirthInput,
|
||
privateCandidate: current.privateCandidate,
|
||
evidence: scoreableEvidence,
|
||
preserveCandidateRange: true,
|
||
});
|
||
const gatedPacket = confirmationGatedPacket(
|
||
computed.packet,
|
||
scoreableEvidence.length,
|
||
scoreableDomains(scoreableEvidence).size,
|
||
);
|
||
const phase = gatedPacket.candidate.status === "ready_for_confirmation"
|
||
? "final" as const
|
||
: activeEvidence.length === 0 ? "first" as const : "intermediate" as const;
|
||
const narrative = await generateRectificationNarrative({
|
||
phase,
|
||
packet: gatedPacket,
|
||
generator: ports.narrativeGenerator,
|
||
context: latestEvidence ? await conversationContext({
|
||
userId,
|
||
current,
|
||
latestUserText: latestEvidence.rawText,
|
||
allEvidence: current.eventEvidence,
|
||
newEvidence: [latestEvidence],
|
||
}) : undefined,
|
||
});
|
||
const turn = turnFromNarrative({
|
||
caseId: command.caseId,
|
||
turnVersion: command.turnVersion + 1,
|
||
pendingConsultationQuestion: current.pendingConsultationQuestion,
|
||
packet: gatedPacket,
|
||
narrative,
|
||
evidence: current.eventEvidence,
|
||
});
|
||
const saved = await ports.store.saveTurn({
|
||
userId,
|
||
caseId: command.caseId,
|
||
expectedVersion: command.turnVersion,
|
||
actionId: command.actionId,
|
||
commandFingerprint: fingerprint,
|
||
userMessage: null,
|
||
turn,
|
||
evidence: [],
|
||
validationReceipt: narrative.validationReceipt,
|
||
privateCandidate: current.privateCandidate,
|
||
});
|
||
return publicTurn(saved);
|
||
} catch (error) {
|
||
throw safeFailure(error);
|
||
}
|
||
},
|
||
|
||
async pause(userId, rawCommand) {
|
||
resetTelemetryOutcome();
|
||
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: 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) {
|
||
resetTelemetryOutcome();
|
||
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: 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) {
|
||
resetTelemetryOutcome();
|
||
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: current.latestTurn.narrative,
|
||
});
|
||
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);
|
||
}
|
||
},
|
||
});
|
||
telemetryOutcomes.set(service, () => lastTelemetryOutcome);
|
||
return service;
|
||
}
|