feat: expose dynamic rectification actions
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
|
||||
<div className="birth-time-assessment-heading">
|
||||
<div><span>出生时间评估</span><h2 id="birth-time-assessment-title">动态候选评估</h2></div>
|
||||
<span className="birth-time-status-badge">动态评估</span>
|
||||
</div>
|
||||
<p className="birth-time-assistant-intent" role="status">{dynamicStatus(props.journey.nextAction)}</p>
|
||||
{generationFailed && <button className="button-secondary birth-time-guided-action" type="button" onClick={props.controller.retryQuestionGeneration}>重试当前问题</button>}
|
||||
{(props.controller.error || props.externalError) && <p className="form-error" role="alert">{props.controller.error || props.externalError}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const action = props.journey.nextAction;
|
||||
const heading = actionHeading(action);
|
||||
const asksQuestion = action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence";
|
||||
|
||||
@@ -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<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") {
|
||||
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) : "";
|
||||
}
|
||||
@@ -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<Awaited<ReturnType<typeof requestBirthTimeGuidePrompt>>>();
|
||||
|
||||
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<JourneyClientResponse>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof birthTimeGuideRequestSchema>;
|
||||
|
||||
@@ -8,6 +8,9 @@ export function createIdentityRequestCache<T>() {
|
||||
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; };
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof versionedJourneyResponseSchema>;
|
||||
function dynamicActionMatchesPhase(value: {
|
||||
readonly nextAction: z.infer<typeof dynamicNextActionSchema>;
|
||||
readonly progress: z.infer<typeof dynamicJourneyProgressSchema>;
|
||||
}): 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<typeof versionedJourneyResponseSchema>;
|
||||
export type DynamicJourneyClientResponse = z.infer<typeof dynamicJourneyResponseSchema>;
|
||||
export type JourneyClientResponse = LegacyJourneyClientResponse | DynamicJourneyClientResponse;
|
||||
export type JourneyAnswer = z.infer<typeof answerSchema>;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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: "时间更早",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<typeof completedTurn>();
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<number>();
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user