From 806d2babbf35cf0182323cd95ae9cf7ec1fca06b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 09:45:32 +0800 Subject: [PATCH] feat: expose dynamic rectification actions --- .../src/app/api/birth-time-guide/route.ts | 55 ++++++- .../src/app/api/birth-time-journey/route.ts | 32 +++- .../components/birth-time-rectification.tsx | 37 +++++ ...se-birth-time-automatic-journey-effects.ts | 125 +++++++++++++++ .../hooks/use-birth-time-guided-journey.ts | 143 ++++++++---------- frontend/src/lib/birth-time-guide-agent.ts | 14 ++ .../birth-time-guided-effect-coordinator.ts | 9 ++ frontend/src/lib/birth-time-journey-client.ts | 34 +++++ .../src/lib/birth-time-journey-request.ts | 6 + .../lib/birth-time-journey-response-schema.ts | 59 +++++++- frontend/tests/birth-time-dynamic-api.test.ts | 63 ++++++++ .../tests/birth-time-guide-client.test.ts | 34 +++++ frontend/tests/birth-time-guide-flow.test.ts | 11 +- .../tests/birth-time-guided-polling.test.ts | 43 ++++++ .../birth-time-guided-review-fixes.test.ts | 15 ++ 15 files changed, 579 insertions(+), 101 deletions(-) create mode 100644 frontend/src/hooks/use-birth-time-automatic-journey-effects.ts create mode 100644 frontend/tests/birth-time-dynamic-api.test.ts diff --git a/frontend/src/app/api/birth-time-guide/route.ts b/frontend/src/app/api/birth-time-guide/route.ts index 11eb8f58..eb44c5c3 100644 --- a/frontend/src/app/api/birth-time-guide/route.ts +++ b/frontend/src/app/api/birth-time-guide/route.ts @@ -6,6 +6,9 @@ import { createBirthTimeGuideService, } from "@/lib/birth-time-guide-service"; import { BirthTimeJourneyActionError, createJourneyTurnActions } from "@/lib/birth-time-journey-actions"; +import { BirthTimeDynamicActionError } from "@/lib/birth-time-dynamic-actions"; +import { BirthTimeJourneyEngineError, createJyotishBirthTimeJourneyEngine } from "@/lib/birth-time-journey-engine"; +import { createBirthTimeJourneyService } from "@/lib/birth-time-journey-service"; import { BirthTimeJourneyStoreError, createSupabaseBirthTimeJourneyStore, @@ -64,6 +67,10 @@ export async function POST(request: Request) { const store = createSupabaseBirthTimeJourneyStore(createAdminSupabaseClient()); const actions = createJourneyTurnActions({ store }); + const journey = createBirthTimeJourneyService({ + store, + engine: createJyotishBirthTimeJourneyEngine(), + }); const model = defaultLanguageModel(); const generator = model ? { @@ -79,6 +86,15 @@ export async function POST(request: Request) { generator, loadCase: store.loadCase, proposeEvidenceDraft: actions.proposeEvidenceDraft, + loadDynamicQuestionBuild: journey.generateDynamicQuestion, + commitDynamicQuestion: async (userId, command, question) => { + const committed = await journey.commitDynamicQuestion(userId, command, question); + const action = committed.nextAction; + if (action.kind !== "ask_dynamic_choice" && action.kind !== "present_low_result") { + throw new BirthTimeDynamicActionError("invalid_turn"); + } + return { nextAction: action }; + }, }); try { @@ -92,6 +108,34 @@ export async function POST(request: Request) { recordJourneyTransitionMetric(response.turn, "turn_advanced"); return NextResponse.json(response); } + case "generate_dynamic_question": { + const current = await store.loadCase(user.id, parsed.data.caseId); + const receipt = current?.journeyProtocol === "dynamic-choice-v2" + ? current.dynamicControl.lastActionReceipt + : null; + if (receipt?.kind === "commit_question" + && receipt.actionId === parsed.data.actionId.toLowerCase() + && receipt.turnVersion === parsed.data.turnVersion) { + return NextResponse.json(await journey.resume(user.id, parsed.data.caseId)); + } + await guide.generateQuestion(user.id, { ...parsed.data, unmatchedNote: null }); + return NextResponse.json(await journey.resume(user.id, parsed.data.caseId)); + } + case "reframe_unmatched": { + const current = await store.loadCase(user.id, parsed.data.caseId); + const receipt = current?.journeyProtocol === "dynamic-choice-v2" + ? current.dynamicControl.lastActionReceipt + : null; + if (receipt?.kind === "unmatched_context" + && receipt.actionId === parsed.data.actionId.toLowerCase() + && receipt.turnVersion === parsed.data.turnVersion + && receipt.questionId === parsed.data.questionId + && receipt.note === parsed.data.note) { + return NextResponse.json(await journey.resume(user.id, parsed.data.caseId)); + } + const response = await journey.submitUnmatchedContext(user.id, parsed.data); + return NextResponse.json(response); + } default: { const exhaustive: never = parsed.data; return exhaustive; @@ -104,20 +148,25 @@ export async function POST(request: Request) { { status: 409 }, ); } - if (error instanceof BirthTimeGuideActionError) { + if (error instanceof BirthTimeGuideActionError + || (error instanceof BirthTimeDynamicActionError && error.reason !== "unavailable")) { const status = error.reason === "case_not_found" ? 404 : 409; return NextResponse.json( { error: "当前步骤不可用", message: "请刷新并使用最新的生时校正步骤。" }, { status }, ); } - if (error instanceof StaleJourneyTurnError || error instanceof BirthTimeJourneyActionError) { + if (error instanceof StaleJourneyTurnError + || error instanceof BirthTimeJourneyActionError + || (error instanceof BirthTimeDynamicActionError && error.reason !== "unavailable")) { return NextResponse.json( { error: "校正状态已更新", message: "请使用最新问题或草稿后重试。" }, { status: 409 }, ); } - if (error instanceof BirthTimeJourneyStoreError) { + if (error instanceof BirthTimeJourneyStoreError + || error instanceof BirthTimeJourneyEngineError + || (error instanceof BirthTimeDynamicActionError && error.reason === "unavailable")) { return NextResponse.json( { error: "生时引导暂时不可用", message: "当前资料已保留,请稍后重试。" }, { status: 503 }, diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index fb7e0532..da7c7426 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -20,6 +20,7 @@ import { GuidedCandidateActionError } from "@/lib/birth-time-guided-candidate"; import { GuidedCandidateStoreConflictError } from "@/lib/birth-time-guided-candidate-store"; import { JourneyTurnInvariantError } from "@/lib/birth-time-journey-turn"; import { JourneyResponseInvariantError } from "@/lib/birth-time-journey-response-schema"; +import { BirthTimeDynamicActionError } from "@/lib/birth-time-dynamic-actions"; import { recordJourneyMetricEvent, recordJourneyTransitionMetric, @@ -114,6 +115,8 @@ export async function POST(request: Request) { parsed.data.questionId, parsed.data.answer, ), "turn_advanced"); + case "answer_dynamic_choice": + return responseWithJourneyMetric(service.answerDynamicChoice(user.id, parsed.data), "turn_advanced"); case "resume": { const response = await service.resume(user.id, parsed.data.caseId); return NextResponse.json(response); @@ -122,7 +125,12 @@ export async function POST(request: Request) { { const before = await service.resume(user.id, parsed.data.caseId); if (before.journeyProtocol === "dynamic-choice-v2") { - throw new GuidedJourneyLegacyMutationError(parsed.data.caseId); + const response = await service.pollDynamicScoringJob( + user.id, + parsed.data.caseId, + parsed.data.jobId, + ); + return NextResponse.json(response); } const response = await service.pollScoringJob(user.id, parsed.data.caseId, parsed.data.jobId); recordScoringJourneyMetric(before, response); @@ -138,10 +146,18 @@ export async function POST(request: Request) { return responseWithJourneyMetric(service.confirmEvidenceDraft(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion, parsed.data.draftId), "turn_advanced"); case "skip_evidence_question": return responseWithJourneyMetric(service.skipEvidenceQuestion(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "turn_advanced"); - case "pause_rectification": - return responseWithJourneyMetric(service.pause(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "journey_paused"); - case "finish_rectification": - return responseWithJourneyMetric(service.finishWithCurrentRange(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "turn_advanced"); + case "pause_rectification": { + const current = await service.resume(user.id, parsed.data.caseId); + return responseWithJourneyMetric(current.journeyProtocol === "dynamic-choice-v2" + ? service.pauseDynamic(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion) + : service.pause(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "journey_paused"); + } + case "finish_rectification": { + const current = await service.resume(user.id, parsed.data.caseId); + return responseWithJourneyMetric(current.journeyProtocol === "dynamic-choice-v2" + ? service.finishDynamic(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion) + : service.finishWithCurrentRange(user.id, parsed.data.caseId, parsed.data.actionId, parsed.data.turnVersion), "turn_advanced"); + } case "revise_evidence_draft": return responseWithJourneyMetric(service.reviseEvidenceDraft({ userId: user.id, @@ -189,6 +205,7 @@ export async function POST(request: Request) { error instanceof RectificationCaseNotFoundError || error instanceof EvidenceRectificationCaseNotFoundError || (error instanceof BirthTimeJourneyActionError && error.reason === "case_not_found") + || (error instanceof BirthTimeDynamicActionError && error.reason === "case_not_found") || (error instanceof GuidedCandidateActionError && error.reason === "case_not_found") ) { return NextResponse.json( @@ -205,6 +222,7 @@ export async function POST(request: Request) { if ( error instanceof StaleJourneyTurnError || error instanceof BirthTimeJourneyActionError + || (error instanceof BirthTimeDynamicActionError && error.reason !== "unavailable") || error instanceof GuidedJourneyLegacyMutationError || error instanceof BirthTimeScoringJobError || error instanceof GuidedCandidateActionError @@ -225,7 +243,9 @@ export async function POST(request: Request) { { status: 409 }, ); } - if (error instanceof BirthTimeJourneyStoreError || error instanceof BirthTimeJourneyEngineError) { + if (error instanceof BirthTimeJourneyStoreError + || error instanceof BirthTimeJourneyEngineError + || (error instanceof BirthTimeDynamicActionError && error.reason === "unavailable")) { return NextResponse.json( { error: "生时评估暂时不可用", message: "已保留当前资料,请稍后重试。" }, { status: 503 }, diff --git a/frontend/src/components/birth-time-rectification.tsx b/frontend/src/components/birth-time-rectification.tsx index b8de212b..8516894a 100644 --- a/frontend/src/components/birth-time-rectification.tsx +++ b/frontend/src/components/birth-time-rectification.tsx @@ -8,6 +8,7 @@ import { assistantIntentCopy } from "@/lib/birth-time-intake-model"; import type { JourneyClientResponse } from "@/lib/birth-time-journey-client"; import { guidedTurnIdentity } from "@/lib/birth-time-guided-turn-identity"; import type { NextAction } from "@/lib/birth-time-journey-turn"; +import type { DynamicNextAction } from "@/lib/birth-time-journey-turn-protocol"; type BirthTimeRectificationProps = { readonly journey: JourneyClientResponse; @@ -35,7 +36,43 @@ function actionHeading(action: NextAction): { readonly title: string; readonly b } } +function dynamicStatus(action: DynamicNextAction): string { + switch (action.kind) { + case "generate_dynamic_question": return "正在准备下一道候选区分问题…"; + case "retry_question_generation": return "问题暂时未能生成,可以重试当前步骤。"; + case "ask_dynamic_choice": return action.question.prompt; + case "clarify_unmatched_answer": return "可以补充一句,再换一道更合适的问题。"; + case "score_pending": return "正在根据刚才的选择缩小候选范围…"; + case "retry_scoring": return "评分暂时未完成,可以重试同一任务。"; + case "present_low_result": return "本次评估已结束并保存当前候选范围。"; + case "present_medium_result": return "已形成较窄候选范围,本次评估已结束。"; + case "request_candidate_confirmation": return "请确认是否使用当前候选时间。"; + case "ready": return `当前排盘使用时间已更新为 ${action.activeTime}。`; + case "paused": return "当前问题和候选范围已保存。"; + default: { + const exhaustive: never = action; + return exhaustive; + } + } +} + export function BirthTimeRectification(props: BirthTimeRectificationProps) { + if (props.journey.journeyProtocol === "dynamic-choice-v2") { + const generationFailed = Boolean(props.controller.error || props.externalError) + && (props.journey.nextAction.kind === "generate_dynamic_question" + || props.journey.nextAction.kind === "retry_question_generation"); + return ( +
+
+
出生时间评估

动态候选评估

+ 动态评估 +
+

{dynamicStatus(props.journey.nextAction)}

+ {generationFailed && } + {(props.controller.error || props.externalError) &&

{props.controller.error || props.externalError}

} +
+ ); + } const action = props.journey.nextAction; const heading = actionHeading(action); const asksQuestion = action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence"; diff --git a/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts new file mode 100644 index 00000000..5766fb9b --- /dev/null +++ b/frontend/src/hooks/use-birth-time-automatic-journey-effects.ts @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { fallbackQuestionCopy } from "@/lib/birth-time-guide-agent"; +import { + generateDynamicBirthTimeQuestion, + pollBirthTimeScoring, + requestBirthTimeGuidePrompt, +} from "@/lib/birth-time-journey-client"; +import type { JourneyClientResponse } from "@/lib/birth-time-journey-client"; +import { + createIdentityRequestCache, + publishCurrentJourney, + scheduleCancellableStart, +} 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 preview: boolean; + readonly pollRun: number; + readonly generationRun: number; + readonly onJourney: (journey: JourneyClientResponse) => void; + readonly setError: (message: string) => void; +}; + +const guideRequests = createIdentityRequestCache>>(); +const generationRequests = createIdentityRequestCache>>(); + +function fallbackQuestion(turn: JourneyClientResponse): string { + const action = turn.nextAction; + if (turn.journeyProtocol === "dynamic-choice-v2") { + return action.kind === "ask_dynamic_choice" ? action.question.prompt : ""; + } + return action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence" + ? fallbackQuestionCopy(action.question) + : ""; +} + +export function useBirthTimeAutomaticJourneyEffects(input: AutomaticEffectsInput): string { + const { generationRun, journey, latest, onJourney, pollRun, preview, setError } = input; + const [agentQuestion, setAgentQuestion] = useState<{ readonly key: string; readonly text: string } | null>(null); + + useEffect(() => { + const turn = journey; + if (!turn || turn.journeyProtocol === "dynamic-choice-v2") return; + const action = turn.nextAction; + if (preview || (action.kind !== "ask_baseline_evidence" && action.kind !== "ask_adaptive_evidence")) return; + const key = `${turn.caseId}:${turn.turnVersion}:${action.question.questionId}`; + let active = true; + void guideRequests.run(key, () => requestBirthTimeGuidePrompt(turn.caseId)).then((response) => { + if (active && response.turnVersion === turn.turnVersion && response.questionId === action.question.questionId) { + setAgentQuestion({ key, text: response.question }); + } + }).catch(() => { if (active) setAgentQuestion(null); }); + return () => { active = false; }; + }, [journey, preview]); + + const generationIdentity = journey?.journeyProtocol === "dynamic-choice-v2" + && (journey.nextAction.kind === "generate_dynamic_question" + || journey.nextAction.kind === "retry_question_generation") + ? `${journey.caseId}:${journey.turnVersion}` + : ""; + + useEffect(() => { + const turn = latest.current; + if (!turn || turn.journeyProtocol !== "dynamic-choice-v2" || preview) return; + if (turn.nextAction.kind !== "generate_dynamic_question" + && 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) => { + 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]); + + const pollIdentity = journey?.nextAction.kind === "score_pending" + ? `${journey.caseId}:${journey.turnVersion}:${journey.nextAction.jobId}` + : ""; + + useEffect(() => { + const turn = latest.current; + if (!turn || turn.nextAction.kind !== "score_pending" || preview) return; + const controller = new AbortController(); + const jobId = turn.nextAction.jobId; + const key = `${turn.caseId}:${turn.turnVersion}:${jobId}`; + const cancelStart = scheduleCancellableStart(() => { + void runBirthTimeScoringPoll({ + initial: turn, + maxAttempts: 7, + signal: controller.signal, + delay: scoringPollDelay, + poll: () => pollBirthTimeScoring(turn.caseId, jobId, controller.signal), + }).then((result) => { + const current = latest.current; + const currentKey = current?.nextAction.kind === "score_pending" + ? `${current.caseId}:${current.turnVersion}:${current.nextAction.jobId}` + : ""; + if (controller.signal.aborted || currentKey !== key) return; + onJourney(result.turn); + latest.current = result.turn; + if (result.kind === "exhausted") setError("评分仍在进行。你可以稍后继续,或重新检查状态。"); + }).catch((caught: unknown) => { + if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "暂时无法读取评分进度,请稍后重试。"); + }); + }); + return () => { cancelStart(); controller.abort(); }; + }, [latest, onJourney, pollIdentity, pollRun, preview, setError]); + + const action = journey?.nextAction; + const key = journey && journey.journeyProtocol !== "dynamic-choice-v2" + && (action?.kind === "ask_baseline_evidence" || action?.kind === "ask_adaptive_evidence") + ? `${journey.caseId}:${journey.turnVersion}:${action.question.questionId}` + : ""; + return agentQuestion?.key === key ? agentQuestion.text : journey ? fallbackQuestion(journey) : ""; +} diff --git a/frontend/src/hooks/use-birth-time-guided-journey.ts b/frontend/src/hooks/use-birth-time-guided-journey.ts index decbefe3..b810e605 100644 --- a/frontend/src/hooks/use-birth-time-guided-journey.ts +++ b/frontend/src/hooks/use-birth-time-guided-journey.ts @@ -1,13 +1,14 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { fallbackQuestionCopy } from "@/lib/birth-time-guide-agent"; import { + answerDynamicBirthTimeChoice, confirmBirthTimeEvidenceDraft, draftBirthTimeEvidence, + finishBirthTimeRectification, pauseBirthTimeRectification, pollBirthTimeScoring, - requestBirthTimeGuidePrompt, + reframeUnmatchedBirthTimeAnswer, resumeBirthTimeJourney, skipBirthTimeEvidenceQuestion, } from "@/lib/birth-time-journey-client"; @@ -19,12 +20,11 @@ import { } from "@/lib/birth-time-guided-client"; import { confirmReviewedBirthTimeDraft } from "@/lib/birth-time-guided-draft-confirmation"; import { - createIdentityRequestCache, + claimMutation, publishCurrentJourney, - scheduleCancellableStart, } from "@/lib/birth-time-guided-effect-coordinator"; -import { runBirthTimeScoringPoll, scoringPollDelay } from "@/lib/birth-time-guided-polling"; import type { EvidenceDatePrecision } from "@/lib/birth-time-question-planner"; +import { useBirthTimeAutomaticJourneyEffects } from "@/hooks/use-birth-time-automatic-journey-effects"; type GuidedJourneyInput = { readonly journey: JourneyClientResponse | null; @@ -49,25 +49,20 @@ export type BirthTimeGuidedController = { readonly retryScoring: () => void; readonly saveCandidate: (resultId: string) => void; readonly confirmCandidate: (resultId: string, time: string) => void; + readonly selectOption: (optionId: string) => void; + readonly submitUnmatchedContext: (note: string) => void; + readonly finish: () => void; + readonly retryQuestionGeneration: () => void; }; -const guidePromptRequests = createIdentityRequestCache>>(); - -function questionFrom(turn: JourneyClientResponse): string { - const action = turn.nextAction; - return action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence" - ? fallbackQuestionCopy(action.question) - : ""; -} - export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeGuidedController { const { journey, onJourney, onReady, onEditBirthTimeDetails, preview } = input; const latest = useRef(journey); const busy = useRef(false); const [pending, setPending] = useState(false); const [error, setError] = useState(""); - const [agentQuestion, setAgentQuestion] = useState<{ readonly key: string; readonly text: string } | null>(null); const [pollRun, setPollRun] = useState(0); + const [generationRun, setGenerationRun] = useState(0); useEffect(() => { latest.current = journey; @@ -78,9 +73,10 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG publishIntermediate: (turn: JourneyClientResponse) => void, ) => Promise) => { const current = journey; - if (!current || busy.current) return; + if (!current) return; + const release = claimMutation(busy); + if (release === null) return; let expected = current; - busy.current = true; setPending(true); setError(""); const publishIntermediate = (turn: JourneyClientResponse) => { @@ -104,73 +100,21 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG }).catch((caught: unknown) => { setError(caught instanceof Error ? caught.message : "当前步骤暂时无法完成,请重试。"); }).finally(() => { - busy.current = false; + release(); setPending(false); }); }, [journey, onJourney]); const actionId = () => globalThis.crypto.randomUUID(); - - useEffect(() => { - const turn = journey; - if (!turn) return; - const action = turn.nextAction; - if (action.kind !== "ask_baseline_evidence" && action.kind !== "ask_adaptive_evidence") return; - const key = `${turn.caseId}:${turn.turnVersion}:${action.question.questionId}`; - if (preview) return; - let active = true; - void guidePromptRequests.run(key, () => requestBirthTimeGuidePrompt(turn.caseId)).then((response) => { - if (active && response.turnVersion === turn.turnVersion && response.questionId === action.question.questionId) { - setAgentQuestion({ key, text: response.question }); - } - }).catch(() => { - if (active) setAgentQuestion(null); - }); - return () => { active = false; }; - }, [journey, preview]); - - const pollIdentity = journey?.nextAction.kind === "score_pending" - ? `${journey.caseId}:${journey.turnVersion}:${journey.nextAction.jobId}` - : ""; - - useEffect(() => { - const turn = latest.current; - if (!turn || turn.nextAction.kind !== "score_pending" || preview) return; - const controller = new AbortController(); - const jobId = turn.nextAction.jobId; - const key = `${turn.caseId}:${turn.turnVersion}:${jobId}`; - const cancelStart = scheduleCancellableStart(() => { - void runBirthTimeScoringPoll({ - initial: turn, - maxAttempts: 7, - signal: controller.signal, - delay: scoringPollDelay, - poll: () => pollBirthTimeScoring(turn.caseId, jobId, controller.signal), - }).then((result) => { - const current = latest.current; - const currentKey = current?.nextAction.kind === "score_pending" - ? `${current.caseId}:${current.turnVersion}:${current.nextAction.jobId}` - : ""; - if (controller.signal.aborted || currentKey !== key) return; - onJourney(result.turn); - latest.current = result.turn; - if (result.kind === "exhausted") setError("评分仍在进行。你可以稍后继续,或重新检查状态。"); - }).catch((caught: unknown) => { - if (!controller.signal.aborted) { - setError(caught instanceof Error ? caught.message : "暂时无法读取评分进度,请稍后重试。"); - } - }); - }); - return () => { - cancelStart(); - controller.abort(); - }; - }, [ - pollIdentity, - onJourney, + const question = useBirthTimeAutomaticJourneyEffects({ + journey, + latest, preview, pollRun, - ]); + generationRun, + onJourney, + setError, + }); const submitMessage = (message: string) => operate((turn) => { if (preview) return Promise.resolve(turn); @@ -211,15 +155,42 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG }; 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 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 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 finish = () => operate((turn) => preview + ? Promise.resolve(turn) + : finishBirthTimeRectification(turn.caseId, actionId(), turn.turnVersion)); + const retryQuestionGeneration = () => { + if (journey?.journeyProtocol !== "dynamic-choice-v2") return; + if (journey.nextAction.kind !== "generate_dynamic_question" + && journey.nextAction.kind !== "retry_question_generation") return; + setError(""); + setGenerationRun((value) => value + 1); + }; - const turn = journey; - const fallback = turn ? questionFrom(turn) : ""; - const action = turn?.nextAction; - const key = turn && (action?.kind === "ask_baseline_evidence" || action?.kind === "ask_adaptive_evidence") - ? `${turn.caseId}:${turn.turnVersion}:${action.question.questionId}` - : ""; + const action = journey?.nextAction; return { - question: agentQuestion?.key === key ? agentQuestion.text : fallback, + question, pending, error, pollRecoverable: Boolean(error) && action?.kind === "score_pending", @@ -233,5 +204,9 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG retryScoring, saveCandidate, confirmCandidate, + selectOption, + submitUnmatchedContext, + finish, + retryQuestionGeneration, }; } diff --git a/frontend/src/lib/birth-time-guide-agent.ts b/frontend/src/lib/birth-time-guide-agent.ts index 1cdb9810..60434fb3 100644 --- a/frontend/src/lib/birth-time-guide-agent.ts +++ b/frontend/src/lib/birth-time-guide-agent.ts @@ -22,6 +22,20 @@ export const birthTimeGuideRequestSchema = z.discriminatedUnion("type", [ turnVersion: turnVersionSchema, message: z.string().trim().min(1).max(500), }).strict(), + z.object({ + type: z.literal("generate_dynamic_question"), + caseId: caseIdSchema, + actionId: actionIdSchema, + turnVersion: turnVersionSchema, + }).strict(), + z.object({ + type: z.literal("reframe_unmatched"), + caseId: caseIdSchema, + actionId: actionIdSchema, + turnVersion: turnVersionSchema, + questionId: z.string().uuid(), + note: z.string().trim().max(240).default(""), + }).strict(), ]).readonly(); export type BirthTimeGuideRequest = z.infer; diff --git a/frontend/src/lib/birth-time-guided-effect-coordinator.ts b/frontend/src/lib/birth-time-guided-effect-coordinator.ts index 7fd277a2..60e1d85f 100644 --- a/frontend/src/lib/birth-time-guided-effect-coordinator.ts +++ b/frontend/src/lib/birth-time-guided-effect-coordinator.ts @@ -8,6 +8,9 @@ export function createIdentityRequestCache() { if (existing) return existing; const request = load(); requests.set(identity, request); + void request.catch(() => { + if (requests.get(identity) === request) requests.delete(identity); + }); return request; }, }; @@ -33,3 +36,9 @@ export function publishCurrentJourney(input: PublishCurrentJourneyInput): boolea input.publish(input.next); return true; } + +export function claimMutation(gate: { current: boolean }): (() => void) | null { + if (gate.current) return null; + gate.current = true; + return () => { gate.current = false; }; +} diff --git a/frontend/src/lib/birth-time-journey-client.ts b/frontend/src/lib/birth-time-journey-client.ts index 87f723ec..401da8e1 100644 --- a/frontend/src/lib/birth-time-journey-client.ts +++ b/frontend/src/lib/birth-time-journey-client.ts @@ -135,6 +135,16 @@ export function pollBirthTimeScoring(caseId: string, jobId: string, signal?: Abo return sendJourneyEvent({ type: "poll_scoring", caseId, jobId }, signal); } +export function answerDynamicBirthTimeChoice(input: { + readonly caseId: string; + readonly actionId: string; + readonly turnVersion: number; + readonly questionId: string; + readonly optionId: string; +}) { + return sendJourneyEvent({ type: "answer_dynamic_choice", ...input }); +} + export function submitBirthTimeLifeEvents(caseId: string, events: readonly LifeEvent[]) { return sendJourneyEvent({ type: "submit_life_events", caseId, events }); } @@ -210,3 +220,27 @@ export async function draftBirthTimeEvidence( const envelope = guideDraftEnvelopeSchema.parse(payload); return { ...envelope, turn: parseJourneyResponse(envelope.turn) }; } + +export async function generateDynamicBirthTimeQuestion( + caseId: string, + actionId: string, + turnVersion: number, +) { + const payload = await sendGuideEvent({ + type: "generate_dynamic_question", + caseId, + actionId, + turnVersion, + }); + return parseJourneyResponse(payload); +} + +export async function reframeUnmatchedBirthTimeAnswer(input: { + readonly caseId: string; + readonly actionId: string; + readonly turnVersion: number; + readonly questionId: string; + readonly note: string; +}) { + return parseJourneyResponse(await sendGuideEvent({ type: "reframe_unmatched", ...input })); +} diff --git a/frontend/src/lib/birth-time-journey-request.ts b/frontend/src/lib/birth-time-journey-request.ts index a97e91e7..ba4cb91b 100644 --- a/frontend/src/lib/birth-time-journey-request.ts +++ b/frontend/src/lib/birth-time-journey-request.ts @@ -10,6 +10,11 @@ const mutationFields = { turnVersion: turnVersionSchema, } as const; const revisionValidationId = "00000000-0000-4000-8000-000000000000"; +const publicChoiceFields = { + ...mutationFields, + questionId: z.string().uuid(), + optionId: z.string().uuid(), +} as const; export const birthTimeJourneyRequestSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("assess") }).strict(), @@ -49,6 +54,7 @@ export const birthTimeJourneyRequestSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("skip_evidence_question"), ...mutationFields }).strict(), z.object({ type: z.literal("pause_rectification"), ...mutationFields }).strict(), z.object({ type: z.literal("finish_rectification"), ...mutationFields }).strict(), + z.object({ type: z.literal("answer_dynamic_choice"), ...publicChoiceFields }).strict(), z.object({ type: z.literal("revise_evidence_draft"), ...mutationFields, diff --git a/frontend/src/lib/birth-time-journey-response-schema.ts b/frontend/src/lib/birth-time-journey-response-schema.ts index 10c32f0e..fa587d24 100644 --- a/frontend/src/lib/birth-time-journey-response-schema.ts +++ b/frontend/src/lib/birth-time-journey-response-schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { candidateResultSchema, journeySnapshotSchema, lifeEventSchema } from "./birth-time-journey.ts"; import { deriveJourneyPermissions, evidenceDraftSchema, journeyPermissionsSchema, journeyProgressSchema, nextActionSchema } from "./birth-time-journey-turn.ts"; +import { dynamicJourneyProgressSchema, dynamicNextActionSchema } from "./birth-time-journey-turn-protocol.ts"; import type { NextAction } from "./birth-time-journey-turn.ts"; export const answerSchema = z.enum(["A", "B", "C", "D"]); @@ -69,7 +70,7 @@ function actionMatchesProgress(value: { } const versionedJourneyResponseSchema = z.object({ - ...responseCore, scoring: versionedScoringSchema.nullable(), answers: z.record(answerSchema).readonly(), lifeEvents: z.array(lifeEventSchema).readonly(), candidateResult: candidateResultSchema.nullable(), + ...responseCore, journeyProtocol: z.undefined().optional(), scoring: versionedScoringSchema.nullable(), answers: z.record(answerSchema).readonly(), lifeEvents: z.array(lifeEventSchema).readonly(), candidateResult: candidateResultSchema.nullable(), turnVersion: z.number().int().nonnegative(), nextAction: nextActionSchema, progress: journeyProgressSchema, permissions: journeyPermissionsSchema, evidenceDraft: evidenceDraftSchema.nullable(), }).strict().superRefine((value, context) => { const confirmedTime = value.snapshot.state === "ready" ? value.snapshot.activeTime : null; @@ -88,9 +89,61 @@ const versionedJourneyResponseSchema = z.object({ } }).readonly(); -export type JourneyClientResponse = z.infer; +function dynamicActionMatchesPhase(value: { + readonly nextAction: z.infer; + readonly progress: z.infer; +}): boolean { + switch (value.nextAction.kind) { + case "generate_dynamic_question": case "ask_dynamic_choice": case "retry_question_generation": + return value.progress.phase === "question"; + case "clarify_unmatched_answer": return value.progress.phase === "clarification"; + case "score_pending": case "retry_scoring": return value.progress.phase === "scoring"; + case "present_low_result": case "present_medium_result": case "request_candidate_confirmation": + return value.progress.phase === "result"; + case "ready": return value.progress.phase === "ready"; + case "paused": return value.progress.phase === "paused"; + default: return assertNever(value.nextAction); + } +} + +const dynamicJourneyResponseSchema = z.object({ + ...responseCore, + scoring: versionedScoringSchema.nullable(), + answers: z.record(answerSchema).readonly(), + lifeEvents: z.array(lifeEventSchema).readonly(), + candidateResult: candidateResultSchema.nullable(), + journeyProtocol: z.literal("dynamic-choice-v2"), + turnVersion: z.number().int().nonnegative(), + nextAction: dynamicNextActionSchema, + progress: dynamicJourneyProgressSchema, + permissions: journeyPermissionsSchema, + evidenceDraft: z.null(), +}).strict().superRefine((value, context) => { + if (!dynamicActionMatchesPhase(value)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["progress", "phase"], message: "dynamic action must agree with progress" }); + } + const action = value.nextAction; + const candidateId = value.candidateResult?.resultId ?? null; + if (action.kind === "present_low_result" && action.resultId !== candidateId) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "resultId"], message: "low result must reference the current candidate" }); + } + if ((action.kind === "present_medium_result" || action.kind === "request_candidate_confirmation") + && action.resultId !== candidateId) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "resultId"], message: "result action must reference the current candidate" }); + } + if (action.kind === "ready" && (value.snapshot.state !== "ready" || value.snapshot.activeTime !== action.activeTime)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["nextAction", "activeTime"], message: "ready must match the active snapshot" }); + } +}).readonly(); + +export type LegacyJourneyClientResponse = z.infer; +export type DynamicJourneyClientResponse = z.infer; +export type JourneyClientResponse = LegacyJourneyClientResponse | DynamicJourneyClientResponse; export type JourneyAnswer = z.infer; export function parseVersionedJourneyResponse(value: unknown): JourneyClientResponse { - return versionedJourneyResponseSchema.parse(value); + const protocol = z.object({ journeyProtocol: z.unknown().optional() }).passthrough().parse(value); + return protocol.journeyProtocol === "dynamic-choice-v2" + ? dynamicJourneyResponseSchema.parse(value) + : versionedJourneyResponseSchema.parse(value); } diff --git a/frontend/tests/birth-time-dynamic-api.test.ts b/frontend/tests/birth-time-dynamic-api.test.ts new file mode 100644 index 00000000..e987071d --- /dev/null +++ b/frontend/tests/birth-time-dynamic-api.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { birthTimeGuideRequestSchema } from "../src/lib/birth-time-guide-agent.ts"; +import { birthTimeJourneyRequestSchema } from "../src/lib/birth-time-journey-request.ts"; +import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts"; +import { storedDynamicJourneyResponse } from "../src/lib/birth-time-journey-response.ts"; +import { dynamicCase, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts"; + +const caseId = "45857b75-4718-4590-aaf5-7113a03ea765"; +const actionId = "a9890e09-d535-46f0-9a36-86017515a5a1"; + +test("choice commands accept only public ids", () => { + const valid = { + type: "answer_dynamic_choice", + caseId, + actionId, + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: persistedQuestion.options[0].optionId, + }; + assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true); + for (const field of ["partitionId", "candidateScores", "confidence", "time"] as const) { + assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, [field]: "forged" }).success, false); + } +}); + +test("unmatched context is optional, trimmed, and bounded", () => { + const valid = { + type: "reframe_unmatched", + caseId, + actionId, + turnVersion: 8, + questionId: persistedQuestion.questionId, + note: " 更像是 2017 年 ", + }; + const parsed = birthTimeGuideRequestSchema.parse(valid); + assert.equal(parsed.type === "reframe_unmatched" ? parsed.note : null, "更像是 2017 年"); + assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, note: "字".repeat(241) }).success, false); + assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, partitionId: "private" }).success, false); +}); + +test("dynamic responses preserve the protocol discriminant without private scoring data", () => { + const response = storedDynamicJourneyResponse(dynamicCase()); + const parsed = parseJourneyResponse(response); + + assert.equal(parsed.journeyProtocol, "dynamic-choice-v2"); + assert.equal(parsed.nextAction.kind, "ask_dynamic_choice"); + const serialized = JSON.stringify(parsed); + assert.doesNotMatch(serialized, /partitionId|candidateScores|agentContext|candidateModel/); + assert.throws(() => parseJourneyResponse({ ...response, partitionId: "forged" })); +}); + +test("dynamic routes authenticate before parsing and dispatch scoped methods", () => { + const journeyRoute = readFileSync(new URL("../src/app/api/birth-time-journey/route.ts", import.meta.url), "utf8"); + const guideRoute = readFileSync(new URL("../src/app/api/birth-time-guide/route.ts", import.meta.url), "utf8"); + assert.ok(journeyRoute.indexOf("auth.getUser") < journeyRoute.indexOf("requestPayload(request)")); + assert.ok(guideRoute.indexOf("auth.getUser") < guideRoute.indexOf("requestPayload(request)")); + assert.match(journeyRoute, /answerDynamicChoice/); + assert.match(journeyRoute, /pollDynamicScoringJob/); + assert.match(guideRoute, /generateQuestion/); + assert.match(guideRoute, /submitUnmatchedContext/); +}); diff --git a/frontend/tests/birth-time-guide-client.test.ts b/frontend/tests/birth-time-guide-client.test.ts index 4bdc1f51..2858cae4 100644 --- a/frontend/tests/birth-time-guide-client.test.ts +++ b/frontend/tests/birth-time-guide-client.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { draftBirthTimeEvidence, + generateDynamicBirthTimeQuestion, + reframeUnmatchedBirthTimeAnswer, requestBirthTimeGuidePrompt, } from "../src/lib/birth-time-journey-client.ts"; import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts"; @@ -94,3 +96,35 @@ test("guide client rejects raw model metadata and malformed nested turns", async await assert.rejects(requestBirthTimeGuidePrompt(caseId)); }); + +test("dynamic guide commands send only public coordination fields", async (context) => { + const payloads: unknown[] = []; + context.mock.method(globalThis, "fetch", async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + payloads.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify(highConfirmationTurn), { status: 200 }); + }); + + await generateDynamicBirthTimeQuestion(caseId, actionId, 4); + await reframeUnmatchedBirthTimeAnswer({ + caseId, + actionId, + turnVersion: 5, + questionId: "11111111-1111-4111-8111-111111111111", + note: " 时间更早 ", + }); + + assert.deepEqual(payloads, [ + { type: "generate_dynamic_question", caseId, actionId, turnVersion: 4 }, + { + type: "reframe_unmatched", + caseId, + actionId, + turnVersion: 5, + questionId: "11111111-1111-4111-8111-111111111111", + note: "时间更早", + }, + ]); +}); diff --git a/frontend/tests/birth-time-guide-flow.test.ts b/frontend/tests/birth-time-guide-flow.test.ts index 9d87244b..8edb88de 100644 --- a/frontend/tests/birth-time-guide-flow.test.ts +++ b/frontend/tests/birth-time-guide-flow.test.ts @@ -9,6 +9,7 @@ const turnSource = read("../src/components/birth-time-guide-turn.tsx"); const draftSource = read("../src/components/birth-time-evidence-draft-card.tsx"); const candidateSource = read("../src/components/birth-time-candidate-result.tsx"); const hookSource = read("../src/hooks/use-birth-time-guided-journey.ts"); +const automaticEffectsSource = read("../src/hooks/use-birth-time-automatic-journey-effects.ts"); test("guided rectification renders exactly the persisted action, never a questionnaire slice", () => { assert.match(rectificationSource, /journey\.nextAction/); @@ -36,12 +37,12 @@ test("draft review is explicit, domain locked, and incomplete confirmation stays }); test("guided orchestration owns fallback copy, unique actions, polling, and retry", () => { - assert.match(hookSource, /fallbackQuestionCopy/); - assert.match(hookSource, /requestBirthTimeGuidePrompt/); - assert.match(hookSource, /crypto\.randomUUID\(\)/); - assert.match(hookSource, /runBirthTimeScoringPoll/); + assert.match(automaticEffectsSource, /fallbackQuestionCopy/); + assert.match(automaticEffectsSource, /requestBirthTimeGuidePrompt/); + assert.match(automaticEffectsSource, /crypto\.randomUUID\(\)/); + assert.match(automaticEffectsSource, /runBirthTimeScoringPoll/); assert.match(hookSource, /retry_scoring/); - assert.match(hookSource, /AbortController/); + assert.match(automaticEffectsSource, /AbortController/); }); test("candidate UI is nextAction-gated and keeps application boundary explicit", () => { diff --git a/frontend/tests/birth-time-guided-polling.test.ts b/frontend/tests/birth-time-guided-polling.test.ts index b1623c47..3b28010c 100644 --- a/frontend/tests/birth-time-guided-polling.test.ts +++ b/frontend/tests/birth-time-guided-polling.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { runBirthTimeScoringPoll } from "../src/lib/birth-time-guided-polling.ts"; +import { claimMutation, publishCurrentJourney } from "../src/lib/birth-time-guided-effect-coordinator.ts"; +import { storedDynamicJourneyResponse } from "../src/lib/birth-time-journey-response.ts"; +import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts"; import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts"; import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts"; @@ -94,3 +97,43 @@ test("bounded polling preserves the pending turn instead of inventing completion assert.equal(result.kind, "exhausted"); assert.equal(result.turn.nextAction.kind, "score_pending"); }); + +test("duplicate option clicks publish one advanced turn", async () => { + const gate = Promise.withResolvers(); + const sent: string[] = []; + const published: typeof completedTurn[] = []; + const lock = { current: false }; + const select = async (optionId: string) => { + const release = claimMutation(lock); + if (release === null) return; + sent.push(optionId); + const turn = await gate.promise; + published.push(turn); + release(); + }; + + const first = select("primary-option"); + const duplicate = select("primary-option"); + gate.resolve(completedTurn); + await Promise.all([first, duplicate]); + + assert.deepEqual(sent, ["primary-option"]); + assert.deepEqual(published, [completedTurn]); +}); + +test("a stale generated question cannot replace a newer turn", () => { + const expected = parseJourneyResponse(storedDynamicJourneyResponse(dynamicCase())); + const current = parseJourneyResponse({ ...expected, turnVersion: expected.turnVersion + 1 }); + let published = 0; + + const accepted = publishCurrentJourney({ + expected, + current, + next: expected, + publish: () => { published += 1; }, + }); + + assert.equal(accepted, false); + assert.equal(current.turnVersion, 8); + assert.equal(published, 0); +}); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index c23d15a8..2e315005 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -72,6 +72,21 @@ test("request identity cache and scheduled polling deduplicate Strict Mode start assert.equal(starts, 1); }); +test("a failed generation identity remains retryable", async () => { + const cache = createIdentityRequestCache(); + let attempts = 0; + await assert.rejects(cache.run("case:4", async () => { + attempts += 1; + throw new TypeError("offline"); + })); + + assert.equal(await cache.run("case:4", async () => { + attempts += 1; + return 8; + }), 8); + assert.equal(attempts, 2); +}); + test("a resolved mutation cannot publish over a changed case or version", () => { const expected = guidedBirthTimePreview("birth-time-rectification"); const current = parseJourneyResponse({ ...expected, turnVersion: expected.turnVersion + 1 });