feat: add agent guided birth time rectification
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
|
||||
import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey";
|
||||
import { guidedTerminalPath } from "@/lib/birth-time-guided-terminal";
|
||||
|
||||
type CandidateResultProps = {
|
||||
readonly journey: JourneyClientResponse;
|
||||
readonly controller: BirthTimeGuidedController;
|
||||
};
|
||||
|
||||
const confidenceLabels = {
|
||||
low: "证据不足",
|
||||
medium: "中等置信",
|
||||
high: "较高置信",
|
||||
} as const;
|
||||
|
||||
export function BirthTimeCandidateResult({ journey, controller }: CandidateResultProps) {
|
||||
const result = journey.candidateResult;
|
||||
const action = journey.nextAction;
|
||||
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-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>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!result) return null;
|
||||
const winner = result.winningSegment;
|
||||
|
||||
return (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
<div className="birth-time-candidate-heading">
|
||||
<b>关键经历评分结果</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>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="birth-time-assessment-unavailable">候选仍然并列或缺少必要计算层,系统不会选择具体分钟。</p>
|
||||
)}
|
||||
<p className="birth-time-evidence-boundary">这是可复现的候选评分,不代表已经证明出生记录中的具体分钟。</p>
|
||||
|
||||
{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>}
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "present_medium_result" && winner && (
|
||||
<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)}>
|
||||
{controller.pending ? "保存中…" : "保存候选范围"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{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>}
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "request_candidate_confirmation" && winner && (
|
||||
<div className="birth-time-confirmation-panel">
|
||||
<b>确认<span className="phrase-nowrap">当前排盘使用时间</span></b>
|
||||
<p><span className="phrase-nowrap">候选时间</span>为 {winner.representativeTime}。确认后它会成为<span className="phrase-nowrap">当前排盘使用时间</span>,<span className="phrase-nowrap">原始填报时间</span>仍会保留。</p>
|
||||
<button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={() => controller.confirmCandidate(result.resultId, winner.representativeTime)}>
|
||||
{controller.pending ? "确认中…" : `确认使用 ${winner.representativeTime}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{action.kind === "ready" && (
|
||||
<div className="birth-time-candidate-terminal" role="status">
|
||||
<p className="birth-time-success-note"><span className="phrase-nowrap">当前排盘使用时间</span>已更新为 {action.activeTime},<span className="phrase-nowrap">原始填报时间</span>仍已保留。</p>
|
||||
<button className="button-primary birth-time-guided-action" disabled={controller.pending} type="button" onClick={controller.acknowledgeReady}>继续使用此排盘</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { lifeEventSchema } from "@/lib/birth-time-evidence";
|
||||
import type { EvidenceDraft } from "@/lib/birth-time-journey-turn";
|
||||
import type { EvidenceDatePrecision, EvidenceDomain } from "@/lib/birth-time-question-planner";
|
||||
|
||||
const domainLabels = {
|
||||
education: "学业与学习环境",
|
||||
relocation: "搬迁与长期居住地",
|
||||
relationship: "重要关系",
|
||||
career: "工作与身份变化",
|
||||
health_pressure: "健康或生活压力",
|
||||
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
|
||||
|
||||
type DraftCardProps = {
|
||||
readonly draft: EvidenceDraft;
|
||||
readonly pending: boolean;
|
||||
readonly onConfirm: (precision: EvidenceDatePrecision, date: string) => void;
|
||||
readonly onSkip: () => void;
|
||||
};
|
||||
|
||||
function parsePrecision(value: string): EvidenceDatePrecision {
|
||||
if (value === "year" || value === "month" || value === "day") return value;
|
||||
throw new TypeError("Unsupported evidence date precision");
|
||||
}
|
||||
|
||||
export function BirthTimeEvidenceDraftCard(props: DraftCardProps) {
|
||||
const [precision, setPrecision] = useState<EvidenceDatePrecision>(props.draft.precision ?? "year");
|
||||
const [date, setDate] = useState(props.draft.date ?? "");
|
||||
const isValid = lifeEventSchema.safeParse({
|
||||
id: props.draft.draftId,
|
||||
domain: props.draft.domain,
|
||||
precision,
|
||||
date,
|
||||
}).success;
|
||||
const inputType = precision === "year" ? "number" : precision;
|
||||
|
||||
return (
|
||||
<div className="birth-time-evidence-draft-card">
|
||||
<div className="birth-time-candidate-heading"><b>确认关键经历</b><span>待确认草稿</span></div>
|
||||
<dl className="birth-time-draft-domain"><div><dt>经历领域</dt><dd>{domainLabels[props.draft.domain]}</dd></div></dl>
|
||||
<div className="birth-time-draft-fields">
|
||||
<label>
|
||||
<span>记得的精度</span>
|
||||
<select disabled={props.pending} value={precision} onChange={(event) => { setPrecision(parsePrecision(event.target.value)); setDate(""); }}>
|
||||
<option value="year">只记得年份</option>
|
||||
<option value="month">记得月份</option>
|
||||
<option value="day">记得具体日期</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>发生时间</span>
|
||||
<input
|
||||
aria-invalid={date.length > 0 && !isValid}
|
||||
disabled={props.pending}
|
||||
inputMode={precision === "year" ? "numeric" : undefined}
|
||||
max={precision === "year" ? String(new Date().getFullYear()) : undefined}
|
||||
min={precision === "year" ? "1900" : undefined}
|
||||
type={inputType}
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{!isValid && <p className="form-error" role="alert">请填写与所选精度一致的有效日期;<span className="phrase-nowrap">不会自动补猜缺失时间</span>。</p>}
|
||||
<div className="birth-time-guided-actions">
|
||||
<button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onSkip}>放弃这条并跳过</button>
|
||||
<button className="button-primary birth-time-guided-action" disabled={props.pending || !isValid} type="button" onClick={() => props.onConfirm(precision, date)}>
|
||||
{props.pending ? "确认中…" : "确认并用于校正"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { JourneyProgress } from "@/lib/birth-time-journey-turn";
|
||||
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
|
||||
|
||||
type BirthTimeGuideTurnProps = {
|
||||
readonly question: string;
|
||||
readonly progress: JourneyProgress;
|
||||
readonly pending: boolean;
|
||||
readonly onSubmit: (message: string) => void;
|
||||
readonly onSkip: () => void;
|
||||
readonly onPause: () => void;
|
||||
};
|
||||
|
||||
export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
const phase = props.progress.adaptiveRound > 0
|
||||
? `自适应第 ${props.progress.adaptiveRound} / ${props.progress.maxAdaptiveRounds} 轮`
|
||||
: "基础证据";
|
||||
|
||||
return (
|
||||
<div className="birth-time-guide-turn" aria-live="polite">
|
||||
<div className="birth-time-question-progress">
|
||||
<b>{phase}</b>
|
||||
<span>已确认 {props.progress.confirmedEvidenceCount} 条 / {props.progress.baselineDomainCount} 个领域</span>
|
||||
</div>
|
||||
<p className="birth-time-guide-question">{protectOnboardingPhrases(props.question)}</p>
|
||||
<label className="birth-time-guide-composer">
|
||||
<span>说说这段经历</span>
|
||||
<textarea
|
||||
aria-describedby="birth-time-guide-hint"
|
||||
disabled={props.pending}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && message.trim()) {
|
||||
event.preventDefault();
|
||||
props.onSubmit(message.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p id="birth-time-guide-hint" className="birth-time-guide-hint">说出大概年份也可以,不需要为了校正而猜测。</p>
|
||||
<div className="birth-time-guided-actions">
|
||||
<button className="button-text birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onPause}>暂停,稍后继续</button>
|
||||
<button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onSkip}>不记得,跳过</button>
|
||||
<button className="button-primary birth-time-guided-action" disabled={props.pending || !message.trim()} type="button" onClick={() => props.onSubmit(message.trim())}>
|
||||
{props.pending ? "整理中…" : "整理为经历草稿"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +1,103 @@
|
||||
"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 {
|
||||
JourneyAnswer,
|
||||
JourneyClientResponse,
|
||||
} from "@/lib/birth-time-journey-client";
|
||||
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";
|
||||
|
||||
type BirthTimeRectificationProps = {
|
||||
readonly journey: JourneyClientResponse;
|
||||
readonly answers: Readonly<Record<string, JourneyAnswer>>;
|
||||
readonly pendingQuestionId: string;
|
||||
readonly error: string;
|
||||
readonly onAnswer: (questionId: string, answer: JourneyAnswer) => void;
|
||||
readonly controller: BirthTimeGuidedController;
|
||||
readonly externalError: string;
|
||||
};
|
||||
|
||||
const fallbackOptions = [
|
||||
{ key: "A", label: "明确有,而且时间大致吻合" },
|
||||
{ key: "B", label: "有类似经历,但时间或程度不完全确定" },
|
||||
{ key: "C", label: "没有明显发生" },
|
||||
{ key: "D", label: "不确定 / 不记得" },
|
||||
] as const;
|
||||
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: "已保存" };
|
||||
default: {
|
||||
const exhaustive: never = action;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function BirthTimeRectification({
|
||||
journey,
|
||||
answers,
|
||||
pendingQuestionId,
|
||||
error,
|
||||
onAnswer,
|
||||
}: BirthTimeRectificationProps) {
|
||||
const questions = journey.questionnaire?.questions.slice(0, 3) ?? [];
|
||||
const answeredCount = Object.keys(answers).length;
|
||||
export function BirthTimeRectification(props: BirthTimeRectificationProps) {
|
||||
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">
|
||||
{journey.snapshot.state === "candidate" ? "候选范围已保存" : "需要先缩小时间范围"}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="birth-time-status-badge">
|
||||
{journey.snapshot.state === "candidate" ? "候选" : "校正中"}
|
||||
</span>
|
||||
<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>{journey.snapshot.reportedRange.label}</dd></div>
|
||||
<div><dt>应用状态</dt><dd>尚未设为排盘时间</dd></div>
|
||||
<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>
|
||||
|
||||
<p className="birth-time-assistant-intent" role="status">
|
||||
{assistantIntentCopy(journey.snapshot.assistantIntent)}
|
||||
</p>
|
||||
|
||||
{questions.length > 0 ? (
|
||||
<div className="birth-time-question-list">
|
||||
<div className="birth-time-question-progress">
|
||||
<b>首轮问题</b>
|
||||
<span>{Math.min(answeredCount, questions.length)} / {questions.length}</span>
|
||||
</div>
|
||||
{questions.map((question, index) => (
|
||||
<fieldset className="birth-time-question" key={question.id}>
|
||||
<legend><span>{index + 1}</span>{question.prompt}</legend>
|
||||
<div className="birth-time-answer-list">
|
||||
{(question.options?.length ? question.options : fallbackOptions).map((option) => (
|
||||
<button
|
||||
aria-pressed={answers[question.id] === option.key}
|
||||
className={answers[question.id] === option.key ? "is-selected" : ""}
|
||||
disabled={Boolean(pendingQuestionId)}
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => onAnswer(question.id, option.key)}
|
||||
>
|
||||
<span>{option.key}</span>{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{pendingQuestionId === question.id && <small role="status">正在更新候选范围…</small>}
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="birth-time-assessment-unavailable">
|
||||
当前资料已经保留,但候选扫描暂时不可用。系统不会把未经验证的分钟用于正式排盘。
|
||||
</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" && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{showsCandidate && <BirthTimeCandidateResult controller={props.controller} journey={props.journey} />}
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user