fix: preserve dynamic action receipts

This commit is contained in:
Jesse_Chen
2026-07-19 10:12:18 +08:00
parent db5a6fbe52
commit db5c12f67f
8 changed files with 217 additions and 52 deletions
@@ -11,13 +11,16 @@ import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
import {
createIdentityRequestCache,
publishCurrentJourney,
runStableJourneyAction,
scheduleCancellableStart,
} from "@/lib/birth-time-guided-effect-coordinator";
import type { StableActionIdentityRegistry } from "@/lib/birth-time-guided-effect-coordinator";
import { runBirthTimeScoringPoll, scoringPollDelay } from "@/lib/birth-time-guided-polling";
type AutomaticEffectsInput = {
readonly journey: JourneyClientResponse | null;
readonly latest: { current: JourneyClientResponse | null };
readonly actionRegistry: StableActionIdentityRegistry;
readonly preview: boolean;
readonly pollRun: number;
readonly generationRun: number;
@@ -25,9 +28,6 @@ type AutomaticEffectsInput = {
readonly setError: (message: string) => void;
};
const guideRequests = createIdentityRequestCache<Awaited<ReturnType<typeof requestBirthTimeGuidePrompt>>>();
const generationRequests = createIdentityRequestCache<Awaited<ReturnType<typeof generateDynamicBirthTimeQuestion>>>();
function fallbackQuestion(turn: JourneyClientResponse): string {
const action = turn.nextAction;
if (turn.journeyProtocol === "dynamic-choice-v2") {
@@ -39,7 +39,9 @@ function fallbackQuestion(turn: JourneyClientResponse): string {
}
export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput): string {
const { generationRun, journey, latest, onJourney, pollRun, preview, setError } = input;
const { actionRegistry, generationRun, journey, latest, onJourney, pollRun, preview, setError } = input;
const [guideRequests] = useState(() => createIdentityRequestCache<Awaited<ReturnType<typeof requestBirthTimeGuidePrompt>>>());
const [generationRequests] = useState(() => createIdentityRequestCache<Awaited<ReturnType<typeof generateDynamicBirthTimeQuestion>>>());
const [agentQuestion, setAgentQuestion] = useState<{ readonly key: string; readonly text: string } | null>(null);
useEffect(() => {
@@ -55,7 +57,7 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput
}
}).catch(() => { if (active) setAgentQuestion(null); });
return () => { active = false; };
}, [journey, preview]);
}, [guideRequests, journey, preview]);
const generationIdentity = journey?.journeyProtocol === "dynamic-choice-v2"
&& (journey.nextAction.kind === "generate_dynamic_question"
@@ -70,18 +72,20 @@ export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput
&& turn.nextAction.kind !== "retry_question_generation") return;
const expected = turn;
const key = `${turn.caseId}:${turn.turnVersion}`;
void generationRequests.run(key, () => generateDynamicBirthTimeQuestion(
turn.caseId,
globalThis.crypto.randomUUID(),
turn.turnVersion,
)).then((next) => {
void generationRequests.run(key, () => runStableJourneyAction(actionRegistry, {
caseId: turn.caseId,
turnVersion: turn.turnVersion,
operation: "generate_dynamic_question",
}, (actionId) => generateDynamicBirthTimeQuestion(
turn.caseId, actionId, turn.turnVersion,
))).then((next) => {
if (publishCurrentJourney({ expected, current: latest.current, next, publish: onJourney })) latest.current = next;
}).catch((caught: unknown) => {
if (latest.current?.caseId === expected.caseId && latest.current.turnVersion === expected.turnVersion) {
setError(caught instanceof Error ? caught.message : "暂时无法生成下一题,请重试。");
}
});
}, [generationIdentity, generationRun, latest, onJourney, preview, setError]);
}, [actionRegistry, generationIdentity, generationRequests, generationRun, latest, onJourney, preview, setError]);
const pollIdentity = journey?.nextAction.kind === "score_pending"
? `${journey.caseId}:${journey.turnVersion}:${journey.nextAction.jobId}`
@@ -21,7 +21,9 @@ import {
import { confirmReviewedBirthTimeDraft } from "@/lib/birth-time-guided-draft-confirmation";
import {
claimMutation,
createStableActionIdentityRegistry,
publishCurrentJourney,
runStableJourneyAction,
} from "@/lib/birth-time-guided-effect-coordinator";
import type { EvidenceDatePrecision } from "@/lib/birth-time-question-planner";
import { useBirthTimeAutomaticJourneyEffects } from "@/hooks/use-birth-time-automatic-journey-effects";
@@ -59,6 +61,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
const { journey, onJourney, onReady, onEditBirthTimeDetails, preview } = input;
const latest = useRef(journey);
const busy = useRef(false);
const [actionRegistry] = useState(() => createStableActionIdentityRegistry());
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [pollRun, setPollRun] = useState(0);
@@ -105,10 +108,28 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
});
}, [journey, onJourney]);
const actionId = () => globalThis.crypto.randomUUID();
const stableCommand = <T>(
caseId: string,
turnVersion: number,
operation: string,
payload: readonly string[],
send: (actionId: string) => Promise<T>,
) => runStableJourneyAction(actionRegistry, {
caseId,
turnVersion,
operation,
payload,
}, send);
const stableAction = <T>(
turn: JourneyClientResponse,
operation: string,
payload: readonly string[],
send: (actionId: string) => Promise<T>,
) => stableCommand(turn.caseId, turn.turnVersion, operation, payload, send);
const question = useBirthTimeAutomaticJourneyEffects({
journey,
latest,
actionRegistry,
preview,
pollRun,
generationRun,
@@ -118,26 +139,36 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
const submitMessage = (message: string) => operate((turn) => {
if (preview) return Promise.resolve(turn);
return draftBirthTimeEvidence(turn.caseId, actionId(), turn.turnVersion, message).then((value) => value.turn);
return stableAction(turn, "draft_evidence", [message.trim()], (actionId) => (
draftBirthTimeEvidence(turn.caseId, actionId, turn.turnVersion, message).then((value) => value.turn)
));
});
const confirmDraft = (precision: EvidenceDatePrecision, date: string) => operate(async (turn, publishIntermediate) => {
if (preview) return turn;
return confirmReviewedBirthTimeDraft({ turn, precision, date }, {
createActionId: actionId,
revise: reviseBirthTimeEvidenceDraft,
revise: (command) => stableCommand(
command.caseId, command.turnVersion, "revise_evidence_draft", [command.precision, command.date],
(actionId) => reviseBirthTimeEvidenceDraft({ ...command, actionId }),
),
publish: publishIntermediate,
confirm: (command) => confirmBirthTimeEvidenceDraft(
command.caseId,
command.actionId,
command.turnVersion,
command.draftId,
confirm: (command) => stableCommand(
command.caseId, command.turnVersion, "confirm_evidence_draft", [command.draftId],
(actionId) => confirmBirthTimeEvidenceDraft(
command.caseId, actionId, command.turnVersion, command.draftId,
),
),
});
});
const skip = () => operate((turn) => preview ? Promise.resolve(turn) : skipBirthTimeEvidenceQuestion(turn.caseId, actionId(), turn.turnVersion));
const pause = () => operate((turn) => preview ? Promise.resolve(turn) : pauseBirthTimeRectification(turn.caseId, actionId(), turn.turnVersion));
const skip = () => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
turn, "skip_evidence_question", [],
(actionId) => skipBirthTimeEvidenceQuestion(turn.caseId, actionId, turn.turnVersion),
));
const pause = () => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
turn, "pause_rectification", [],
(actionId) => pauseBirthTimeRectification(turn.caseId, actionId, turn.turnVersion),
));
const resume = () => operate((turn) => preview ? Promise.resolve(turn) : resumeBirthTimeJourney(turn.caseId));
const acknowledgeReady = () => {
if (journey?.nextAction.kind === "ready") onReady(journey);
@@ -153,33 +184,39 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
? pollBirthTimeScoring(current.caseId, current.nextAction.jobId)
: Promise.resolve(current));
};
const saveCandidate = (resultId: string) => operate((turn) => preview ? Promise.resolve(turn) : saveGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId: actionId(), turnVersion: turn.turnVersion, resultId }));
const confirmCandidate = (resultId: string, time: string) => operate((turn) => preview ? Promise.resolve(turn) : confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId: actionId(), turnVersion: turn.turnVersion, resultId, time }));
const saveCandidate = (resultId: string) => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
turn, "save_guided_candidate", [resultId],
(actionId) => saveGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId }),
));
const confirmCandidate = (resultId: string, time: string) => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
turn, "confirm_guided_candidate", [resultId, time],
(actionId) => confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
));
const selectOption = (optionId: string) => operate((turn) => {
if (preview || turn.journeyProtocol !== "dynamic-choice-v2"
|| turn.nextAction.kind !== "ask_dynamic_choice") return Promise.resolve(turn);
return answerDynamicBirthTimeChoice({
caseId: turn.caseId,
actionId: actionId(),
turnVersion: turn.turnVersion,
questionId: turn.nextAction.question.questionId,
optionId,
});
const questionId = turn.nextAction.question.questionId;
return stableAction(turn, "answer_dynamic_choice", [questionId, optionId], (actionId) => (
answerDynamicBirthTimeChoice({
caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, questionId, optionId,
})
));
});
const submitUnmatchedContext = (note: string) => operate((turn) => {
if (preview || turn.journeyProtocol !== "dynamic-choice-v2"
|| turn.nextAction.kind !== "clarify_unmatched_answer") return Promise.resolve(turn);
return reframeUnmatchedBirthTimeAnswer({
caseId: turn.caseId,
actionId: actionId(),
turnVersion: turn.turnVersion,
questionId: turn.nextAction.questionId,
note,
});
const questionId = turn.nextAction.questionId;
return stableAction(turn, "reframe_unmatched", [questionId, note.trim()], (actionId) => (
reframeUnmatchedBirthTimeAnswer({
caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, questionId, note,
})
));
});
const finish = () => operate((turn) => preview
? Promise.resolve(turn)
: finishBirthTimeRectification(turn.caseId, actionId(), turn.turnVersion));
: stableAction(turn, "finish_rectification", [], (actionId) => (
finishBirthTimeRectification(turn.caseId, actionId, turn.turnVersion)
)));
const retryQuestionGeneration = () => {
if (journey?.journeyProtocol !== "dynamic-choice-v2") return;
if (journey.nextAction.kind !== "generate_dynamic_question"
@@ -10,7 +10,6 @@ type ConfirmDraftInput = {
type RevisionCommand = {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly precision: EvidenceDatePrecision;
readonly date: string;
@@ -18,13 +17,11 @@ type RevisionCommand = {
type ConfirmationCommand = {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly draftId: string;
};
type ConfirmDraftPorts = {
readonly createActionId: () => string;
readonly revise: (command: RevisionCommand) => Promise<JourneyClientResponse>;
readonly publish: (turn: JourneyClientResponse) => void;
readonly confirm: (command: ConfirmationCommand) => Promise<JourneyClientResponse>;
@@ -50,7 +47,6 @@ export async function confirmReviewedBirthTimeDraft(
? input.turn
: await ports.revise({
caseId: input.turn.caseId,
actionId: ports.createActionId(),
turnVersion: input.turn.turnVersion,
precision: parsed.precision,
date: parsed.date,
@@ -60,7 +56,6 @@ export async function confirmReviewedBirthTimeDraft(
if (!currentDraft) throw new GuidedDraftConfirmationError("经历草稿已经变化,请使用最新内容。");
return ports.confirm({
caseId: revised.caseId,
actionId: ports.createActionId(),
turnVersion: revised.turnVersion,
draftId: currentDraft.draftId,
});
@@ -1,5 +1,21 @@
import type { JourneyClientResponse } from "./birth-time-journey-response-schema.ts";
type StableActionIdentityInput = {
readonly caseId: string;
readonly turnVersion: number;
readonly operation: string;
readonly payload?: readonly string[];
};
export function stableActionIdentity(input: StableActionIdentityInput): string {
return JSON.stringify([
input.caseId,
input.turnVersion,
input.operation,
...(input.payload ?? []),
]);
}
export function createIdentityRequestCache<T>() {
const requests = new Map<string, Promise<T>>();
return {
@@ -16,6 +32,31 @@ export function createIdentityRequestCache<T>() {
};
}
export function createStableActionIdentityRegistry(
createId: () => string = () => globalThis.crypto.randomUUID(),
) {
const actionIds = new Map<string, string>();
return {
async run<T>(identity: string, operation: (actionId: string) => Promise<T>): Promise<T> {
const actionId = actionIds.get(identity) ?? createId();
actionIds.set(identity, actionId);
const result = await operation(actionId);
if (actionIds.get(identity) === actionId) actionIds.delete(identity);
return result;
},
};
}
export type StableActionIdentityRegistry = ReturnType<typeof createStableActionIdentityRegistry>;
export function runStableJourneyAction<T>(
registry: StableActionIdentityRegistry,
identity: StableActionIdentityInput,
operation: (actionId: string) => Promise<T>,
): Promise<T> {
return registry.run(stableActionIdentity(identity), operation);
}
export function scheduleCancellableStart(start: () => void): () => void {
const timer = globalThis.setTimeout(start, 0);
return () => globalThis.clearTimeout(timer);