feat: add agent guided birth time rectification

This commit is contained in:
Jesse_Chen
2026-07-18 18:40:23 +08:00
parent e5520f6bb8
commit 7ecd1be663
103 changed files with 11877 additions and 628 deletions
@@ -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>
);