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
+26
View File
@@ -0,0 +1,26 @@
.birth-time-question-progress { flex-wrap: wrap; gap: var(--space-2) var(--space-4); line-height: 1.5; }
.birth-time-question-progress b { color: var(--color-ink); font-weight: 600; }
.birth-time-choice-surface { min-width: 0; display: grid; gap: var(--space-4); }
.birth-time-choice-question { min-width: 0; display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; }
.birth-time-choice-question legend { width: 100%; margin: 0 0 var(--space-1); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; }
.birth-time-primary-choices { display: grid; gap: var(--space-2); }
.birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.birth-time-choice-option { min-height: 52px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.5; text-align: left; text-wrap: pretty; word-break: auto-phrase; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; }
.birth-time-choice-option.is-primary { width: 100%; padding: var(--space-3) var(--space-4); }
.birth-time-choice-option.is-secondary { min-height: 44px; flex: 1 1 0; padding: var(--space-2) var(--space-4); color: var(--color-ink-secondary); text-align: center; }
.birth-time-choice-option:hover:not(:disabled) { border-color: var(--color-border-strong); background: var(--color-canvas-soft); }
.birth-time-choice-option:active:not(:disabled) { transform: translateY(1px); }
.birth-time-choice-option[data-selected="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-ink); }
.birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.birth-time-choice-question:disabled .birth-time-choice-option { cursor: wait; opacity: .62; }
.birth-time-choice-pending { margin: 0; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-unmatched-note { display: grid; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; }
.birth-time-unmatched-note textarea { min-height: var(--space-24); resize: vertical; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 400; line-height: 1.55; }
.birth-time-reframe-actions, .birth-time-journey-controls { justify-content: flex-end; }
.birth-time-new-assessment { display: grid; justify-items: start; gap: var(--space-2); }
.birth-time-new-assessment small { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; }
@media (max-width: 767px) {
.birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: grid; grid-template-columns: 1fr; }
.birth-time-choice-option.is-secondary, .birth-time-reframe-actions > button, .birth-time-journey-controls > button { width: 100%; }
}
+1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Script from "next/script";
import "./globals.css";
import "./birth-time-choice.css";
export const metadata: Metadata = {
title: "Jyotisha · 印度占星",
@@ -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>
);
}
@@ -27,6 +27,8 @@ import {
} from "@/lib/birth-time-guided-effect-coordinator";
import type { EvidenceDatePrecision } from "@/lib/birth-time-question-planner";
import { useBirthTimeAutomaticJourneyEffects } from "@/hooks/use-birth-time-automatic-journey-effects";
import { advanceDynamicBirthTimePreview } from "@/lib/birth-time-dynamic-preview";
import type { DynamicPreviewCommand } from "@/lib/birth-time-dynamic-preview";
type GuidedJourneyInput = {
readonly journey: JourneyClientResponse | null;
@@ -57,6 +59,12 @@ export type BirthTimeGuidedController = {
readonly retryQuestionGeneration: () => void;
};
function previewAction(turn: JourneyClientResponse, command: DynamicPreviewCommand) {
return turn.journeyProtocol === "dynamic-choice-v2"
? advanceDynamicBirthTimePreview(turn, command)
: turn;
}
export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeGuidedController {
const { journey, onJourney, onReady, onEditBirthTimeDetails, preview } = input;
const latest = useRef(journey);
@@ -165,11 +173,11 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
turn, "skip_evidence_question", [],
(actionId) => skipBirthTimeEvidenceQuestion(turn.caseId, actionId, turn.turnVersion),
));
const pause = () => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
const pause = () => operate((turn) => preview ? Promise.resolve(previewAction(turn, { kind: "pause" })) : stableAction(
turn, "pause_rectification", [],
(actionId) => pauseBirthTimeRectification(turn.caseId, actionId, turn.turnVersion),
));
const resume = () => operate((turn) => preview ? Promise.resolve(turn) : resumeBirthTimeJourney(turn.caseId));
const resume = () => operate((turn) => preview ? Promise.resolve(previewAction(turn, { kind: "resume" })) : resumeBirthTimeJourney(turn.caseId));
const acknowledgeReady = () => {
if (journey?.nextAction.kind === "ready") onReady(journey);
};
@@ -193,8 +201,9 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
(actionId) => confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
));
const selectOption = (optionId: string) => operate((turn) => {
if (preview || turn.journeyProtocol !== "dynamic-choice-v2"
if (turn.journeyProtocol !== "dynamic-choice-v2"
|| turn.nextAction.kind !== "ask_dynamic_choice") return Promise.resolve(turn);
if (preview) return Promise.resolve(previewAction(turn, { kind: "select", optionId }));
const questionId = turn.nextAction.question.questionId;
return stableAction(turn, "answer_dynamic_choice", [questionId, optionId], (actionId) => (
answerDynamicBirthTimeChoice({
@@ -203,8 +212,9 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
));
});
const submitUnmatchedContext = (note: string) => operate((turn) => {
if (preview || turn.journeyProtocol !== "dynamic-choice-v2"
if (turn.journeyProtocol !== "dynamic-choice-v2"
|| turn.nextAction.kind !== "clarify_unmatched_answer") return Promise.resolve(turn);
if (preview) return Promise.resolve(previewAction(turn, { kind: "reframe" }));
const questionId = turn.nextAction.questionId;
return stableAction(turn, "reframe_unmatched", [questionId, note.trim()], (actionId) => (
reframeUnmatchedBirthTimeAnswer({
@@ -213,7 +223,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
));
});
const finish = () => operate((turn) => preview
? Promise.resolve(turn)
? Promise.resolve(previewAction(turn, { kind: "finish" }))
: stableAction(turn, "finish_rectification", [], (actionId) => (
finishBirthTimeRectification(turn.caseId, actionId, turn.turnVersion)
)));
@@ -0,0 +1,50 @@
import type {
PublicDynamicChoiceQuestion,
TimeRange,
} from "./birth-time-dynamic-choice.ts";
type ChoiceOption = PublicDynamicChoiceQuestion["options"][number];
export type ChoiceQuestionGroups = {
readonly primary: readonly ChoiceOption[];
readonly unknown: ChoiceOption;
readonly unmatched: ChoiceOption;
};
export type ChoiceSelectionIntent = {
readonly kind: "submit";
readonly optionId: string;
readonly effective: boolean;
};
export class ChoiceQuestionModelError extends Error {
readonly name = "ChoiceQuestionModelError";
}
export function choiceQuestionGroups(
question: PublicDynamicChoiceQuestion,
): ChoiceQuestionGroups {
const primary = question.options.filter((option) => option.kind === "primary");
const unknown = question.options.find((option) => option.kind === "unknown");
const unmatched = question.options.find((option) => option.kind === "unmatched");
if (!unknown || !unmatched) {
throw new ChoiceQuestionModelError("Dynamic question is missing a reserved choice");
}
return { primary, unknown, unmatched };
}
export function choiceSelectionIntent(option: ChoiceOption): ChoiceSelectionIntent {
return {
kind: "submit",
optionId: option.optionId,
effective: option.kind === "primary",
};
}
export function normalizeUnmatchedNote(note: string): string {
return Array.from(note.trim()).slice(0, 240).join("");
}
export function rangeLabel(range: TimeRange): string {
return `${range.startTime}${range.endTime}`;
}
@@ -0,0 +1,162 @@
import { parseJourneyResponse } from "./birth-time-journey-client.ts";
import type { DynamicJourneyClientResponse } from "./birth-time-journey-response-schema.ts";
import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
const caseId = "7299894c-10a8-4b45-91d1-339007282c50";
const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7";
const jobId = "5da741ba-4713-4c27-9cf4-a85e52dc4658";
class DynamicPreviewInvariantError extends Error {
readonly name = "DynamicPreviewInvariantError";
}
const questions = [
{
questionId: "education-shift",
prompt: "哪段时间更接近一次明显的升学、转学或学习方向变化?",
options: [
{ optionId: "earlier", label: "更接近 2007—2009 年", kind: "primary" },
{ optionId: "later", label: "更接近 2010—2012 年", kind: "primary" },
{ optionId: "unknown", label: "不记得", kind: "unknown" },
{ optionId: "unmatched", label: "都不符合", kind: "unmatched" },
],
},
{
questionId: "relocation-shift",
prompt: "第一次明显离开熟悉环境,更接近下面哪个阶段?",
options: [
{ optionId: "school", label: "求学阶段", kind: "primary" },
{ optionId: "work", label: "开始工作之后", kind: "primary" },
{ optionId: "unknown-2", label: "不记得", kind: "unknown" },
{ optionId: "unmatched-2", label: "都不符合", kind: "unmatched" },
],
},
] as const;
const candidate = {
resultId,
confidence: "medium",
canApply: false,
winningSegment: {
startTime: "05:38",
endTime: "05:49",
representativeTime: "05:43",
widthMinutes: 12,
},
eventCount: 3,
domainCount: 0,
topScore: 14.4,
secondScore: 12.1,
marginPercent: 15.97,
reasons: [],
evidence: [],
algorithmVersion: "birth-time-dynamic-scoring-v2",
} satisfies CandidateResult;
const highCandidate = {
...candidate,
confidence: "high",
canApply: true,
winningSegment: { ...candidate.winningSegment, widthMinutes: 5 },
eventCount: 4,
domainCount: 3,
marginPercent: 24,
} satisfies CandidateResult;
function response(input: {
readonly nextAction: DynamicNextAction;
readonly phase: "question" | "clarification" | "scoring" | "result" | "ready" | "paused";
readonly turnVersion?: number;
readonly answeredCount?: number;
readonly effectiveAnswerCount?: number;
readonly candidateResult?: CandidateResult | null;
readonly snapshotState?: "rectifying" | "candidate" | "confirming" | "ready";
readonly activeTime?: string | null;
}): DynamicJourneyClientResponse {
const parsed = parseJourneyResponse({
caseId,
snapshot: {
state: input.snapshotState ?? "rectifying",
assistantIntent: "start_standard_rectification",
input: input.phase === "ready" ? "none" : input.phase === "result" ? "candidate_actions" : "rectification_questions",
route: input.phase === "ready" ? "direct_chart" : "rectification",
confidence: input.candidateResult?.confidence ?? null,
canApply: input.nextAction.kind === "request_candidate_confirmation",
activeTime: input.activeTime ?? null,
reportedRange: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" },
},
questionnaire: null,
scoring: null,
answers: {},
lifeEvents: [],
candidateResult: input.candidateResult ?? null,
journeyProtocol: "dynamic-choice-v2",
turnVersion: input.turnVersion ?? 1,
nextAction: input.nextAction,
progress: {
phase: input.phase,
answeredCount: input.answeredCount ?? 0,
effectiveAnswerCount: input.effectiveAnswerCount ?? 0,
currentRange: { startTime: "04:00", endTime: "07:59" },
previousRange: null,
plateauCount: 0,
},
permissions: { canConfirmCandidate: input.nextAction.kind === "request_candidate_confirmation" },
evidenceDraft: null,
});
if (parsed.journeyProtocol !== "dynamic-choice-v2") {
throw new DynamicPreviewInvariantError("Dynamic preview parsed as legacy journey");
}
return parsed;
}
export function dynamicBirthTimePreview(
state: "generating" | "question-retry" | "question" | "clarification" | "scoring" | "scoring-retry" | "low" | "medium" | "confirmation" | "ready" | "paused" = "question",
): DynamicJourneyClientResponse {
switch (state) {
case "generating": return response({ nextAction: { kind: "generate_dynamic_question" }, phase: "question" });
case "question-retry": return response({ nextAction: { kind: "retry_question_generation" }, phase: "question" });
case "question": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[0] }, phase: "question" });
case "clarification": return response({ nextAction: { kind: "clarify_unmatched_answer", questionId: questions[0].questionId }, phase: "clarification", answeredCount: 1, turnVersion: 2 });
case "scoring": return response({ nextAction: { kind: "score_pending", jobId }, phase: "scoring", answeredCount: 1, effectiveAnswerCount: 1, turnVersion: 2 });
case "scoring-retry": return response({ nextAction: { kind: "retry_scoring", jobId }, phase: "scoring", answeredCount: 1, effectiveAnswerCount: 1, turnVersion: 2 });
case "low": return response({ nextAction: { kind: "present_low_result", resultId: null }, phase: "result", answeredCount: 2, effectiveAnswerCount: 1, snapshotState: "candidate", turnVersion: 3 });
case "medium": return response({ nextAction: { kind: "present_medium_result", resultId }, phase: "result", answeredCount: 3, effectiveAnswerCount: 2, candidateResult: candidate, snapshotState: "candidate", turnVersion: 4 });
case "confirmation": return response({ nextAction: { kind: "request_candidate_confirmation", resultId }, phase: "result", answeredCount: 4, effectiveAnswerCount: 4, candidateResult: highCandidate, snapshotState: "confirming", turnVersion: 5 });
case "ready": return response({ nextAction: { kind: "ready", activeTime: "05:43" }, phase: "ready", answeredCount: 4, effectiveAnswerCount: 4, candidateResult: highCandidate, snapshotState: "ready", activeTime: "05:43", turnVersion: 6 });
case "paused": return response({ nextAction: { kind: "paused" }, phase: "paused", turnVersion: 2 });
}
}
export type DynamicPreviewCommand =
| { readonly kind: "select"; readonly optionId: string }
| { readonly kind: "reframe" }
| { readonly kind: "finish" }
| { readonly kind: "pause" }
| { readonly kind: "resume" }
| { readonly kind: "retry_scoring" };
export function advanceDynamicBirthTimePreview(
turn: DynamicJourneyClientResponse,
command: DynamicPreviewCommand,
): DynamicJourneyClientResponse {
switch (command.kind) {
case "select": {
const action = turn.nextAction;
if (action.kind !== "ask_dynamic_choice") return turn;
const option = action.question.options.find((item) => item.optionId === command.optionId);
if (!option) return turn;
switch (option.kind) {
case "unmatched": return dynamicBirthTimePreview("clarification");
case "primary": return dynamicBirthTimePreview("scoring");
case "unknown": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[1] }, phase: "question", answeredCount: 1, turnVersion: 2 });
}
}
case "reframe": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[1] }, phase: "question", answeredCount: 1, turnVersion: 3 });
case "finish": return dynamicBirthTimePreview("low");
case "pause": return dynamicBirthTimePreview("paused");
case "resume": return dynamicBirthTimePreview("question");
case "retry_scoring": return dynamicBirthTimePreview("medium");
}
}
@@ -1,5 +1,6 @@
import { parseJourneyResponse } from "./birth-time-journey-client.ts";
import type { JourneyClientResponse } from "./birth-time-journey-client.ts";
import { dynamicBirthTimePreview } from "./birth-time-dynamic-preview.ts";
const caseId = "7299894c-10a8-4b45-91d1-339007282c50";
const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7";
@@ -89,6 +90,7 @@ export function isGuidedBirthTimePreview(mode: string): boolean {
}
export function guidedBirthTimePreview(mode: string): JourneyClientResponse {
if (mode === "birth-time-rectification") return dynamicBirthTimePreview();
if (mode === "birth-time-rectification-draft" || mode === "birth-time-rectification-events") {
return response({
snapshot: { ...response().snapshot, input: "life_events", assistantIntent: "collect_dated_life_events" },
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
choiceQuestionGroups,
choiceSelectionIntent,
normalizeUnmatchedNote,
} from "../src/lib/birth-time-choice-question-model.ts";
import type { PublicDynamicChoiceQuestion } from "../src/lib/birth-time-dynamic-choice.ts";
import {
advanceDynamicBirthTimePreview,
dynamicBirthTimePreview,
} from "../src/lib/birth-time-dynamic-preview.ts";
const question: PublicDynamicChoiceQuestion = {
questionId: "education-shift",
prompt: "哪段时间更接近一次明显的学习方向变化?",
options: [
{ optionId: "early", label: "更接近 2007—2009 年", kind: "primary" },
{ optionId: "late", label: "更接近 2010—2012 年", kind: "primary" },
{ optionId: "unknown", label: "不记得", kind: "unknown" },
{ optionId: "unmatched", label: "都不符合", kind: "unmatched" },
],
};
test("click intent submits primary and unknown options immediately", () => {
const groups = choiceQuestionGroups(question);
assert.deepEqual(groups.primary.map((option) => option.optionId), ["early", "late"]);
assert.deepEqual(choiceSelectionIntent(groups.primary[0]), {
kind: "submit", optionId: "early", effective: true,
});
assert.deepEqual(choiceSelectionIntent(groups.unknown), {
kind: "submit", optionId: "unknown", effective: false,
});
});
test("unmatched requests clarification without creating scoring evidence", () => {
const groups = choiceQuestionGroups(question);
assert.deepEqual(choiceSelectionIntent(groups.unmatched), {
kind: "submit", optionId: "unmatched", effective: false,
});
assert.equal(normalizeUnmatchedNote(" 大约是在搬家之后 "), "大约是在搬家之后");
assert.equal(normalizeUnmatchedNote("甲".repeat(260)).length, 240);
});
test("the rendered component keeps one-click and optional-note semantics", () => {
const source = readFileSync(
new URL("../src/components/birth-time-choice-question.tsx", import.meta.url),
"utf8",
);
assert.match(source, /<fieldset/);
assert.match(source, /<legend/);
assert.match(source, /onSelect\(intent\.optionId\)/);
assert.match(source, /maxLength=\{240\}/);
assert.doesNotMatch(source, /整理为经历草稿|记得的精度|发生时间|第.*\/.*轮/);
assert.doesNotMatch(source, /required/);
});
test("preview choices expose real local interaction states without authentication", () => {
const start = dynamicBirthTimePreview();
const scoring = advanceDynamicBirthTimePreview(start, { kind: "select", optionId: "earlier" });
const clarification = advanceDynamicBirthTimePreview(start, { kind: "select", optionId: "unmatched" });
const reframed = advanceDynamicBirthTimePreview(clarification, { kind: "reframe" });
const terminal = advanceDynamicBirthTimePreview(reframed, { kind: "finish" });
assert.equal(scoring.nextAction.kind, "score_pending");
assert.equal(clarification.nextAction.kind, "clarify_unmatched_answer");
assert.equal(reframed.nextAction.kind, "ask_dynamic_choice");
assert.equal(terminal.nextAction.kind, "present_low_result");
});
test("dynamic previews cover every visible result and recovery state", () => {
const expected = new Map([
["generating", "generate_dynamic_question"],
["question-retry", "retry_question_generation"],
["question", "ask_dynamic_choice"],
["clarification", "clarify_unmatched_answer"],
["scoring", "score_pending"],
["scoring-retry", "retry_scoring"],
["low", "present_low_result"],
["medium", "present_medium_result"],
["confirmation", "request_candidate_confirmation"],
["ready", "ready"],
["paused", "paused"],
] as const);
for (const [state, action] of expected) {
assert.equal(dynamicBirthTimePreview(state).nextAction.kind, action);
}
});
+12 -1
View File
@@ -5,6 +5,8 @@ import { guidedTurnIdentity } from "../src/lib/birth-time-guided-turn-identity.t
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const rectificationSource = read("../src/components/birth-time-rectification.tsx");
const legacyRectificationSource = read("../src/components/birth-time-legacy-rectification.tsx");
const choiceSource = read("../src/components/birth-time-choice-question.tsx");
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");
@@ -24,7 +26,8 @@ test("each persisted question has a stable remount identity so skipped input can
guidedTurnIdentity(3, "education_entry"),
guidedTurnIdentity(4, "relationship_entry"),
);
assert.match(rectificationSource, /key=\{guidedTurnIdentity\(props\.journey\.turnVersion, action\.question\.questionId\)\}/);
assert.match(legacyRectificationSource, /key=\{guidedTurnIdentity\(props\.journey\.turnVersion, action\.question\.questionId\)\}/);
assert.match(rectificationSource, /key=\{`\$\{props\.journey\.turnVersion\}:\$\{action\.question\.questionId\}`\}/);
});
test("draft review is explicit, domain locked, and incomplete confirmation stays disabled", () => {
@@ -64,4 +67,12 @@ test("guided controls expose live status and minimum target classes", () => {
assert.match(draftSource, /role=["']alert/);
assert.match(turnSource, /birth-time-guided-action/);
assert.match(candidateSource, /birth-time-guided-action/);
assert.match(choiceSource, /aria-busy=\{props\.pending\}/);
assert.match(choiceSource, /birth-time-guided-action/);
});
test("the active v2 surface cannot render the legacy prose and date draft flow", () => {
assert.doesNotMatch(rectificationSource, /BirthTimeGuideTurn|BirthTimeEvidenceDraftCard|<textarea/);
assert.match(rectificationSource, /<BirthTimeChoiceQuestion/);
assert.match(rectificationSource, /<BirthTimeUnmatchedClarification/);
});
@@ -10,6 +10,10 @@ const source = readFileSync(
new URL("../src/components/birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const legacySource = readFileSync(
new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url),
"utf8",
);
const pageSource = readFileSync(
new URL("../src/app/page.tsx", import.meta.url),
"utf8",
@@ -35,7 +39,7 @@ test("rectification UI is driven only by the persisted guided action", () => {
test("development previews cover every guided state with legal persisted actions", () => {
const expectedActions = new Map([
["birth-time-rectification", "ask_baseline_evidence"],
["birth-time-rectification", "ask_dynamic_choice"],
["birth-time-rectification-draft", "review_evidence_draft"],
["birth-time-rectification-score-pending", "score_pending"],
["birth-time-rectification-retry", "retry_scoring"],
@@ -50,6 +54,7 @@ test("development previews cover every guided state with legal persisted actions
assert.equal(isGuidedBirthTimePreview(mode), true);
assert.equal(guidedBirthTimePreview(mode).nextAction.kind, action);
}
assert.equal(guidedBirthTimePreview("birth-time-rectification").journeyProtocol, "dynamic-choice-v2");
assert.match(pageSource, /guidedBirthTimePreview\(previewMode\)/);
});
@@ -90,8 +95,10 @@ test("declared-time edits preserve the current journey until the revised profile
});
test("rectification renders draft review and candidates only from nextAction", () => {
assert.match(source, /action\.kind === "review_evidence_draft"/);
assert.match(source, /<BirthTimeEvidenceDraftCard/);
assert.match(legacySource, /action\.kind === "review_evidence_draft"/);
assert.match(legacySource, /<BirthTimeEvidenceDraftCard/);
assert.doesNotMatch(source, /BirthTimeEvidenceDraftCard|BirthTimeGuideTurn/);
assert.match(source, /<BirthTimeChoiceQuestion/);
assert.match(source, /<BirthTimeCandidateResult/);
assert.match(pageSource, /controller=\{birthTimeGuided\}/);
assert.doesNotMatch(pageSource, /submitBirthTimeLifeEvents/);