diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1f4921e0..28206467 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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:00–23: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 修复提交与部署验收 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 50fcabe5..d56b26c2 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -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"), }, ]); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2a941d8b..a0caf63c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(null); - const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(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()); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); const rectificationOpenInFlight = useRef(false); - const rectificationContinuationInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(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()} /> )} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 44025993..f731dbb5 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -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(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 ( -
-
- -
-
-
- ); - } - - 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 ( - <> -
-
- - {switchError &&

{switchError}

} -
-
-
-
- - -
-
- - ); - } - - if (mode === "v4") { - return setMode("agentic")} />; - } - return ; } - -function ChatLoadingRow() { - return ( - - ); -} diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index a2413fa7..63005ab1 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -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 = //; -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 && (
{suggestions.map((question) => ( - + ))}
)} diff --git a/frontend/src/lib/rectification-agentic/session.ts b/frontend/src/lib/rectification-agentic/session.ts index 2faaa1a3..470a3de9 100644 --- a/frontend/src/lib/rectification-agentic/session.ts +++ b/frontend/src/lib/rectification-agentic/session.ts @@ -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) { diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index eda7f772..179c39c5 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -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. diff --git a/frontend/src/mastra/rectification-tools.ts b/frontend/src/mastra/rectification-tools.ts index 6d86124a..9caa885d 100644 --- a/frontend/src/mastra/rectification-tools.ts +++ b/frontend/src/mastra/rectification-tools.ts @@ -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 = { 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 @@ -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, diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 1ef7bd93..a9cf4723 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -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, //); + 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, / { @@ -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]*? { +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, / { - 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, / { +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, / { +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, / { @@ -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", () => { diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 2e0a1aab..bdc9cbfe 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -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*
\s*\{showControls && \(\s*
/); - assert.match(component, /分析过程<\/span>/); - assert.match( - component, - /[\s\S]*?/); + assert.doesNotMatch(wrapper, /RectificationV4Panel|继续旧版校正|transitionRectificationV4/); assert.match(page, /\{!rectificationSurfaceOpen && \(\s*
{ + assert.doesNotMatch(component, /loadActiveRectificationV4|RectificationV4Panel|transitionRectificationV4/); + assert.match(component, /return /); +}); + +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"/); +}); diff --git a/frontend/tests/rectification-agentic-session.test.ts b/frontend/tests/rectification-agentic-session.test.ts index ebdce53a..e0328d2d 100644 --- a/frontend/tests/rectification-agentic-session.test.ts +++ b/frontend/tests/rectification-agentic-session.test.ts @@ -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 () => { diff --git a/frontend/tests/rectification-agentic-tools.test.ts b/frontend/tests/rectification-agentic-tools.test.ts index 8364d95b..70f7ed76 100644 --- a/frontend/tests/rectification-agentic-tools.test.ts +++ b/frontend/tests/rectification-agentic-tools.test.ts @@ -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[] = [];