refactor: make birth time rectification conversational

This commit is contained in:
Jesse_Chen
2026-07-27 22:31:11 +08:00
parent b569cf7825
commit a550b98da7
25 changed files with 756 additions and 1788 deletions
+2 -45
View File
@@ -840,20 +840,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; }
}
/* Conversational rectification shell */
.conversation.is-rectification { overflow: hidden; padding-bottom: 0; }
.rectification-chat { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--color-ink); }
.rectification-message-list { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--space-6); }
.rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
.rectification-composer-wrap { position: static; flex: 0 0 auto; }
.rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); }
.rectification-correction-target p { margin: 0; }
.conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); }
.conversational-loading { min-height: min(52vh, 460px); display: grid; place-items: center; padding: var(--space-6); }
@media (max-width: 430px) {
.rectification-correction-target { align-items: stretch; display: grid; }
}
/* Rectification reuses the ordinary session message list and composer. */
.rectification-chat { display: contents; }
/* Starter workbench */
.welcome {
@@ -1666,34 +1654,3 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
.birth-time-clock-menu .select-item { justify-content: flex-start; }
/* Birth-time rectification V4 */
.rectification-v4-panel { width: min(860px, 100%); margin: 0 auto; padding: clamp(20px, 4vw, 40px); display: grid; gap: 20px; overflow-y: auto; }
.rectification-v4-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.rectification-v4-header h2 { margin: 4px 0 8px; font-size: clamp(24px, 4vw, 36px); letter-spacing: -0.035em; }
.rectification-v4-header p, .rectification-v4-context p, .rectification-v4-processing p, .rectification-v4-result p { margin: 0; color: var(--color-ink-secondary); line-height: 1.65; }
.rectification-v4-eyebrow { color: var(--color-action) !important; font-size: var(--type-caption); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.rectification-v4-context, .rectification-v4-processing, .rectification-v4-result, .rectification-v4-notice { padding: 18px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.rectification-v4-context { display: grid; gap: 6px; }
.rectification-v4-processing { display: grid; gap: 8px; }
.rectification-v4-ranges { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0 18px; }
.rectification-v4-ranges > div { display: grid; gap: 5px; padding: 16px; border-radius: var(--radius-md); background: var(--color-surface); }
.rectification-v4-ranges span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.rectification-v4-ranges strong { font-size: 24px; letter-spacing: -.02em; }
.rectification-v4-evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.rectification-v4-evidence-grid h3 { margin: 0 0 8px; font-size: 15px; }
.rectification-v4-evidence-grid ul { margin: 0; padding-left: 18px; color: var(--color-ink-secondary); line-height: 1.6; }
.rectification-v4-uncertainty { margin-top: 16px !important; padding-top: 14px; border-top: 1px solid var(--color-border); font-size: var(--type-caption); }
.rectification-v4-actions, .rectification-v4-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; }
.rectification-v4-saved { display: inline-flex; align-items: center; gap: 7px; color: var(--color-action); font-weight: 700; }
.rectification-v4-saved svg, .rectification-v4-footer svg { width: 16px; height: 16px; }
.rectification-v4-composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; }
.rectification-v4-composer label { grid-column: 1 / -1; font-weight: 650; line-height: 1.55; }
.rectification-v4-composer textarea { min-height: 112px; resize: vertical; }
.rectification-v4-footer button { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.rectification-v4-footer button:disabled { cursor: default; opacity: .5; }
@media (max-width: 680px) {
.rectification-v4-panel { padding: 18px 14px 24px; }
.rectification-v4-header { display: grid; }
.rectification-v4-ranges, .rectification-v4-evidence-grid { grid-template-columns: 1fr; }
}
+4 -117
View File
@@ -54,13 +54,11 @@ import {
type RectificationCardAction,
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts";
import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client";
import {
createRectificationQuestionHandoffCoordinator,
} from "@/lib/rectification-question-handoff";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import type { ConversationalRectificationMessage } from "@/hooks/use-conversational-rectification";
import {
requestBirthTimeAssessment,
type JourneyClientResponse,
@@ -169,13 +167,6 @@ type ChatSession = {
rectificationCaseId: string | null;
};
function durableRectificationMessages(
messages: readonly ConversationalRectificationMessage[],
): Message[] {
const durable = messages.map(({ role, text }) => ({ role, text }));
if (durable.length <= 500) return durable;
return [durable[0]!, ...durable.slice(-(500 - 1))];
}
type RequestError = { sessionId: string; message: string };
type StreamingReply = { sessionId: string; text: string };
type BirthPlace = {
@@ -956,8 +947,6 @@ export default function Home() {
);
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationTurn | null>(null);
const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState("");
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
@@ -1015,9 +1004,6 @@ export default function Home() {
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification";
const visibleRectificationTurn = activeSession?.id === rectificationSessionId
? rectificationInitialTurn
: null;
const rectificationSurfaceOpen = activeRectificationSession
&& activeSession.id === rectificationSessionId;
const visibleSessions = sessions
@@ -1612,7 +1598,7 @@ export default function Home() {
setComposerNotice("");
if (nextSession?.sessionType === "birth_time_rectification") {
setRectificationError("");
if (nextSession.id !== rectificationSessionId || !rectificationInitialTurn) {
if (nextSession.id !== rectificationSessionId) {
resumeRectificationSession.current(nextSession);
}
}
@@ -2041,20 +2027,6 @@ export default function Home() {
);
}
function synchronizeRectificationQuestion(
turn: ConversationalRectificationTurn,
fallbackSession: ChatSession | undefined = activeSession,
) {
if (!turn.pendingConsultationQuestion) return;
if (!rectificationQuestionHandoff.current.peek() && fallbackSession) {
rectificationQuestionHandoff.current.synchronizeDurableQuestion(
turn.pendingConsultationQuestion,
{ sessionId: fallbackSession.id, theme: fallbackSession.theme },
);
}
setRectificationPendingQuestion(turn.pendingConsultationQuestion);
}
async function openBirthTimeRectification(
pendingConsultationQuestion: string | null = null,
sourceSessionOverride: ChatSession | null = null,
@@ -2072,8 +2044,6 @@ export default function Home() {
rectificationOpenInFlight.current = true;
setRectificationLoading(true);
setRectificationError("");
setRectificationInitialTurn(null);
setRectificationOpeningAssistantText("");
setRectificationPendingQuestion(requestedQuestion);
setDraft("");
setDraftTheme(null);
@@ -2103,78 +2073,6 @@ export default function Home() {
void openBirthTimeRectification(null, session);
};
function handleConversationalRectificationTurn(
turn: ConversationalRectificationTurn,
messages: readonly ConversationalRectificationMessage[],
) {
const requestIdentity = accountRefreshGuard.current.begin();
setRectificationInitialTurn(turn);
synchronizeRectificationQuestion(turn);
if (activeSession?.sessionType === "birth_time_rectification") {
const boundSession = {
...activeSession,
messages: durableRectificationMessages(messages),
rectificationCaseId: turn.caseId,
updatedAt: timestamp(),
};
updateSession(activeSession.id, () => boundSession);
void rectificationPersistence.current.enqueue(
boundSession.id,
() => persistSession(boundSession),
).catch(() => {
setComposerNotice("校正进度已经保留,但会话关联暂时未同步到云端。");
});
}
setAccount((current) => current ? {
...current,
hasConfirmedBirthTime: current.hasConfirmedBirthTime
|| (turn.status === "completed" && turn.candidate.status === "confirmed"),
rectificationCase: ["active", "paused", "confirming"].includes(turn.status)
? {
caseId: turn.caseId,
journeyProtocol: "conversational-evidence-v3",
status: turn.status,
turnVersion: turn.turnVersion,
isRevision: current.rectificationCase?.caseId === turn.caseId
? current.rectificationCase.isRevision
: current.hasConfirmedBirthTime,
preservesActiveTime: current.rectificationCase?.caseId === turn.caseId
? current.rectificationCase.preservesActiveTime
: current.hasConfirmedBirthTime,
}
: current.rectificationCase?.caseId === turn.caseId
? null
: current.rectificationCase,
} : current);
if (turn.status === "completed"
&& turn.candidate.status === "confirmed"
&& turn.candidate.representativeTime) {
setProfile((current) => ({
...current,
time: turn.candidate.representativeTime ?? current.time,
birthTimeStatus: "confirmed",
rectificationCaseId: turn.caseId,
}));
setProfileDraft((current) => ({
...current,
time: turn.candidate.representativeTime ?? current.time,
birthTimeStatus: "confirmed",
rectificationCaseId: turn.caseId,
}));
}
void fetchAccount()
.then((latest) => {
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
setAccount((current) => {
if (turn.status === "completed" || turn.status === "abandoned") return latest;
if (latest.rectificationCase?.caseId !== turn.caseId
|| latest.rectificationCase.turnVersion < turn.turnVersion) return current;
return latest;
});
})
.catch(() => undefined);
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
if (record.role !== "other") return;
if (synastryPendingId) return;
@@ -2943,7 +2841,7 @@ export default function Home() {
</button>
</header>
<div className={`conversation ${activeSession?.messages.length ? "" : "is-empty"} ${rectificationSurfaceOpen ? "is-rectification" : ""}`}>
<div className={`conversation ${rectificationSurfaceOpen || activeSession?.messages.length ? "" : "is-empty"}`}>
{!rectificationSurfaceOpen && (!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
@@ -3127,28 +3025,17 @@ export default function Home() {
<div ref={conversationEnd} />
</div>
))}
{rectificationSurfaceOpen && (!visibleRectificationTurn && rectificationError ? (
<div className="conversational-rectification-start-error" role="alert">
<p>{rectificationError}</p>
<button type="button" disabled={rectificationLoading} onClick={() => void openBirthTimeRectification(null, activeSession ?? null)}>
{rectificationLoading ? "正在重试…" : "重新建立校正记录"}
</button>
</div>
) : (
{rectificationSurfaceOpen && (
<ConversationalBirthTimeRectification
initialTurn={visibleRectificationTurn}
initialMessages={activeSession?.messages ?? []}
openingAssistantText={rectificationOpeningAssistantText}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)}
/>
))}
)}
</div>
{!rectificationSurfaceOpen && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
@@ -1,337 +1,21 @@
"use client";
import { ArrowUp, Check, Copy, RotateCcw, Square, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ChatMessageRow } from "./chat-message-row.tsx";
import { AppLoadingIndicator } from "./app-loading-indicator.tsx";
import { ModelSelector } from "./model-selector.tsx";
import { Button } from "./ui/button.tsx";
import { Textarea } from "./ui/textarea.tsx";
import type { PublicLanguageModel } from "../lib/public-models.ts";
import {
RectificationV4Panel,
type RectificationV4Continuation,
} from "./rectification-v4-panel.tsx";
import {
type ConversationalRectificationMessage,
type ConversationalRectificationStoredMessage,
type ConversationalRectificationController,
} from "../hooks/use-conversational-rectification.ts";
import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts";
import type { PublicLanguageModel } from "../lib/public-models.ts";
type SurfaceProps = Readonly<{
controller: ConversationalRectificationController;
openingAssistantText?: string;
export type ConversationalBirthTimeRectificationProps = Readonly<{
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onContinueOriginalQuestion?: (question: string) => void;
}>;
const ANSWER_UNDO_WINDOW_MS = 2_500;
function safely(request: Promise<unknown>) {
void request.catch(() => undefined);
}
export function ConversationalRectificationSurface({
controller,
openingAssistantText = "",
models,
selectedModelId,
onSelectModel,
pendingConsultationQuestion,
continuationPending = false,
onContinueOriginalQuestion,
}: SurfaceProps) {
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [submission, setSubmission] = useState<Readonly<{
text: string;
phase: "undo" | "generating";
turnVersion: number;
}> | null>(null);
const turn = controller.turn;
const messageCount = controller.messages?.length ?? 0;
const latestMessageText = controller.messages?.[messageCount - 1]?.text ?? turn?.narrative ?? "";
const latestAssistantKey = [...(controller.messages ?? [])]
.reverse()
.find((message) => message.role === "assistant")?.renderKey
?? `assistant-${turn?.turnVersion ?? 0}`;
useEffect(() => () => {
if (undoTimer.current) clearTimeout(undoTimer.current);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({
behavior: controller.pending || submission !== null || reduceMotion ? "auto" : "smooth",
block: "end",
});
}, [controller.error, controller.pending, latestMessageText, messageCount, submission, turn?.turnVersion]);
const pendingQuestion = turn?.status === "completed"
? turn.pendingConsultationQuestion
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
if (!turn) {
return (
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
{openingAssistantText
? <ChatMessageRow message={{
role: "assistant",
text: openingAssistantText,
renderKey: "rectification-opening-assistant",
state: "streaming",
}} />
: <div className="conversational-loading" aria-live="polite" role="status">
<AppLoadingIndicator title="正在建立校正记录…" detail="正在加载校正进度,准备第一条问题。" />
</div>}
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
</section>
);
}
const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed";
const canConfirm = turn.actions.includes("confirm")
&& turn.candidate.status === "ready_for_confirmation"
&& Boolean(turn.candidate.representativeTime);
const canContinue = turn.actions.includes("continue_original_question")
&& Boolean(pendingQuestion)
&& Boolean(onContinueOriginalQuestion);
const busy = controller.pending || submission !== null;
const submit = () => {
const text = controller.draft.trim();
if (!canAnswer || !text || busy) return;
controller.setDraft("");
setSubmission({ text, phase: "undo", turnVersion: turn.turnVersion });
undoTimer.current = setTimeout(async () => {
undoTimer.current = null;
setSubmission({ text, phase: "generating", turnVersion: turn.turnVersion });
try {
await controller.answer(undefined, text);
} catch {
controller.setDraft(text);
} finally {
setSubmission(null);
composer.current?.focus();
}
}, ANSWER_UNDO_WINDOW_MS);
};
const copyMessage = async (message: ConversationalRectificationMessage) => {
try {
await navigator.clipboard.writeText(message.text);
setCopiedMessageKey(message.renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === message.renderKey ? null : current
)), 1_500);
} catch {
// Clipboard permission failures must not interrupt the conversation.
}
};
const regenerateMessage = async (messageKey: string) => {
setRegeneratingMessageKey(messageKey);
try {
await controller.regenerate();
} finally {
setRegeneratingMessageKey((current) => current === messageKey ? null : current);
}
};
const undoSubmission = () => {
if (submission?.phase !== "undo") return;
if (undoTimer.current) clearTimeout(undoTimer.current);
undoTimer.current = null;
controller.setDraft(submission.text);
setSubmission(null);
requestAnimationFrame(() => composer.current?.focus());
};
return (
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
<div className="message-list rectification-message-list">
<span className="sr-only" aria-live="polite">{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
{(controller.messages ?? [{
role: "assistant" as const,
text: turn.narrative,
renderKey: `assistant-${turn.turnVersion}`,
}]).map((message) => (
<div className="rectification-message-entry" key={message.renderKey}>
<ChatMessageRow
message={regeneratingMessageKey === message.renderKey
? { role: "assistant", text: "", renderKey: message.renderKey, state: "thinking" }
: {
role: message.role,
text: message.text,
renderKey: message.renderKey,
state: "settled",
}}
/>
{message.role === "assistant" && regeneratingMessageKey !== message.renderKey && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback[message.renderKey] === "up"}
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: current[message.renderKey] === "up" ? undefined : "up",
}))}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback[message.renderKey] === "down"}
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: current[message.renderKey] === "down" ? undefined : "down",
}))}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
{copiedMessageKey === message.renderKey
? <Check aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
<button
aria-label="重跑回答"
disabled={busy || message.renderKey !== latestAssistantKey || !canAnswer}
title={message.renderKey === latestAssistantKey ? "重跑" : "只能重跑最新回答"}
type="button"
onClick={() => safely(regenerateMessage(message.renderKey))}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
)}
</div>
))}
{submission && turn.turnVersion === submission.turnVersion && (
<ChatMessageRow
message={{ role: "user", text: submission.text, renderKey: "pending-evidence", state: "settled" }}
/>
)}
{controller.pending && canAnswer && regeneratingMessageKey === null && (
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
)}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
<div ref={conversationEnd} />
</div>
{(canAnswer || canConfirm || canContinue) && <div className="composer-wrap rectification-composer-wrap">
{(canConfirm || canContinue) && (
<div className="composer-suggestions" aria-label="生时校正操作">
{canConfirm && (
<button
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
disabled={busy}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
{turn.candidate.representativeTime}
</button>
)}
{canContinue && (
<button
disabled={busy || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
>
{continuationPending ? "正在继续回答…" : "返回原问题"}
</button>
)}
</div>
)}
{canAnswer && <>
{controller.correctionTarget && (
<div className="rectification-correction-target" role="status">
<p>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</p>
<button disabled={busy} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
</button>
</div>
)}
<form className="composer" onSubmit={(event) => { event.preventDefault(); submit(); }}>
<label className="sr-only" htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
</label>
<Textarea
id="conversational-rectification-answer"
ref={composer}
autoFocus
disabled={busy}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:更正为 2020 年 11 月离职"
: "像聊天一样回答即可,例如:2018 年 6 月去了上海工作"}
rows={2}
value={controller.draft}
onChange={(event) => controller.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
{submission?.phase === "undo" ? (
<Button aria-label="撤回发送,本次不计入校正" title="撤回发送" size="icon" type="button" onClick={undoSubmission}>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={busy ? "正在核对" : "发送"} disabled={busy || !controller.draft.trim()} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<div className="composer-footer">
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy}
onSelect={onSelectModel}
/>
</div>
</>}
</div>}
</section>
);
}
type ConversationalBirthTimeRectificationProps = Readonly<{
initialTurn?: ConversationalRectificationTurn | null;
initialMessages?: readonly ConversationalRectificationStoredMessage[];
openingAssistantText?: string;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onTurn?: (
turn: ConversationalRectificationTurn,
messages: readonly ConversationalRectificationMessage[],
) => void;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>;
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
return (
<RectificationV4Panel
pendingConsultationQuestion={props.pendingConsultationQuestion}
continuationPending={props.continuationPending}
onPendingChange={props.onPendingChange}
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
/>
);
return <RectificationV4Panel {...props} />;
}
+211 -163
View File
@@ -1,47 +1,16 @@
"use client";
import { ArrowUp, Check, Pause, Play, Square } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { useEffect, 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 type { ChatMessageView } from "@/lib/chat-message-view";
import type { PublicLanguageModel } from "@/lib/public-models";
import type { RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts";
import { ChatMessageRow } from "./chat-message-row";
import { ModelSelector } from "./model-selector";
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;
@@ -50,155 +19,234 @@ export type RectificationV4Continuation = Readonly<{
acceptedRange: Readonly<{ start: string; end: string }>;
}>;
export function RectificationV4Panel(props: Readonly<{
type RectificationV4PanelProps = Readonly<{
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>) {
}>;
export function rectificationV4ChatMessages(
data: RectificationV4ApiResponse | null,
processing: boolean,
pendingConsultationQuestion?: string | null,
): readonly ChatMessageView[] {
if (!data) {
return [{
role: "assistant",
text: "",
renderKey: "rectification-loading",
state: "thinking",
}];
}
const messages: ChatMessageView[] = [];
if (pendingConsultationQuestion?.trim()) {
messages.push({
role: "assistant",
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingConsultationQuestion.trim()}`,
renderKey: "rectification-pending-consultation",
state: "settled",
});
}
for (const turn of data.turns) {
messages.push({
role: "assistant",
text: turn.question,
renderKey: `rectification-question-${turn.id}`,
state: "settled",
});
if (turn.answer) {
messages.push({
role: "user",
text: turn.answer,
renderKey: `rectification-answer-${turn.id}`,
state: "settled",
});
}
}
const caseValue = data.case;
const primary = caseValue.latestSnapshot?.clusters[0];
if (caseValue.acceptedRange) {
messages.push({
role: "assistant",
text: `候选范围已保存为 ${caseValue.acceptedRange.start}${caseValue.acceptedRange.end}。这是校正得到的候选范围,原出生时间没有被自动改写。`,
renderKey: `rectification-accepted-${caseValue.version}`,
state: "settled",
});
} else if (caseValue.status === "range_ready" && primary) {
messages.push({
role: "assistant",
text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`,
renderKey: `rectification-range-${caseValue.version}`,
state: "settled",
});
}
if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange) {
messages.push({
role: "assistant",
text: caseValue.currentQuestion.prompt,
renderKey: `rectification-current-${caseValue.currentQuestion.id}`,
state: "settled",
});
}
if (processing) {
messages.push({
role: "assistant",
text: "",
renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`,
state: "thinking",
});
} else if (caseValue.status === "paused") {
messages.push({
role: "assistant",
text: "进度已经保存。准备好后,我们可以从这里继续。",
renderKey: `rectification-paused-${caseValue.version}`,
state: "settled",
});
} else if (caseValue.status === "abandoned") {
messages.push({
role: "assistant",
text: "这次校正已经结束,原出生时间没有被改写。",
renderKey: `rectification-abandoned-${caseValue.version}`,
state: "settled",
});
}
return messages;
}
export function RectificationV4Panel(props: RectificationV4PanelProps) {
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 conversationEnd = useRef<HTMLDivElement>(null);
const caseValue = controller.data?.case;
const processing = Boolean(caseValue && (
caseValue.status === "processing"
|| ["pending", "processing"].includes(controller.job?.status ?? "")
));
const messages = rectificationV4ChatMessages(
controller.data,
processing,
props.pendingConsultationQuestion,
);
const canAnswer = Boolean(caseValue?.currentQuestion)
&& !processing
&& !controller.pending
&& ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? "");
const canAcceptRange = caseValue?.status === "range_ready"
&& Boolean(caseValue.latestSnapshot?.canAcceptRange)
&& !caseValue.acceptedRange;
const handoff = controller.handoff;
const canContinue = Boolean(accepted && handoff?.status === "pending" && props.onContinueOriginalQuestion);
const canContinue = Boolean(
caseValue?.acceptedRange
&& handoff?.status === "pending"
&& props.onContinueOriginalQuestion,
);
const showControls = Boolean(caseValue && caseValue.status !== "abandoned" && (
canAnswer || canAcceptRange || canContinue || caseValue.status === "paused"
));
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({
behavior: processing || controller.pending || reduceMotion ? "auto" : "smooth",
block: "end",
});
}, [controller.error, controller.pending, messages.length, processing]);
async function submit(event: React.FormEvent) {
event.preventDefault();
const answer = draft.trim();
if (!answer || !canAnswer) return;
const result = await controller.answer(answer);
const result = await controller.answer(answer, props.selectedModelId || null);
if (result) setDraft("");
}
function continueOriginalQuestion() {
if (!caseValue?.acceptedRange || !handoff) return;
props.onContinueOriginalQuestion?.({
protocol: "rectification-evidence-v4",
question: handoff.question,
caseId: caseValue.id,
caseVersion: caseValue.version,
acceptedRange: caseValue.acceptedRange,
});
}
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>
<section className="rectification-chat" aria-label="生时校正对话" aria-busy={processing || controller.pending}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
<div ref={conversationEnd} />
</div>
{props.pendingConsultationQuestion && (
<aside className="rectification-v4-context">
<b></b>
<p>{props.pendingConsultationQuestion}</p>
</aside>
)}
{showControls && (
<div className="composer-wrap">
{(canAcceptRange || canContinue || caseValue?.status === "paused") && (
<div className="composer-suggestions" aria-label="生时校正操作">
{canAcceptRange && (
<button type="button" disabled={controller.pending} onClick={() => void controller.acceptRange()}>
</button>
)}
{canContinue && (
<button type="button" disabled={props.continuationPending} onClick={continueOriginalQuestion}>
{props.continuationPending ? "正在回到原问题…" : "带着候选范围继续原问题"}
</button>
)}
{caseValue?.status === "paused" && (
<button type="button" disabled={controller.pending} onClick={() => void controller.resume()}>
</button>
)}
</div>
)}
{processing && (
<div className="rectification-v4-processing" role="status">
<AppLoadingIndicator title={phaseCopy[phase]} detail="回答已经保存,计算在后台继续" />
<p></p>
{canAnswer && (
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label="继续描述你的经历"
value={draft}
disabled={controller.pending}
placeholder="继续说你记得的人生经历…"
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>
)}
<div className="composer-footer">
<ModelSelector
models={props.models}
selectedModelId={props.selectedModelId}
disabled={controller.pending || processing}
onSelect={props.onSelectModel}
/>
</div>
</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>
);
}
+2 -2
View File
@@ -133,8 +133,8 @@ export function useRectificationV4(input: {
pending,
error,
clearError: () => setError(""),
answer: (answer: string) => data
? mutate(() => answerRectificationV4(data.case.id, data.case.version, answer))
answer: (answer: string, modelId?: string | null) => data
? mutate(() => answerRectificationV4(data.case.id, data.case.version, answer, modelId))
: Promise.resolve(null),
pause: () => data
? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "pause"))
@@ -14,10 +14,15 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
const now = options.now ?? (() => new Date());
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
const [events, turns] = await Promise.all([
store.loadEvents(userId, caseValue.id),
store.loadTurns(userId, caseValue.id),
]);
return {
case: caseValue,
job: jobId ? await store.loadJob(userId, jobId) : null,
events: [...await store.loadEvents(userId, caseValue.id)],
events: [...events],
turns: [...turns],
};
}
@@ -34,7 +39,7 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
calculationSpec: input.calculationSpec,
calculationSpecHash: calculationSpecHash(input.calculationSpec),
evidenceSetHash: evidenceSetHash([]),
currentQuestion: openingQuestion(),
currentQuestion: openingQuestion(input.calculationSpec.candidateRange),
latestSnapshot: null,
acceptedRange: null,
createdAt: timestamp,
@@ -57,11 +62,12 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
return store.loadJob(userId, jobId);
},
async answer(input: { readonly userId: string; readonly caseId: string; readonly actionId: string; readonly expectedCaseVersion: number; readonly answer: string }) {
async answer(input: { readonly userId: string; readonly caseId: string; readonly actionId: string; readonly expectedCaseVersion: number; readonly answer: string; readonly modelId?: string | null }) {
const current = await store.loadCase(input.userId, input.caseId);
if (!current?.currentQuestion) return null;
const saved = await store.submitAnswer({
...input,
modelId: input.modelId ?? null,
question: current.currentQuestion,
jobId: randomUUID(),
turnId: randomUUID(),
+2 -2
View File
@@ -57,9 +57,9 @@ export async function loadRectificationV4(caseId: string): Promise<Rectification
return json(await fetch(`/api/rectification/v4/cases/${caseId}`, { cache: "no-store" }), rectificationV4ApiResponseSchema);
}
export function answerRectificationV4(caseId: string, expectedCaseVersion: number, answer: string) {
export function answerRectificationV4(caseId: string, expectedCaseVersion: number, answer: string, modelId?: string | null) {
return post(`/api/rectification/v4/cases/${caseId}/answers`, {
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, answer,
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, answer, modelId: modelId || null,
});
}
@@ -150,6 +150,21 @@ export const rectificationV4QuestionSchema = z.object({
}).strict();
export type RectificationV4Question = z.infer<typeof rectificationV4QuestionSchema>;
export const rectificationV4TurnSchema = z.object({
id: z.string().uuid(),
caseId: z.string().uuid(),
caseVersion: z.number().int().positive(),
questionId: z.string().uuid().nullable(),
questionDomain: evidenceDomainSchema.nullable(),
questionTargetEventId: z.string().uuid().nullable(),
question: z.string().trim().min(1).max(1_000),
answer: z.string().max(4_000),
modelId: z.string().trim().min(1).max(120).nullable(),
actionId: z.string().uuid(),
createdAt: z.string().datetime({ offset: true }),
}).strict();
export type RectificationV4Turn = z.infer<typeof rectificationV4TurnSchema>;
export const rectificationV4CaseSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
@@ -177,6 +192,7 @@ export const answerRequestSchema = z.object({
actionId: z.string().uuid(),
expectedCaseVersion: z.number().int().nonnegative(),
answer: z.string().trim().min(1).max(4_000),
modelId: z.string().trim().min(1).max(120).nullable().optional(),
}).strict();
export const reviseEventRequestSchema = z.object({
@@ -218,6 +234,7 @@ export const rectificationV4ApiResponseSchema = z.object({
case: rectificationV4CaseSchema,
job: rectificationV4JobSchema.nullable(),
events: z.array(lifeEventRevisionSchema),
turns: z.array(rectificationV4TurnSchema),
}).strict();
export type RectificationV4ApiResponse = z.infer<typeof rectificationV4ApiResponseSchema>;
@@ -43,6 +43,12 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
owned(userId, caseId);
return events.get(caseId) ?? [];
},
async loadTurns(userId, caseId) {
owned(userId, caseId);
return [...turns.values()]
.filter((turn) => turn.caseId === caseId)
.sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt));
},
async createCase(input) {
const replay = actionResults.get(`${input.case.userId}:${input.actionId}`);
if (replay) return owned(input.case.userId, replay.caseId);
@@ -85,6 +91,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
questionTargetEventId: input.question.targetEventId,
question: input.question.prompt,
answer: input.answer,
modelId: input.modelId,
actionId: input.actionId,
createdAt: input.now,
};
@@ -123,7 +130,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
};
const turn: RectificationV4Turn = {
id: input.revision.id, caseId: current.id, caseVersion: version, questionId: null, questionDomain: null,
questionTargetEventId: null, question: "修订事件", answer: "", actionId: input.actionId, createdAt: input.now,
questionTargetEventId: null, question: "修订事件", answer: "", modelId: null, actionId: input.actionId, createdAt: input.now,
};
const job = {
id: input.jobId, caseId: current.id, status: "pending" as const, phase: "scoring_candidates" as const,
@@ -174,10 +181,14 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
const claimed = { ...job, status: "processing" as const, workerId, updatedAt: now };
jobs.set(job.id, claimed);
const caseValue = cases.get(job.caseId)!;
const caseTurns = [...turns.values()]
.filter((turn) => turn.caseId === job.caseId)
.sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt));
return {
job: claimed,
case: caseValue,
turn: turns.get(job.turnId)!,
turns: caseTurns,
events: events.get(job.caseId) ?? [],
attemptedRefinementEventIds: [...new Set(
[...turns.values()]
@@ -0,0 +1,129 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
import {
evidenceDomainSchema,
type CandidateSnapshot,
type LifeEventRevision,
type RectificationV4Question,
type RectificationV4Turn,
} from "./contracts.ts";
import { planNextQuestion } from "./question-planner.ts";
const outputSchema = z.object({
domain: evidenceDomainSchema,
targetEventId: z.string().uuid().nullable(),
prompt: z.string().trim().min(1).max(1_000),
recallCost: z.enum(["low", "medium", "high"]),
reason: z.string().trim().min(1).max(240),
}).strict();
const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim()
|| path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology");
const agents = new Map<string, Agent>();
const internalCopyPattern = /(?:|(?:||)||\b(?:education|relocation|relationship|career|finance|health_pressure|family|other)\b)/iu;
function agentFor(modelId: string | null) {
const model = modelId ? resolveLanguageModel(modelId) : defaultLanguageModel();
const selected = model ?? defaultLanguageModel();
if (!selected) return null;
const cached = agents.get(selected.id);
if (cached) return cached;
const agent = new Agent({
id: `rectification-v4-question-${selected.id}`,
name: "Rectification V4 Conversational Question Author",
model: selected.model,
skills: [jyotishSkillPath],
instructions: "Return only the requested JSON. Act as a birth-time rectification conversation partner, not a questionnaire. Respond to the user's latest concrete experience, then ask at most one natural open question that can materially improve evidence quality or distinguish the remaining candidate range. Choose the next evidence domain from context; never follow a fixed domain order. Never expose domain labels, event ids, scores, routing metadata, gate reasons, or implementation status in the visible prompt.",
});
agents.set(selected.id, agent);
return agent;
}
function latestByEvent(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()];
}
export async function authorRectificationV4Question(input: Readonly<{
modelId: string | null;
candidateRange: Readonly<{ start: string; end: string }>;
snapshot: CandidateSnapshot | null;
turns: readonly RectificationV4Turn[];
events: readonly LifeEventRevision[];
attemptedRefinementEventIds: readonly string[];
}>): Promise<RectificationV4Question> {
const fallback = () => planNextQuestion({
events: input.events,
attemptedRefinementEventIds: input.attemptedRefinementEventIds,
latestAnswer: input.turns.at(-1)?.answer,
});
const agent = agentFor(input.modelId);
if (!agent) return fallback();
const events = latestByEvent(input.events);
const allowedTargets = new Map(events.map((event) => [event.eventId, event]));
const recentTurns = input.turns.slice(-6).flatMap((turn) => [
{ role: "assistant", text: turn.question },
...(turn.answer ? [{ role: "user", text: turn.answer }] : []),
]);
const prompt = JSON.stringify({
task: "Write the next assistant message for an open-ended birth-time rectification conversation.",
constraints: [
"First acknowledge or connect to the latest user experience; do not say merely that an answer is complete or recorded.",
"Ask zero or one question, never a checklist, form, domain menu, or fixed sequence.",
"Prefer continuing the current event when a date/detail clarification can change scoring; otherwise choose the highest-information missing life dimension.",
"The visible prompt must not mention internal domains, ids, scores, weights, gates, processing phases, or that a model selected a route.",
"targetEventId must be null or one of allowedTargetEventIds.",
],
candidateRange: input.candidateRange,
currentCandidateRange: input.snapshot?.clusters[0]
? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime }
: null,
recentConversation: recentTurns,
existingEvidence: events.map((event) => ({
eventId: event.eventId,
domain: event.domain,
summary: event.summary,
date: event.dateRange.label,
precision: event.dateRange.precision,
scoreability: event.scoreability,
})),
attemptedRefinementEventIds: input.attemptedRefinementEventIds,
allowedTargetEventIds: [...allowedTargets.keys()],
allowedDomains: evidenceDomainSchema.options,
});
try {
const result = await agent.generate(
[{ role: "user", content: prompt }],
{
abortSignal: AbortSignal.timeout(35_000),
structuredOutput: { schema: outputSchema, jsonPromptInjection: "inline" },
},
);
const parsed = outputSchema.safeParse(result.object ?? (result.text ? JSON.parse(result.text) : null));
if (!parsed.success || internalCopyPattern.test(parsed.data.prompt)) return fallback();
const target = parsed.data.targetEventId ? allowedTargets.get(parsed.data.targetEventId) : null;
return {
id: randomUUID(),
domain: target?.domain ?? parsed.data.domain,
targetEventId: target?.eventId ?? null,
prompt: parsed.data.prompt,
recallCost: parsed.data.recallCost,
reason: parsed.data.reason,
};
} catch (error) {
console.warn("rectification_v4_question_author_failed", {
modelId: input.modelId,
errorName: error instanceof Error ? error.name : "UnknownError",
});
return fallback();
}
}
@@ -1,82 +1,52 @@
import { randomUUID } from "node:crypto";
import type { EvidenceDomain, LifeEventRevision, RectificationV4Question } from "./contracts.ts";
import type { LifeEventRevision, RectificationV4Question } from "./contracts.ts";
import { scoreableEvents } from "./evidence-ledger.ts";
const domainOrder: readonly EvidenceDomain[] = [
"education", "relocation", "relationship", "career", "finance", "health_pressure", "family",
];
const recallCost: Readonly<Record<EvidenceDomain, number>> = {
education: 1, relocation: 1, relationship: 1, career: 1, finance: 2, health_pressure: 2, family: 2, other: 3,
};
const prompts: Readonly<Record<EvidenceDomain, string>> = {
education: "请说一件你记得最清楚的升学、复读、转学或毕业事件,并给出尽可能准确的年月。",
relocation: "请说一次影响较大的搬家或长期迁居,并给出尽可能准确的年月。",
relationship: "请说一段重要关系明确开始或结束的时间;开始和结束请分开说。",
career: "请说一次明确的入职、离职、转行或职责突变,并给出尽可能准确的年月。",
finance: "请说一次明显的收入、负债或资产变化,并给出尽可能准确的年月。",
health_pressure: "请说一次明确的疾病、手术、事故或长期压力起点,并给出尽可能准确的年月。",
family: "请说一件对你影响很大的家庭事件和时间;这一类先作为背景,不直接参与评分。",
other: "请再补充一件日期明确、对人生方向影响较大的事件;如果暂时想不到,也可以回复“暂停”。",
};
function refinementQuestion(event: LifeEventRevision, id?: string): RectificationV4Question {
return {
id: id ?? randomUUID(),
domain: event.domain,
targetEventId: event.eventId,
prompt: `之前提到“${event.summary.slice(0, 120)},目前时间是${event.dateRange.label}如果记得,请补充更具体的日期;不记得可以回复“跳过”`,
prompt: `刚才提到${event.summary.slice(0, 120)}很重要。你目前记得的时间是${event.dateRange.label}如果还能想起更具体的月份或日期,可以继续说,不确定也没关系`,
recallCost: "medium",
reason: "缩小已有事件的日期范围,用于检验候选时间对日期误差是否稳定。",
reason: "缩小已有事件的日期范围,用于检验候选范围对日期误差是否稳定。",
};
}
export function planNextQuestion(input: {
readonly askedDomains: readonly EvidenceDomain[];
readonly coveredDomains: readonly EvidenceDomain[];
readonly candidateSplitByDomain?: Readonly<Partial<Record<EvidenceDomain, number>>>;
readonly events?: readonly LifeEventRevision[];
readonly attemptedRefinementEventIds?: readonly string[];
readonly latestAnswer?: string;
readonly id?: string;
}): RectificationV4Question {
const asked = new Set(input.askedDomains);
const covered = new Set(input.coveredDomains);
const candidates = domainOrder.filter((domain) => !asked.has(domain));
if (candidates.length > 0) {
candidates.sort((left, right) => {
const leftValue = (input.candidateSplitByDomain?.[left] ?? 0) + (covered.has(left) ? 0 : 1) - recallCost[left] * 0.1;
const rightValue = (input.candidateSplitByDomain?.[right] ?? 0) + (covered.has(right) ? 0 : 1) - recallCost[right] * 0.1;
return rightValue - leftValue || domainOrder.indexOf(left) - domainOrder.indexOf(right);
});
const domain = candidates[0]!;
const cost = recallCost[domain] === 1 ? "low" : recallCost[domain] === 2 ? "medium" : "high";
return {
id: input.id ?? randomUUID(),
domain,
targetEventId: null,
prompt: prompts[domain],
recallCost: cost,
reason: input.candidateSplitByDomain?.[domain]
? "该领域最能区分当前候选时间,同时回忆成本较低。"
: "先收集高回忆率、可核对日期的人生事件。",
};
}
const attempted = new Set(input.attemptedRefinementEventIds ?? []);
const target = scoreableEvents(input.events ?? [])
.filter((event) => event.dateRange.precision !== "day" && !attempted.has(event.eventId))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.eventId.localeCompare(right.eventId))[0];
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || left.eventId.localeCompare(right.eventId))[0];
if (target) return refinementQuestion(target, input.id);
return {
id: input.id ?? randomUUID(),
domain: "other",
targetEventId: null,
prompt: prompts.other,
recallCost: "high",
reason: "现有事件仍不足以通过稳定性门槛,需要新的明确日期证据,或由用户主动暂停。",
prompt: input.latestAnswer
? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。"
: "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。",
recallCost: "low",
reason: "模型不可用时保持开放叙述,不退回固定领域问卷。",
};
}
export function openingQuestion(id?: string): RectificationV4Question {
return planNextQuestion({ askedDomains: [], coveredDomains: [], id });
export function openingQuestion(
candidateRange: Readonly<{ start: string; end: string }>,
id?: string,
): RectificationV4Question {
return {
id: id ?? randomUUID(),
domain: "other",
targetEventId: null,
prompt: `我会先在 ${candidateRange.start}${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`,
recallCost: "low",
reason: "首轮允许开放叙述,由后续模型根据真实经历选择高信息量问题。",
};
}
+5 -13
View File
@@ -5,25 +5,15 @@ import type {
RectificationV4Job,
RectificationV4Phase,
RectificationV4Question,
RectificationV4Turn,
} from "./contracts.ts";
export type RectificationV4Turn = Readonly<{
id: string;
caseId: string;
caseVersion: number;
questionId: string | null;
questionDomain: LifeEventRevision["domain"] | null;
questionTargetEventId: string | null;
question: string;
answer: string;
actionId: string;
createdAt: string;
}>;
export type { RectificationV4Turn } from "./contracts.ts";
export type ClaimedRectificationV4Job = Readonly<{
job: RectificationV4Job;
case: RectificationV4Case;
turn: RectificationV4Turn;
turns: readonly RectificationV4Turn[];
events: readonly LifeEventRevision[];
attemptedRefinementEventIds: readonly string[];
}>;
@@ -46,6 +36,7 @@ export interface RectificationV4Store {
findActiveCase(userId: string): Promise<RectificationV4Case | null>;
loadCase(userId: string, caseId: string): Promise<RectificationV4Case | null>;
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
loadTurns(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]>;
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
submitAnswer(input: {
readonly userId: string;
@@ -53,6 +44,7 @@ export interface RectificationV4Store {
readonly actionId: string;
readonly expectedCaseVersion: number;
readonly answer: string;
readonly modelId: string | null;
readonly question: RectificationV4Question;
readonly jobId: string;
readonly turnId: string;
@@ -4,16 +4,17 @@ import {
lifeEventRevisionSchema,
rectificationV4CaseSchema,
rectificationV4JobSchema,
rectificationV4TurnSchema,
type CandidateSnapshot,
type LifeEventRevision,
type RectificationV4Case,
type RectificationV4Job,
type RectificationV4Turn,
} from "./contracts.ts";
import type {
ClaimedRectificationV4Job,
CompleteRectificationV4JobInput,
RectificationV4Store,
RectificationV4Turn,
} from "./store.ts";
import { RectificationV4StoreError } from "./store.ts";
import { evidenceSetHash } from "./fingerprints.ts";
@@ -114,7 +115,7 @@ function jobValue(row: Row): RectificationV4Job {
}
function turnValue(row: Row): RectificationV4Turn {
return {
return rectificationV4TurnSchema.parse({
id: String(row.id),
caseId: String(row.case_id),
caseVersion: Number(row.case_version),
@@ -123,9 +124,10 @@ function turnValue(row: Row): RectificationV4Turn {
questionTargetEventId: row.question_target_event_id ? String(row.question_target_event_id) : null,
question: String(row.question),
answer: String(row.answer),
modelId: row.model_id ? String(row.model_id) : null,
actionId: String(row.action_id),
createdAt: timestamp(row.created_at),
};
});
}
export function createRectificationV4SupabaseStore(supabase: SupabaseClient): RectificationV4Store {
@@ -160,6 +162,15 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
return ((data ?? []) as Row[]).map(eventRevision);
}
async function loadTurnsByCase(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]> {
if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found");
const { data, error } = await supabase.from("birth_time_rectification_v4_turns")
.select("*").eq("case_id", caseId).eq("user_id", userId)
.order("case_version", { ascending: true });
if (error) throw storeError(error);
return ((data ?? []) as Row[]).map(turnValue);
}
async function rpc(name: string, args: Row): Promise<unknown> {
const { data, error } = await supabase.rpc(name, args);
if (error) throw storeError(error);
@@ -176,6 +187,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
},
loadCase: loadCaseById,
loadEvents: loadEventsByCase,
loadTurns: loadTurnsByCase,
async createCase(input) {
const id = String(await rpc("create_birth_time_rectification_v4_case", {
p_user_id: input.case.userId,
@@ -205,6 +217,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
p_question_target_event_id: input.question.targetEventId,
p_question: input.question.prompt,
p_answer: input.answer,
p_model_id: input.modelId,
p_job_id: input.jobId,
p_now: input.now,
}));
@@ -266,23 +279,21 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
if (!jobRow) throw new RectificationV4StoreError("not_found");
const userId = String(jobRow.user_id);
const caseId = String(jobRow.case_id);
const [caseResult, turnRow, events, turnRows] = await Promise.all([
const [caseResult, turnRow, events, turns] = await Promise.all([
loadCaseById(userId, caseId),
rowById("birth_time_rectification_v4_turns", String(jobRow.turn_id)),
loadEventsByCase(userId, caseId),
supabase.from("birth_time_rectification_v4_turns")
.select("question_target_event_id").eq("case_id", caseId),
loadTurnsByCase(userId, caseId),
]);
if (!caseResult || !turnRow) throw new RectificationV4StoreError("not_found");
if (turnRows.error) throw storeError(turnRows.error);
return {
job: jobValue(jobRow),
case: caseResult,
turn: turnValue(turnRow),
turns,
events,
attemptedRefinementEventIds: [...new Set(
((turnRows.data ?? []) as Row[])
.flatMap((row) => row.question_target_event_id ? [String(row.question_target_event_id)] : []),
turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : []),
)],
};
},
+29 -10
View File
@@ -1,5 +1,10 @@
import { randomUUID } from "node:crypto";
import type { CandidateSnapshot, EvidenceDomain } from "./contracts.ts";
import type {
CandidateSnapshot,
LifeEventRevision,
RectificationV4Case,
RectificationV4Question,
} from "./contracts.ts";
import { rectificationV4AlgorithmVersion } from "./contracts.ts";
import type { RectificationV4CandidateEngine } from "./candidate-engine.ts";
import { buildCandidateClusters } from "./candidate-clusters.ts";
@@ -8,13 +13,21 @@ import { evidenceSetHash } from "./fingerprints.ts";
import { extractV4EventRevisions } from "./extraction.ts";
import { latestEventRevisions, scoreableEvents } from "./evidence-ledger.ts";
import { planNextQuestion } from "./question-planner.ts";
import type { RectificationV4Store } from "./store.ts";
import type { ClaimedRectificationV4Job, RectificationV4Store } from "./store.ts";
export function createRectificationV4Worker(input: {
readonly store: RectificationV4Store;
readonly engine: RectificationV4CandidateEngine;
readonly workerId?: string;
readonly now?: () => Date;
readonly questionAuthor?: (context: Readonly<{
modelId: string | null;
candidateRange: RectificationV4Case["calculationSpec"]["candidateRange"];
snapshot: CandidateSnapshot | null;
turns: ClaimedRectificationV4Job["turns"];
events: readonly LifeEventRevision[];
attemptedRefinementEventIds: readonly string[];
}>) => Promise<RectificationV4Question>;
}) {
const workerId = input.workerId ?? randomUUID();
const now = input.now ?? (() => new Date());
@@ -70,14 +83,20 @@ export function createRectificationV4Worker(input: {
};
}
await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "planning_question", now: now().toISOString() });
const covered = events.map((event) => event.domain);
const asked = [...covered, ...(claimed.turn.questionDomain ? [claimed.turn.questionDomain] : [])];
const nextQuestion = snapshot?.canAcceptRange ? null : planNextQuestion({
askedDomains: [...new Set(asked)] as EvidenceDomain[],
coveredDomains: [...new Set(covered)] as EvidenceDomain[],
events,
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
});
const nextQuestion = snapshot?.canAcceptRange ? null : input.questionAuthor
? await input.questionAuthor({
modelId: claimed.turn.modelId,
candidateRange: claimed.case.calculationSpec.candidateRange,
snapshot,
turns: claimed.turns,
events,
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
})
: planNextQuestion({
events,
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
latestAnswer: claimed.turn.answer,
});
await input.store.completeJob({
workerId,
jobId: claimed.job.id,