feat: add click-first birth time questions
This commit is contained in:
@@ -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,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);
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user