feat: add click-first birth time questions

This commit is contained in:
Jesse_Chen
2026-07-19 10:39:47 +08:00
parent db5c12f67f
commit c74c0bd70f
13 changed files with 664 additions and 105 deletions
@@ -18,13 +18,14 @@ const confidenceLabels = {
export function BirthTimeCandidateResult({ journey, controller }: CandidateResultProps) {
const result = journey.candidateResult;
const action = journey.nextAction;
const dynamic = journey.journeyProtocol === "dynamic-choice-v2";
const terminalPath = guidedTerminalPath(journey);
if (!result && action.kind === "present_low_result") {
return (
<div className="birth-time-candidate-result" aria-live="polite">
<p className="birth-time-assessment-unavailable"> {journey.snapshot.reportedRange.label}</p>
<p className="birth-time-assessment-unavailable"></p>
<p className="birth-time-evidence-boundary"><span className="phrase-nowrap">使</span></p>
{terminalPath && <button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.editBirthTimeDetails}></button>}
{terminalPath && <NewAssessmentAction controller={controller} />}
</div>
);
}
@@ -34,15 +35,15 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
return (
<div className="birth-time-candidate-result" aria-live="polite">
<div className="birth-time-candidate-heading">
<b></b>
<b>{dynamic ? "候选范围评估结果" : "关键经历评分结果"}</b>
<span>{confidenceLabels[result.confidence]}</span>
</div>
{winner ? (
<dl className="birth-time-candidate-grid">
<div><dt></dt><dd>{winner.startTime}{winner.endTime}</dd></div>
<div><dt></dt><dd>{winner.representativeTime}</dd></div>
<div><dt></dt><dd>{result.eventCount} / {result.domainCount} </dd></div>
<div><dt></dt><dd>{result.marginPercent}%</dd></div>
{!dynamic && <div><dt></dt><dd>{result.eventCount} / {result.domainCount} </dd></div>}
{!dynamic && <div><dt></dt><dd>{result.marginPercent}%</dd></div>}
</dl>
) : (
<p className="birth-time-assessment-unavailable"></p>
@@ -51,11 +52,17 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
{action.kind === "present_low_result" && (
<div className="birth-time-candidate-terminal" role="status">
<p><span className="phrase-nowrap">使</span></p>
{terminalPath && <button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.editBirthTimeDetails}></button>}
<p>{dynamic ? "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。" : "本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。"}</p>
{terminalPath && <NewAssessmentAction controller={controller} />}
</div>
)}
{action.kind === "present_medium_result" && winner && (
{action.kind === "present_medium_result" && winner && dynamic && (
<div className="birth-time-candidate-terminal" role="status">
<p><span className="phrase-nowrap">使</span></p>
<NewAssessmentAction controller={controller} />
</div>
)}
{action.kind === "present_medium_result" && winner && !dynamic && (
<div className="birth-time-candidate-terminal">
<p><span className="phrase-nowrap"></span></p>
<button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.saveCandidate(result.resultId)}>
@@ -66,7 +73,7 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
{action.kind === "candidate_saved" && (
<div className="birth-time-candidate-terminal" role="status">
<p className="birth-time-success-note"><span className="phrase-nowrap">使</span></p>
{terminalPath && <button className="button-secondary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.editBirthTimeDetails}></button>}
{terminalPath && <NewAssessmentAction controller={controller} />}
</div>
)}
{action.kind === "request_candidate_confirmation" && winner && (
@@ -87,3 +94,14 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
</div>
);
}
function NewAssessmentAction({ controller }: {
readonly controller: BirthTimeGuidedController;
}) {
return (
<div className="birth-time-new-assessment">
<button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button"></button>
<small></small>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import {
choiceQuestionGroups,
choiceSelectionIntent,
normalizeUnmatchedNote,
rangeLabel,
} from "@/lib/birth-time-choice-question-model";
import type {
DynamicJourneyProgress,
PublicDynamicChoiceQuestion,
} from "@/lib/birth-time-journey-turn-protocol";
type SharedQuestionProps = {
readonly progress: DynamicJourneyProgress;
readonly pending: boolean;
readonly error: string;
readonly onPause: () => void;
readonly onFinish: () => void;
};
type ChoiceQuestionProps = SharedQuestionProps & {
readonly question: PublicDynamicChoiceQuestion;
readonly onSelect: (optionId: string) => void;
};
type ClarificationProps = SharedQuestionProps & {
readonly onReframe: (note: string) => void;
};
export function BirthTimeQuestionProgress({ progress }: {
readonly progress: DynamicJourneyProgress;
}) {
return (
<div className="birth-time-question-progress">
<b> {progress.effectiveAnswerCount} </b>
<span>{rangeLabel(progress.currentRange)}</span>
</div>
);
}
export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
const groups = choiceQuestionGroups(props.question);
const [selectedId, setSelectedId] = useState("");
const select = (option: PublicDynamicChoiceQuestion["options"][number]) => {
const intent = choiceSelectionIntent(option);
setSelectedId(intent.optionId);
props.onSelect(intent.optionId);
};
return (
<div className="birth-time-choice-surface" aria-busy={props.pending}>
<BirthTimeQuestionProgress progress={props.progress} />
<fieldset className="birth-time-choice-question" disabled={props.pending}>
<legend>{props.question.prompt}</legend>
<div className="birth-time-primary-choices">
{groups.primary.map((option) => (
<button
className="birth-time-choice-option is-primary"
data-selected={selectedId === option.optionId}
key={option.optionId}
onClick={() => select(option)}
type="button"
>
{option.label}
</button>
))}
</div>
<div className="birth-time-special-choices">
{[groups.unknown, groups.unmatched].map((option) => (
<button
className="birth-time-choice-option is-secondary"
data-selected={selectedId === option.optionId}
key={option.optionId}
onClick={() => select(option)}
type="button"
>
{option.label}
</button>
))}
</div>
</fieldset>
{props.pending && <p className="birth-time-choice-pending" role="status"></p>}
<JourneyControls {...props} />
</div>
);
}
export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
const [note, setNote] = useState("");
return (
<div className="birth-time-choice-surface" aria-busy={props.pending}>
<BirthTimeQuestionProgress progress={props.progress} />
<fieldset className="birth-time-choice-question" disabled={props.pending}>
<legend></legend>
<label className="birth-time-unmatched-note">
<span></span>
<textarea
maxLength={240}
onChange={(event) => setNote(event.target.value)}
placeholder="例如:这段变化发生得更早,或情况不太一样"
value={note}
/>
</label>
<div className="birth-time-reframe-actions">
<button className="button-secondary birth-time-guided-action" onClick={() => props.onReframe("")} type="button"></button>
<button className="button-primary birth-time-guided-action" onClick={() => props.onReframe(normalizeUnmatchedNote(note))} type="button"></button>
</div>
</fieldset>
{props.pending && <p className="birth-time-choice-pending" role="status"></p>}
<JourneyControls {...props} />
</div>
);
}
function JourneyControls(props: SharedQuestionProps) {
return (
<>
<div className="birth-time-journey-controls">
<button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onPause} type="button"></button>
<button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onFinish} type="button"></button>
</div>
{props.error && <p className="form-error" role="alert">{props.error}</p>}
</>
);
}
@@ -0,0 +1,83 @@
"use client";
import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result";
import { BirthTimeEvidenceDraftCard } from "@/components/birth-time-evidence-draft-card";
import { BirthTimeGuideTurn } from "@/components/birth-time-guide-turn";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
import type { LegacyJourneyClientResponse } from "@/lib/birth-time-journey-response-schema";
import { guidedTurnIdentity } from "@/lib/birth-time-guided-turn-identity";
import type { NextAction } from "@/lib/birth-time-journey-turn";
type LegacyRectificationProps = {
readonly journey: LegacyJourneyClientResponse;
readonly controller: BirthTimeGuidedController;
readonly externalError: string;
};
function actionHeading(action: NextAction): { readonly title: string; readonly badge: string } {
switch (action.kind) {
case "ask_baseline_evidence": return { title: "回想一条关键经历", badge: "基础证据" };
case "ask_adaptive_evidence": return { title: "继续缩小候选范围", badge: "自适应校正" };
case "review_evidence_draft": return { title: "确认经历草稿", badge: "待确认" };
case "score_pending": return { title: "正在比较候选时间", badge: "评分中" };
case "retry_scoring": return { title: "评分需要重试", badge: "可恢复" };
case "present_low_result": return { title: "候选范围已保存", badge: "证据不足" };
case "present_medium_result": return { title: "候选范围已形成", badge: "中等置信" };
case "candidate_saved": return { title: "候选范围已保存", badge: "已保存" };
case "request_candidate_confirmation": return { title: "确认候选时间", badge: "待确认" };
case "ready": return { title: "排盘时间已更新", badge: "已完成" };
case "paused": return { title: "校正已暂停", badge: "已保存" };
}
}
export function BirthTimeLegacyRectification(props: LegacyRectificationProps) {
const action = props.journey.nextAction;
const heading = actionHeading(action);
const asksQuestion = action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence";
const showsCandidate = action.kind === "present_low_result"
|| action.kind === "present_medium_result"
|| action.kind === "candidate_saved"
|| action.kind === "request_candidate_confirmation"
|| action.kind === "ready";
const error = props.controller.error || props.externalError;
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">{heading.title}</h2></div>
<span className="birth-time-status-badge">{heading.badge}</span>
</div>
<dl className="birth-time-range-summary">
<div><dt></dt><dd>{props.journey.snapshot.reportedRange.label}</dd></div>
<div><dt></dt><dd>{action.kind === "ready" ? `当前使用 ${action.activeTime}` : "尚未更新排盘时间"}</dd></div>
</dl>
<p className="birth-time-assistant-intent" role="status">{assistantIntentCopy(props.journey.snapshot.assistantIntent)}</p>
{asksQuestion && (
<BirthTimeGuideTurn
key={guidedTurnIdentity(props.journey.turnVersion, action.question.questionId)}
pending={props.controller.pending}
progress={props.journey.progress}
question={props.controller.question}
onPause={props.controller.pause}
onSkip={props.controller.skip}
onSubmit={props.controller.submitMessage}
/>
)}
{action.kind === "review_evidence_draft" && props.journey.evidenceDraft && (
<BirthTimeEvidenceDraftCard
key={props.journey.evidenceDraft.draftId}
draft={props.journey.evidenceDraft}
pending={props.controller.pending}
onConfirm={props.controller.confirmDraft}
onSkip={props.controller.skip}
/>
)}
{action.kind === "score_pending" && <p className="birth-time-scoring-status" role="status">使</p>}
{action.kind === "retry_scoring" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button"></button>}
{action.kind === "paused" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button"></button>}
{showsCandidate && <BirthTimeCandidateResult controller={props.controller} journey={props.journey} />}
{error && <p className="form-error" role="alert">{error}</p>}
</section>
);
}
@@ -1,13 +1,14 @@
"use client";
import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result";
import { BirthTimeEvidenceDraftCard } from "@/components/birth-time-evidence-draft-card";
import { BirthTimeGuideTurn } from "@/components/birth-time-guide-turn";
import {
BirthTimeChoiceQuestion,
BirthTimeQuestionProgress,
BirthTimeUnmatchedClarification,
} from "@/components/birth-time-choice-question";
import { BirthTimeLegacyRectification } from "@/components/birth-time-legacy-rectification";
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
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 = {
@@ -16,72 +17,50 @@ type BirthTimeRectificationProps = {
readonly externalError: string;
};
function actionHeading(action: NextAction): { readonly title: string; readonly badge: string } {
function dynamicHeading(action: DynamicNextAction): { readonly title: string; readonly badge: string } {
switch (action.kind) {
case "ask_baseline_evidence": return { title: "回想一条关键经历", badge: "基础证据" };
case "ask_adaptive_evidence": return { title: "继续缩小候选范围", badge: "自适应校正" };
case "review_evidence_draft": return { title: "确认经历草稿", badge: "待确认" };
case "score_pending": return { title: "正在比较候选时间", badge: "评分中" };
case "generate_dynamic_question": return { title: "准备下一道问题", badge: "正在生成" };
case "retry_question_generation": return { title: "换一道问题", badge: "可重试" };
case "ask_dynamic_choice": return { title: "选择更接近的情况", badge: "点击作答" };
case "clarify_unmatched_answer": return { title: "换一道更合适的问题", badge: "可选补充" };
case "score_pending": return { title: "正在缩小候选范围", badge: "评分中" };
case "retry_scoring": return { title: "评分需要重试", badge: "可恢复" };
case "present_low_result": return { title: "候选范围已保存", badge: "证据不足" };
case "present_medium_result": return { title: "候选范围已形成", badge: "中等置信" };
case "candidate_saved": return { title: "候选范围已保存", badge: "已保存" };
case "present_low_result": return { title: "本次评估已结束", badge: "范围已保存" };
case "present_medium_result": return { title: "候选范围已形成", badge: "评估已结束" };
case "request_candidate_confirmation": return { title: "确认候选时间", badge: "待确认" };
case "ready": return { title: "排盘时间已更新", badge: "已完成" };
case "paused": return { title: "校正已暂停", badge: "已保存" };
default: {
const exhaustive: never = action;
return exhaustive;
}
case "paused": return { title: "评估已暂停", badge: "进度已保存" };
}
}
function dynamicStatus(action: DynamicNextAction): string {
function statusCopy(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 "generate_dynamic_question": return "系统正在根据当前候选范围准备更有区分度的问题";
case "retry_question_generation": return "这道问题暂时没有生成成功,可以原地重试。";
case "ask_dynamic_choice": return "点击最接近的选项即可,不需要再填写日期或时间。";
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;
}
case "paused": return "当前问题和候选范围已保存,可以稍后从这里继续。";
}
}
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>
);
if (props.journey.journeyProtocol !== "dynamic-choice-v2") {
return <BirthTimeLegacyRectification {...props} journey={props.journey} />;
}
const action = props.journey.nextAction;
const heading = actionHeading(action);
const asksQuestion = action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence";
const heading = dynamicHeading(action);
const error = props.controller.error || props.externalError;
const showsProgress = action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer";
const showsCandidate = action.kind === "present_low_result"
|| action.kind === "present_medium_result"
|| action.kind === "candidate_saved"
|| action.kind === "request_candidate_confirmation"
|| action.kind === "ready";
const error = props.controller.error || props.externalError;
return (
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
@@ -89,53 +68,43 @@ export function BirthTimeRectification(props: BirthTimeRectificationProps) {
<div><span></span><h2 id="birth-time-assessment-title">{heading.title}</h2></div>
<span className="birth-time-status-badge">{heading.badge}</span>
</div>
<dl className="birth-time-range-summary">
<div><dt></dt><dd>{props.journey.snapshot.reportedRange.label}</dd></div>
<div><dt></dt><dd>{action.kind === "ready" ? `当前使用 ${action.activeTime}` : "尚未更新排盘时间"}</dd></div>
</dl>
<p className="birth-time-assistant-intent" role="status">{assistantIntentCopy(props.journey.snapshot.assistantIntent)}</p>
{asksQuestion && (
<BirthTimeGuideTurn
key={guidedTurnIdentity(props.journey.turnVersion, action.question.questionId)}
{showsProgress && <BirthTimeQuestionProgress progress={props.journey.progress} />}
<p className="birth-time-assistant-intent" role="status">{statusCopy(action)}</p>
{action.kind === "ask_dynamic_choice" && (
<BirthTimeChoiceQuestion
key={`${props.journey.turnVersion}:${action.question.questionId}`}
error={error}
onFinish={props.controller.finish}
onPause={props.controller.pause}
onSelect={props.controller.selectOption}
pending={props.controller.pending}
progress={props.journey.progress}
question={props.controller.question}
onPause={props.controller.pause}
onSkip={props.controller.skip}
onSubmit={props.controller.submitMessage}
question={action.question}
/>
)}
{action.kind === "review_evidence_draft" && props.journey.evidenceDraft && (
<BirthTimeEvidenceDraftCard
key={props.journey.evidenceDraft.draftId}
draft={props.journey.evidenceDraft}
{action.kind === "clarify_unmatched_answer" && (
<BirthTimeUnmatchedClarification
error={error}
onFinish={props.controller.finish}
onPause={props.controller.pause}
onReframe={props.controller.submitUnmatchedContext}
pending={props.controller.pending}
onConfirm={props.controller.confirmDraft}
onSkip={props.controller.skip}
progress={props.journey.progress}
/>
)}
{(action.kind === "generate_dynamic_question" || action.kind === "retry_question_generation") && error && (
<button className="button-secondary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryQuestionGeneration} type="button"></button>
)}
{action.kind === "score_pending" && (
<div className="birth-time-scoring-status" aria-live="polite">
<b>使</b>
<p></p>
{props.controller.pollRecoverable && <button className="button-secondary birth-time-guided-action" type="button" onClick={props.controller.retryScoring}></button>}
</div>
)}
{action.kind === "retry_scoring" && (
<div className="birth-time-scoring-status" role="status">
<p><span className="phrase-nowrap"></span></p>
<button className="button-primary birth-time-guided-action" disabled={props.controller.pending} type="button" onClick={props.controller.retryScoring}></button>
</div>
)}
{action.kind === "paused" && (
<div className="birth-time-candidate-terminal" role="status">
<p></p>
<button className="button-primary birth-time-guided-action" disabled={props.controller.pending} type="button" onClick={props.controller.resume}></button>
<b></b>
{props.controller.pollRecoverable && <button className="button-secondary birth-time-guided-action" onClick={props.controller.retryScoring} type="button"></button>}
</div>
)}
{action.kind === "retry_scoring" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.retryScoring} type="button"></button>}
{action.kind === "paused" && <button className="button-primary birth-time-guided-action" disabled={props.controller.pending} onClick={props.controller.resume} type="button"></button>}
{showsCandidate && <BirthTimeCandidateResult controller={props.controller} journey={props.journey} />}
{error && <p className="form-error" role="alert">{error}</p>}
{error && action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer" && <p className="form-error" role="alert">{error}</p>}
</section>
);
}