feat: complete verifiable birth-time rectification flow

This commit is contained in:
Jesse_Chen
2026-07-21 22:16:18 +08:00
parent eb8ed8bee4
commit bfc6870614
117 changed files with 6215 additions and 1038 deletions
@@ -16,6 +16,23 @@ const confidenceLabels = {
high: "较高置信",
} as const;
const gateLabels: Readonly<Record<string, string>> = {
event_quality: "经历质量",
cross_domain_coverage: "跨领域覆盖",
required_layers: "必需计算层",
neighbor_stability: "相邻分钟稳定性",
leave_one_event_out: "删除单条经历复算",
three_engine_input_parity: "三引擎同输入对照",
public_holdout_release: "公开 AA 盲测",
};
const gateStatusLabels: Readonly<Record<string, string>> = {
pass: "通过",
fail: "未通过",
blocked: "尚未具备条件",
not_evaluated: "尚未执行",
};
export function BirthTimeCandidateResult({ journey, controller, error }: CandidateResultProps) {
const result = journey.candidateResult;
const action = journey.nextAction;
@@ -66,7 +83,14 @@ export function BirthTimeCandidateResult({ journey, controller, error }: Candida
<p>{[...receipt.usedDivisionalCharts, ...receipt.usedArudha, ...receipt.dashaTracks].join("、") || "无"}</p>
<p>{receipt.auxiliaryLayers.join("、") || "无"}</p>
<p>{receipt.missingLayers.join("、") || "无"}</p>
{receipt.hardBlockers.length > 0 && <p>{receipt.hardBlockers.join("、")}</p>}
{receipt.gates && (
<ul>
{Object.entries(receipt.gates).map(([name, gate]) => (
<li key={name}>{gateLabels[name] ?? name}{gateStatusLabels[gate.status] ?? gate.status}</li>
))}
</ul>
)}
{receipt.confirmationAllowed === false && <p></p>}
</details>
)}
@@ -0,0 +1,205 @@
"use client";
import { useRef, useState } from "react";
import { lifeEventSchema } from "@/lib/birth-time-journey";
import type { LifeEvent } from "@/lib/birth-time-evidence";
type EventDraft = {
readonly rowId: string;
readonly eventId: string;
readonly domain: LifeEvent["domain"] | "";
readonly precision: LifeEvent["precision"];
readonly date: string;
};
type BirthTimeLifeEventsProps = {
readonly initialEvents: readonly LifeEvent[];
readonly pending: boolean;
readonly onSubmit: (events: readonly LifeEvent[]) => void;
};
const domainOptions = [
{ value: "education", label: "学业或学习方向" },
{ value: "relocation", label: "搬家、离乡或长期异地" },
{ value: "relationship", label: "重要关系节点" },
{ value: "career", label: "工作或身份变化" },
{ value: "health_pressure", label: "健康、事故或低谷" },
] as const;
const initialDomains = ["education", "career", "relationship"] as const;
function initialDrafts(events: readonly LifeEvent[]): readonly EventDraft[] {
if (events.length > 0) {
return events.map((event, index) => ({
rowId: `stored-${index}`,
eventId: event.id,
domain: event.domain,
precision: event.precision,
date: event.date,
}));
}
return initialDomains.map((domain, index) => ({
rowId: `initial-${index}`,
eventId: "",
domain,
precision: "month",
date: "",
}));
}
function dateInputType(precision: LifeEvent["precision"]) {
switch (precision) {
case "year": return "number";
case "month": return "month";
case "day": return "date";
}
}
function parseDomain(value: string): EventDraft["domain"] {
switch (value) {
case "":
case "education":
case "relocation":
case "relationship":
case "career":
case "health_pressure":
return value;
default:
return "";
}
}
function parsePrecision(value: string): EventDraft["precision"] {
switch (value) {
case "year":
case "month":
case "day":
return value;
default:
return "month";
}
}
export function BirthTimeLifeEvents({
initialEvents,
pending,
onSubmit,
}: BirthTimeLifeEventsProps) {
const [drafts, setDrafts] = useState(() => initialDrafts(initialEvents));
const [error, setError] = useState("");
const nextRowId = useRef(0);
function patchDraft(rowId: string, patch: Partial<EventDraft>) {
setDrafts((current) => current.map((draft) => (
draft.rowId === rowId ? { ...draft, ...patch } : draft
)));
}
function addDraft() {
setDrafts((current) => current.length >= 6 ? current : [...current, {
rowId: `added-${nextRowId.current++}`,
eventId: "",
domain: "",
precision: "month",
date: "",
}]);
}
function removeDraft(rowId: string) {
setDrafts((current) => current.length <= 3
? current
: current.filter((draft) => draft.rowId !== rowId));
}
function submit() {
const parsed = drafts.map((draft) => lifeEventSchema.safeParse({
id: draft.eventId || crypto.randomUUID(),
domain: draft.domain,
precision: draft.precision,
date: draft.date,
}));
if (parsed.some((item) => !item.success)) {
setError("请为每条经历选择类型,并填写与精度一致的日期。");
return;
}
const events = parsed.flatMap((item) => item.success ? [item.data] : []);
if (new Set(events.map((event) => event.domain)).size < 2) {
setError("请至少选择两个不同领域的经历,以便区分候选时间。");
return;
}
setError("");
onSubmit(events);
}
return (
<div className="birth-time-life-events">
<div className="birth-time-question-progress">
<b></b>
<span>{drafts.length} / 6</span>
</div>
<p className="birth-time-evidence-note">
使
</p>
<div className="birth-time-event-list">
{drafts.map((draft, index) => (
<fieldset className="birth-time-event-row" key={draft.rowId}>
<legend> {index + 1}</legend>
<label>
<span></span>
<select
required
value={draft.domain}
onChange={(event) => patchDraft(draft.rowId, { domain: parseDomain(event.target.value) })}
>
<option value=""></option>
{domainOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label>
<span></span>
<select
value={draft.precision}
onChange={(event) => patchDraft(draft.rowId, {
precision: parsePrecision(event.target.value),
date: "",
})}
>
<option value="year"></option>
<option value="month"></option>
<option value="day"></option>
</select>
</label>
<label>
<span></span>
<input
required
inputMode={draft.precision === "year" ? "numeric" : undefined}
min={draft.precision === "year" ? "1900" : undefined}
max={draft.precision === "year" ? String(new Date().getFullYear()) : undefined}
type={dateInputType(draft.precision)}
value={draft.date}
onChange={(event) => patchDraft(draft.rowId, { date: event.target.value })}
/>
</label>
{drafts.length > 3 && (
<button className="button-text birth-time-event-remove" type="button" onClick={() => removeDraft(draft.rowId)}>
</button>
)}
</fieldset>
))}
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="birth-time-evidence-actions">
<button className="button-secondary" disabled={pending || drafts.length >= 6} type="button" onClick={addDraft}>
</button>
<button className="button-primary" disabled={pending} type="button" onClick={submit}>
{pending ? "正在比较候选…" : "比较候选时间"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { toast } from "sonner";
type BirthTimeSoftNoticeProps = Readonly<{
message: string;
onDismiss: () => void;
durationMs?: number;
}>;
export function BirthTimeSoftNotice({
message,
onDismiss,
durationMs = 4_500,
}: BirthTimeSoftNoticeProps) {
useEffect(() => {
if (!message) return;
let toastId: string | number | undefined;
let timeout: number | undefined;
const frame = window.requestAnimationFrame(() => {
toastId = toast("出生时间尚未校正", {
description: message,
duration: durationMs,
});
timeout = window.setTimeout(onDismiss, durationMs);
});
return () => {
window.cancelAnimationFrame(frame);
if (timeout !== undefined) window.clearTimeout(timeout);
if (toastId !== undefined) toast.dismiss(toastId);
};
}, [durationMs, message, onDismiss]);
return null;
}
@@ -4,7 +4,6 @@ import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { ChatMessageContent } from "./chat-message-content.tsx";
import {
@@ -20,6 +19,7 @@ type EvidenceDomain = NonNullable<
const domainLabels = {
career: "事业与身份",
education: "学业与学习",
finance: "收入与资产",
relocation: "搬迁与居住地",
relationship: "重要关系",
family: "家庭变化",
@@ -42,6 +42,8 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification
const confirmed = candidate.status === "confirmed" && turn.status === "completed";
const status = confirmed
? "已明确确认"
: turn.status === "completed"
? "范围已保存 · 分钟未确认"
: candidate.status === "ready_for_confirmation"
? "待确认 · 未验证"
: "待验证 · 未确认";
@@ -71,94 +73,25 @@ function CandidateSummary({ turn }: { readonly turn: ConversationalRectification
);
}
function TechnicalReceipt({ turn }: { readonly turn: ConversationalRectificationTurn }) {
const receipt = turn.technicalReceipt;
return (
<details className="conversational-technical-receipt">
<summary></summary>
<dl>
<div><dt></dt><dd><code>{receipt.calculationVersion}</code></dd></div>
<div><dt></dt><dd>{receipt.stableLayers.join("、") || "无"}</dd></div>
<div><dt></dt><dd>{receipt.sensitiveLayers.join("、") || "无"}</dd></div>
<div><dt></dt><dd>{receipt.candidateDifferenceRefs.join("、") || "无"}</dd></div>
</dl>
</details>
);
}
export function ConversationalRectificationSurface({
controller,
pendingConsultationQuestion,
continuationPending = false,
onContinueOriginalQuestion,
}: SurfaceProps) {
const [abandonArmedFor, setAbandonArmedFor] = useState<string | null>(null);
const [localAnnouncement, setLocalAnnouncement] = useState<Readonly<{
identity: string;
message: string;
}> | null>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const abandonTrigger = useRef<HTMLButtonElement>(null);
const abandonCancel = useRef<HTMLButtonElement>(null);
const abandonConfirm = useRef<HTMLButtonElement>(null);
const terminalStatus = useRef<HTMLDivElement>(null);
const restoreAbandonFocus = useRef(false);
const focusTerminalForCase = useRef<string | null>(null);
const [eventYear, setEventYear] = useState("");
const [eventMonth, setEventMonth] = useState("");
const currentYear = new Date().getFullYear();
const eventYears = Array.from({ length: 101 }, (_, index) => currentYear - index);
const turn = controller.turn;
const pendingQuestion = turn?.status === "completed"
? turn.pendingConsultationQuestion
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
const abandonIdentity = turn
? `${turn.caseId}:${turn.turnVersion}:${turn.status}`
: null;
const canAbandon = Boolean(
turn?.actions.includes("abandon")
&& turn.status !== "abandoned"
&& turn.status !== "completed",
);
const abandonArmed = canAbandon && abandonArmedFor === abandonIdentity;
const statusAnnouncement = localAnnouncement?.identity === abandonIdentity
? localAnnouncement.message
: "";
useEffect(() => {
if (abandonArmed) {
abandonCancel.current?.focus();
return;
}
if (restoreAbandonFocus.current) {
restoreAbandonFocus.current = false;
abandonTrigger.current?.focus();
}
}, [abandonArmed]);
useEffect(() => {
const requestedCase = focusTerminalForCase.current;
if (!requestedCase) return;
if (!turn || turn.caseId !== requestedCase) {
focusTerminalForCase.current = null;
return;
}
if (turn.status === "abandoned") {
focusTerminalForCase.current = null;
terminalStatus.current?.focus();
}
}, [turn]);
if (!turn) {
return (
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
<div className="conversational-empty-state" aria-live="polite">
<p></p>
<button
className="button-primary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.start(pendingQuestion))}
>
{controller.pending ? "正在建立校正记录…" : "开始生时校正"}
</button>
</div>
<p className="conversational-empty-state" aria-live="polite" role="status"></p>
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
</section>
);
@@ -166,46 +99,21 @@ export function ConversationalRectificationSurface({
const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed";
const requestedDomains = turn.evidenceRequest?.domains ?? [];
const submit = () => {
if (canAnswer && controller.draft.trim() && !controller.pending) safely(controller.answer());
const submit = async () => {
if (!canAnswer || !controller.draft.trim() || controller.pending) return;
const dateLabel = eventYear
? `${eventYear}${eventMonth ? ` ${Number(eventMonth)}` : ""}`
: "时间不确定";
const answer = `发生时间:${dateLabel}\n事件详情:${controller.draft.trim()}`;
try {
await controller.answer(undefined, answer);
setEventYear("");
setEventMonth("");
} catch {
// The controller owns the visible request error and keeps the draft available for retry.
}
};
const focusComposer = () => composer.current?.focus();
const continueLocally = () => {
if (abandonIdentity) {
setLocalAnnouncement({
identity: abandonIdentity,
message: "现在可以继续填写真实经历,输入框已就绪;发送后才会推进校正进度。",
});
}
focusComposer();
};
const closeAbandonDialog = () => {
restoreAbandonFocus.current = true;
setAbandonArmedFor(null);
};
const handleAbandonDialogKey = (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key === "Escape") {
event.preventDefault();
closeAbandonDialog();
return;
}
if (event.key !== "Tab") return;
if (event.shiftKey && document.activeElement === abandonCancel.current) {
event.preventDefault();
abandonConfirm.current?.focus();
} else if (!event.shiftKey && document.activeElement === abandonConfirm.current) {
event.preventDefault();
abandonCancel.current?.focus();
}
};
const confirmAbandon = () => {
focusTerminalForCase.current = turn.caseId;
void controller.abandon().catch(() => {
if (focusTerminalForCase.current === turn.caseId) {
focusTerminalForCase.current = null;
}
});
};
return (
<section
@@ -213,13 +121,17 @@ export function ConversationalRectificationSurface({
aria-busy={controller.pending}
aria-label="生时校正对话"
>
<CandidateSummary turn={turn} />
<article className="conversational-narrative" aria-label="校正分析">
<ChatMessageContent text={turn.narrative} />
<div className="conversational-narrative-body">
<ChatMessageContent text={turn.narrative} />
</div>
</article>
{requestedDomains.length > 0 && (
<fieldset className="conversational-domain-picker">
<legend></legend>
<legend className="conversational-domain-question"></legend>
<div>
{requestedDomains.map((domain) => (
<button
@@ -240,11 +152,11 @@ export function ConversationalRectificationSurface({
</fieldset>
)}
<form
{canAnswer && <form
className="conversational-composer"
onSubmit={(event) => {
event.preventDefault();
submit();
void submit();
}}
>
{controller.correctionTarget && (
@@ -267,13 +179,44 @@ export function ConversationalRectificationSurface({
{controller.correctionTarget ? "填写更正后的真实经历" : "补充真实经历"}
<span></span>
</label>
<div className="conversational-event-date" role="group" aria-label="经历发生时间">
<label>
<select
aria-label="经历发生年份"
disabled={controller.pending || !canAnswer}
value={eventYear}
onChange={(event) => {
setEventYear(event.target.value);
if (!event.target.value) setEventMonth("");
}}
>
<option value=""></option>
{eventYears.map((year) => <option key={year} value={year}>{year} </option>)}
</select>
</label>
<label>
<select
aria-label="经历发生月份"
disabled={controller.pending || !canAnswer || !eventYear}
value={eventMonth}
onChange={(event) => setEventMonth(event.target.value)}
>
<option value=""></option>
{Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
<option key={month} value={month}>{month} </option>
))}
</select>
</label>
</div>
<textarea
id="conversational-rectification-answer"
disabled={controller.pending || !canAnswer}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:其实是 2020 年 11 月离职"
: "请描述一件已经发生的事,并尽量写明年份或月份"}
: "请描述一件已经发生的具体事件"}
ref={composer}
rows={4}
value={controller.draft}
@@ -281,7 +224,7 @@ export function ConversationalRectificationSurface({
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
submit();
void submit();
}
}}
/>
@@ -295,7 +238,7 @@ export function ConversationalRectificationSurface({
{controller.pending ? "正在核对…" : "发送这段经历"}
</button>
</div>
</form>
</form>}
{turn.evidenceRecap.length > 0 && (
<section className="conversational-evidence-recap" aria-labelledby="conversational-evidence-title">
@@ -328,9 +271,6 @@ export function ConversationalRectificationSurface({
</section>
)}
<CandidateSummary turn={turn} />
<TechnicalReceipt turn={turn} />
{turn.actions.includes("confirm")
&& turn.candidate.status === "ready_for_confirmation"
&& turn.candidate.representativeTime && (
@@ -351,17 +291,15 @@ export function ConversationalRectificationSurface({
)}
<div
aria-label={turn.status === "abandoned" ? "生时校正终态" : undefined}
aria-live="polite"
className="conversational-status"
ref={terminalStatus}
tabIndex={turn.status === "abandoned" ? -1 : undefined}
>
{turn.status === "paused" && <p></p>}
{turn.status === "abandoned" && <p></p>}
{turn.status === "completed" && turn.candidate.status === "confirmed"
&& <p></p>}
{statusAnnouncement && <p>{statusAnnouncement}</p>}
{turn.status === "completed" && turn.candidate.status !== "confirmed"
&& <p></p>}
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
</div>
@@ -378,85 +316,13 @@ export function ConversationalRectificationSurface({
>
{continuationPending
? "正在继续回答原问题…"
: "使用新确认时间继续回答原问题"}
: turn.candidate.status === "confirmed"
? "使用新确认时间继续回答原问题"
: "返回原对话并继续回答"}
</button>
</section>
)}
<footer className="conversational-session-actions">
{turn.status === "paused" ? (
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={continueLocally}
>
</button>
) : turn.actions.includes("pause") ? (
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.pause())}
>
</button>
) : null}
{canAbandon && !abandonArmed && (
<button
className="conversational-abandon"
disabled={controller.pending}
ref={abandonTrigger}
type="button"
onClick={() => {
setLocalAnnouncement(null);
setAbandonArmedFor(abandonIdentity);
}}
>
</button>
)}
</footer>
{abandonArmed && (
<div className="conversational-abandon-scrim">
<section
aria-describedby="conversational-abandon-description"
aria-labelledby="conversational-abandon-title"
aria-modal="true"
className="conversational-abandon-confirmation"
onKeyDown={handleAbandonDialogKey}
role="alertdialog"
>
<h3 id="conversational-abandon-title"></h3>
<p id="conversational-abandon-description">
</p>
<div>
<button
className="button-secondary"
disabled={controller.pending}
ref={abandonCancel}
type="button"
onClick={closeAbandonDialog}
>
</button>
<button
className="conversational-abandon is-confirm"
disabled={controller.pending}
ref={abandonConfirm}
type="button"
onClick={confirmAbandon}
>
</button>
</div>
</section>
</div>
)}
</section>
);
}
+7 -3
View File
@@ -91,12 +91,16 @@ export function SidebarProvider({
const open = controlledOpen ?? uncontrolledOpen;
const isMobile = viewport === "mobile";
const setOpen = useCallback((nextOpen: boolean) => {
const commitOpen = useCallback((nextOpen: boolean) => {
userChangedDesktopState.current = true;
if (!isControlled) setUncontrolledOpen(nextOpen);
onOpenChange?.(nextOpen);
}, [isControlled, onOpenChange]);
const setOpen = useCallback((nextOpen: boolean) => {
commitOpen(nextOpen);
}, [commitOpen]);
const setOpenMobile = useCallback((nextOpen: boolean) => {
if (openMobileRef.current === nextOpen) return;
openMobileRef.current = nextOpen;
@@ -121,7 +125,7 @@ export function SidebarProvider({
if (shouldHandleSidebarShortcut(event)) {
event.preventDefault();
if (isMobile) setOpenMobile(!openMobile);
else setOpen(!open);
else commitOpen(!open);
}
if (event.key === "Escape" && isMobile && openMobile && !escapeBlocked) {
event.preventDefault();
@@ -131,7 +135,7 @@ export function SidebarProvider({
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [escapeBlocked, isMobile, open, openMobile, setOpen, setOpenMobile]);
}, [commitOpen, escapeBlocked, isMobile, open, openMobile, setOpenMobile]);
useEffect(() => {
if (openMobile && !wasMobileOpen.current) {
+22
View File
@@ -0,0 +1,22 @@
"use client"
import { Toaster as Sonner, type ToasterProps } from "sonner"
function Toaster(props: ToasterProps) {
return (
<Sonner
position="bottom-right"
closeButton={false}
toastOptions={{
classNames: {
toast: "group toast",
title: "toast-title",
description: "toast-description",
},
}}
{...props}
/>
)
}
export { Toaster }
@@ -1,111 +0,0 @@
"use client";
import { useEffect, useId, useRef, type KeyboardEvent } from "react";
import { keepFocusWithin } from "@/lib/focus-trap";
type UnverifiedBirthTimeChoiceProps = Readonly<{
canUseUnverifiedTime: boolean;
unverifiedTime?: string | null;
pending?: boolean;
onUseUnverifiedTime: () => void;
onContinueGenerally: () => void;
onRectifyFirst: () => void;
onCancel: () => void;
}>;
export function UnverifiedBirthTimeChoice({
canUseUnverifiedTime,
unverifiedTime,
pending = false,
onUseUnverifiedTime,
onContinueGenerally,
onRectifyFirst,
onCancel,
}: UnverifiedBirthTimeChoiceProps) {
const titleId = useId();
const descriptionId = useId();
const dialog = useRef<HTMLElement>(null);
const initialAction = useRef<HTMLButtonElement>(null);
useEffect(() => {
const frame = window.requestAnimationFrame(() => initialAction.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, []);
function handleDialogKeyDown(event: KeyboardEvent<HTMLElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!pending) onCancel();
return;
}
const container = dialog.current;
if (container) keepFocusWithin(event.nativeEvent, container);
}
return (
<div className="unverified-birth-time-choice-scrim">
<section
aria-busy={pending}
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-live="polite"
aria-modal="true"
className="onboarding-card birth-time-transition-card unverified-birth-time-choice"
onKeyDown={handleDialogKeyDown}
ref={dialog}
role="alertdialog"
>
<div className="onboarding-card-heading">
<b id={titleId}></b>
<small>使 Jyotisha</small>
</div>
<p id={descriptionId}>
{canUseUnverifiedTime
? "你可以只在当前聊天临时使用填报时间,也可以先完成生时校正再问。新建聊天后会再次温和提醒。"
: "你目前没有可直接使用的具体分钟,系统不会替你猜一个时间。可以先校正,或改问不依赖出生分钟的一般问题。"}
</p>
<div className="onboarding-card-actions">
{canUseUnverifiedTime ? (
<button
className="button-secondary"
disabled={pending}
ref={initialAction}
type="button"
onClick={onUseUnverifiedTime}
>
{unverifiedTime
? `先用 ${unverifiedTime}(未校正)询问`
: "先用未校正时间询问"}
</button>
) : (
<button
className="button-secondary"
disabled={pending}
ref={initialAction}
type="button"
onClick={onContinueGenerally}
>
</button>
)}
<button
className="button-primary"
disabled={pending}
type="button"
onClick={onRectifyFirst}
>
{pending ? "正在进入生时校正…" : "先校正再询问"}
</button>
<button
className="button-secondary"
disabled={pending}
type="button"
onClick={onCancel}
>
</button>
</div>
</section>
</div>
);
}