feat: rebuild birth time rectification workflow

This commit is contained in:
Jesse_Chen
2026-07-27 09:57:14 +08:00
parent 380aba4628
commit 2ca245d643
66 changed files with 5826 additions and 699 deletions
@@ -8,7 +8,10 @@ import { ModelSelector } from "./model-selector.tsx";
import { Button } from "./ui/button.tsx";
import { Textarea } from "./ui/textarea.tsx";
import {
useConversationalRectification,
RectificationV4Panel,
type RectificationV4Continuation,
} from "./rectification-v4-panel.tsx";
import {
type ConversationalRectificationMessage,
type ConversationalRectificationStoredMessage,
type ConversationalRectificationController,
@@ -319,31 +322,15 @@ type ConversationalBirthTimeRectificationProps = Readonly<{
messages: readonly ConversationalRectificationMessage[],
) => void;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (question: string) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>;
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
const pendingChange = useRef(props.onPendingChange);
useEffect(() => {
pendingChange.current = props.onPendingChange;
}, [props.onPendingChange]);
useEffect(() => () => pendingChange.current?.(false), []);
const controller = useConversationalRectification({
initialTurn: props.initialTurn,
initialMessages: props.initialMessages,
modelId: props.selectedModelId,
onTurn: props.onTurn,
onPendingChange: props.onPendingChange,
});
return (
<ConversationalRectificationSurface
controller={controller}
openingAssistantText={props.openingAssistantText}
models={props.models}
selectedModelId={props.selectedModelId}
onSelectModel={props.onSelectModel}
<RectificationV4Panel
pendingConsultationQuestion={props.pendingConsultationQuestion}
continuationPending={props.continuationPending}
onPendingChange={props.onPendingChange}
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
/>
);
@@ -0,0 +1,204 @@
"use client";
import { ArrowUp, Check, Pause, Play, Square } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useRectificationV4 } from "@/hooks/use-rectification-v4";
import type { CandidateCluster, LifeEventRevision } from "@/lib/rectification-v4/contracts";
import { AppLoadingIndicator } from "./app-loading-indicator";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
const phaseCopy = {
extracting_evidence: "正在整理经历",
scoring_candidates: "正在比较候选时间",
checking_robustness: "正在做稳定性复核",
planning_question: "正在准备下一步问题",
collecting_evidence: "正在准备下一步问题",
complete: "正在整理结果",
} as const;
function minutes(time: string) {
const [hour = 0, minute = 0] = time.split(":").map(Number);
return hour * 60 + minute;
}
function inCluster(time: string, cluster: CandidateCluster) {
const value = minutes(time);
const start = minutes(cluster.startTime);
const end = minutes(cluster.endTime);
return end >= start ? value >= start && value <= end : value >= start || value <= end;
}
function latestEvents(events: readonly LifeEventRevision[]) {
const latest = new Map<string, LifeEventRevision>();
for (const event of events) {
const current = latest.get(event.eventId);
if (!current || current.revision < event.revision) latest.set(event.eventId, event);
}
return [...latest.values()].sort((left, right) => left.dateRange.start.localeCompare(right.dateRange.start));
}
function eventText(event: LifeEventRevision) {
return `${event.dateRange.label} · ${event.summary}`;
}
export type RectificationV4Continuation = Readonly<{
protocol: "rectification-evidence-v4";
question: string;
caseId: string;
caseVersion: number;
acceptedRange: Readonly<{ start: string; end: string }>;
}>;
export function RectificationV4Panel(props: Readonly<{
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>) {
const controller = useRectificationV4({
pendingConsultationQuestion: props.pendingConsultationQuestion,
onPendingChange: props.onPendingChange,
});
const [draft, setDraft] = useState("");
const composer = useRef<HTMLTextAreaElement>(null);
const data = controller.data;
const caseValue = data?.case;
const snapshot = caseValue?.latestSnapshot;
const primary = snapshot?.clusters[0];
const allEvents = useMemo(() => latestEvents(data?.events ?? []), [data?.events]);
const eventById = useMemo(() => new Map(allEvents.map((event) => [event.eventId, event])), [allEvents]);
const evidence = useMemo(() => {
if (!snapshot || !primary) return { supporting: [] as LifeEventRevision[], conflicting: [] as LifeEventRevision[] };
const candidates = snapshot.candidates.filter((candidate) => inCluster(candidate.time, primary));
const supporting = new Set(candidates.flatMap((candidate) => candidate.supportingEventIds));
const conflicting = new Set(candidates.flatMap((candidate) => candidate.conflictingEventIds));
return {
supporting: [...supporting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
conflicting: [...conflicting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
};
}, [eventById, primary, snapshot]);
if (controller.loading) {
return <section className="rectification-v4-panel"><AppLoadingIndicator title="正在打开生时校正" detail="正在恢复已经保存的进度" /></section>;
}
if (!caseValue) {
return <section className="rectification-v4-panel" role="alert"><p>{controller.error || "暂时无法打开生时校正。"}</p></section>;
}
const processing = caseValue.status === "processing" || ["pending", "processing"].includes(controller.job?.status ?? "");
const phase = controller.job?.phase ?? caseValue.phase;
const canAnswer = Boolean(caseValue.currentQuestion) && !processing && ["awaiting_answer", "range_ready"].includes(caseValue.status);
const accepted = caseValue.acceptedRange;
const handoff = controller.handoff;
const canContinue = Boolean(accepted && handoff?.status === "pending" && props.onContinueOriginalQuestion);
async function submit(event: React.FormEvent) {
event.preventDefault();
const answer = draft.trim();
if (!answer || !canAnswer) return;
const result = await controller.answer(answer);
if (result) setDraft("");
}
return (
<section className="rectification-v4-panel" aria-busy={processing || controller.pending}>
<header className="rectification-v4-header">
<div>
<p className="rectification-v4-eyebrow"> · </p>
<h2></h2>
<p> {caseValue.calculationSpec.candidateRange.start}{caseValue.calculationSpec.candidateRange.end} </p>
</div>
{caseValue.status === "paused" ? (
<Button type="button" variant="outline" disabled={controller.pending} onClick={() => void controller.resume()}><Play aria-hidden="true" /></Button>
) : caseValue.status !== "abandoned" && !accepted ? (
<Button type="button" variant="outline" disabled={processing || controller.pending} onClick={() => void controller.pause()}><Pause aria-hidden="true" /></Button>
) : null}
</header>
{props.pendingConsultationQuestion && (
<aside className="rectification-v4-context">
<b></b>
<p>{props.pendingConsultationQuestion}</p>
</aside>
)}
{processing && (
<div className="rectification-v4-processing" role="status">
<AppLoadingIndicator title={phaseCopy[phase]} detail="回答已经保存,计算在后台继续" />
<p></p>
</div>
)}
{snapshot && primary && !processing && (
<article className="rectification-v4-result">
<p className="rectification-v4-eyebrow"></p>
<div className="rectification-v4-ranges">
<div><span></span><strong>{primary.startTime}{primary.endTime}</strong></div>
{snapshot.clusters[1] && <div><span></span><strong>{snapshot.clusters[1].startTime}{snapshot.clusters[1].endTime}</strong></div>}
</div>
<div className="rectification-v4-evidence-grid">
<div>
<h3></h3>
{evidence.supporting.length > 0 ? <ul>{evidence.supporting.map((event) => <li key={event.id}>{eventText(event)}</li>)}</ul> : <p></p>}
</div>
<div>
<h3></h3>
{evidence.conflicting.length > 0 ? <ul>{evidence.conflicting.map((event) => <li key={event.id}>{eventText(event)}</li>)}</ul> : <p></p>}
</div>
</div>
<p className="rectification-v4-uncertainty"></p>
<div className="rectification-v4-actions">
{caseValue.currentQuestion && !accepted && <Button type="button" variant="outline" onClick={() => composer.current?.focus()}></Button>}
{snapshot.canAcceptRange && !accepted && <Button type="button" disabled={controller.pending} onClick={() => void controller.acceptRange()}><Check aria-hidden="true" /></Button>}
{accepted && <span className="rectification-v4-saved"><Check aria-hidden="true" /> {accepted.start}{accepted.end}</span>}
</div>
</article>
)}
{caseValue.status === "paused" && <p className="rectification-v4-notice"></p>}
{caseValue.status === "abandoned" && <p className="rectification-v4-notice"></p>}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
{canAnswer && (
<form className="rectification-v4-composer" onSubmit={submit}>
<label htmlFor="rectification-v4-answer">{caseValue.currentQuestion?.prompt}</label>
<Textarea
id="rectification-v4-answer"
ref={composer}
value={draft}
disabled={controller.pending}
placeholder="例如:2015 年高中毕业后复读一年,2016 年再次毕业。"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<Button aria-label="提交这段经历" disabled={!draft.trim() || controller.pending} size="icon" type="submit"><ArrowUp aria-hidden="true" /></Button>
</form>
)}
<footer className="rectification-v4-footer">
{canContinue && accepted && handoff && (
<Button type="button" disabled={props.continuationPending} onClick={() => props.onContinueOriginalQuestion?.({
protocol: "rectification-evidence-v4",
question: handoff.question,
caseId: caseValue.id,
caseVersion: caseValue.version,
acceptedRange: accepted,
})}>
{props.continuationPending ? "正在回到原问题…" : "带着候选范围继续原问题"}
</Button>
)}
{accepted && handoff?.status === "in_progress" && <span className="rectification-v4-saved"></span>}
{accepted && handoff?.status === "consumed" && <span className="rectification-v4-saved"><Check aria-hidden="true" /></span>}
{!accepted && caseValue.status !== "abandoned" && (
<button type="button" disabled={processing || controller.pending} onClick={() => void controller.abandon()}><Square aria-hidden="true" /></button>
)}
</footer>
</section>
);
}