feat(rectification): stream agent execution and redesign entry routing

Replace the Direct Agentic textStream relay with a durable V9 agent runtime:

- agentic-rectification.ts: short boundary-only system prompt (no gate->scan
  ->score->diagnostics copy); pins skills/jyotish-birth-time-rectification;
  per-action bounded maxSteps (opening/read-only 6, evidence 8, rescore 12,
  accept/confirm 6) with a hard ceiling and repeated-tool-call detection.
- rectification-v9-tools.ts: ten Case-ref tools (read-case, propose/confirm/
  revise-evidence, compare-candidates, read-diagnostics, offer-candidates,
  accept-candidate, confirm-birth-time, close-case). Inputs are minimal refs
  only; RPC-backed evidence ledger, fingerprint cache reuse, receipts, and
  accepted!=confirmed semantics; confirm requires gate + grounded consent.
- /api/rectification/agent: caseId/sessionId/requestId/action/message; exact
  Case<->Session binding verified server-side; client history never overrides
  the durable dossier; pending turn -> completed/failed/retryable; consumes
  result.fullStream and emits allowlisted NDJSON only (reasoning/raw/provider
  metadata/tool payloads/birth data/scores never forwarded); first-turn real
  skill.started/skill.loaded gate with one controlled retry; billing bound to
  rectification:case:{caseId}.
- New forward migration 20260813010000_agentic_rectification_v9_agent_api.sql:
  case dossier/compute, turn finalize, fingerprint-cached candidate persist,
  case-scoped accept, consent-gated confirm, guarded transitions,
  needs_rebaseline profile guard, run_phases receipt table, and the
  rectification_runtime_version feature flag (v9 default, legacy read-only).
- Frontend: homepage/sidebar entry routing now uses the server Case open API
  (openRectificationFromHomepage/openRectificationSession/startNewRectification)
  with exact sessionId/caseId and server-owned shouldStartOpening; CTA driven
  by entry-summary; chat restores from persisted turns, candidate cards from
  the Candidate Snapshot API, activity from real NDJSON + persisted receipts;
  direct durable candidate-accept endpoint for the UI cards.
This commit is contained in:
Jesse
2026-08-11 17:17:48 +08:00
parent d394dd0585
commit ca7484666f
14 changed files with 4367 additions and 558 deletions
@@ -2,22 +2,40 @@
import type { PublicLanguageModel } from "../lib/public-models.ts";
import type { ChatMessage } from "../lib/chat-message-view.ts";
import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx";
import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx";
export type PersistedRectificationTurn = Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tools: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
}>;
export type ConversationalBirthTimeRectificationProps = Readonly<{
caseId: string;
sessionId: string;
initialMessages: readonly ChatMessage[];
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedRectificationTurn[];
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
pendingConsultationQuestion?: string | null;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
}>;
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
return <AgenticRectificationChat {...props} />;
return <RectificationAgenticChat {...props} />;
}
@@ -5,85 +5,142 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import { membershipHref } from "@/lib/membership";
import { PUBLIC_RECTIFICATION_PHASES, type PublicRectificationPhase } from "@/lib/rectification-agentic/v9/public-receipt";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import { ModelSelector } from "./model-selector";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
type AgenticRectificationChatProps = Readonly<{
sessionId: string;
initialMessages: readonly ChatMessage[];
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
pendingConsultationQuestion?: string | null;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
type PersistedTurn = Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tools: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
}>;
type RenderMessage = ChatMessageView;
type CandidateResult = Readonly<{
resultId: string;
candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
selectedTime: string | null;
selectionStatus: "accepted" | "confirmed" | null;
selectionKind: string | null;
}> | null;
type RectificationAgenticChatProps = Readonly<{
caseId: string;
sessionId: string;
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedTurn[];
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
}>;
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
type RenderMessage = ChatMessageView & {
renderKey: string;
activity?: readonly string[];
receiptStatus?: string;
};
type AgenticRectificationRequest = Readonly<
| { action: "opening" }
| { action: "message"; message: string }
>;
const PHASE_LABELS: Readonly<Partial<Record<PublicRectificationPhase, string>>> = {
"run.started": "开始本轮执行",
"skill.started": "正在加载专用方法",
"skill.loaded": "专用方法已加载",
"case.loaded": "已读取校正记录",
"evidence.proposed": "记录了一条事件草稿",
"evidence.confirmed": "事件已确认",
"candidates.comparing": "正在比较候选时间",
"candidates.updated": "候选已更新",
"diagnostics.completed": "稳健性诊断完成",
"candidate.accepted": "已采用候选时间",
"birth_time.confirmed": "校正时间已确认",
"run.completed": "本轮完成",
"run.failed": "本轮失败",
};
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
function isPublicPhase(value: unknown): value is PublicRectificationPhase {
return typeof value === "string" && (PUBLIC_RECTIFICATION_PHASES as readonly string[]).includes(value);
}
function labelForPhase(phase: string): string {
return isPublicPhase(phase) ? (PHASE_LABELS[phase] ?? phase) : phase;
}
function activityFromReceipt(receipt: PersistedTurn["receipt"]): string[] {
if (!receipt) return [];
const phases = (receipt.phases ?? []).filter(isPublicPhase);
return phases.map(labelForPhase).slice(0, 12);
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
sessionId,
initialMessages,
readonly,
shouldStartOpening,
initialTurns,
models,
selectedModelId,
onSelectModel,
onMessagesChange,
onCompleted,
pendingConsultationQuestion,
onPendingChange,
onProfileIncomplete,
onSaved,
pendingConsultationQuestion,
onRestart,
} = props;
const pendingQuestion = pendingConsultationQuestion?.trim();
const [messages, setMessages] = useState<RenderMessage[]>(() => [
...initialMessages.map((message, index) => ({
...message,
renderKey: `agentic-message-${index}`,
state: "settled" as const,
})),
...(initialMessages.length === 0 && pendingQuestion ? [{
role: "assistant" as const,
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingQuestion}`,
renderKey: "agentic-pending-consultation",
state: "settled" as const,
}] : []),
]);
const [messages, setMessages] = useState<RenderMessage[]>(() =>
initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
activity: activityFromReceipt(turn.receipt),
receiptStatus: turn.receipt?.status,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
}),
);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const composer = useRef<HTMLTextAreaElement>(null);
const conversation = useRef<HTMLElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
@@ -102,49 +159,77 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
});
}, [busy, error, messages, savedTime]);
const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => {
const trimmed = request.action === "message" ? request.message.trim() : "";
if ((request.action === "message" && !trimmed) || busy) return;
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const loadCandidate = useCallback(async (): Promise<CandidateResult> => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
if (!response.ok) return null;
const payload = await response.json().catch(() => null);
const latest = payload?.latest_result;
if (!latest || typeof latest.result_id !== "string") return null;
return {
resultId: latest.result_id,
candidates: Array.isArray(latest.candidates) ? latest.candidates : [],
overallConfidence: latest.overall_confidence === "high" || latest.overall_confidence === "medium" ? latest.overall_confidence : "low",
selectionAllowed: latest.selection_allowed === true,
confirmationAllowed: latest.confirmation_allowed === true,
representativeTime: typeof latest.representative_time === "string" ? latest.representative_time : null,
selectedTime: typeof latest.selected_time === "string" ? latest.selected_time : null,
selectionKind: typeof latest.selection_kind === "string" ? latest.selection_kind : null,
};
} catch {
return null;
}
}, [caseId, sessionId]);
useEffect(() => {
let active = true;
void loadCandidate().then((result) => {
if (active) setCandidateResult(result);
});
return () => { active = false; };
}, [loadCandidate]);
const send = useCallback(async (action: "opening" | "message", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || busy || readonly) return;
setError("");
setSuggestions([]);
setPending(true);
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
const settledMessages = messages
.filter((message) => message.state === "settled")
.map((message) => ({
role: message.role,
text: message.text,
...(message.suggestions ? { suggestions: message.suggestions } : {}),
}));
const history = settledMessages.map((message) => ({ role: message.role, text: message.text }));
const turnKey = keyCounter.current;
const userRenderKey = `agentic-user-${turnKey}`;
const assistantRenderKey = `agentic-assistant-${turnKey}`;
const userRenderKey = `v9-user-${turnKey}`;
const assistantRenderKey = `v9-assistant-${turnKey}`;
setMessages((current) => [
...current,
...(showUserMessage && request.action === "message"
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: [] },
]);
setDraft("");
let raw = "";
let streamedSavedStatus: "accepted" | "confirmed" | null = null;
let liveActivity: string[] = [];
const activitySet = new Set<string>();
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
requestId,
caseId,
sessionId,
requestId,
action,
modelId: selectedModelId,
history,
action: request.action,
...(request.action === "message" ? { message: trimmed } : {}),
...(action === "message" ? { message: trimmed } : {}),
}),
});
if (!response.ok) {
@@ -164,7 +249,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
return;
}
if (!response.body) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setMessages((current) => current.filter((item) => item.renderKey !== assistantRenderKey));
setError("服务暂时不可用,请稍后再试。");
return;
}
@@ -182,33 +267,35 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: { type: string; text?: string; message?: string; result?: CandidateResult };
let event: { type?: unknown; text?: unknown; message?: unknown };
try {
event = JSON.parse(line) as { type: string; text?: string; message?: string; result?: CandidateResult };
event = JSON.parse(line) as { type?: unknown; text?: unknown; message?: unknown };
} catch {
continue;
}
if (event.type === "delta" && typeof event.text === "string") {
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
const parsed = parseAgentReply(raw, "general");
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, state: "streaming" }
: message));
setSuggestions(parsed.suggestions);
const saved = raw.match(savedSentinel);
if (saved) setSavedTime(saved[1]);
} else if (event.type === "candidates" && event.result) {
setCandidateResult(event.result);
if (event.result.selectedTime && event.result.selectionStatus) {
setSavedTime(event.result.selectedTime);
streamedSavedStatus = event.result.selectionStatus;
setSavedStatus(event.result.selectionStatus);
}
} else if (event.type === "run.failed") {
streamFailed = true;
} else if (event.type === "error") {
streamFailed = true;
setError(event.message || "生时校正暂时不可用,请稍后再试。");
} else if (event.type === "done") {
setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。");
} else if (event.type === "run.completed") {
completed = true;
} else if (isPublicPhase(event.type)) {
if (!activitySet.has(event.type)) {
activitySet.add(event.type);
liveActivity = [...liveActivity, labelForPhase(event.type)];
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, activity: liveActivity }
: message));
}
}
}
}
@@ -217,22 +304,19 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => succeeded
? current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled" }
? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled", activity: liveActivity }
: message)
: current.filter((message) => message.renderKey !== assistantRenderKey));
setSuggestions(succeeded ? parsed.suggestions : []);
if (succeeded) {
onMessagesChange?.([
...settledMessages,
...(request.action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text, suggestions: parsed.suggestions },
]);
onCompleted?.();
}
const saved = raw.match(savedSentinel);
if (saved) {
setSavedTime(saved[1]);
onSaved?.(saved[1], streamedSavedStatus ?? savedStatus ?? "accepted");
await loadCandidate().then((result) => {
if (result) setCandidateResult(result);
});
}
} catch {
setError("生时校正暂时不可用,请稍后再试。");
@@ -240,72 +324,78 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
} finally {
setPending(false);
}
}, [busy, messages, onCompleted, onMessagesChange, onProfileIncomplete, onSaved, savedStatus, selectedModelId, sessionId, setPending]);
}, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
useEffect(() => {
let active = true;
void fetch(`/api/rectification/agent?sessionId=${encodeURIComponent(sessionId)}`)
.then((response) => response.ok ? response.json() : null)
.then((payload) => {
const result = payload?.result as CandidateResult | null | undefined;
if (!active || !result) return;
setCandidateResult(result);
if (result.selectedTime && result.selectionStatus) {
setSavedTime(result.selectedTime);
setSavedStatus(result.selectionStatus);
}
})
.catch(() => undefined);
return () => { active = false; };
}, [sessionId]);
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
void send("opening", "");
}, [readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (time: string) => {
if (!candidateResult || acceptingTime) return;
if (!candidateResult || acceptingTime || readonly) return;
setError("");
setAcceptingTime(time);
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "accept_candidate",
sessionId,
resultId: candidateResult.resultId,
time,
}),
});
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
resultId: candidateResult.resultId,
candidateId: time,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true) throw new Error(payload?.message || payload?.error || "暂时无法采用该候选时间");
if (!response.ok || payload?.ok !== true) {
throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
}
const status = payload.status === "confirmed" ? "confirmed" : "accepted";
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionStatus: status } : current);
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: status === "confirmed" ? "engine_confirmed" : "user_accepted" } : current);
setSavedTime(payload.saved_time);
setSavedStatus(status);
onSaved?.(payload.saved_time, status);
onCompleted?.();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingTime(null);
}
}, [acceptingTime, candidateResult, onSaved, sessionId]);
useEffect(() => {
if (initialMessages.length > 0 || openingStarted.current) return;
openingStarted.current = true;
void send({ action: "opening" }, false);
}, [initialMessages.length, send]);
}, [acceptingTime, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]);
async function submit(event: React.FormEvent) {
event.preventDefault();
await send({ action: "message", message: draft });
await send("message", draft);
}
const canSend = !busy;
const canSend = !busy && !readonly;
return (
<>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
{pendingConsultationQuestion.trim()}
</p>
)}
{messages.map((message) => (
<div key={message.renderKey} className="rectification-message-wrap">
<ChatMessageRow message={message} />
{message.activity && message.activity.length > 0 && (
<details className="rectification-activity">
<summary></summary>
<ol>
{message.activity.map((step) => <li key={step}>{step}</li>)}
</ol>
</details>
)}
</div>
))}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
<section className="rectification-candidates" aria-label="生时校正候选时间">
<div className="rectification-candidates-heading">
@@ -322,7 +412,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
type="button"
className={`rectification-candidate${selected ? " is-selected" : ""}`}
key={`${candidateResult.resultId}-${candidate.time}`}
disabled={selected || Boolean(acceptingTime)}
disabled={selected || Boolean(acceptingTime) || readonly}
onClick={() => void acceptCandidate(candidate.time)}
>
<span className="rectification-candidate-time">
@@ -346,6 +436,12 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
{readonly && (
<div className="rectification-terminal-actions">
<p className="rectification-terminal-note"></p>
<Button type="button" onClick={onRestart}></Button>
</div>
)}
</div>
</section>
@@ -353,7 +449,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
{suggestions.length > 0 && !busy && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{suggestions.map((question) => (
<button key={question} type="button" onClick={() => void send({ action: "message", message: question })}>{question}</button>
<button key={question} type="button" onClick={() => void send("message", question)}>{question}</button>
))}
</div>
)}
@@ -361,10 +457,12 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label="继续描述你的经历或回答"
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
value={draft}
disabled={!canSend}
placeholder="继续说你记得的人生经历,或回答刚才的问题…"
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: "继续说你记得的人生经历,或回答刚才的问题…"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
@@ -382,7 +480,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy}
disabled={busy || readonly}
onSelect={onSelectModel}
/>
</div>