refactor: make birth time rectification conversational

This commit is contained in:
Jesse_Chen
2026-07-27 22:33:12 +08:00
parent b569cf7825
commit a550b98da7
25 changed files with 753 additions and 1785 deletions
+15
View File
@@ -1548,3 +1548,18 @@
- 防复发:需要同时访问内部 API 与 staging PostgreSQL 的服务必须在 Compose 合同中显式加入两个现有私有网络。
- 相关记录:BUG-082
- 修复版本:本次修复提交
## BUG-085 | 生时校正回退为独立面板和固定领域问卷
- 状态:investigating
- 首次发现:2026-07-27
- 最近更新:2026-07-27
- 影响面:生时校正 V4 聊天界面、历史恢复、模型选择、下一问规划与 staging 验收
- 用户现象:进入生时校正后看到独立的校正面板、证据区域和固定问题;交互不像普通 session,领域也不再根据用户刚讲的经历动态选择。
- 触发条件:V4 页面入口渲染旧式 `RectificationV4Panel` 视觉结构,页面给会话容器添加 `is-rectification`,同时问题规划器按硬编码领域顺序和模板生成下一问。
- 根因:组件 wrapper 无条件绕过原普通聊天 Surface;普通 session CSS 又显式排除 `is-rectification`;`question-planner.ts` 把教育、迁移、关系、事业、财务、健康压力和家庭写成固定顺序与固定文案,测试还把这些实现细节当成产品合同。
- 修复:V4 复用普通 session 的消息列表、输入框和模型选择器,并从持久化 turns 恢复完整对话;回答时原子保存所选模型 ID,Worker 将完整 turns、事件台账、日期精度、已追问事件与候选范围交给模型动态生成下一问。确定性 planner 只保留日期修订和开放叙述降级,不再轮询领域或输出固定问卷;候选范围仍不得表述为已确认出生分钟。
- 验证:聚焦 V4/domain/service/replay/handoff/migration、普通 session UI 合同和 consultation entrypoint 共 59 个测试通过;staging 构建、迁移和登录态 smoke 完成后更新为 resolved 并填写精确提交与部署 SHA。
- 防复发:可见生时校正必须复用普通聊天 Surface;测试应锁定自然语言消息、turn 恢复、模型 ID 传递和无固定领域控件,不得锁定领域顺序或问题模板。模型只负责选择和表达下一条高信息量问题,证据修订、评分、稳定性门、范围接受、handoff 与扣费继续由确定性后端负责。
- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084
- 修复版本:待提交(staging 验收中)
@@ -1,6 +1,7 @@
import { setTimeout as sleep } from "node:timers/promises";
import { createRectificationV4CandidateEngine } from "../src/lib/rectification-v4/candidate-engine.ts";
import { createRectificationV4SupabaseStore } from "../src/lib/rectification-v4/supabase-store.ts";
import { authorRectificationV4Question } from "../src/lib/rectification-v4/question-author.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts";
@@ -14,6 +15,7 @@ const worker = createRectificationV4Worker({
engine: createRectificationV4CandidateEngine({
apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
}),
questionAuthor: authorRectificationV4Question,
});
do {
+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,
@@ -0,0 +1,58 @@
begin;
alter table public.birth_time_rectification_v4_turns
add column model_id text
check (model_id is null or length(btrim(model_id)) between 1 and 120);
drop function public.submit_birth_time_rectification_v4_answer(
uuid, uuid, uuid, bigint, uuid, uuid, text, uuid, text, text, uuid, timestamptz
);
create function public.submit_birth_time_rectification_v4_answer(
p_user_id uuid, p_case_id uuid, p_action_id uuid, p_expected_version bigint,
p_turn_id uuid, p_question_id uuid, p_question_domain text, p_question_target_event_id uuid, p_question text,
p_answer text, p_model_id text, p_job_id uuid, p_now timestamptz
) returns uuid
language plpgsql security definer set search_path = '' as $$
declare v_case public.birth_time_rectification_v4_cases%rowtype; v_job_id uuid;
begin
select action.job_id into v_job_id from public.birth_time_rectification_v4_actions action
where action.user_id = p_user_id and action.action_id = p_action_id;
if v_job_id is not null then return v_job_id; end if;
select value.* into v_case from public.birth_time_rectification_v4_cases value
where value.id = p_case_id and value.user_id = p_user_id for update;
if not found then raise exception 'rectification_v4_case_not_found'; end if;
if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if;
if v_case.status not in ('awaiting_answer', 'range_ready') then raise exception 'rectification_v4_case_not_awaiting_answer'; end if;
insert into public.birth_time_rectification_v4_turns(
id, case_id, user_id, case_version, question_id, question_domain, question_target_event_id,
question, answer, model_id, action_id, created_at
) values (
p_turn_id, p_case_id, p_user_id, p_expected_version + 1, p_question_id, p_question_domain, p_question_target_event_id,
p_question, p_answer, nullif(btrim(p_model_id), ''), p_action_id, p_now
);
update public.birth_time_rectification_v4_cases set
version = p_expected_version + 1, status = 'processing', phase = 'extracting_evidence',
current_question = null, updated_at = p_now
where id = p_case_id;
insert into public.birth_time_rectification_v4_jobs(
id, case_id, user_id, turn_id, status, phase, expected_case_version,
evidence_set_hash, calculation_spec_hash, created_at, updated_at
) values (
p_job_id, p_case_id, p_user_id, p_turn_id, 'pending', 'extracting_evidence', p_expected_version + 1,
v_case.evidence_set_hash, v_case.calculation_spec_hash, p_now, p_now
);
insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, job_id, created_at)
values (p_user_id, p_action_id, p_case_id, p_job_id, p_now);
return p_job_id;
end;
$$;
revoke all on function public.submit_birth_time_rectification_v4_answer(
uuid, uuid, uuid, bigint, uuid, uuid, text, uuid, text, text, text, uuid, timestamptz
) from public, anon, authenticated;
grant execute on function public.submit_birth_time_rectification_v4_answer(
uuid, uuid, uuid, bigint, uuid, uuid, text, uuid, text, text, text, uuid, timestamptz
) to service_role;
commit;
@@ -205,16 +205,6 @@ test("a bound rectification session and a homepage restart share the v4 active-c
assert.match(hook, /loadRectificationV4\(existingHandoff\.caseId\)/);
});
test("historical completed rectification does not replace the account's unfinished case", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("function handleConversationalRectificationTurn");
const end = source.indexOf("async function draftSynastryQuestionFromChart", start);
const handler = source.slice(start, end);
assert.match(handler, /current\.rectificationCase\?\.caseId === turn\.caseId[\s\S]*?\? null[\s\S]*?: current\.rectificationCase/);
assert.match(handler, /turn\.status === "completed" \|\| turn\.status === "abandoned"\) return latest/);
});
test("rectify-first suggestions hand the source question to a dedicated rectification session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("function chooseConversationSuggestion");
@@ -1,843 +1,139 @@
import assert from "node:assert/strict";
import { spawn, type ChildProcess } from "node:child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
ConversationalRectificationSurface,
} from "../src/components/conversational-birth-time-rectification.tsx";
import type { ConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts";
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
import { rectificationV4ChatMessages } from "../src/components/rectification-v4-panel.tsx";
import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts";
// The repository keeps JSX in preserve mode for Next.js; direct Node SSR needs the classic global.
Object.assign(globalThis, { React });
const id = "00000000-0000-4000-8000-000000000901";
const questionId = "00000000-0000-4000-8000-000000000902";
const now = "2026-07-27T00:00:00.000Z";
const turn: ConversationalRectificationTurn = {
caseId: "00000000-0000-4000-8000-000000000821",
journeyProtocol: "conversational-evidence-v3",
status: "confirming",
turnVersion: 6,
narrative: "## 当前判断\n\n**05:18** 只是待验证候选;D9 与 D10 仍需真实经历交叉验证。",
candidate: {
status: "ready_for_confirmation",
representativeTime: "05:18",
rangeStart: "05:16",
rangeEnd: "05:20",
},
technicalReceipt: {
calculationVersion: "rectification-technical-v1",
stableLayers: ["D1"],
sensitiveLayers: ["D9", "D10"],
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
},
evidenceRequest: {
domains: ["relationship", "career", "relocation"],
datePrecision: "month_preferred",
freeTextAllowed: true,
},
evidenceRecap: [{
id: "00000000-0000-4000-8000-000000000822",
summary: "开始第一份长期工作",
dateLabel: "2021 年 7 月",
domain: "career",
isCorrection: false,
}],
actions: ["answer", "pause", "abandon", "confirm"],
pendingConsultationQuestion: "我适合什么时候换工作?",
};
const messages = [{
role: "user" as const,
text: "2021 年 7 月开始第一份长期工作。",
renderKey: "user-history-1",
}, {
role: "assistant" as const,
text: "这段事业经历很有区分度。目前已经形成一个待确认候选,下一步请说说一段重要关系经历。",
renderKey: "assistant-history-1",
}];
function controller(overrides: Partial<ConversationalRectificationController> = {}): ConversationalRectificationController {
function response(overrides: Record<string, unknown> = {}): RectificationV4ApiResponse {
return {
turn,
messages,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
getSnapshot: () => ({
turn, messages, draft: "", selectedDomain: null, correctionTarget: null, pending: false, error: "",
}),
subscribe: () => () => undefined,
synchronizeInitialTurn: () => undefined,
setDraft: () => undefined,
selectDomain: () => undefined,
beginEvidenceCorrection: () => undefined,
cancelEvidenceCorrection: () => undefined,
start: async () => turn,
resume: async () => turn,
answer: async () => turn,
regenerate: async () => turn,
pause: async () => turn,
abandon: async () => turn,
confirm: async () => turn,
...overrides,
};
}
function surfaceProps(value: ConversationalRectificationController) {
return {
controller: value,
models: [{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }] as const,
selectedModelId: "deepseek-chat",
onSelectModel: () => undefined,
};
}
test("rectification is a language-first exchange with one free-text answer path", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controller()),
));
assert.match(markup, /2021 年 7 月开始第一份长期工作/);
assert.match(markup, /这段事业经历很有区分度/);
assert.match(markup, /目前已经形成一个待确认候选/);
assert.doesNotMatch(markup, /当前候选 05:18|校正进度 ·/);
assert.doesNotMatch(markup, /D9 与 D10|当前判断/);
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /像聊天一样回答即可/);
assert.match(markup, /2018 年 6 月去了上海工作/);
assert.equal((markup.match(/<textarea/g) ?? []).length, 1);
assert.doesNotMatch(markup, /data-evidence-domain=|<select|<fieldset/);
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
});
test("a resumed legacy turn replaces repeated technical prose with actionable guidance", () => {
const legacyTurn = {
...turn,
status: "active",
candidate: { ...turn.candidate, status: "pending_validation" },
narrative: "05:30 是范围内的待验证候选。D1 保持稳定;D9 与 D24 呈现分钟敏感差异。",
actions: ["answer", "pause", "abandon"],
} satisfies ConversationalRectificationTurn;
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controller({ turn: legacyTurn })),
));
assert.match(markup, /2021 年 7 月开始第一份长期工作/);
assert.match(markup, /下一步请说说一段重要关系经历/);
assert.doesNotMatch(markup, /D1 保持稳定/);
});
test("an incomplete concrete event gets a targeted clarification instead of the generic question", () => {
const incompleteTurn = {
...turn,
status: "active",
candidate: { ...turn.candidate, status: "pending_validation" },
evidenceRecap: [{
...turn.evidenceRecap[0]!,
summary: "离开家去北京开始工作",
dateLabel: "日期待补充",
domain: "relocation",
}],
actions: ["answer", "pause", "abandon"],
} satisfies ConversationalRectificationTurn;
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controller({
turn: incompleteTurn,
messages: [{
role: "assistant",
text: "你提到“离开家去北京开始工作”,它大致是什么年月?",
renderKey: "assistant-clarification",
}],
})),
));
assert.match(markup, /你提到“离开家去北京开始工作”/);
assert.match(markup, /大致是什么年月/);
assert.doesNotMatch(markup, /接下来请说一件/);
});
test("an uninitialized surface shows progress without a second start card", () => {
const emptyController = controller({
turn: null,
pending: true,
getSnapshot: () => ({
turn: null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: true,
error: "",
}),
});
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(emptyController),
));
assert.match(markup, /正在建立校正记录/);
assert.doesNotMatch(markup, /系统会先说明候选边界|开始生时校正<\/button>/);
});
test("evidence history stays out of the chat surface while confirmation remains explicit", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controller()),
));
assert.doesNotMatch(markup, /已记录的真实经历|更正这条经历:开始第一份长期工作/);
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
assert.doesNotMatch(markup, /当前候选 05:18|待确认,尚未验证/);
assert.match(markup, /确认采用 05:18(尚未验证)/);
assert.match(markup, /aria-label="确认将 05:18 设为当前排盘时间;当前分钟尚未验证"/);
assert.doesNotMatch(markup, /已记录 1 条经历|候选只用于继续验证|这一步不会自动采用候选/);
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
});
test("terminal workflow states do not append synthetic assistant bubbles", () => {
for (const state of [
{ status: "paused", candidateStatus: "pending_validation" },
{ status: "abandoned", candidateStatus: "pending_validation" },
{ status: "completed", candidateStatus: "confirmed" },
] as const) {
const terminalTurn = {
...turn,
status: state.status,
candidate: { ...turn.candidate, status: state.candidateStatus },
actions: [],
} satisfies ConversationalRectificationTurn;
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controller({ turn: terminalTurn })),
));
assert.doesNotMatch(markup, /校正已暂停,输入与现有证据都已保留/);
assert.doesNotMatch(markup, /本次校正已放弃,候选时间没有应用/);
assert.doesNotMatch(markup, /候选时间已经过你的明确确认|候选范围已保存/);
}
});
test("an active correction target remains cancellable without rendering evidence history", () => {
const revisedTurn = {
...turn,
evidenceRecap: [{ ...turn.evidenceRecap[0]!, isCorrection: true }],
} satisfies ConversationalRectificationTurn;
const correctionTarget = revisedTurn.evidenceRecap[0]!;
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controller({
turn: revisedTurn,
draft: "更正:其实是 2020 年 11 月离职",
correctionTarget,
getSnapshot: () => ({
turn: revisedTurn,
draft: "更正:其实是 2020 年 11 月离职",
selectedDomain: null,
correctionTarget,
pending: false,
error: "",
}),
})),
));
assert.match(markup, /正在更正/);
assert.match(markup, /开始第一份长期工作/);
assert.match(markup, /取消更正/);
assert.doesNotMatch(markup, /(已修订)|已记录的真实经历/);
});
test("v4 markup and responsive CSS expose accessibility and uncertainty contracts", () => {
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const component = readFileSync(
new URL("../src/components/rectification-v4-panel.tsx", import.meta.url),
"utf8",
);
const wrapper = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
assert.match(component, /<section className="rectification-v4-panel" aria-busy=\{processing \|\| controller\.pending\}>/);
assert.match(component, /id="rectification-v4-answer"/);
assert.match(component, /disabled=\{controller\.pending\}/);
assert.match(component, /aria-label="提交这段经历"/);
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(component, /候选范围,不是已确认的出生分钟/);
assert.match(component, /不会把峰值分钟当作真实出生时间/);
assert.match(component, /保存这个范围/);
assert.match(component, /protocol: "rectification-evidence-v4"[\s\S]*?caseId: caseValue\.id,[\s\S]*?caseVersion: caseValue\.version,[\s\S]*?acceptedRange: accepted/);
assert.match(wrapper, /<RectificationV4Panel[\s\S]*?onPendingChange=\{props\.onPendingChange\}/);
assert.match(css, /\.rectification-v4-panel \{[^}]*width: min\(860px, 100%\)[^}]*overflow-y: auto/);
assert.match(css, /\.rectification-v4-composer textarea \{[^}]*min-height: 112px/);
assert.match(css, /@media\s*\(max-width:\s*680px\)[\s\S]*?\.rectification-v4-ranges, \.rectification-v4-evidence-grid \{ grid-template-columns: 1fr; \}/);
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
});
test("the v4 panel rehydrates the active case and event revisions instead of session transcript state", () => {
const wrapper = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const client = readFileSync(new URL("../src/lib/rectification-v4/client.ts", import.meta.url), "utf8");
const v4Export = wrapper.slice(wrapper.indexOf("export function ConversationalBirthTimeRectification"));
assert.match(v4Export, /return \([\s\S]*?<RectificationV4Panel/);
assert.doesNotMatch(v4Export, /initialTurn|initialMessages|useConversationalRectification/);
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
assert.match(hook, /await loadRectificationV4\(existingHandoff\.caseId\)/);
assert.match(hook, /await createRectificationV4\(\)/);
assert.match(client, /rectificationV4ApiResponseSchema/);
});
type CdpResponse = Readonly<{
id?: number;
result?: unknown;
error?: Readonly<{ message?: string }>;
}>;
const CDP_CONNECT_TIMEOUT_MS = 3_000;
const CDP_COMMAND_TIMEOUT_MS = 3_000;
const EXTERNAL_PROBE_TIMEOUT_MS = 1_000;
async function withHardDeadline<T>(
operation: Promise<T>,
timeoutMs: number,
label: string,
onTimeout?: () => void,
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
try {
onTimeout?.();
} catch {
// Timeout still rejects even if the best-effort cancellation hook itself fails.
}
reject(new Error(`Timed out waiting for ${label}`));
}, timeoutMs);
});
try {
return await Promise.race([operation, deadline]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
class CdpSession {
private nextId = 0;
private readonly pending = new Map<number, {
resolve(value: unknown): void;
reject(error: Error): void;
timeout: ReturnType<typeof setTimeout>;
}>();
private closedError: Error | null = null;
private constructor(private readonly socket: WebSocket) {
socket.addEventListener("message", (event) => {
let message: CdpResponse;
try {
message = JSON.parse(String(event.data)) as CdpResponse;
} catch {
this.rejectPending(new Error("Chromium CDP returned malformed JSON"));
return;
}
if (message.id === undefined) return;
const request = this.pending.get(message.id);
if (!request) return;
this.pending.delete(message.id);
clearTimeout(request.timeout);
if (message.error) request.reject(new Error(message.error.message ?? "CDP command failed"));
else request.resolve(message.result);
});
socket.addEventListener("close", () => {
this.rejectPending(new Error("Chromium CDP socket closed"));
});
socket.addEventListener("error", () => {
this.rejectPending(new Error("Chromium CDP socket failed"));
});
}
static async connect(url: string): Promise<CdpSession> {
const socket = new WebSocket(url);
await withHardDeadline(new Promise<void>((resolveConnection, rejectConnection) => {
const connected = () => {
cleanup();
resolveConnection();
};
const failed = () => {
cleanup();
rejectConnection(new Error("Unable to connect to Chromium CDP"));
};
const closed = () => {
cleanup();
rejectConnection(new Error("Chromium CDP closed before connecting"));
};
const cleanup = () => {
socket.removeEventListener("open", connected);
socket.removeEventListener("error", failed);
socket.removeEventListener("close", closed);
};
socket.addEventListener("open", connected, { once: true });
socket.addEventListener("error", failed, { once: true });
socket.addEventListener("close", closed, { once: true });
}), CDP_CONNECT_TIMEOUT_MS, "Chromium CDP connection", () => socket.close());
return new CdpSession(socket);
}
async send(method: string, params: unknown = {}): Promise<unknown> {
if (this.closedError) throw this.closedError;
if (this.socket.readyState !== WebSocket.OPEN) {
throw new Error("Chromium CDP socket is not open");
}
const id = ++this.nextId;
const response = new Promise<unknown>((resolveResponse, rejectResponse) => {
const timeout = setTimeout(() => {
if (!this.pending.delete(id)) return;
rejectResponse(new Error(`Timed out waiting for CDP command ${method}`));
}, CDP_COMMAND_TIMEOUT_MS);
this.pending.set(id, { resolve: resolveResponse, reject: rejectResponse, timeout });
});
try {
this.socket.send(JSON.stringify({ id, method, params }));
} catch (error) {
const request = this.pending.get(id);
if (request) {
this.pending.delete(id);
clearTimeout(request.timeout);
request.reject(error instanceof Error ? error : new Error(String(error)));
}
}
return response;
}
async evaluate<T>(expression: string): Promise<T> {
const response = await this.send("Runtime.evaluate", {
expression,
awaitPromise: true,
returnByValue: true,
}) as {
result?: { value?: T };
exceptionDetails?: { text?: string };
};
if (response.exceptionDetails) {
throw new Error(response.exceptionDetails.text ?? `Browser evaluation failed: ${expression}`);
}
return response.result?.value as T;
}
close() {
this.rejectPending(new Error("Chromium CDP session closed"));
if (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN) {
this.socket.close();
}
}
private rejectPending(error: Error) {
this.closedError ??= error;
for (const request of this.pending.values()) {
clearTimeout(request.timeout);
request.reject(error);
}
this.pending.clear();
}
}
function chromiumExecutable(): string {
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
const systemCandidates = [
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
const cacheRoots = [
join(homedir(), "Library/Caches/ms-playwright"),
join(homedir(), ".cache/ms-playwright"),
];
for (const cacheRoot of cacheRoots) {
if (!existsSync(cacheRoot)) continue;
const headlessVersions = readdirSync(cacheRoot)
.filter((entry) => entry.startsWith("chromium_headless_shell-"))
.sort()
.reverse();
for (const version of headlessVersions) {
const candidates = [
join(cacheRoot, version, "chrome-headless-shell-mac-arm64/chrome-headless-shell"),
join(cacheRoot, version, "chrome-headless-shell-mac-x64/chrome-headless-shell"),
join(cacheRoot, version, "chrome-headless-shell-linux64/chrome-headless-shell"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
}
const versions = readdirSync(cacheRoot)
.filter((entry) => entry.startsWith("chromium-"))
.sort()
.reverse();
for (const version of versions) {
const candidates = [
join(cacheRoot, version, "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
join(cacheRoot, version, "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
join(cacheRoot, version, "chrome-linux/chrome"),
join(cacheRoot, version, "chrome-linux64/chrome"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
}
}
for (const candidate of systemCandidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error("A Playwright headless Chromium is required for the real DOM contract; install it without pointing CHROME_PATH at the user's desktop Chrome.");
}
async function waitFor<T>(probe: () => Promise<T | null | false>, label: string): Promise<T> {
const deadline = Date.now() + 8_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const remaining = deadline - Date.now();
const value = await withHardDeadline(
Promise.resolve().then(probe),
Math.max(1, Math.min(EXTERNAL_PROBE_TIMEOUT_MS, remaining)),
`${label} probe`,
);
if (value !== null && value !== false) return value;
} catch (error) {
lastError = error;
}
const remaining = deadline - Date.now();
if (remaining > 0) {
await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(30, remaining)));
}
}
throw new Error(`Timed out waiting for ${label}${lastError ? `: ${String(lastError)}` : ""}`);
}
async function fetchJsonWithDeadline<T>(url: string): Promise<T> {
const abort = new AbortController();
try {
const response = await withHardDeadline(
fetch(url, { signal: abort.signal }),
EXTERNAL_PROBE_TIMEOUT_MS,
`fetch ${url}`,
() => abort.abort(),
);
if (!response.ok) throw new Error(`Chromium target list returned HTTP ${response.status}`);
return await withHardDeadline(
response.json() as Promise<T>,
EXTERNAL_PROBE_TIMEOUT_MS,
`JSON body from ${url}`,
() => abort.abort(),
);
} finally {
abort.abort();
}
}
async function terminateChildProcess(browser: ChildProcess): Promise<void> {
try {
const alreadyExited = browser.exitCode !== null || browser.signalCode !== null;
if (!alreadyExited) {
const closed = new Promise<void>((resolveClosed) => {
const done = () => {
browser.removeListener("close", done);
browser.removeListener("error", done);
resolveClosed();
};
browser.once("close", done);
browser.once("error", done);
});
browser.kill("SIGKILL");
await withHardDeadline(closed, 3_000, "Chromium process exit");
}
} finally {
browser.stdout?.destroy();
browser.stderr?.destroy();
}
}
async function launchFixture(htmlPath: string, userDataDirectory: string): Promise<{
browser: ChildProcess;
cdp: CdpSession;
}> {
const executable = chromiumExecutable();
const browser = spawn(executable, [
executable.includes("chrome-headless-shell") ? "--headless" : "--headless=new",
"--disable-background-networking",
"--disable-component-update",
"--disable-default-apps",
"--disable-extensions",
"--disable-features=Translate",
"--disable-gpu",
"--disable-sync",
"--no-first-run",
"--remote-debugging-port=0",
`--user-data-dir=${userDataDirectory}`,
pathToFileURL(htmlPath).href,
], { stdio: ["ignore", "pipe", "pipe"] });
let launchError: Error | null = null;
let cdp: CdpSession | null = null;
browser.on("error", (error) => {
launchError = error;
});
browser.stdout?.resume();
browser.stderr?.resume();
try {
const activePort = join(userDataDirectory, "DevToolsActivePort");
const port = await waitFor(async () => {
if (launchError) throw launchError;
if (browser.exitCode !== null || browser.signalCode !== null) {
throw new Error(
`Chromium exited before CDP was ready (${browser.exitCode ?? browser.signalCode})`,
);
}
if (!existsSync(activePort)) return null;
const parsed = Number(readFileSync(activePort, "utf8").split("\n")[0]);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
throw new Error("Chromium wrote an invalid DevTools port");
}
return parsed;
}, "Chromium DevTools port");
const target = await waitFor(async () => {
const targets = await fetchJsonWithDeadline<Array<{
type?: string;
webSocketDebuggerUrl?: string;
}>>(`http://127.0.0.1:${port}/json/list`);
return targets.find((candidate) => candidate.type === "page")?.webSocketDebuggerUrl ?? null;
}, "Chromium page target");
cdp = await CdpSession.connect(target);
return { browser, cdp };
} catch (error) {
cdp?.close();
let cleanupError: unknown;
try {
await terminateChildProcess(browser);
} catch (caught) {
cleanupError = caught;
} finally {
rmSync(userDataDirectory, { force: true, recursive: true });
}
if (cleanupError) {
throw new AggregateError([error, cleanupError], "Chromium fixture launch and cleanup failed");
}
throw error;
}
}
test("Chromium harness keeps the browser sandbox and bounds every external wait", () => {
const source = readFileSync(fileURLToPath(import.meta.url), "utf8");
const unsafeSandboxFlag = ["--no", "sandbox"].join("-");
assert.equal(source.includes(`"${unsafeSandboxFlag}"`), false);
assert.match(source, /withHardDeadline/);
assert.match(source, /rejectPending/);
assert.match(source, /terminateChildProcess/);
assert.match(source, /fetchJsonWithDeadline/);
});
test("real Chromium at 390px verifies the v4 range surface, keyboard focus, and no horizontal overflow", {
timeout: 30_000,
}, async () => {
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
const directory = mkdtempSync(join(tmpdir(), "rectification-v4-browser-"));
const entryPath = join(directory, "fixture.tsx");
const bundlePath = join(directory, "fixture.js");
const htmlPath = join(directory, "fixture.html");
const userDataDirectory = join(directory, "chrome-profile");
const componentPath = join(frontendRoot, "src/components/rectification-v4-panel.tsx");
const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
.replace(/^@import[^;]+;\s*/gm, "");
const fixture = `
import React from "react";
import { createRoot } from "react-dom/client";
import { RectificationV4Panel } from ${JSON.stringify(componentPath)};
const eventId = "00000000-0000-4000-8000-000000000822";
const now = "2026-07-27T00:00:00.000Z";
const data = {
case: {
id: "00000000-0000-4000-8000-000000000821",
userId: "00000000-0000-4000-8000-000000000823",
protocol: "rectification-evidence-v4",
version: 4,
status: "range_ready",
phase: "complete",
calculationSpec: {
version: "rectification-calculation-spec-v4",
birthDate: "1990-01-01",
candidateRange: { start: "05:00", end: "06:00" },
latitude: 25.03,
longitude: 121.56,
timezoneOffsetHours: 8,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
},
calculationSpecHash: "a".repeat(64),
evidenceSetHash: "b".repeat(64),
currentQuestion: {
id: "00000000-0000-4000-8000-000000000824",
domain: "career",
targetEventId: eventId,
prompt: "如果要修订这段经历,请直接写出正确年月和发生了什么。",
recallCost: "low",
reason: "核对日期敏感性",
},
latestSnapshot: {
id: "00000000-0000-4000-8000-000000000825",
caseId: "00000000-0000-4000-8000-000000000821",
caseVersion: 4,
evidenceSetHash: "b".repeat(64),
calculationSpecHash: "a".repeat(64),
algorithmVersion: "rectification-v4-range-scoring-1",
candidates: [{
time: "05:18",
score: 9.5,
supportingEventIds: [eventId],
conflictingEventIds: [],
}],
clusters: [{
rank: 1,
startTime: "05:16",
endTime: "05:20",
representativeTime: "05:18",
widthMinutes: 5,
peakScore: 9.5,
scoreMass: 9.5,
}],
robustness: {
neighborSupportMinutes: 5,
leaveOneOutRetentionRate: 0.8,
dateSensitivityRetentionRate: 0.9,
calculationSpecHashMatched: true,
},
canConfirmExactMinute: false,
canAcceptRange: true,
gateReasons: [],
createdAt: now,
},
acceptedRange: null,
createdAt: now,
updatedAt: now,
case: {
id,
userId: "00000000-0000-4000-8000-000000000900",
protocol: "rectification-evidence-v4",
version: 2,
status: "awaiting_answer",
phase: "collecting_evidence",
calculationSpec: {
version: "rectification-calculation-spec-v4",
birthDate: "1997-08-08",
candidateRange: { start: "04:50", end: "05:10" },
latitude: 36.419,
longitude: 114.213,
timezoneOffsetHours: 8,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
},
job: null,
events: [{
id: "00000000-0000-4000-8000-000000000826",
eventId,
revision: 2,
domain: "career",
eventKind: "career_change",
summary: "2021 年开始第一份长期工作",
rawText: "2021 年 7 月开始第一份长期工作",
dateRange: { start: "2021-07-01", end: "2021-07-31", precision: "month", label: "2021-07" },
scoreability: "scoreable",
supersedesRevisionId: null,
createdAt: now,
}],
};
calculationSpecHash: "a".repeat(64),
evidenceSetHash: "b".repeat(64),
currentQuestion: {
id: questionId,
domain: "relocation",
targetEventId: null,
prompt: "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?",
recallCost: "low",
reason: "根据对话选择下一条高信息量追问。",
},
latestSnapshot: null,
acceptedRange: null,
createdAt: now,
updatedAt: now,
...overrides,
},
job: null,
events: [],
turns: [{
id: "00000000-0000-4000-8000-000000000903",
caseId: id,
caseVersion: 1,
questionId: "00000000-0000-4000-8000-000000000904",
questionDomain: "other",
questionTargetEventId: null,
question: "请从你最确定的一段人生经历开始说。",
answer: "2015年毕业后复读,2016年再次毕业。",
modelId: "gpt-5.5",
actionId: "00000000-0000-4000-8000-000000000905",
createdAt: now,
}],
};
}
globalThis.fetch = async (input, init) => {
const path = String(input);
if (path === "/api/rectification/v4/handoff" && !init?.method) return new Response(null, { status: 204 });
if (path === "/api/rectification/v4/cases" && init?.method === "POST") {
return Response.json(data);
}
throw new Error("unexpected fetch " + path);
};
createRoot(document.getElementById("root")).render(<RectificationV4Panel />);
globalThis.__rectificationReady = true;
`;
let browser: ChildProcess | null = null;
let cdp: CdpSession | null = null;
try {
writeFileSync(entryPath, fixture);
await withHardDeadline(build({
absWorkingDir: frontendRoot,
bundle: true,
define: { "process.env.NODE_ENV": '"test"' },
entryPoints: [entryPath],
format: "iife",
jsx: "automatic",
logLevel: "silent",
nodePaths: [join(frontendRoot, "node_modules")],
outfile: bundlePath,
platform: "browser",
}), 10_000, "browser fixture bundle");
writeFileSync(htmlPath, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}\nhtml,body{height:auto;overflow:auto}body{padding:12px}#root{width:100%;min-width:0}</style></head><body><main id="root"></main><script src="${pathToFileURL(bundlePath).href}"></script></body></html>`);
test("v4 rectification reuses the ordinary session message list, composer, and model selector", () => {
const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8");
const wrapper = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory));
await cdp.send("Runtime.enable");
await cdp.send("Page.bringToFront");
await cdp.send("Emulation.setDeviceMetricsOverride", {
width: 390,
height: 844,
deviceScaleFactor: 1,
mobile: true,
});
await waitFor(
() => cdp?.evaluate<boolean>("globalThis.__rectificationReady === true") ?? Promise.resolve(false),
"React fixture readiness",
);
await waitFor(
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('05:16–05:20')
&& document.body.textContent.includes('保存这个范围')
&& document.getElementById('rectification-v4-answer') !== null`) ?? Promise.resolve(false),
"v4 range surface",
);
assert.match(component, /className="message-list"/);
assert.match(component, /<ChatMessageRow/);
assert.match(component, /className="composer-wrap"/);
assert.match(component, /className="composer"/);
assert.match(component, /<Textarea/);
assert.match(component, /<ModelSelector/);
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
assert.match(wrapper, /<RectificationV4Panel \{\.\.\.props\} \/>/);
assert.match(page, /rectificationSurfaceOpen \|\| activeSession\?\.messages\.length/);
assert.doesNotMatch(page, /is-rectification/);
assert.match(css, /\.rectification-chat \{ display: contents; \}/);
});
const layout = await cdp.evaluate<{
viewport: number;
scrollWidth: number;
panelWidth: number;
rangeColumns: string;
exactMinuteClaimVisible: boolean;
}>(`(() => {
const panel = document.querySelector('.rectification-v4-panel');
return {
viewport: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
panelWidth: panel.getBoundingClientRect().width,
rangeColumns: getComputedStyle(document.querySelector('.rectification-v4-ranges')).gridTemplateColumns,
exactMinuteClaimVisible: document.body.textContent.includes('已确认出生分钟'),
};
})()`);
assert.equal(layout.viewport, 390);
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
assert.ok(layout.panelWidth <= 366, `panel overflowed padded viewport: ${layout.panelWidth}px`);
assert.equal(layout.rangeColumns.trim().split(/\s+/).length, 1);
assert.equal(layout.exactMinuteClaimVisible, false);
test("turn history and the context-aware next question render as one chat timeline", () => {
const messages = rectificationV4ChatMessages(response(), false);
await cdp.evaluate("document.getElementById('rectification-v4-answer').focus()");
assert.equal(
await cdp.evaluate<boolean>("document.activeElement?.id === 'rectification-v4-answer'"),
true,
);
} finally {
try {
cdp?.close();
if (browser) await terminateChildProcess(browser);
} finally {
rmSync(directory, { force: true, recursive: true });
}
assert.deepEqual(messages.map(({ role, text }) => [role, text]), [
["assistant", "请从你最确定的一段人生经历开始说。"],
["user", "2015年毕业后复读,2016年再次毕业。"],
["assistant", "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?"],
]);
});
test("processing is an ordinary assistant thinking message after the saved answer", () => {
const messages = rectificationV4ChatMessages(response({ currentQuestion: null, status: "processing" }), true);
assert.equal(messages.at(-1)?.role, "assistant");
assert.equal(messages.at(-1)?.state, "thinking");
});
test("range messages never claim an exact confirmed birth minute", () => {
const rangeReady = response({
status: "range_ready",
phase: "complete",
currentQuestion: null,
latestSnapshot: {
clusters: [{ startTime: "05:26", endTime: "05:30" }],
canAcceptRange: true,
},
});
const candidateText = rectificationV4ChatMessages(rangeReady, false).at(-1)?.text ?? "";
assert.match(candidateText, /05:26–05:30/);
assert.match(candidateText, /候选范围/);
assert.match(candidateText, /不是已确认的出生分钟/);
const acceptedText = rectificationV4ChatMessages(response({
status: "range_ready",
phase: "complete",
currentQuestion: null,
acceptedRange: { start: "05:26", end: "05:30" },
}), false).at(-1)?.text ?? "";
assert.match(acceptedText, /原出生时间没有被自动改写/);
});
test("the retired questionnaire panel and fixed-domain controls are absent", () => {
const component = readFileSync(new URL("../src/components/rectification-v4-panel.tsx", import.meta.url), "utf8");
const wrapper = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
for (const retired of [
"生时校正 · 事件证据法",
"rectification-v4-evidence-grid",
"支持这个范围的经历",
"ConversationalRectificationSurface",
]) {
assert.doesNotMatch(component, new RegExp(retired));
assert.doesNotMatch(wrapper, new RegExp(retired));
}
assert.doesNotMatch(css, /Birth-time rectification V4|\.rectification-v4-panel/);
});
@@ -68,15 +68,15 @@ test("visible rectification copy does not replace a tailored Agent answer with a
assert.match(narrative, /事业转折/);
});
test("the rectification chat renders the controller's alternating message history", () => {
test("the v4 rectification chat renders persisted turns as alternating message history", () => {
const source = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
new URL("../src/components/rectification-v4-panel.tsx", import.meta.url),
"utf8",
);
assert.match(source, /controller\.messages/);
assert.match(source, /controller\.messages[\s\S]*?\.map\(\(message\)/);
const messageHistoryIndex = source.indexOf("controller.messages");
assert.doesNotMatch(source, /rectification-progress-details|turn\.evidenceRecap\.map/);
assert.ok(messageHistoryIndex >= 0);
assert.match(source, /for \(const turn of data\.turns\)/);
assert.match(source, /role: "assistant"[\s\S]*?text: turn\.question/);
assert.match(source, /role: "user"[\s\S]*?text: turn\.answer/);
assert.match(source, /messages\.map\(\(message\) => <ChatMessageRow/);
assert.doesNotMatch(source, /rectification-progress-details|evidenceRecap\.map/);
});
@@ -1,11 +1,5 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ConversationalRectificationSurface } from "../src/components/conversational-birth-time-rectification.tsx";
import { createConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts";
import type { ConversationalRectificationController } from "../src/hooks/use-conversational-rectification.ts";
import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts";
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
import {
@@ -13,8 +7,6 @@ import {
createRectificationQuestionHandoffCoordinator,
} from "../src/lib/rectification-question-handoff.ts";
Object.assign(globalThis, { React });
const pendingQuestion = "未来半年是否适合换工作?";
function confirmedTurn(): ConversationalRectificationTurn {
@@ -43,108 +35,6 @@ function confirmedTurn(): ConversationalRectificationTurn {
};
}
function controllerFor(turn: ConversationalRectificationTurn): ConversationalRectificationController {
const snapshot = {
turn,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: false,
error: "",
} as const;
return {
...snapshot,
getSnapshot: () => snapshot,
subscribe: () => () => undefined,
synchronizeInitialTurn: () => undefined,
setDraft: () => undefined,
selectDomain: () => undefined,
beginEvidenceCorrection: () => undefined,
cancelEvidenceCorrection: () => undefined,
start: async () => turn,
resume: async () => turn,
answer: async () => turn,
regenerate: async () => turn,
pause: async () => turn,
abandon: async () => turn,
confirm: async () => turn,
};
}
function surfaceProps(
controller: ConversationalRectificationController,
overrides: Record<string, unknown> = {},
) {
return {
controller,
models: [{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }] as const,
selectedModelId: "deepseek-chat",
onSelectModel: () => undefined,
...overrides,
};
}
test("confirmed surface requires an explicit click before continuing the original question", () => {
let continuationCalls = 0;
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controllerFor(confirmedTurn()), {
onContinueOriginalQuestion: () => { continuationCalls += 1; },
}),
));
assert.doesNotMatch(markup, /原问题:未来半年是否适合换工作?/);
assert.match(markup, />返回原问题</);
assert.equal(continuationCalls, 0);
});
test("continuation action is visibly locked while ordinary consultation is pending", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controllerFor(confirmedTurn()), {
continuationPending: true,
onContinueOriginalQuestion: () => undefined,
}),
));
assert.match(markup, /<button[^>]+disabled=""[^>]*>正在继续回答…<\/button>/);
});
test("choosing rectify-first captures the visible question and passes it only to v3 start", async () => {
const coordinator = createRectificationQuestionHandoffCoordinator<"career" | "general">();
const commands: unknown[] = [];
const handoff = coordinator.capture({
question: ` ${pendingQuestion} `,
sessionId: "session-original",
theme: "career",
});
const firstTurn: ConversationalRectificationTurn = {
...confirmedTurn(),
status: "active" as const,
turnVersion: 0,
candidate: {
...confirmedTurn().candidate,
status: "pending_validation" as const,
},
actions: ["answer", "pause", "abandon"],
};
const rectification = createConversationalRectificationController({
async send(command) {
commands.push(command);
return firstTurn;
},
createActionId: () => "00000000-0000-4000-8000-000000001011",
});
await rectification.start(handoff.question);
assert.deepEqual(commands, [{
type: "start",
actionId: "00000000-0000-4000-8000-000000001011",
pendingConsultationQuestion: pendingQuestion,
}]);
});
test("pause, refresh, and a new device recover the durable question without losing local session and theme", () => {
const paused = {
...confirmedTurn(),
@@ -420,21 +310,3 @@ test("refresh restores only the server-owned pending question and replacement wi
assert.equal(refreshed?.question, "新问题");
assert.equal(refreshed?.turn.pendingConsultationQuestion, "新问题");
});
test("confirmed surface never revives an old local question after durable consumption", () => {
const consumed = {
...confirmedTurn(),
pendingConsultationQuestion: null,
actions: [],
};
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(controllerFor(consumed), {
pendingConsultationQuestion: "浏览器里的旧问题",
onContinueOriginalQuestion: () => undefined,
}),
));
assert.doesNotMatch(markup, /使用新确认时间继续回答原问题/);
assert.doesNotMatch(markup, /浏览器里的旧问题/);
});
+17 -15
View File
@@ -6,7 +6,7 @@ import { dateRangeFromDeclared, sampledDates } from "../src/lib/rectification-v4
import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts";
import { appendEventRevision, latestEventRevisions } from "../src/lib/rectification-v4/evidence-ledger.ts";
import { extractV4EventRevisions } from "../src/lib/rectification-v4/extraction.ts";
import { planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts";
import { openingQuestion, planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts";
const now = new Date("2026-07-26T00:00:00.000Z");
@@ -73,42 +73,44 @@ test("decision gate can accept a stable range but never an exact minute", () =>
assert.equal(result.canConfirmExactMinute, false);
});
test("question planner asks one deterministic low-recall question at a time", () => {
const question = planNextQuestion({
askedDomains: ["education"], coveredDomains: ["education"],
candidateSplitByDomain: { relocation: 0.8, relationship: 0.2 }, id: randomUUID(),
});
assert.equal(question.domain, "relocation");
test("opening question invites free narration without a fixed domain", () => {
const question = openingQuestion({ start: "04:50", end: "05:10" }, randomUUID());
assert.equal(question.domain, "other");
assert.equal(question.targetEventId, null);
assert.equal(question.prompt.includes("搬家"), true);
assert.match(question.prompt, /04:50–05:10/);
assert.match(question.prompt, /不是已确认的出生分钟/);
assert.match(question.prompt, /不需要按固定领域回答/);
assert.doesNotMatch(question.prompt, /毕业|搬家|恋爱|工作|财务|健康/);
});
test("question planner refines imprecise scoreable events after domain coverage", () => {
test("fallback planner refines an imprecise event, then returns to open narration", () => {
const eventId = randomUUID();
const event = appendEventRevision([], {
eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业",
rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"),
}, { id: randomUUID(), now });
const question = planNextQuestion({
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
events: [event],
attemptedRefinementEventIds: [],
latestAnswer: "2016年高中毕业",
id: randomUUID(),
});
assert.equal(question.targetEventId, eventId);
assert.equal(question.prompt.includes("高中毕业"), true);
assert.equal(question.domain, "education");
assert.match(question.prompt, /高中毕业/);
assert.match(question.prompt, /月份或日期/);
const fallback = planNextQuestion({
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
events: [event],
attemptedRefinementEventIds: [eventId],
latestAnswer: "2016年高中毕业",
id: randomUUID(),
});
assert.equal(fallback.targetEventId, null);
assert.equal(fallback.domain, "other");
assert.equal(fallback.prompt.includes("暂停"), true);
assert.match(fallback.prompt, /继续讲另一件/);
assert.doesNotMatch(fallback.prompt, /搬家|恋爱|事业|财务|健康/);
});
test("targeted date answer appends a revision without duplicating the scoreable event", () => {
@@ -3,6 +3,7 @@ import test from "node:test";
import { readFileSync } from "node:fs";
const sql = readFileSync(new URL("../supabase/migrations/20260726020000_birth_time_rectification_v4.sql", import.meta.url), "utf8");
const conversationalSql = readFileSync(new URL("../supabase/migrations/20260727010000_rectification_v4_conversational_turns.sql", import.meta.url), "utf8");
test("v4 migration creates canonical append-only storage and leased jobs", () => {
for (const table of [
@@ -19,6 +20,13 @@ test("v4 migration creates canonical append-only storage and leased jobs", () =>
assert.match(sql, /domain not in \('family', 'other'\) or scoreability = 'context_only'/);
});
test("v4 conversational migration persists the selected model with each turn", () => {
assert.match(conversationalSql, /add column model_id text/i);
assert.match(conversationalSql, /p_model_id text/i);
assert.match(conversationalSql, /question, answer, model_id, action_id/i);
assert.match(conversationalSql, /grant execute on function public\.submit_birth_time_rectification_v4_answer/i);
});
test("v4 handoff SQL enforces range acceptance, leases, idempotent settlement, and no profile time write", () => {
for (const functionName of [
"attach_birth_time_rectification_v4_question",
@@ -50,6 +50,14 @@ test("fixture replay returns ranges only and never mutates the profile birth min
const worker = createRectificationV4Worker({
store,
now,
questionAuthor: async () => ({
id: randomUUID(),
domain: "other",
targetEventId: null,
prompt: "请继续讲另一件时间比较清楚的人生变化。",
recallCost: "low",
reason: "Replay keeps narration open instead of depending on a fixed domain order.",
}),
engine: {
async score({ calculationSpec, events }) {
const ids = events.map((event) => event.eventId);
@@ -98,7 +106,6 @@ test("fixture replay returns ranges only and never mutates the profile birth min
loaded.case.version,
"2020年5月开始恋爱,2022年3月分手",
);
const snapshot = loaded.case.latestSnapshot;
assert.ok(snapshot);
assert.equal(snapshot.canConfirmExactMinute, false);
+44 -57
View File
@@ -3,7 +3,6 @@ import test from "node:test";
import { randomUUID } from "node:crypto";
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
@@ -21,7 +20,6 @@ const spec: CalculationSpec = {
};
test("same calculation spec resumes the unfinished case", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
@@ -84,13 +82,16 @@ test("answer is durably queued and poll remains read only", async () => {
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.equal(created.case.status, "awaiting_answer");
assert.equal(created.case.currentQuestion?.domain, "education");
assert.equal(created.case.currentQuestion?.domain, "other");
assert.match(created.case.currentQuestion?.prompt ?? "", /不需要按固定领域回答/);
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
modelId: "gpt-5.5",
});
assert.equal(queued?.case.status, "processing");
assert.equal(queued?.job?.status, "pending");
assert.equal(queued?.turns.at(-1)?.modelId, "gpt-5.5");
const before = JSON.stringify([...store.jobs.values()]);
const polled = await service.loadCase(userId, created.case.id);
assert.equal(polled?.job, null);
@@ -115,7 +116,9 @@ test("worker extracts dated events, keeps one question and never confirms an exa
const done = await service.loadCase(userId, created.case.id);
assert.equal(done?.case.status, "awaiting_answer");
assert.equal(done?.events.length, 2);
assert.equal(done?.case.currentQuestion?.domain, "relocation");
assert.equal(done?.case.currentQuestion?.domain, "education");
assert.equal(done?.events.some((event) => event.eventId === done.case.currentQuestion?.targetEventId), true);
assert.match(done?.case.currentQuestion?.prompt ?? "", /月份或日期/);
assert.equal(done?.case.latestSnapshot, null);
assert.equal(queued?.job?.status, "pending");
});
@@ -136,81 +139,65 @@ test("completed job rejects stale case or calculation hashes", async () => {
}, fixedNow().toISOString()), /stale_job/);
});
test("worker moves from domain coverage to targeted date refinement without a null-question dead state", async () => {
test("worker gives the selected model full conversation context for the next question", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const scoreableCounts: number[] = [];
const contexts: Array<{
modelId: string | null;
turnAnswers: string[];
eventSummaries: string[];
}> = [];
const worker = createRectificationV4Worker({
store,
now: fixedNow,
engine: {
async score({ calculationSpec, events }) {
scoreableCounts.push(events.length);
const ids = events.map((event) => event.eventId);
return {
resultId: randomUUID(),
calculationSpecHash: calculationSpecHash(calculationSpec),
candidates: [
{ time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] },
],
robustness: {
neighborSupportMinutes: 3,
leaveOneOutRetentionRate: 1,
dateSensitivityRetentionRate: 0.5,
},
missingLayers: [],
};
},
engine: { async score() { throw new Error("engine must not run before enough events"); } },
questionAuthor: async (context) => {
contexts.push({
modelId: context.modelId,
turnAnswers: context.turns.map((turn) => turn.answer),
eventSummaries: context.events.map((event) => event.summary),
});
return {
id: randomUUID(),
domain: "other",
targetEventId: null,
prompt: context.turns.length === 1
? "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?"
: "你提到毕业和搬家是连续发生的。那次搬家前后,生活节奏还有什么明显变化?",
recallCost: "low",
reason: "根据完整对话选择下一条高信息量追问。",
};
},
});
let current = created;
for (const answer of [
"2016年高中毕业",
"2018年8月搬家到北京",
"2020年5月开始恋爱",
"2021年3月入职公司",
"2022年4月收入明显变化",
"2023年5月住院",
"2024年6月家庭发生变化",
]) {
for (const [answer, modelId] of [
["2015年7月高中毕业后复读一年,2016年6月再次毕业", "gpt-5.5"],
["2018年8月搬到北京,之后开始独立生活", "deepseek-chat"],
] as const) {
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: current.case.version,
answer,
modelId,
});
assert.ok(queued?.job);
assert.equal(await worker.runOnce(), true);
current = (await service.loadCase(userId, created.case.id))!;
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
}
const targetEventId = current.case.currentQuestion?.targetEventId;
assert.ok(targetEventId);
assert.equal(current.case.status, "awaiting_answer");
assert.equal(current.case.currentQuestion?.prompt.includes("更具体的日期"), true);
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: current.case.version,
answer: "2016年6月8日",
});
assert.ok(queued?.job);
assert.equal(await worker.runOnce(), true);
current = (await service.loadCase(userId, created.case.id))!;
const targetedRevisions = current.events.filter((event) => event.eventId === targetEventId);
assert.deepEqual(targetedRevisions.map((event) => event.revision), [1, 2]);
assert.equal(targetedRevisions[1]?.dateRange.precision, "day");
assert.equal(scoreableCounts.at(-1), 6);
assert.equal(contexts.length, 2);
assert.equal(contexts[0]?.modelId, "gpt-5.5");
assert.deepEqual(contexts[1]?.turnAnswers, [
"2015年7月高中毕业后复读一年,2016年6月再次毕业",
"2018年8月搬到北京,之后开始独立生活",
]);
assert.equal(contexts[1]?.modelId, "deepseek-chat");
assert.equal(contexts[1]?.eventSummaries.length, 3);
assert.match(current.case.currentQuestion?.prompt ?? "", /毕业和搬家/);
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
assert.notEqual(current.case.currentQuestion?.targetEventId, targetEventId);
});