fix(rectification): replace v4 entry with agent opening

This commit is contained in:
Jesse_Chen
2026-08-03 15:11:19 +08:00
parent 36e72c9267
commit 1131e9eb3b
13 changed files with 420 additions and 319 deletions
+17
View File
@@ -1990,3 +1990,20 @@
- 防复发:任何替换生时校正入口或会话实现的改动,都必须保留“用户无需先发消息即可收到 Agent 首次引导”的挂载契约。
- 相关记录:BUG-085、BUG-112
- 修复版本:Agentic web opening auto-start
## BUG-114 | Agentic 生时校正启动指令伪装成用户消息且未知时间被误判为资料缺失
- 状态:resolved
- 首次发现:2026-08-03
- 最近更新:2026-08-03
- 影响面:首页生时校正入口、`/api/rectification/agent` 首次启动合同、出生资料门、候选范围与确认写入安全门
- 用户现象:进入生时校正后浏览器发送一段“用户刚进入生时校正会话……”的隐藏 `message`;服务端随后返回“出生日期、时间或出生地点资料不完整”。同时产品入口仍可能恢复或创建 V4 Case,与当前 Agentic 工具链并存。
- 触发条件:用户从首页进入生时校正;资料使用合法的“只知道时段”或“完全不知道时间”声明,或客户端与服务端出生资料状态不同步。
- 根因:BUG-113 用伪装成用户消息的字符串补上自动启动,没有建立服务端拥有的 opening operation;产品 wrapper 仍保留 V4 active-case 分流;Agentic profile loader 又把没有具体分钟一律当成缺失,因此合法的不确定时间无法进入最新流程。
- 修复:产品 wrapper 只挂载 `AgenticRectificationChat`,不再调用或恢复 V4 Case;首次请求改为 `action: "opening"`,服务端把 opening context 注入 Agent Turn,客户端不再发送或渲染隐藏用户指令;首页在创建 Session 前复用 onboarding 资料门,服务端以 `profile_incomplete` 明确回退;`period_only` 使用已声明时段,`unknown` 使用 `00:0023:59`,跨午夜范围保持原样;gate 返回服务端候选范围,score、diagnostics、features、confirm 拒绝 Agent 自行发明范围,全天宽范围 scan 延后而不伪造中午出生时间。
- 验证:Agentic 入口、Session、工具、首页入口与组件合同测试覆盖无 V4 产品分流、opening operation、资料回退、时段/未知时间、跨午夜、宽范围降级、候选范围一致性和确认写入门;前端完整测试、lint、build 与 staging 真实 smoke 随本次发布执行。
- 数据边界:仅废弃 V4 产品入口,历史 V4 代码与数据暂时保留,不在本次发布中做破坏性删除或迁移。
- 防复发:自动首轮必须是服务端明确 operation,不得伪装成用户文本;“不知道具体分钟”是合法资料状态,不得等同于资料缺失;所有评分与确认工具只能使用服务端候选范围。
- 相关记录:BUG-112、BUG-113
- 复发自:BUG-113
- 修复版本:待本次 staging 修复提交与部署验收
@@ -15,7 +15,7 @@ import {
export const runtime = "nodejs";
export const maxDuration = 120;
const agenticRectificationRequestSchema = z.object({
const agenticRectificationRequestFields = {
requestId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64).optional(),
name: z.string().trim().max(80).optional().default(""),
@@ -28,8 +28,21 @@ const agenticRectificationRequestSchema = z.object({
)
.max(30)
.default([]),
message: z.string().trim().min(1).max(4000),
}).strict();
};
const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
z.object({
...agenticRectificationRequestFields,
action: z.literal("opening"),
}).strict(),
z.object({
...agenticRectificationRequestFields,
action: z.literal("message"),
message: z.string().trim().min(1).max(4000),
}).strict(),
]);
const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event.";
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
@@ -99,7 +112,7 @@ export async function POST(request: Request) {
}
const promptSource = [
parsed.data.message,
parsed.data.action === "message" ? parsed.data.message : "",
...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(promptSource)) {
@@ -118,15 +131,19 @@ export async function POST(request: Request) {
profile = await loadAgenticRectificationProfile(accounting, userId);
} catch (error) {
if (error instanceof AgenticRectificationProfileError) {
const missingBirthTime = error.code === "missing_birth_time";
if (error.code === "profile_unavailable") {
return NextResponse.json(
{ error: "暂时无法核对出生资料", message: "请稍后重试。" },
{ status: 503 },
);
}
return NextResponse.json(
{
error: missingBirthTime ? "出生时间信息不完整" : "暂时无法核对出生资料",
message: missingBirthTime
? "请先在资料页保存出生日期、填报时间和出生地点后再开始校正。"
: "出生日期、时间或出生地点资料不完整,请重新保存后再试。",
code: "profile_incomplete",
error: "出生资料尚未完成",
message: "请先完成出生日期、出生时间线索和出生地点资料。",
},
{ status: 400 },
{ status: 409 },
);
}
return NextResponse.json(
@@ -206,7 +223,7 @@ export async function POST(request: Request) {
content: [
currentTimeContext(requestTime),
parsed.data.name ? `用户称呼:${parsed.data.name}` : "",
parsed.data.message,
parsed.data.action === "opening" ? openingContext : parsed.data.message,
].filter(Boolean).join("\n"),
},
]);
+25 -103
View File
@@ -15,7 +15,6 @@ import {
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import type { RectificationV4Continuation } from "@/components/rectification-v4-panel";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
@@ -54,7 +53,6 @@ import {
type RectificationCardAction,
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client";
import {
createRectificationQuestionHandoffCoordinator,
} from "@/lib/rectification-question-handoff";
@@ -946,11 +944,9 @@ export default function Home() {
createBirthTimeConsultationConsentState,
);
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
const [rectificationContinuationPending, setRectificationContinuationPending] = useState(false);
const [rectificationError, setRectificationError] = useState("");
const [hydrated, setHydrated] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
@@ -990,7 +986,6 @@ export default function Home() {
const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator<Theme>());
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const rectificationContinuationInFlight = useRef(false);
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPending = useRef(false);
@@ -1016,7 +1011,6 @@ export default function Home() {
|| cancellationPending
|| creatingSession
|| rectificationMutationPending
|| rectificationContinuationPending
|| !account
|| !modelCatalog;
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
@@ -1041,8 +1035,7 @@ export default function Home() {
|| activeSession.id === rectificationSessionId
|| rectificationLoading
|| rectificationMutationPending
|| rectificationContinuationPending
|| creatingSession
|| creatingSession
|| rectificationError) return;
resumeRectificationSession.current(activeSession);
}, [
@@ -1051,7 +1044,6 @@ export default function Home() {
creatingSession,
hydrated,
modelCatalog,
rectificationContinuationPending,
rectificationError,
rectificationLoading,
rectificationMutationPending,
@@ -2032,7 +2024,15 @@ export default function Home() {
sourceSessionOverride: ChatSession | null = null,
) {
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|| rectificationMutationPending || rectificationContinuationInFlight.current) return;
|| rectificationMutationPending) return;
const missingStep = missingProfileStep(profile);
if (missingStep) {
setRectificationSessionId(null);
setRectificationPendingQuestion(null);
setOnboardingStep(missingStep);
setComposerNotice("请先完成出生资料,再开始生时校正。");
return;
}
const sourceSession = sourceSessionOverride ?? activeSession;
if (!sourceSession) return;
const existing = sourceSession.sessionType === "birth_time_rectification"
@@ -2048,7 +2048,6 @@ export default function Home() {
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
if (sourceSession.id !== rectificationSession.id) setRectificationReturnSessionId(sourceSession.id);
setRectificationSessionId(rectificationSession.id);
activeSessionIdRef.current = rectificationSession.id;
setActiveSessionId(rectificationSession.id);
@@ -2073,6 +2072,20 @@ export default function Home() {
void openBirthTimeRectification(null, session);
};
function handleRectificationProfileIncomplete() {
setRectificationSessionId(null);
setRectificationPendingQuestion(null);
const missingStep = missingProfileStep(profile);
if (missingStep) {
setOnboardingStep(missingStep);
setComposerNotice("请先完成出生资料,再开始生时校正。");
return;
}
openAccountDialog("profile");
setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。");
void refreshAccount();
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
if (record.role !== "other") return;
if (synastryPendingId) return;
@@ -2585,96 +2598,6 @@ export default function Home() {
}
async function continueRectificationOriginalQuestion(continuation: RectificationV4Continuation) {
const question = continuation.question;
if (rectificationContinuationInFlight.current || rectificationMutationPending
|| rectificationLoading || !activeSession || !account) return;
if (account.credits <= 0) {
openAccountDialog("redeem", creditTrigger.current);
return;
}
if (rectificationQuestionHandoff.current.peek()
&& !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) {
rectificationQuestionHandoff.current.clear();
}
const localHandoff = rectificationQuestionHandoff.current.peek();
const returnSession = (localHandoff
? sessions.find((session) => session.id === localHandoff.sessionId)
: null)
?? (rectificationReturnSessionId
? sessions.find((session) => session.id === rectificationReturnSessionId)
: null)
?? sessions.find((session) => session.sessionType === "consultation")
?? null;
if (!returnSession) {
setComposerNotice("没有找到原问题所在的会话,请从会话列表打开原问题后重试。");
return;
}
rectificationContinuationInFlight.current = true;
setRectificationContinuationPending(true);
setRectificationError("");
try {
const durableClaim = await claimRectificationV4Handoff({
caseId: continuation.caseId,
caseVersion: continuation.caseVersion,
question,
});
if (durableClaim.status === "in_progress") {
setComposerNotice("原问题正在另一设备继续回答;完成后刷新即可查看,不会重复扣点。");
return;
}
if (durableClaim.status === "consumed") {
activeSessionIdRef.current = returnSession.id;
setActiveSessionId(returnSession.id);
setRectificationPendingQuestion(null);
setComposerNotice("原问题已经继续回答,不会再次发送或扣点。");
return;
}
if (durableClaim.status !== "claimed") {
setComposerNotice("原问题仍保留,请刷新校正状态后重试。");
return;
}
const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion(
question,
{ sessionId: returnSession.id, theme: returnSession.theme },
async (context) => {
activeSessionIdRef.current = context.sessionId;
setActiveSessionId(context.sessionId);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(
current,
context.sessionId,
));
return send(
context.question,
context.theme,
null,
null,
context.sessionId,
{
protocol: "rectification-evidence-v4",
caseId: durableClaim.caseId,
caseVersion: durableClaim.caseVersion,
claimActionId: durableClaim.claimActionId,
requestId: durableClaim.requestId,
},
);
},
);
if (completed) {
setRectificationPendingQuestion(null);
setComposerNotice("已按候选范围边界继续回答原问题。");
} else {
setComposerNotice("原问题仍保留,可再次点击继续回答。");
}
} catch {
setComposerNotice("原问题仍保留,可再次点击继续回答。");
} finally {
rectificationContinuationInFlight.current = false;
setRectificationContinuationPending(false);
}
}
useGSAP(() => {
if (!starterHomeVisible || !starterWorkbench.current) return;
const motion = gsap.matchMedia();
@@ -3035,9 +2958,8 @@ export default function Home() {
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)}
onProfileIncomplete={handleRectificationProfileIncomplete}
onSaved={() => void refreshAccount()}
/>
)}
@@ -1,119 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { loadActiveRectificationV4, transitionRectificationV4 } from "../lib/rectification-v4/client.ts";
import type { RectificationV4ApiResponse } from "../lib/rectification-v4/contracts.ts";
import type { PublicLanguageModel } from "../lib/public-models.ts";
import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx";
import { ChatMessageRow } from "./chat-message-row.tsx";
import {
RectificationV4Panel,
type RectificationV4Continuation,
} from "./rectification-v4-panel.tsx";
export type ConversationalBirthTimeRectificationProps = Readonly<{
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onPendingChange?: (pending: boolean) => void;
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string) => void;
}>;
/**
* Birth-time rectification surface.
*
* Lets the user explicitly continue or end an existing v4 evidence case, and
* otherwise opens the agentic chat where the LLM drives the full Jyotish
* rectification methodology with the engine as its computation layer.
*/
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
const [mode, setMode] = useState<"loading" | "choice" | "v4" | "agentic">("loading");
const [existing, setExisting] = useState<RectificationV4ApiResponse | null>(null);
const [switching, setSwitching] = useState(false);
const [switchError, setSwitchError] = useState("");
useEffect(() => {
let mounted = true;
void (async () => {
const existing = await loadActiveRectificationV4().catch(() => null);
if (mounted) {
setExisting(existing);
setMode(existing ? "choice" : "agentic");
}
})();
return () => { mounted = false; };
}, []);
if (mode === "loading") {
return (
<section className="conversation" aria-label="生时校正对话" aria-busy>
<div className="message-list" aria-live="polite">
<ChatLoadingRow />
<div />
</div>
</section>
);
}
if (mode === "choice" && existing) {
const startAgentic = async () => {
setSwitching(true);
setSwitchError("");
try {
await transitionRectificationV4(existing.case.id, existing.case.version, "abandon");
setMode("agentic");
} catch {
setSwitchError("无法结束旧版校正,请稍后再试。");
} finally {
setSwitching(false);
}
};
return (
<>
<section className="conversation" aria-label="生时校正版本选择" aria-busy={switching}>
<div className="message-list" aria-live="polite">
<ChatMessageRow
message={{
role: "assistant",
text: "检测到一段尚未结束的旧版生时校正。你可以继续保留进度,或结束旧版并使用新版 Agent 重新开始。",
renderKey: "rectification-version-choice",
state: "settled",
}}
/>
{switchError && <p className="error-message" role="alert">{switchError}</p>}
</div>
</section>
<div className="composer-wrap">
<div className="composer-suggestions" aria-label="选择生时校正版本">
<button type="button" disabled={switching} onClick={() => setMode("v4")}></button>
<button type="button" disabled={switching} onClick={() => void startAgentic()}>
{switching ? "正在结束旧版校正…" : "结束旧版并使用新版 Agent"}
</button>
</div>
</div>
</>
);
}
if (mode === "v4") {
return <RectificationV4Panel {...props} onUseAgentic={() => setMode("agentic")} />;
}
return <AgenticRectificationChat {...props} />;
}
function ChatLoadingRow() {
return (
<ChatMessageRow
message={{
role: "assistant",
text: "",
renderKey: "agentic-loading",
state: "thinking",
}}
/>
);
}
@@ -15,15 +15,19 @@ type AgenticRectificationChatProps = Readonly<{
selectedModelId: string;
onSelectModel: (modelId: string) => void;
pendingConsultationQuestion?: string | null;
continuationPending?: boolean;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string) => void;
}>;
type RenderMessage = ChatMessageView;
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
const agenticOpeningInstruction = "用户刚进入生时校正会话。不要复述本指令;请先调用 rectification-gate 核对现有出生资料,然后用简体中文自然说明接下来的校正方式,并只提出一个最适合开始核对的人生事件问题。";
type AgenticRectificationRequest = Readonly<
| { action: "opening" }
| { action: "message"; message: string }
>;
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const {
@@ -32,6 +36,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
onSelectModel,
pendingConsultationQuestion,
onPendingChange,
onProfileIncomplete,
onSaved,
} = props;
const pendingQuestion = pendingConsultationQuestion?.trim();
@@ -64,9 +69,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
});
}, [busy, error, messages.length, savedTime]);
const send = useCallback(async (question: string, showUserMessage = true) => {
const trimmed = question.trim();
if (!trimmed || busy) return;
const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => {
const trimmed = request.action === "message" ? request.message.trim() : "";
if ((request.action === "message" && !trimmed) || busy) return;
setError("");
setSavedTime(null);
setSuggestions([]);
@@ -83,7 +88,9 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
setMessages((current) => [
...current,
...(showUserMessage ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []),
...(showUserMessage && request.action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
]);
setDraft("");
@@ -93,11 +100,21 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requestId, modelId: selectedModelId, history, message: trimmed }),
body: JSON.stringify({
requestId,
modelId: selectedModelId,
history,
action: request.action,
...(request.action === "message" ? { message: trimmed } : {}),
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
const message = payload?.message || payload?.error || `请求失败(${response.status}`;
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
if (response.status === 402) setError(`咨询点数不足:${message}`);
else if (response.status === 401) setError("请先登录。");
else setError(message);
@@ -156,17 +173,17 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
} finally {
setPending(false);
}
}, [busy, messages, onSaved, selectedModelId, setPending]);
}, [busy, messages, onProfileIncomplete, onSaved, selectedModelId, setPending]);
useEffect(() => {
if (openingStarted.current) return;
openingStarted.current = true;
void send(agenticOpeningInstruction, false);
void send({ action: "opening" }, false);
}, [send]);
async function submit(event: React.FormEvent) {
event.preventDefault();
await send(draft);
await send({ action: "message", message: draft });
}
const canSend = !busy;
@@ -190,7 +207,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
{suggestions.length > 0 && !busy && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{suggestions.map((question) => (
<button key={question} type="button" onClick={() => void send(question)}>{question}</button>
<button key={question} type="button" onClick={() => void send({ action: "message", message: question })}>{question}</button>
))}
</div>
)}
@@ -25,7 +25,8 @@ export class AgenticRectificationProfileError extends Error {
export type AgenticRectificationProfile = Readonly<{
birth_date: string;
reported_time: string;
reported_time: string | null;
candidateRange: AgenticRectificationContext["candidateRange"];
lat: number;
lon: number;
tz: number;
@@ -35,10 +36,59 @@ export type AgenticRectificationProfile = Readonly<{
}>;
const timeValue = (value: unknown): string | null => {
if (typeof value !== "string" || !value) return null;
return value.length >= 5 ? value.slice(0, 5) : null;
const time = typeof value === "string" ? value.slice(0, 5) : "";
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
};
const periodRanges = {
early_morning: { start_time: "04:00", end_time: "07:59" },
morning: { start_time: "08:00", end_time: "11:59" },
afternoon: { start_time: "12:00", end_time: "17:59" },
evening: { start_time: "18:00", end_time: "22:59" },
late_night: { start_time: "23:00", end_time: "03:59" },
} as const;
function shiftedTime(time: string, offsetMinutes: number): string {
const [hour = 0, minute = 0] = time.split(":").map(Number);
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
function candidateRangeFrom(input: {
activeTime: string | null;
reportedTime: string | null;
source: string;
period: unknown;
uncertaintyBefore: number | null;
uncertaintyAfter: number | null;
}): AgenticRectificationContext["candidateRange"] {
const referenceTime = input.activeTime ?? input.reportedTime;
if (referenceTime) {
const fallback = input.source === "hospital_record" || input.source === "hospital"
? 2
: input.source === "family_exact" || input.source === "family_clear"
? 15
: input.source === "approximate" || input.source === "family_vague"
? 60
: 2;
return {
start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)),
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const period = typeof input.period === "string"
? periodRanges[input.period as keyof typeof periodRanges]
: null;
if (period) return period;
if (input.source === "period_only") throw new AgenticRectificationProfileError("missing_birth_time_period");
}
if (input.source === "unknown" || input.source === "legacy_import") {
return { start_time: "00:00", end_time: "23:59" };
}
throw new AgenticRectificationProfileError("missing_birth_time");
}
function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter: number | null, timeSource: string | null): AgenticRectificationContext["declaredAccuracy"] {
const before = uncertaintyBefore ?? 0;
const after = uncertaintyAfter ?? 0;
@@ -50,16 +100,21 @@ function declaredAccuracyFrom(uncertaintyBefore: number | null, uncertaintyAfter
return "unknown";
}
switch (timeSource) {
case "hospital": return "minute";
case "family_clear": return "15min";
case "family_vague": return "1hour";
case "hospital":
case "hospital_record": return "minute";
case "family_clear":
case "family_exact": return "15min";
case "family_vague":
case "approximate": return "1hour";
default: return "unknown";
}
}
function timeSourceFrom(value: unknown): AgenticRectificationContext["timeSource"] {
const source = typeof value === "string" ? value.trim() : "";
if (source === "hospital" || source === "family_clear" || source === "family_vague") return source;
if (source === "hospital" || source === "hospital_record") return "hospital";
if (source === "family_clear" || source === "family_exact") return "family_clear";
if (source === "family_vague" || source === "approximate" || source === "period_only") return "family_vague";
return "unknown";
}
@@ -82,10 +137,8 @@ export async function loadAgenticRectificationProfile(
if (!/^\d{4}-\d{2}-\d{2}$/.test(birthDate)) {
throw new AgenticRectificationProfileError("missing_birth_date");
}
const reportedTime = timeValue(data.active_birth_time ?? data.reported_birth_time);
if (!reportedTime || !/^\d{2}:\d{2}$/.test(reportedTime)) {
throw new AgenticRectificationProfileError("missing_birth_time");
}
const activeTime = timeValue(data.active_birth_time);
const reportedTime = timeValue(data.reported_birth_time);
const lat = numberOrNull(data.latitude);
const lon = numberOrNull(data.longitude);
const tz = numberOrNull(data.timezone_offset);
@@ -95,16 +148,25 @@ export async function loadAgenticRectificationProfile(
const timeSource = timeSourceFrom(data.birth_time_source);
const uncertaintyBefore = numberOrNull(data.uncertainty_before_minutes);
const uncertaintyAfter = numberOrNull(data.uncertainty_after_minutes);
const candidateRange = candidateRangeFrom({
activeTime,
reportedTime,
source: typeof data.birth_time_source === "string" ? data.birth_time_source.trim() : "",
period: data.birth_time_period,
uncertaintyBefore,
uncertaintyAfter,
});
return {
birth_date: birthDate,
reported_time: reportedTime,
reported_time: activeTime ?? reportedTime,
candidateRange,
lat,
lon,
tz,
declaredAccuracy: declaredAccuracyFrom(uncertaintyBefore, uncertaintyAfter, data.birth_time_source),
timeSource,
baselineActiveTime: timeValue(data.active_birth_time),
baselineActiveTime: activeTime,
};
}
@@ -122,6 +184,7 @@ export function createAgenticRectificationContext(
lon: profile.lon,
tz: profile.tz,
},
candidateRange: profile.candidateRange,
declaredAccuracy: profile.declaredAccuracy,
timeSource: profile.timeSource,
async applyConfirmedBirthTime(time) {
+1 -1
View File
@@ -12,7 +12,7 @@ Write in concise Simplified Chinese as a natural conversation. Acknowledge what
METHODOLOGY
- Load and follow the jyotish-vedic-astrology skill before every substantive step. Its references (birth-time-rectification-advanced.md, birth-time-rectification-decision-tree.md, oracle overlays) are your method source.
- ALL computation goes through the provided engine tools: rectification-gate, rectification-scan, rectification-score, rectification-diagnostics, rectification-candidate-features, rectification-confirm. Never invent a candidate time, score, date, divisional-chart fact, or birth minute in prose.
- Workflow: run rectification-gate first to learn the starting accuracy and which dated events are most valuable. Then collect dated life events conversationally (the user narrates; ask for a date when the event is not dated, but do not press endlessly). Then run rectification-scan to see how layers change minute-to-minute, rectification-score to see candidate minutes, rectification-diagnostics to see what is weak, and ask one or two natural follow-ups to fill the weakest domain or the most unstable event. Re-score. When the candidate is stable across events and domains, run rectification-confirm.
- Workflow: run rectification-gate first to learn the server-owned candidate_range, starting accuracy, and which dated events are most valuable. Always reuse that exact candidate_range in later tools; never create or widen one yourself. Then collect dated life events conversationally (the user narrates; ask for a date when the event is not dated, but do not press endlessly). Then run rectification-scan when available, rectification-score to see candidate minutes, rectification-diagnostics to see what is weak, and ask one or two natural follow-ups to fill the weakest domain or the most unstable event. Re-score. When the candidate is stable across events and domains, run rectification-confirm.
- Use the decision tree: Dasha plus dated events establish the frame; D9 and D10 are core for relationship and career; D4/D24/D2/D11/D7/D30 are topic-specific; D60 is reference-only and never drives a conclusion.
- Keep event ids stable: reuse the same id for the same life event in every tool call.
+71 -6
View File
@@ -23,16 +23,22 @@ const timePattern = /^\d{2}:\d{2}$/;
/** Birth fields supplied by the server from the user profile (never by the LLM). */
export type AgenticRectificationBirth = Readonly<{
birth_date: string;
reported_time: string;
reported_time: string | null;
lat: number;
lon: number;
tz: number;
}>;
export type AgenticRectificationCandidateRange = Readonly<{
start_time: string;
end_time: string;
}>;
export type AgenticRectificationContext = Readonly<{
userId: string;
engineBase?: string;
birth: AgenticRectificationBirth;
candidateRange: AgenticRectificationCandidateRange;
declaredAccuracy?: "minute" | "15min" | "1hour" | "unknown";
timeSource?: "hospital" | "family_clear" | "family_vague" | "unknown";
applyConfirmedBirthTime: (time: string) => Promise<Readonly<{
@@ -61,6 +67,33 @@ export const candidateRangeSchema = z.object({
end_time: z.string().regex(timePattern),
});
function clockMinute(value: string): number {
const [hour = 0, minute = 0] = value.split(":").map(Number);
return hour * 60 + minute;
}
function rangeScanWindow(range: AgenticRectificationCandidateRange) {
const start = clockMinute(range.start_time);
let end = clockMinute(range.end_time);
if (end < start) end += 1_440;
const width = end - start;
const center = Math.round((start + end) / 2) % 1_440;
return {
width,
centerTime: `${String(Math.floor(center / 60)).padStart(2, "0")}:${String(center % 60).padStart(2, "0")}`,
uncertaintyMinutes: Math.max(1, Math.ceil(width / 2)),
};
}
function requireServerCandidateRange(
requested: AgenticRectificationCandidateRange,
serverOwned: AgenticRectificationCandidateRange,
) {
if (requested.start_time !== serverOwned.start_time || requested.end_time !== serverOwned.end_time) {
throw new Error(`candidate_range_mismatch: use ${serverOwned.start_time}-${serverOwned.end_time}`);
}
}
const defaultEventKind: Record<RectificationDomain, string> = {
education: "education_milestone",
relocation: "relocation",
@@ -226,6 +259,24 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
time_source: z.enum(["hospital", "family_clear", "family_vague", "unknown"]).optional(),
}).strict(),
execute: async (input) => {
if (!ctx.birth.reported_time) {
return {
endpoint: "server_owned_rectification_preflight",
effective_accuracy: ctx.declaredAccuracy ?? "unknown",
candidate_range: ctx.candidateRange,
lagna_boundary: { is_sensitive: null, note: "requires_dated_event_scoring_across_candidate_range" },
enabled_vargas: {},
summary: {
headline: "broad_candidate_range",
enabled: [],
warned: ["candidate_range_requires_event_scoring"],
disabled: ["single_minute_precision_claims"],
confidence_floor: "low",
recommended_events: ["education", "career", "relationship", "relocation"],
next_action: "collect one clearly dated life event",
},
};
}
const body = {
year: Number(ctx.birth.birth_date.slice(0, 4)),
month: Number(ctx.birth.birth_date.slice(5, 7)),
@@ -245,6 +296,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
return {
endpoint: data.endpoint,
effective_accuracy: data.effective_accuracy,
candidate_range: ctx.candidateRange,
lagna_boundary: data.lagna_boundary,
enabled_vargas: data.enabled_vargas,
summary: {
@@ -263,22 +315,30 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
const scanTool = createTool({
id: "rectification-scan",
description:
"Scan how chart layers (D1/D4/D9/D10/D24/D30 ascendants, arudhas, KP cusps) change minute-to-minute across the candidate window around the reported birth time. Use this to understand which layers are sensitive and where transitions happen.",
"Scan how chart layers change minute-to-minute across the server-owned candidate range. Wide ranges are deferred until dated-event scoring narrows the evidence.",
inputSchema: z.object({
uncertainty_minutes: z.number().int().min(1).max(180).optional(),
step_minutes: z.number().int().min(1).max(30).optional(),
}).strict(),
execute: async (input) => {
const scanWindow = rangeScanWindow(ctx.candidateRange);
if (scanWindow.width > 360) {
return {
scope: "candidate_time_sensitivity_scan",
status: "deferred_wide_range",
candidate_range: ctx.candidateRange,
boundary: "Collect dated events and score the server-owned full range before running a local sensitivity scan.",
};
}
const body = {
year: Number(ctx.birth.birth_date.slice(0, 4)),
month: Number(ctx.birth.birth_date.slice(5, 7)),
day: Number(ctx.birth.birth_date.slice(8, 10)),
hour: Number(ctx.birth.reported_time.slice(0, 2)),
minute: Number(ctx.birth.reported_time.slice(3, 5)),
hour: Number(scanWindow.centerTime.slice(0, 2)),
minute: Number(scanWindow.centerTime.slice(3, 5)),
lat: ctx.birth.lat,
lon: ctx.birth.lon,
tz: ctx.birth.tz,
time_uncertainty_minutes: input.uncertainty_minutes,
time_uncertainty_minutes: scanWindow.uncertaintyMinutes,
step_minutes: input.step_minutes,
};
const data = await postEngine(base, "/api/rectification/sensitivity_scan", body);
@@ -286,6 +346,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
return {
scope: data.scope,
status: data.status,
candidate_range: ctx.candidateRange,
center_time: data.center_time,
uncertainty_minutes: data.uncertainty_minutes,
step_minutes: data.step_minutes,
@@ -326,6 +387,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
events: z.array(agenticRectificationEventSchema).min(1).max(40),
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const data = await postEngine(base, "/api/rectification/v5/score", v5Request(ctx, input.candidate_range, input.events));
return compactScoreResult(data);
},
@@ -340,6 +402,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
events: z.array(agenticRectificationEventSchema).min(1).max(40),
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const data = await postEngine(base, "/api/rectification/v5/diagnostics", v5Request(ctx, input.candidate_range, input.events));
const diagnostics = (data.diagnostics && typeof data.diagnostics === "object")
? data.diagnostics as Record<string, unknown>
@@ -375,6 +438,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
candidate_range: candidateRangeSchema,
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const data = await postEngine(base, "/api/rectification/v5/candidate-features", {
birth_date: ctx.birth.birth_date,
start_time: input.candidate_range.start_time,
@@ -408,6 +472,7 @@ export function createAgenticRectificationTools(ctx: AgenticRectificationContext
events: z.array(agenticRectificationEventSchema).min(1).max(40),
}).strict(),
execute: async (input) => {
requireServerCandidateRange(input.candidate_range, ctx.candidateRange);
const body = {
birth_date: ctx.birth.birth_date,
start_time: input.candidate_range.start_time,
+37 -38
View File
@@ -78,35 +78,35 @@ test("ordinary product drafts keep the public question and clear hidden routing
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
});
test("homepage birth-time card opens the v4 evidence surface instead of ordinary consultation", () => {
test("homepage birth-time card opens the latest Agentic surface instead of ordinary consultation", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(source, /function openBirthTimeRectification/);
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(component, /<RectificationV4Panel/);
assert.match(component, /<AgenticRectificationChat \{\.\.\.props\} \/>/);
assert.doesNotMatch(component, /RectificationV4Panel|loadActiveRectificationV4|transitionRectificationV4/);
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
assert.doesNotMatch(source.slice(
source.indexOf("async function openBirthTimeRectification"),
source.indexOf("function handleConversationalRectificationTurn"),
), /sendConversationalRectificationCommand/);
), /sendConversationalRectificationCommand|rectification\/v4/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
test("homepage opens the v4 panel without invoking the retired v3 start command", () => {
test("homepage mounts the Agentic surface without invoking retired rectification starters", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.doesNotMatch(handler, /sendConversationalRectificationCommand/);
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.doesNotMatch(handler, /sendConversationalRectificationCommand|loadRectificationV4Handoff|createRectificationV4/);
assert.match(page, /<ConversationalBirthTimeRectification/);
assert.match(component, /<RectificationV4Panel/);
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
assert.match(hook, /await createRectificationV4\(\)/);
assert.match(component, /<AgenticRectificationChat/);
assert.match(chat, /void send\(\{ action: "opening" \}, false\)/);
});
test("a stale v4 mutation refreshes the same case after a 409", () => {
@@ -117,7 +117,7 @@ test("a stale v4 mutation refreshes the same case after a 409", () => {
assert.match(hook, /loadRectificationV4\(caseId\)/);
});
test("homepage birth-time card opens its dedicated session before v4 data loads", () => {
test("homepage birth-time card opens its dedicated session before the Agent starts", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
@@ -132,30 +132,32 @@ test("homepage birth-time card opens its dedicated session before v4 data loads"
assert.ok(handler.indexOf("setSessions((current) => [") < firstAwait);
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
assert.doesNotMatch(handler, /onNarrativeDelta|sendConversationalRectificationCommand/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
});
test("the v4 panel owns case recovery while the page persists only the dedicated session shell", () => {
test("the page persists only the dedicated session shell while the Agent owns opening", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.match(handler, /persistSession\(rectificationSession, "create"\)/);
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
assert.match(hook, /existingHandoff[\s\S]*?loadRectificationV4\(existingHandoff\.caseId\)[\s\S]*?createRectificationV4\(\)/);
assert.match(component, /<AgenticRectificationChat/);
assert.doesNotMatch(component, /loadRectificationV4Handoff|createRectificationV4/);
});
test("a direct homepage start restores any active v4 case before creating another", () => {
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
test("a direct homepage start does not restore or create a V4 case", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
assert.match(hook, /existingHandoff\s*\? await loadRectificationV4\(existingHandoff\.caseId\)\s*:\s*await createRectificationV4\(\)/);
assert.doesNotMatch(hook, /sendConversationalRectificationCommand/);
assert.doesNotMatch(handler, /loadRectificationV4Handoff|createRectificationV4|rectification\/v4/);
assert.doesNotMatch(component, /loadRectificationV4Handoff|createRectificationV4|RectificationV4Panel/);
});
test("rectification cards render only inside the active rectification session", () => {
@@ -181,28 +183,28 @@ test("selecting a rectification session resumes it without an intermediate confi
assert.match(source, /<ConversationalBirthTimeRectification/);
});
test("homepage reuses the dedicated rectification session while v4 restores the active case", () => {
test("homepage reuses the dedicated rectification session for the Agentic surface", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.match(handler, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
assert.match(handler, /existing \?\? createSession/);
assert.match(hook, /loadRectificationV4Handoff|createRectificationV4/);
assert.match(component, /<AgenticRectificationChat/);
});
test("a bound rectification session and a homepage restart share the v4 active-case loader", () => {
test("a bound rectification session and homepage restart share the same Agentic session shell", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.match(handler, /sourceSession\.sessionType === "birth_time_rectification"[\s\S]*?sourceSession[\s\S]*?sessions\.find/);
assert.match(hook, /loadRectificationV4Handoff\(\)/);
assert.match(hook, /loadRectificationV4\(existingHandoff\.caseId\)/);
assert.match(component, /<AgenticRectificationChat/);
assert.doesNotMatch(component, /loadRectificationV4Handoff|loadRectificationV4/);
});
test("rectify-first suggestions hand the source question to a dedicated rectification session", () => {
@@ -219,17 +221,14 @@ test("rectify-first suggestions hand the source question to a dedicated rectific
assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/);
});
test("completed handoffs return only after the user clicks and target the source session", () => {
test("rectify-first handoffs stay as Agent context without a V4 continuation claim", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /automaticRectificationContinuation/);
assert.match(source, /const returnSession = \(localHandoff/);
assert.match(source, /session\.sessionType === "consultation"/);
assert.match(source, /onContinueOriginalQuestion=\{\(continuation\) => void continueRectificationOriginalQuestion\(continuation\)\}/);
assert.match(source, /claimRectificationV4Handoff\(\{[\s\S]*?caseId: continuation\.caseId,[\s\S]*?caseVersion: continuation\.caseVersion,[\s\S]*?question/);
assert.match(source, /sessionId: returnSession\.id/);
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
assert.match(chat, /之后再回到你原来的问题/);
assert.doesNotMatch(source, /onContinueOriginalQuestion|continueRectificationOriginalQuestion|claimRectificationV4Handoff/);
});
test("ordinary consultation uses current birth data without a rectification notice", () => {
@@ -108,56 +108,34 @@ function analysisTrace(label: string) {
} as const;
}
test("agentic rectification requests an Agent-generated opening when the surface mounts", () => {
test("agentic rectification requests a server-owned opening when the surface mounts", () => {
const component = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(component, /const openingStarted = useRef\(false\)/);
assert.match(
component,
/useEffect\(\(\) => \{[\s\S]*?openingStarted\.current = true;[\s\S]*?void send\(agenticOpeningInstruction, false\);[\s\S]*?\}, \[send\]\);/,
/useEffect\(\(\) => \{[\s\S]*?openingStarted\.current = true;[\s\S]*?void send\(\{ action: "opening" \}, false\);[\s\S]*?\}, \[send\]\);/,
);
assert.match(component, /const send = useCallback\(async \(question: string, showUserMessage = true\) =>/);
assert.match(component, /\.\.\.\(showUserMessage \? \[\{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" \} satisfies RenderMessage\] : \[\]\)/);
assert.match(component, /const send = useCallback\(async \(request: AgenticRectificationRequest, showUserMessage = true\) =>/);
assert.match(component, /showUserMessage && request\.action === "message"/);
assert.doesNotMatch(component, /agenticOpeningInstruction|用户刚进入生时校正会话/);
});
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");
test("latest Agentic rectification reuses the ordinary session message list, composer, and model selector", () => {
const component = readFileSync(new URL("../src/components/rectification-agentic-chat.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");
assert.match(
component,
/<>\s*<section className="conversation"[\s\S]*?<\/section>\s*\{showControls && \(\s*<div className="composer-wrap"/,
);
assert.match(component, /<section className="conversation"/);
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, /aria-label="赞"/);
assert.match(component, /aria-label="踩"/);
assert.match(component, /aria-label="复制回答"/);
assert.match(component, /aria-label="重新生成回答"/);
assert.match(component, /<details className="rectification-analysis">/);
assert.match(component, /<span>分析过程<\/span>/);
assert.match(
component,
/<RectificationAnalysisDetails trace=\{message\.analysisTrace\} \/>[\s\S]*?<RectificationMessageRow[\s\S]*?<div className="rectification-message-actions"/,
);
assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/);
assert.match(component, /controller\.regenerate\(\)/);
assert.match(component, /controller\.answer\(answer, props\.selectedModelId \|\| null\)/);
assert.doesNotMatch(wrapper, /existing \? "v4" : "agentic"/);
assert.match(wrapper, /继续旧版校正/);
assert.match(wrapper, /结束旧版并使用新版 Agent/);
assert.match(wrapper, /transitionRectificationV4\(existing\.case\.id, existing\.case\.version, "abandon"\)/);
assert.match(wrapper, /<RectificationV4Panel \{\.\.\.props\} onUseAgentic=/);
assert.match(component, /controller\.abandon\(\)/);
assert.match(component, /Runtime/);
assert.match(component, /Deployment SHA/);
assert.match(component, /fallbackCode/);
assert.match(wrapper, /return <AgenticRectificationChat \{\.\.\.props\} \/>/);
assert.doesNotMatch(wrapper, /RectificationV4Panel|继续旧版校正|transitionRectificationV4/);
assert.match(page, /\{!rectificationSurfaceOpen && \(\s*<div className=\{`conversation/);
assert.match(page, /\{rectificationSurfaceOpen && \(\s*<ConversationalBirthTimeRectification/);
assert.doesNotMatch(page, /is-rectification/);
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const component = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const route = readFileSync(
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
"utf8",
);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
test("birth-time rectification entry no longer routes through V4", () => {
assert.doesNotMatch(component, /loadActiveRectificationV4|RectificationV4Panel|transitionRectificationV4/);
assert.match(component, /return <AgenticRectificationChat \{\.\.\.props\} \/>/);
});
test("opening is a server-owned operation rather than a hidden user prompt", () => {
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
assert.match(chat, /action: "opening"/);
assert.match(route, /z\.literal\("opening"\)/);
assert.match(route, /parsed\.data\.action === "opening"/);
});
test("incomplete profiles stay in the shared onboarding flow", () => {
const opening = page.slice(
page.indexOf("async function openBirthTimeRectification"),
page.indexOf("resumeRectificationSession.current ="),
);
assert.match(opening, /const missingStep = missingProfileStep\(profile\)/);
assert.match(opening, /setOnboardingStep\(missingStep\)/);
assert.ok(
opening.indexOf("const missingStep = missingProfileStep(profile)")
< opening.indexOf("setRectificationSessionId(rectificationSession.id)"),
);
assert.match(chat, /payload\?\.code === "profile_incomplete"/);
});
@@ -52,6 +52,7 @@ test("loadAgenticRectificationProfile derives birth fields, accuracy and baselin
const profile = await loadAgenticRectificationProfile(client as never, userId);
assert.equal(profile.birth_date, "1990-05-12");
assert.equal(profile.reported_time, "14:31");
assert.deepEqual(profile.candidateRange, { start_time: "14:21", end_time: "14:41" });
assert.equal(profile.lat, 31.23);
assert.equal(profile.lon, 121.47);
assert.equal(profile.tz, 8);
@@ -68,6 +69,43 @@ test("loadAgenticRectificationProfile treats hospital source as minute accuracy"
const profile = await loadAgenticRectificationProfile(client as never, userId);
assert.equal(profile.declaredAccuracy, "minute");
assert.equal(profile.timeSource, "hospital");
assert.deepEqual(profile.candidateRange, { start_time: "09:03", end_time: "09:07" });
});
test("loadAgenticRectificationProfile accepts a period-only declaration without a fake minute", async () => {
const { client } = fakeAccounting(fakeProfileRow({
reported_birth_time: null,
active_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "late_night",
}));
const profile = await loadAgenticRectificationProfile(client as never, userId);
assert.equal(profile.reported_time, null);
assert.deepEqual(profile.candidateRange, { start_time: "23:00", end_time: "03:59" });
assert.equal(profile.declaredAccuracy, "unknown");
});
test("loadAgenticRectificationProfile accepts an unknown time as the full day", async () => {
const { client } = fakeAccounting(fakeProfileRow({
reported_birth_time: null,
active_birth_time: null,
birth_time_source: "unknown",
}));
const profile = await loadAgenticRectificationProfile(client as never, userId);
assert.equal(profile.reported_time, null);
assert.deepEqual(profile.candidateRange, { start_time: "00:00", end_time: "23:59" });
});
test("loadAgenticRectificationProfile preserves a cross-midnight uncertainty range", async () => {
const { client } = fakeAccounting(fakeProfileRow({
reported_birth_time: "00:10:00",
active_birth_time: null,
birth_time_source: "approximate",
uncertainty_before_minutes: 30,
uncertainty_after_minutes: 30,
}));
const profile = await loadAgenticRectificationProfile(client as never, userId);
assert.deepEqual(profile.candidateRange, { start_time: "23:40", end_time: "00:40" });
});
test("loadAgenticRectificationProfile rejects a missing birth date", async () => {
@@ -52,6 +52,7 @@ function makeCtx(applyConfirmedBirthTime?: AgenticRectificationContext["applyCon
userId: "user-1",
engineBase: "http://engine.test",
birth,
candidateRange: { start_time: "14:00", end_time: "15:00" },
declaredAccuracy: "15min",
timeSource: "family_clear",
applyConfirmedBirthTime: applyConfirmedBirthTime ?? (async (time) => ({ ok: true as const, saved_time: time })),
@@ -196,7 +197,7 @@ test("score tool normalizes year-precision events into the V5 date range contrac
engine.restore();
});
test("scan tool posts the reported time and uncertainty to /api/rectification/sensitivity_scan", async () => {
test("scan tool derives its center and uncertainty from the server-owned range", async () => {
const engine = installEngine([
{
path: "/api/rectification/sensitivity_scan",
@@ -223,7 +224,7 @@ test("scan tool posts the reported time and uncertainty to /api/rectification/se
},
]);
const tools = createAgenticRectificationTools(makeCtx());
const result = await runTool(tools, "rectification-scan", { uncertainty_minutes: 30 });
const result = await runTool(tools, "rectification-scan", {});
const sent = requestBody(engine);
assert.equal(sent.hour, 14);
assert.equal(sent.minute, 30);
@@ -233,6 +234,48 @@ test("scan tool posts the reported time and uncertainty to /api/rectification/se
engine.restore();
});
test("gate keeps period-only profiles on the server-owned range without inventing a minute", async () => {
const engine = installEngine([]);
const tools = createAgenticRectificationTools({
...makeCtx(),
birth: { ...birth, reported_time: null },
candidateRange: { start_time: "23:00", end_time: "03:59" },
declaredAccuracy: "unknown",
});
const result = await runTool(tools, "rectification-gate", {});
assert.equal(engine.calls.length, 0);
assert.equal(result.endpoint, "server_owned_rectification_preflight");
assert.deepEqual(result.candidate_range, { start_time: "23:00", end_time: "03:59" });
engine.restore();
});
test("scan defers an unknown full-day range instead of using a fake noon birth time", async () => {
const engine = installEngine([]);
const tools = createAgenticRectificationTools({
...makeCtx(),
birth: { ...birth, reported_time: null },
candidateRange: { start_time: "00:00", end_time: "23:59" },
});
const result = await runTool(tools, "rectification-scan", {});
assert.equal(engine.calls.length, 0);
assert.equal(result.status, "deferred_wide_range");
engine.restore();
});
test("score rejects an Agent-invented range", async () => {
const engine = installEngine([]);
const tools = createAgenticRectificationTools(makeCtx());
await assert.rejects(
() => runTool(tools, "rectification-score", {
candidate_range: { start_time: "14:15", end_time: "14:45" },
events: sampleEvents,
}),
/candidate_range_mismatch/,
);
assert.equal(engine.calls.length, 0);
engine.restore();
});
test("save tool rejects before a confirmation gate exists", async () => {
const engine = installEngine([]);
const applied: string[] = [];