fix: preserve dynamic action receipts
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user