feat: expose dynamic rectification actions

This commit is contained in:
Jesse_Chen
2026-07-19 09:45:32 +08:00
parent 5c5b60b3e4
commit 806d2babbf
15 changed files with 579 additions and 101 deletions
+52 -3
View File
@@ -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: "时间更早",
},
]);
});
+6 -5
View File
@@ -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 });