From 397b6ef7c277a23e4229f2177d67ed00652516a2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 12 Aug 2026 09:28:47 +0800 Subject: [PATCH] fix(rectification): surface real activity context --- docs/BUG_HISTORY.md | 15 + .../api/rectification/cases/[caseId]/route.ts | 1 + frontend/src/app/globals.css | 48 +++ .../components/rectification-agentic-chat.tsx | 117 ++++--- .../lib/rectification-agentic/v9/agent-run.ts | 16 +- .../rectification-agentic/v9/case-service.ts | 12 +- .../rectification-agentic/v9/engine-client.ts | 54 ++++ .../v9/public-receipt.ts | 29 ++ .../v9/stream-mapping.ts | 66 ++-- .../rectification-agentic/v9/tool-service.ts | 10 + frontend/src/mastra/rectification-v9-tools.ts | 42 ++- ...0_rectification_birth_context_activity.sql | 286 ++++++++++++++++++ .../tests/rectification-agentic-entry.test.ts | 32 ++ .../rectification-v9-case-service.test.ts | 28 +- .../tests/rectification-v9-database.test.ts | 48 ++- .../rectification-v9-engine-contract.test.ts | 27 ++ .../tests/rectification-v9-migration.test.ts | 80 +++++ .../rectification-v9-status-security.test.ts | 23 +- .../tests/rectification-v9-stream.test.ts | 69 ++++- .../tests/rectification-v9-test-support.ts | 2 + 20 files changed, 907 insertions(+), 98 deletions(-) create mode 100644 frontend/supabase/migrations/20260813020000_rectification_birth_context_activity.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 50eae223..d3eeac37 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2945,3 +2945,18 @@ - 防复发:任何声明为免费的 Agent action 必须在授权和结算两个阶段保持同一策略,不能只跳过预授权而继续结算。 - 相关记录:BUG-163、BUG-168、BUG-169、BUG-170 - 修复版本:待提交 + +## BUG-172 | V9 执行记录顺序与技法展示失真,Agent 缺少完整出生地时区上下文 + +- 状态:resolved(本地候选) +- 首次发现:2026-08-12 +- 最近更新:2026-08-12 +- 影响面:V9 生时校正 Activity、SSE/Turn receipt、`rectification-read-case`、Profile baseline 与结果失效链。 +- 用户现象:Agent 消息下方出现无样式的白底黑字执行记录,并固定显示“开始本轮执行、正在加载专用方法、专用方法已加载、本轮完成”等泛化文案;界面没有展示引擎实际使用的分盘、大运或行运,也无法确认 Agent 是否收到生日、出生地、精确经纬度与时区。 +- 触发条件:打开或继续 V9 生时校正 Case,渲染已持久化 Turn receipt,或让 Agent 调用 `rectification-read-case`。 +- 根因:前端从 receipt phases 重建固定步骤且 `
` 没有专用样式;read-case 只返回 Case/证据摘要,没有合并服务端 Profile compute context;引擎结果与公开 receipt 之间也没有安全、可验证的技法 provenance 字段。 +- 修复:Activity 移到对应 Agent 消息上方,使用现有颜色、间距、圆角与键盘焦点样式;已完成记录仅从成功工具 receipt 生成,并仅在引擎返回 allowlist provenance 时展示技法。服务端内部 read-case 增加出生日期、地点标签、经纬度、IANA 时区、UTC offset、填报/当前时间与候选范围;公开 Case GET、React props、receipt 与 SSE 不返回出生上下文或原始评分,只投影 allowlist 工具和技法。Profile 的地点标签或时区 ID 变化会使可恢复 Case `needs_rebaseline` 并失效活动结果。 +- 验证:新增 UI 静态合同、SSE 安全投影、内部 read-case、引擎 provenance、Profile 完整性与迁移排序/权限回归;本地聚焦测试、TypeScript、ESLint 与 PostgreSQL fixture 结果见本轮交付记录。 +- 防复发:明确区分“服务端内部 Agent 工具结果”和“公开 UI/API projection”;Activity 不得从泛化 phase 文案推导技法,不得公开思维链、Prompt、工具参数、出生资料、原始分数、权重、规则 ID 或 Provider metadata。 +- 相关记录:BUG-163、BUG-170、BUG-171 +- 修复版本:待提交 diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index a90193e1..7488cca3 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -119,6 +119,7 @@ function turnReceipt( status: receiptStatusFromTurn(receipt.status), phases: receipt.phases.map((phase) => phase.phase), tools: receipt.tools, + methods: receipt.methods, started_at: receipt.startedAt, completed_at: receipt.completedAt, }; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 9d4335c3..81889881 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2811,3 +2811,51 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled flex-direction: column; } } + +.rectification-message-wrap { display: grid; gap: var(--space-2); } +.rectification-activity { + width: min(720px, 100%); + margin: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-canvas-muted) 72%, var(--color-canvas)); + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.rectification-activity summary { + display: flex; + min-height: 36px; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + color: var(--color-ink-secondary); + cursor: pointer; + font-weight: 600; + list-style: none; +} +.rectification-activity summary::-webkit-details-marker { display: none; } +.rectification-activity summary::before { content: "+"; color: var(--color-ink-tertiary); font-family: var(--font-mono); } +.rectification-activity[open] summary::before { content: "−"; } +.rectification-activity summary:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; } +.rectification-activity ol { + display: grid; + gap: var(--space-1); + margin: 0; + padding: 0 var(--space-4) var(--space-3) calc(var(--space-4) + 18px); +} +.rectification-activity-methods { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border-top: 1px solid var(--color-border); +} +.rectification-activity-methods > span { color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 600; } +.rectification-activity-methods ul { display: flex; flex-wrap: wrap; gap: var(--space-1); margin: 0; padding: 0; list-style: none; } +.rectification-activity-methods li { + padding: 3px 7px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-ink-secondary); +} diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index e0be9cce..a1806cad 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -5,7 +5,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { parseAgentReply } from "@/lib/agent-reply"; import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view"; import { membershipHref } from "@/lib/membership"; -import { PUBLIC_RECTIFICATION_PHASES, type PublicRectificationPhase } from "@/lib/rectification-agentic/v9/public-receipt"; +import { + isPublicRectificationMethod, + isPublicRectificationTool, + type PublicRectificationMethod, + type PublicRectificationTool, +} from "@/lib/rectification-agentic/v9/public-receipt"; import type { PublicLanguageModel } from "@/lib/public-models"; import { ChatMessageRow } from "./chat-message-row"; import { ModelSelector } from "./model-selector"; @@ -21,6 +26,7 @@ type PersistedTurn = Readonly<{ status: string; phases: readonly string[]; tools: readonly string[]; + methods?: readonly string[]; skill_name?: string; skill_version?: string; }> | null; @@ -58,38 +64,52 @@ type RectificationAgenticChatProps = Readonly<{ type RenderMessage = ChatMessageView & { renderKey: string; /** V9 execution-receipt steps; separate from shared consultation activity. */ - receiptActivity?: readonly string[]; + receiptActivity?: ReceiptActivity; receiptStatus?: string; }; -const PHASE_LABELS: Readonly>> = { - "run.started": "开始本轮执行", - "skill.started": "正在加载专用方法", - "skill.loaded": "专用方法已加载", - "case.loaded": "已读取校正记录", - "evidence.proposed": "记录了一条事件草稿", - "evidence.confirmed": "事件已确认", - "candidates.comparing": "正在比较候选时间", - "candidates.updated": "候选已更新", - "diagnostics.completed": "稳健性诊断完成", - "candidate.accepted": "已采用候选时间", - "birth_time.confirmed": "校正时间已确认", - "run.completed": "本轮完成", - "run.failed": "本轮失败", +type ReceiptActivity = Readonly<{ + steps: readonly string[]; + methods: readonly string[]; +}>; + +const TOOL_LABELS: Readonly> = { + "rectification-read-case": "读取校正记录", + "rectification-propose-evidence": "整理事件证据", + "rectification-confirm-evidence": "确认事件证据", + "rectification-revise-evidence": "修订事件证据", + "rectification-compare-candidates": "比较候选时间", + "rectification-read-diagnostics": "检查候选稳健性", + "rectification-offer-candidates": "生成候选建议", + "rectification-accept-candidate": "采用候选时间", + "rectification-confirm-birth-time": "确认校正时间", + "rectification-close-case": "关闭校正记录", }; -function isPublicPhase(value: unknown): value is PublicRectificationPhase { - return typeof value === "string" && (PUBLIC_RECTIFICATION_PHASES as readonly string[]).includes(value); -} +const METHOD_LABELS: Readonly> = { + "d1-rashi": "D1 本命盘", + "d2-hora": "D2 财帛分盘", + "d4-chaturthamsha": "D4 地产迁移分盘", + "d9-navamsa": "D9 婚姻分盘", + "d10-dashamsa": "D10 事业分盘", + "d11-labhamsha": "D11 收益分盘", + "d24-chaturvimshamsha": "D24 教育分盘", + "d30-trimshamsha": "D30 健康压力分盘", + "vimshottari-dasha": "Vimshottari 大运", + "narayana-dasha": "Narayana 大运", + gochara: "Gochara 行运", + ashtakavarga: "Ashtakavarga", + shadbala: "Shadbala", + "arudha-pada": "Arudha Pada", + "functional-benefic-malefic": "功能吉凶星", +}; -function labelForPhase(phase: string): string { - return isPublicPhase(phase) ? (PHASE_LABELS[phase] ?? phase) : phase; -} - -function activityFromReceipt(receipt: PersistedTurn["receipt"]): string[] { - if (!receipt) return []; - const phases = (receipt.phases ?? []).filter(isPublicPhase); - return phases.map(labelForPhase).slice(0, 12); +function activityFromReceipt(receipt: PersistedTurn["receipt"]): ReceiptActivity { + if (!receipt) return { steps: [], methods: [] }; + return { + steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool).map((tool) => TOOL_LABELS[tool]))], + methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod).map((method) => METHOD_LABELS[method]))], + }; } export function RectificationAgenticChat(props: RectificationAgenticChatProps) { @@ -213,13 +233,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ...(action === "message" ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []), - { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", receiptActivity: [] }, + { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", receiptActivity: { steps: [], methods: [] } }, ]); setDraft(""); let raw = ""; - let liveActivity: string[] = []; - const activitySet = new Set(); + let liveActivity: ReceiptActivity = { steps: [], methods: [] }; + const activitySteps = new Set(); + const activityMethods = new Set(); try { const response = await fetch("/api/rectification/agent", { method: "POST", @@ -268,9 +289,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; - let event: { type?: unknown; text?: unknown; message?: unknown }; + let event: { type?: unknown; text?: unknown; message?: unknown; tool?: unknown; methods?: unknown }; try { - event = JSON.parse(line) as { type?: unknown; text?: unknown; message?: unknown }; + event = JSON.parse(line) as { type?: unknown; text?: unknown; message?: unknown; tool?: unknown; methods?: unknown }; } catch { continue; } @@ -289,10 +310,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。"); } else if (event.type === "run.completed") { completed = true; - } else if (isPublicPhase(event.type)) { - if (!activitySet.has(event.type)) { - activitySet.add(event.type); - liveActivity = [...liveActivity, labelForPhase(event.type)]; + } else { + const tool = isPublicRectificationTool(event.tool) ? event.tool : null; + if (tool) activitySteps.add(TOOL_LABELS[tool]); + if (Array.isArray(event.methods)) { + for (const method of event.methods) { + if (isPublicRectificationMethod(method)) activityMethods.add(METHOD_LABELS[method]); + } + } + if (tool || activityMethods.size !== liveActivity.methods.length) { + liveActivity = { steps: [...activitySteps], methods: [...activityMethods] }; setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, receiptActivity: liveActivity } : message)); @@ -386,15 +413,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { )} {messages.map((message) => (
- - {message.receiptActivity && message.receiptActivity.length > 0 && ( + {message.receiptActivity && (message.receiptActivity.steps.length > 0 || message.receiptActivity.methods.length > 0) && (
本轮做了什么 -
    - {message.receiptActivity.map((step) =>
  1. {step}
  2. )} -
+ {message.receiptActivity.steps.length > 0 && ( +
    + {message.receiptActivity.steps.map((step) =>
  1. {step}
  2. )} +
+ )} + {message.receiptActivity.methods.length > 0 && ( +
+ 使用技法 +
    {message.receiptActivity.methods.map((method) =>
  • {method}
  • )}
+
+ )}
)} +
))} {candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && ( diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 5c7fd92d..96c392ed 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -154,6 +154,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise(); + const emittedActivity = new Set(); const repeatedCalls = new Map(); const startedAt = Date.now(); @@ -182,8 +183,11 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise; export type V9EngineDiagnostics = Readonly<{ @@ -67,10 +70,55 @@ export type V9EngineDiagnostics = Readonly<{ diagnostics: Readonly>; missingLayers: readonly string[]; canConfirmExactMinute: boolean; + executedMethods: readonly PublicRectificationMethod[]; }>; const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/; +const DOMAIN_METHODS: Readonly> = { + education: ["d24-chaturvimshamsha"], + relocation: ["d4-chaturthamsha"], + relationship: ["d9-navamsa"], + career: ["d10-dashamsa"], + finance: ["d2-hora", "d11-labhamsha"], + health_pressure: ["d30-trimshamsha"], +}; + +function techniqueLayers(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + return Object.values(value as Record).flatMap((eventRows) => { + if (!eventRows || typeof eventRows !== "object") return []; + return Object.values(eventRows as Record).flatMap((candidate) => { + if (!candidate || typeof candidate !== "object") return []; + const layers = (candidate as Record).technique_layers; + return Array.isArray(layers) ? layers.filter((item): item is string => typeof item === "string") : []; + }); + }); +} + +function executedMethods( + events: readonly V9EngineEvent[], + layers: readonly string[], +): PublicRectificationMethod[] { + const methods = new Set([ + "d1-rashi", + "vimshottari-dasha", + "narayana-dasha", + ]); + for (const event of events) { + for (const method of DOMAIN_METHODS[event.domain] ?? []) methods.add(method); + } + const normalized = layers.map((layer) => layer.toLowerCase()); + if (normalized.some((layer) => layer.includes("controlled_transit") || layer.includes("gochara"))) methods.add("gochara"); + if (normalized.some((layer) => layer.includes("ashtakavarga"))) methods.add("ashtakavarga"); + if (normalized.some((layer) => layer.includes("shadbala"))) methods.add("shadbala"); + if (normalized.some((layer) => layer.includes("arudha"))) methods.add("arudha-pada"); + if (normalized.some((layer) => layer.includes("functional_benefic") || layer.includes("functional_malefic"))) { + methods.add("functional-benefic-malefic"); + } + return [...methods]; +} + function clockMinute(value: string): number { const [hour = 0, minute = 0] = value.split(":").map(Number); return hour * 60 + minute; @@ -248,6 +296,7 @@ export async function runV9CandidateScore(input: { throw new RectificationEngineError("engine_no_candidates", "the engine returned no usable candidates"); } const diagnostics = engineDiagnostics(data); + const methods = executedMethods(input.events, techniqueLayers(data.event_contribution_matrix)); const marginPercent = engineNumber(diagnostics.primary_secondary_margin_percent) ?? engineNumber(data.margin_percent) ?? null; @@ -270,6 +319,7 @@ export async function runV9CandidateScore(input: { selectionAllowed: candidates.length > 0, confirmationAllowed: data.can_confirm_exact_minute === true, representativeTime: candidates[0]?.time ?? null, + executedMethods: methods, }; } @@ -300,6 +350,9 @@ export async function runV9Diagnostics(input: { }); const diagnostics = engineDiagnostics(data); const missingLayers = Array.isArray(data.missing_layers) ? data.missing_layers as string[] : []; + const discriminatingLayers = Array.isArray(diagnostics.most_discriminating_layers) + ? diagnostics.most_discriminating_layers.filter((item): item is string => typeof item === "string") + : []; return { algorithmVersion: String(data.algorithm_version ?? "rectification-v5"), engineResultId: String(data.result_id ?? ""), @@ -316,6 +369,7 @@ export async function runV9Diagnostics(input: { }, missingLayers, canConfirmExactMinute: data.can_confirm_exact_minute === true, + executedMethods: executedMethods(input.events, discriminatingLayers), }; } diff --git a/frontend/src/lib/rectification-agentic/v9/public-receipt.ts b/frontend/src/lib/rectification-agentic/v9/public-receipt.ts index 226a846e..d2c86bdf 100644 --- a/frontend/src/lib/rectification-agentic/v9/public-receipt.ts +++ b/frontend/src/lib/rectification-agentic/v9/public-receipt.ts @@ -42,6 +42,27 @@ export const PUBLIC_RECTIFICATION_TOOLS = [ export type PublicRectificationTool = (typeof PUBLIC_RECTIFICATION_TOOLS)[number]; +export const PUBLIC_RECTIFICATION_METHODS = [ + "d1-rashi", + "d2-hora", + "d4-chaturthamsha", + "d9-navamsa", + "d10-dashamsa", + "d11-labhamsha", + "d24-chaturvimshamsha", + "d30-trimshamsha", + "vimshottari-dasha", + "narayana-dasha", + "gochara", + "ashtakavarga", + "shadbala", + "arudha-pada", + "functional-benefic-malefic", +] as const; + +export type PublicRectificationMethod = + (typeof PUBLIC_RECTIFICATION_METHODS)[number]; + export const RECEIPT_STATUSES = [ "completed", "degraded", @@ -59,6 +80,7 @@ export type RectificationExecutionReceipt = Readonly<{ engineVersion: string | null; phases: readonly PublicRectificationPhase[]; toolsUsed: readonly PublicRectificationTool[]; + methods: readonly PublicRectificationMethod[]; status: RectificationReceiptStatus; startedAt: string; completedAt: string; @@ -66,6 +88,7 @@ export type RectificationExecutionReceipt = Readonly<{ const PHASE_SET = new Set(PUBLIC_RECTIFICATION_PHASES); const TOOL_SET = new Set(PUBLIC_RECTIFICATION_TOOLS); +const METHOD_SET = new Set(PUBLIC_RECTIFICATION_METHODS); export function isPublicRectificationPhase( value: unknown, @@ -79,6 +102,12 @@ export function isPublicRectificationTool( return typeof value === "string" && TOOL_SET.has(value); } +export function isPublicRectificationMethod( + value: unknown, +): value is PublicRectificationMethod { + return typeof value === "string" && METHOD_SET.has(value); +} + /** * The exact NDJSON activity stream events the API is allowed to emit. * Anything not in this list must be dropped before it reaches the browser. diff --git a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts index 7a1b02b2..6b8e3e28 100644 --- a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts +++ b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts @@ -8,8 +8,10 @@ */ import type { AgentChunkType } from "@mastra/core/stream"; import { - PUBLIC_RECTIFICATION_TOOLS, + isPublicRectificationMethod, + isPublicRectificationTool, safeActivityEvent, + type PublicRectificationMethod, type PublicRectificationPhase, type PublicRectificationTool, } from "./public-receipt"; @@ -17,6 +19,8 @@ import { export type PublicStreamEvent = Readonly<{ type: PublicRectificationPhase; text?: string; + tool?: PublicRectificationTool; + methods?: readonly PublicRectificationMethod[]; }>; export type StreamObservation = Readonly<{ @@ -29,17 +33,8 @@ export type StreamObservation = Readonly<{ finished: boolean; }>; -const TOOL_PHASE_ON_CALL: Readonly> = { - "rectification-read-case": "case.loaded", - "rectification-propose-evidence": "evidence.proposed", - "rectification-confirm-evidence": "evidence.confirmed", - "rectification-revise-evidence": "evidence.proposed", +const TOOL_PHASE_ON_CALL: Readonly>> = { "rectification-compare-candidates": "candidates.comparing", - "rectification-read-diagnostics": "diagnostics.completed", - "rectification-offer-candidates": "candidates.updated", - "rectification-accept-candidate": "candidate.accepted", - "rectification-confirm-birth-time": "birth_time.confirmed", - "rectification-close-case": "run.completed", }; const TOOL_PHASE_ON_RESULT: Readonly> = { @@ -55,8 +50,27 @@ const TOOL_PHASE_ON_RESULT: Readonly> = "rectification-close-case": "run.completed", }; +const METHOD_TOOLS = new Set([ + "rectification-compare-candidates", + "rectification-read-diagnostics", +]); + export function isPublicRectificationToolName(value: unknown): value is PublicRectificationTool { - return typeof value === "string" && (PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes(value); + return isPublicRectificationTool(value); +} + +function resultMethods(chunk: AgentChunkType): PublicRectificationMethod[] { + if (chunk.type !== "tool-result") return []; + const payload = chunk.payload && typeof chunk.payload === "object" + ? chunk.payload as unknown as Record + : {}; + const candidates = [payload.result, payload.output, (chunk as unknown as { object?: unknown }).object]; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object") continue; + const methods = (candidate as Record).executed_methods; + if (Array.isArray(methods)) return [...new Set(methods.filter(isPublicRectificationMethod))]; + } + return []; } /** @@ -67,18 +81,21 @@ export function isPublicRectificationToolName(value: unknown): value is PublicRe export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicStreamEvent | null { switch (chunk.type) { case "start": - return { type: "run.started" }; + return null; case "tool-call": { const toolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : ""; if (toolName === "skill") return { type: "skill.started" }; + if (!isPublicRectificationTool(toolName)) return null; const phase = TOOL_PHASE_ON_CALL[toolName]; - return phase ? { type: phase } : null; + return phase ? { type: phase, tool: toolName } : null; } case "tool-result": { const toolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : ""; if (toolName === "skill") return { type: "skill.loaded" }; + if (!isPublicRectificationTool(toolName)) return null; const phase = TOOL_PHASE_ON_RESULT[toolName]; - return phase ? { type: phase } : null; + const methods = METHOD_TOOLS.has(toolName) ? resultMethods(chunk) : []; + return phase ? { type: phase, tool: toolName, ...(methods.length > 0 ? { methods } : {}) } : null; } case "text-delta": { const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : ""; @@ -109,9 +126,22 @@ export function streamToolNames(chunk: AgentChunkType): PublicRectificationTool[ /** Safe activity event for the web client; drops anything not allowlisted. */ export function safePublicEvent(value: unknown): PublicStreamEvent | null { if (!value || typeof value !== "object") return null; - const event = value as { type?: unknown; text?: unknown }; + const event = value as { type?: unknown; text?: unknown; tool?: unknown; methods?: unknown }; const type = safeActivityEvent(event.type); if (!type) return null; - const text = typeof event.text === "string" ? event.text.slice(0, 4_000) : undefined; - return { type, ...(text !== undefined ? { text } : {}) }; + const text = type === "answer.delta" && typeof event.text === "string" + ? event.text.slice(0, 4_000) + : undefined; + const tool = type !== "answer.delta" && isPublicRectificationTool(event.tool) + ? event.tool + : undefined; + const methods = tool && METHOD_TOOLS.has(tool) && Array.isArray(event.methods) + ? [...new Set(event.methods.filter(isPublicRectificationMethod))] + : []; + return { + type, + ...(text !== undefined ? { text } : {}), + ...(tool ? { tool } : {}), + ...(methods.length > 0 ? { methods } : {}), + }; } diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 66ff73bc..9be155cc 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -15,8 +15,10 @@ import { type EvidenceKind, } from "./evidence-model"; import { + isPublicRectificationMethod, isPublicRectificationPhase, isPublicRectificationTool, + type PublicRectificationMethod, type PublicRectificationPhase, type PublicRectificationTool, } from "./public-receipt"; @@ -442,6 +444,7 @@ export async function insertV9ToolReceipt( resultFingerprint?: string | null; engineVersion?: string | null; safeErrorCode?: string | null; + executedMethods?: readonly PublicRectificationMethod[]; }, ): Promise { if (!isPublicRectificationTool(input.toolName)) { @@ -450,6 +453,10 @@ export async function insertV9ToolReceipt( if (!isPublicRectificationPhase(input.publicPhase)) { throw new RectificationToolServiceError("phase_not_allowlisted"); } + const executedMethods = [...new Set(input.executedMethods ?? [])]; + if (!executedMethods.every(isPublicRectificationMethod)) { + throw new RectificationToolServiceError("method_not_allowlisted"); + } await rpc( accounting, "insert_agentic_rectification_tool_receipt", @@ -464,6 +471,7 @@ export async function insertV9ToolReceipt( p_result_fingerprint: input.resultFingerprint ?? null, p_engine_version: input.engineVersion ?? null, p_safe_error_code: input.safeErrorCode ?? null, + p_executed_methods: executedMethods, }, ); } @@ -502,6 +510,7 @@ export type V9TurnReceipt = Readonly<{ engineVersion: string | null; phases: readonly Readonly<{ phase: string; tool: string | null }>[]; tools: readonly string[]; + methods: readonly PublicRectificationMethod[]; startedAt: string; completedAt: string | null; }>; @@ -531,6 +540,7 @@ export async function loadV9TurnReceipt( engineVersion: rowText(row.engine_version), phases, tools: rowArray(row.tools).map((item) => String(item)), + methods: rowArray(row.methods).filter(isPublicRectificationMethod), startedAt: String(row.started_at ?? ""), completedAt: rowText(row.completed_at), }; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index dfce37bf..f04b003c 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -12,6 +12,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; import type { SupabaseClient } from "@supabase/supabase-js"; +import type { PublicRectificationMethod } from "@/lib/rectification-agentic/v9/public-receipt"; import { canonicalToolInputFingerprint, candidateRangeFingerprint, @@ -59,7 +60,29 @@ export type RectificationV9Context = Readonly<{ const dateLike = /^\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?$/; -function safeCaseProjection(dossier: ReturnType): Record { +function safeBirthContext(compute: Awaited>): Record { + const snapshot = compute.baselineBirthSnapshot; + return { + birth_date: String(snapshot.birth_date ?? ""), + reported_birth_time: typeof snapshot.reported_birth_time === "string" ? snapshot.reported_birth_time : null, + active_birth_time: typeof snapshot.active_birth_time === "string" ? snapshot.active_birth_time : null, + candidate_range: compute.candidateRange, + birthplace: { + label: String(snapshot.birth_place_label ?? ""), + latitude: Number(snapshot.latitude), + longitude: Number(snapshot.longitude), + }, + timezone: { + id: String(snapshot.timezone_id ?? ""), + offset: Number(snapshot.timezone_offset), + }, + }; +} + +function safeCaseProjection( + dossier: ReturnType, + compute: Awaited>, +): Record { const caseRow = dossier.case; const latest = dossier.latestResult; return { @@ -81,6 +104,7 @@ function safeCaseProjection(dossier: ReturnType): R by_kind: dossier.kindCounts, scorable: dossier.scorable.length, }, + birth_context: safeBirthContext(compute), latest_result: latest ? { result_id: latest.resultId, @@ -224,7 +248,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { toolName: string, publicPhase: string, status: "started" | "completed" | "failed" | "skipped", - extra: { inputFingerprint?: string | null; resultFingerprint?: string | null; engineVersion?: string | null; safeErrorCode?: string | null } = {}, + extra: { inputFingerprint?: string | null; resultFingerprint?: string | null; engineVersion?: string | null; safeErrorCode?: string | null; executedMethods?: readonly PublicRectificationMethod[] } = {}, ) => { try { await insertV9ToolReceipt(accounting, userId, caseId, turnId, { @@ -235,6 +259,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { resultFingerprint: extra.resultFingerprint ?? null, engineVersion: extra.engineVersion ?? null, safeErrorCode: extra.safeErrorCode ?? null, + executedMethods: extra.executedMethods ?? [], }); } catch (error) { // Receipt persistence must never break the tool result; it is auditable @@ -254,7 +279,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const readCaseTool = createTool({ id: "rectification-read-case", description: - "读取服务端 Case 与证据摘要(状态、Skill 版本、候选范围、已确认/待确认事件、最近候选快照)。任何一轮开始必须先调用本工具。input 只允许 caseId。", + "读取服务端 Case、证据摘要和权威出生上下文。birth_context 中的日期、地点、经纬度、IANA 时区、偏移、填报/当前时间及候选范围均来自服务端,必须原样使用,不得猜测或替换。任何一轮开始必须先调用本工具。input 只允许 caseId。", inputSchema: z.object({ caseId: z.string().uuid() }).strict(), execute: async (input) => { assertCaseRef(input); @@ -262,7 +287,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { await receipt("rectification-read-case", "case.loaded", "started", { inputFingerprint }); try { const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); - const projection = safeCaseProjection(parseDossierForTools(dossier)); + const compute = await loadV9CaseCompute(accounting, userId, input.caseId); + const projection = safeCaseProjection(parseDossierForTools(dossier), compute); await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) }); return projection; } catch (error) { @@ -422,7 +448,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const compareCandidatesTool = createTool({ id: "rectification-compare-candidates", description: - "用当前已确认证据运行候选比较(Vimshottari + Narayana 双轨,D9/D10 核心)。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。", + "用当前已确认证据运行候选比较。服务端按事件的实际领域选择分盘,并结合 Vimshottari、Narayana 与稳健性诊断。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。", inputSchema: z.object({ caseId: z.string().uuid() }).strict(), execute: async (input) => { assertCaseRef(input); @@ -481,8 +507,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { inputFingerprint, resultFingerprint: hashResult(projection), engineVersion: persisted.algorithmVersion ?? engineVersion, + executedMethods: score.executedMethods, }); - return projection; + return { ...projection, executed_methods: score.executedMethods }; } catch (error) { await receipt("rectification-compare-candidates", "candidates.comparing", "failed", { inputFingerprint, @@ -527,8 +554,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { inputFingerprint, resultFingerprint: hashResult(projection), engineVersion: diagnostics.algorithmVersion, + executedMethods: diagnostics.executedMethods, }); - return projection; + return { ...projection, executed_methods: diagnostics.executedMethods }; } catch (error) { await receipt("rectification-read-diagnostics", "diagnostics.completed", "failed", { inputFingerprint, diff --git a/frontend/supabase/migrations/20260813020000_rectification_birth_context_activity.sql b/frontend/supabase/migrations/20260813020000_rectification_birth_context_activity.sql new file mode 100644 index 00000000..a543b216 --- /dev/null +++ b/frontend/supabase/migrations/20260813020000_rectification_birth_context_activity.sql @@ -0,0 +1,286 @@ +-- Created 2026-08-12. Filename intentionally follows the existing +-- 20260813010000 migration; the identifier is an ordering key. +begin; + +alter table public.agentic_rectification_tool_receipts + add column if not exists executed_methods jsonb not null default '[]'::jsonb + check (jsonb_typeof(executed_methods) = 'array'); + +drop function if exists public.insert_agentic_rectification_tool_receipt( + uuid, uuid, uuid, text, text, text, text, text, text, text +); + +create or replace function public.insert_agentic_rectification_tool_receipt( + p_user_id uuid, + p_case_id uuid, + p_turn_id uuid, + p_tool_name text, + p_public_phase text, + p_status text, + p_input_fingerprint text, + p_result_fingerprint text, + p_engine_version text, + p_safe_error_code text, + p_executed_methods jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_receipt_id uuid; + v_turn_count bigint; +begin + if p_user_id is null or p_case_id is null or p_turn_id is null + or p_executed_methods is null + or jsonb_typeof(p_executed_methods) <> 'array' + or exists ( + select 1 + from jsonb_array_elements_text(p_executed_methods) as method(value) + where method.value not in ( + 'd1-rashi', 'd2-hora', 'd4-chaturthamsha', 'd9-navamsa', + 'd10-dashamsa', 'd11-labhamsha', 'd24-chaturvimshamsha', + 'd30-trimshamsha', 'vimshottari-dasha', 'narayana-dasha', + 'gochara', 'ashtakavarga', 'shadbala', 'arudha-pada', + 'functional-benefic-malefic' + ) + ) then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + if not exists ( + select 1 from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + ) then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + select count(*) into v_turn_count + from public.agentic_rectification_turns + where id = p_turn_id and case_id = p_case_id; + if v_turn_count = 0 then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + + insert into public.agentic_rectification_tool_receipts ( + case_id, turn_id, tool_name, public_phase, status, + input_fingerprint, result_fingerprint, engine_version, safe_error_code, + executed_methods, completed_at + ) values ( + p_case_id, p_turn_id, p_tool_name, p_public_phase, p_status, + p_input_fingerprint, p_result_fingerprint, p_engine_version, p_safe_error_code, + p_executed_methods, + case when p_status = 'completed' then pg_catalog.now() else null end + ) returning id into v_receipt_id; + + return jsonb_build_object('receipt_id', v_receipt_id); +end; +$$; + +revoke all on function public.insert_agentic_rectification_tool_receipt( + uuid, uuid, uuid, text, text, text, text, text, text, text, jsonb +) from public, anon, authenticated; +grant execute on function public.insert_agentic_rectification_tool_receipt( + uuid, uuid, uuid, text, text, text, text, text, text, text, jsonb +) to service_role; + +create or replace function public.get_agentic_rectification_turn_receipt( + p_user_id uuid, + p_case_id uuid, + p_turn_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_turn public.agentic_rectification_turns%rowtype; + v_case public.agentic_rectification_cases%rowtype; + v_phases jsonb; + v_tools jsonb; + v_methods jsonb; + v_engine_version text; +begin + if p_user_id is null or p_case_id is null or p_turn_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + select * into v_turn + from public.agentic_rectification_turns + where id = p_turn_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + + select coalesce(jsonb_agg( + jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name) + order by rp.sequence + ), '[]'::jsonb) into v_phases + from public.agentic_rectification_run_phases rp + where rp.turn_id = p_turn_id; + + select coalesce(jsonb_agg(tool_name order by tool_name), '[]'::jsonb) into v_tools + from ( + select distinct tr.tool_name + from public.agentic_rectification_tool_receipts tr + where tr.turn_id = p_turn_id and tr.status = 'completed' + ) tools; + + select coalesce(jsonb_agg(method order by method), '[]'::jsonb) into v_methods + from ( + select distinct jsonb_array_elements_text(tr.executed_methods) as method + from public.agentic_rectification_tool_receipts tr + where tr.turn_id = p_turn_id and tr.status = 'completed' + ) methods; + + select max(tr.engine_version) into v_engine_version + from public.agentic_rectification_tool_receipts tr + where tr.turn_id = p_turn_id and tr.engine_version is not null; + + return jsonb_build_object( + 'turn_id', v_turn.id, + 'status', v_turn.status, + 'skill_name', v_case.skill_name, + 'skill_version', v_case.skill_version, + 'engine_version', v_engine_version, + 'phases', v_phases, + 'tools', v_tools, + 'methods', v_methods, + 'started_at', v_turn.created_at, + 'completed_at', v_turn.completed_at + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid) + to service_role; + +create temporary table rectification_birth_context_affected_cases on commit drop as +select c.id +from public.agentic_rectification_cases c +join public.profiles p on p.id = c.user_id +where c.status = any (public.agentic_rectification_resumable_statuses()) + and ( + nullif(btrim(c.baseline_birth_snapshot ->> 'birth_place_label'), '') is null + or nullif(btrim(c.baseline_birth_snapshot ->> 'timezone_id'), '') is null + or c.baseline_birth_snapshot ->> 'birth_place_label' is distinct from p.birth_place_label + or c.baseline_birth_snapshot ->> 'timezone_id' is distinct from p.timezone_id + ); + +update public.agentic_rectification_cases c +set baseline_birth_snapshot = c.baseline_birth_snapshot + || jsonb_build_object( + 'birth_place_label', p.birth_place_label, + 'timezone_id', p.timezone_id + ) +from public.profiles p, rectification_birth_context_affected_cases affected +where c.id = affected.id + and p.id = c.user_id + and nullif(btrim(coalesce(p.birth_place_label, '')), '') is not null + and nullif(btrim(coalesce(p.timezone_id, '')), '') is not null; + +update public.agentic_rectification_cases c +set status = 'needs_rebaseline', + updated_at = pg_catalog.now(), + last_activity_at = pg_catalog.now() +from rectification_birth_context_affected_cases affected +where c.id = affected.id; + +update public.agentic_rectification_results r +set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() +from rectification_birth_context_affected_cases affected +where r.case_id = affected.id + and r.invalidated_at is null; + +create or replace function public.agentic_rectification_profiles_rebaseline_guard() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if old.birth_date is distinct from new.birth_date + or old.birth_place_label is distinct from new.birth_place_label + or old.reported_birth_time is distinct from new.reported_birth_time + or old.active_birth_time is distinct from new.active_birth_time + or old.birth_time_source is distinct from new.birth_time_source + or old.birth_time_period is distinct from new.birth_time_period + or old.uncertainty_before_minutes is distinct from new.uncertainty_before_minutes + or old.uncertainty_after_minutes is distinct from new.uncertainty_after_minutes + or old.latitude is distinct from new.latitude + or old.longitude is distinct from new.longitude + or old.timezone_id is distinct from new.timezone_id + or old.timezone_offset is distinct from new.timezone_offset then + update public.agentic_rectification_cases + set status = 'needs_rebaseline', + updated_at = pg_catalog.now(), + last_activity_at = pg_catalog.now() + where user_id = new.id + and status = any (public.agentic_rectification_resumable_statuses()); + end if; + return new; +end; +$$; + +revoke all on function public.agentic_rectification_profiles_rebaseline_guard() + from public, anon, authenticated; +drop trigger if exists agentic_rectification_profiles_rebaseline_guard_trigger on public.profiles; +create trigger agentic_rectification_profiles_rebaseline_guard_trigger +after update of birth_date, birth_place_label, reported_birth_time, active_birth_time, + birth_time_source, birth_time_period, uncertainty_before_minutes, uncertainty_after_minutes, + latitude, longitude, timezone_id, timezone_offset +on public.profiles +for each row execute function public.agentic_rectification_profiles_rebaseline_guard(); + +create or replace function public.invalidate_agentic_rectification_results_on_profile_change() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if old.birth_date is distinct from new.birth_date + or old.birth_place_label is distinct from new.birth_place_label + or old.reported_birth_time is distinct from new.reported_birth_time + or old.birth_time_source is distinct from new.birth_time_source + or old.birth_time_period is distinct from new.birth_time_period + or old.uncertainty_before_minutes is distinct from new.uncertainty_before_minutes + or old.uncertainty_after_minutes is distinct from new.uncertainty_after_minutes + or old.latitude is distinct from new.latitude + or old.longitude is distinct from new.longitude + or old.timezone_id is distinct from new.timezone_id + or old.timezone_offset is distinct from new.timezone_offset + or ( + (old.active_birth_time is distinct from new.active_birth_time + or old.birth_time_status is distinct from new.birth_time_status) + and coalesce(new.birth_time_status, '') not in ('accepted', 'confirmed') + ) then + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where user_id = new.id + and invalidated_at is null; + end if; + return new; +end; +$$; + +revoke all on function public.invalidate_agentic_rectification_results_on_profile_change() + from public, anon, authenticated; +drop trigger if exists profiles_invalidate_agentic_rectification_results on public.profiles; +create trigger profiles_invalidate_agentic_rectification_results +after update of birth_date, birth_place_label, reported_birth_time, active_birth_time, + birth_time_status, birth_time_source, birth_time_period, uncertainty_before_minutes, + uncertainty_after_minutes, latitude, longitude, timezone_id, timezone_offset +on public.profiles +for each row execute function public.invalidate_agentic_rectification_results_on_profile_change(); + +commit; diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 75841466..1fcdc05d 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -169,6 +169,38 @@ test("Agentic rectification scrolls the conversation container as streamed messa assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/); }); +test("rectification activity renders real completed work above the Agent message", () => { + const activityHelper = chat.slice( + chat.indexOf("function activityFromReceipt"), + chat.indexOf("export function RectificationAgenticChat"), + ); + assert.match(activityHelper, /receipt\.tools/); + assert.doesNotMatch(activityHelper, /receipt\.phases/); + assert.match(activityHelper, /filter\(isPublicRectificationTool\)/); + assert.match(activityHelper, /TOOL_LABELS\[tool\]/); + assert.match(activityHelper, /receipt\.methods/); + assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/); + assert.match(activityHelper, /METHOD_LABELS\[method\]/); + assert.match(chat, /"rectification-read-case": "读取校正记录"/); + + const messageRender = chat.slice( + chat.indexOf('{messages.map((message) => ('), + chat.indexOf('{candidateResult?.selectionAllowed'), + ); + assert.ok(messageRender.indexOf('className="rectification-activity"') < messageRender.indexOf("")); + for (const genericCopy of ["开始本轮执行", "正在加载专用方法", "专用方法已加载", "本轮完成"]) { + assert.doesNotMatch(chat, new RegExp(genericCopy)); + } + + const activityStyles = styles.slice(styles.indexOf(".rectification-message-wrap")); + assert.match(activityStyles, /\.rectification-activity \{[\s\S]*border:/); + assert.match(activityStyles, /\.rectification-activity \{[\s\S]*border-radius:/); + assert.match(activityStyles, /\.rectification-activity \{[\s\S]*background:/); + assert.match(activityStyles, /\.rectification-activity \{[\s\S]*color:/); + assert.match(activityStyles, /\.rectification-activity summary:focus-visible/); + assert.doesNotMatch(activityStyles, /gradient|glow/i); +}); + test("candidate state renders from the snapshot API and never from sentinels", () => { assert.match(chat, /当前可能的出生时间/); assert.match(chat, /可以先采用一个作为当前排盘时间,也可以继续补充事件/); diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index 47d675c2..b09fac31 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -44,6 +44,7 @@ function fakeAccounting(overrides: { const completeProfile = { birth_date: "1997-08-08", + birth_place_label: "河北省邯郸市", reported_birth_time: "05:00", active_birth_time: null, birth_time_source: "family_exact", @@ -52,6 +53,7 @@ const completeProfile = { uncertainty_after_minutes: 10, latitude: 36.420487, longitude: 114.209936, + timezone_id: "Asia/Shanghai", timezone_offset: 8, }; @@ -68,6 +70,8 @@ test("loadV9RectificationProfile derives baseline snapshot, fingerprint and rang const profile = await loadV9RectificationProfile(accounting, "user-1"); assert.equal(profile.userId, "user-1"); assert.equal(profile.baseline.birth_date, "1997-08-08"); + assert.equal(profile.baseline.birth_place_label, "河北省邯郸市"); + assert.equal(profile.baseline.timezone_id, "Asia/Shanghai"); assert.equal(profile.baselineFingerprint.length, 64); assert.deepEqual(profile.candidateRange, { start_time: "04:50", end_time: "05:10" }); assert.ok(!("password" in profile.baseline)); @@ -84,18 +88,18 @@ test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async }); test("loadV9RectificationProfile rejects incomplete profiles without creating a case", async () => { - const incomplete = { - ...completeProfile, - latitude: null, - longitude: null, - timezone_offset: null, - }; - const accounting = fakeAccounting({ profile: incomplete }); - await assert.rejects( - () => loadV9RectificationProfile(accounting, "user-1"), - (error: unknown) => - error instanceof RectificationCaseServiceError && error.code === "profile_incomplete", - ); + for (const incomplete of [ + { ...completeProfile, latitude: null, longitude: null, timezone_offset: null }, + { ...completeProfile, birth_place_label: "" }, + { ...completeProfile, timezone_id: "" }, + ]) { + const accounting = fakeAccounting({ profile: incomplete }); + await assert.rejects( + () => loadV9RectificationProfile(accounting, "user-1"), + (error: unknown) => + error instanceof RectificationCaseServiceError && error.code === "profile_incomplete", + ); + } }); test("period-only profiles derive a period range; unknown derives the full day", async () => { diff --git a/frontend/tests/rectification-v9-database.test.ts b/frontend/tests/rectification-v9-database.test.ts index 19fc3c85..6b2f13e1 100644 --- a/frontend/tests/rectification-v9-database.test.ts +++ b/frontend/tests/rectification-v9-database.test.ts @@ -819,6 +819,7 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" }); assert.equal(migration.status, 0, migration.stderr); assert.match(migration.stdout, /applied 20260813010000_agentic_rectification_v9_agent_api\.sql/); + assert.match(migration.stdout, /applied 20260813020000_rectification_birth_context_activity\.sql/); assert.match(migration.stdout, /applied 20260811030000_feature_flags_admin_runtime_read_policy\.sql/); // The runtime selector flag is published and enabled. @@ -859,8 +860,9 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" update public.profiles set birth_date = '1997-08-08', reported_birth_time = '05:00', active_birth_time = null, birth_time_source = 'family_exact', uncertainty_before_minutes = 10, - uncertainty_after_minutes = 10, latitude = 36.420487, - longitude = 114.209936, timezone_offset = 8 + uncertainty_after_minutes = 10, birth_place_label = '河北省邯郸市武安市', + latitude = 36.420487, longitude = 114.209936, + timezone_id = 'Asia/Shanghai', timezone_offset = 8 where id = '66666666-6666-4666-8666-666666666666'; `); fixture.psql(` @@ -875,7 +877,7 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" '11111111-1111-4111-8111-111111111111', '66666666-6666-4666-8666-666666666666', '22222222-2222-4222-8222-222222222222', 'draft', 'jyotish-birth-time-rectification', '9.0.0', '${fingerprint}', - '{"birth_date":"1997-08-08","latitude":36.420487,"longitude":114.209936,"timezone_offset":8,"birth_time_source":"family_exact"}'::jsonb, + '{"birth_date":"1997-08-08","birth_place_label":"河北省邯郸市武安市","latitude":36.420487,"longitude":114.209936,"timezone_id":"Asia/Shanghai","timezone_offset":8,"birth_time_source":"family_exact"}'::jsonb, '{"start_time":"04:50","end_time":"05:10"}'::jsonb ); `); @@ -909,6 +911,36 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" }); assert.equal(phase.error, null, rpcError(phase.error)); + const insertReceipt = (tool: string, status: string, methods: string[]) => + service.rpc("insert_agentic_rectification_tool_receipt", { + p_user_id: "66666666-6666-4666-8666-666666666666", + p_case_id: "11111111-1111-4111-8111-111111111111", + p_turn_id: "33333333-3333-4333-8333-333333333333", + p_tool_name: tool, + p_public_phase: tool === "rectification-read-case" ? "case.loaded" : "candidates.comparing", + p_status: status, + p_input_fingerprint: null, + p_result_fingerprint: null, + p_engine_version: tool === "rectification-compare-candidates" ? "fixture-engine" : null, + p_safe_error_code: status === "failed" ? "fixture_failure" : null, + p_executed_methods: methods, + }); + for (const result of [ + await insertReceipt("rectification-read-case", "completed", []), + await insertReceipt("rectification-compare-candidates", "completed", [ + "d1-rashi", "vimshottari-dasha", "d10-dashamsa", + ]), + await insertReceipt("rectification-read-diagnostics", "failed", ["d9-navamsa"]), + ]) { + assert.equal(result.error, null, rpcError(result.error)); + } + const invalidMethod = await insertReceipt( + "rectification-compare-candidates", + "completed", + ["private-technique"], + ); + assert.match(rpcError(invalidMethod.error), /agentic_rectification_invalid_input/); + const receipt = await service.rpc("get_agentic_rectification_turn_receipt", { p_user_id: "66666666-6666-4666-8666-666666666666", p_case_id: "11111111-1111-4111-8111-111111111111", @@ -918,6 +950,16 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" const receiptRow = receipt.data as Record; assert.equal(receiptRow.status, "completed"); assert.equal(receiptRow.skill_version, "9.0.0"); + assert.equal(receiptRow.engine_version, "fixture-engine"); + assert.deepEqual(receiptRow.tools, [ + "rectification-compare-candidates", + "rectification-read-case", + ]); + assert.deepEqual(receiptRow.methods, [ + "d1-rashi", + "d10-dashamsa", + "vimshottari-dasha", + ]); // Consent must be grounded in the source turn before candidate lookup. const consent = await service.rpc("confirm_agentic_rectification_birth_time", { diff --git a/frontend/tests/rectification-v9-engine-contract.test.ts b/frontend/tests/rectification-v9-engine-contract.test.ts index 7e287e18..45ac1fb2 100644 --- a/frontend/tests/rectification-v9-engine-contract.test.ts +++ b/frontend/tests/rectification-v9-engine-contract.test.ts @@ -31,6 +31,19 @@ const REAL_ENGINE_SCORE_RESPONSE = { { time: "04:52", score: 2.9, supporting_event_ids: [], conflicting_event_ids: [] }, { time: "04:53", score: 1.2, supporting_event_ids: [], conflicting_event_ids: [] }, ], + event_contribution_matrix: { + "00000000-0000-4000-8000-000000000001": { + "04:50": { + points: 3.85, + rule_ids: ["vim_md_domain_house", "controlled_transit_jupiter_domain_house"], + technique_layers: [ + "vim_md_domain_house", + "controlled_transit_jupiter_domain_house", + "ashtakavarga_target_house_support_auxiliary", + ], + }, + }, + }, diagnostics: { primary_cluster_retention_rate: 0.86, leave_one_event_out_retention_rate: 0.81, @@ -152,6 +165,14 @@ test("runV9CandidateScore derives rank/support/gating from the real engine respo assert.equal(result.overallConfidence, "high", "margin>=40 and retention>=0.8"); assert.equal(result.marginPercent, 42.5); assert.equal(result.algorithmVersion, "rectification-v5-matrix-scoring-2"); + assert.deepEqual(result.executedMethods, [ + "d1-rashi", + "vimshottari-dasha", + "narayana-dasha", + "d24-chaturvimshamsha", + "gochara", + "ashtakavarga", + ]); } finally { restore(); } @@ -215,6 +236,12 @@ test("runV9Diagnostics maps the real diagnostics response keys", async () => { assert.equal(result.diagnostics.primary_secondary_margin_percent, 42.5); assert.equal(result.diagnostics.leave_one_event_out_retention_rate, 0.81); assert.deepEqual(result.diagnostics.most_discriminating_layers, ["vimsottari_dasha"]); + assert.deepEqual(result.executedMethods, [ + "d1-rashi", + "vimshottari-dasha", + "narayana-dasha", + "d24-chaturvimshamsha", + ]); } finally { restore(); } diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index 9d015e34..5aa6df6e 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -319,3 +319,83 @@ test("v9 agent api migration must never be duplicated into the identity foundati "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", ); }); + +// --------------------------------------------------------------------------- +// 20260813020000_rectification_birth_context_activity.sql +// --------------------------------------------------------------------------- + +const birthContextActivityMigration = readFileSync( + new URL( + "../supabase/migrations/20260813020000_rectification_birth_context_activity.sql", + import.meta.url, + ), + "utf8", +); + +const birthContextActivityMigrationCopy = fileURLToPath( + new URL( + "../db/migrations/20260813020000_rectification_birth_context_activity.sql", + import.meta.url, + ), +); + +test("birth-context activity migration follows the existing V9 API migration in one transaction", () => { + assert.ok( + "20260813020000_rectification_birth_context_activity.sql" > + "20260813010000_agentic_rectification_v9_agent_api.sql", + ); + assert.match(birthContextActivityMigration, /^-- Created 2026-08-12\.[\s\S]*\nbegin;[\s\S]*^commit;$/m); +}); + +test("birth-context activity migration persists only allowlisted completed tool methods", () => { + assert.match( + birthContextActivityMigration, + /add column if not exists executed_methods jsonb not null default '\[\]'::jsonb/, + ); + assert.match( + birthContextActivityMigration, + /drop function if exists public\.insert_agentic_rectification_tool_receipt\(\s*uuid, uuid, uuid, text, text, text, text, text, text, text\s*\)/, + ); + const insertReceipt = birthContextActivityMigration.slice( + birthContextActivityMigration.indexOf("create or replace function public.insert_agentic_rectification_tool_receipt"), + birthContextActivityMigration.indexOf("create or replace function public.get_agentic_rectification_turn_receipt"), + ); + assert.match(insertReceipt, /p_executed_methods jsonb/); + assert.match(insertReceipt, /uuid, uuid, uuid, text, text, text, text, text, text, text, jsonb/); + for (const method of [ + "d1-rashi", "d2-hora", "d4-chaturthamsha", "d9-navamsa", "d10-dashamsa", + "d11-labhamsha", "d24-chaturvimshamsha", "d30-trimshamsha", + "vimshottari-dasha", "narayana-dasha", "gochara", "ashtakavarga", + "shadbala", "arudha-pada", "functional-benefic-malefic", + ]) { + assert.match(insertReceipt, new RegExp(`'${method}'`)); + } + + const getReceipt = birthContextActivityMigration.slice( + birthContextActivityMigration.indexOf("create or replace function public.get_agentic_rectification_turn_receipt"), + birthContextActivityMigration.indexOf("create temporary table rectification_birth_context_affected_cases"), + ); + assert.equal((getReceipt.match(/tr\.status = 'completed'/g) ?? []).length, 2); + assert.match(getReceipt, /'methods', v_methods/); +}); + +test("birth-context profile changes rebaseline resumable cases and invalidate active results", () => { + assert.match(birthContextActivityMigration, /baseline_birth_snapshot[\s\S]*'birth_place_label'[\s\S]*'timezone_id'/); + assert.match( + birthContextActivityMigration, + /update public\.agentic_rectification_cases c\s+set status = 'needs_rebaseline'[\s\S]*from rectification_birth_context_affected_cases affected\s+where c\.id = affected\.id;/, + ); + assert.match(birthContextActivityMigration, /status = any \(public\.agentic_rectification_resumable_statuses\(\)\)/); + assert.match(birthContextActivityMigration, /update public\.agentic_rectification_results r[\s\S]*r\.invalidated_at is null/); + assert.ok((birthContextActivityMigration.match(/old\.birth_place_label is distinct from new\.birth_place_label/g) ?? []).length >= 2); + assert.ok((birthContextActivityMigration.match(/old\.timezone_id is distinct from new\.timezone_id/g) ?? []).length >= 2); + assert.ok((birthContextActivityMigration.match(/after update of birth_date, birth_place_label/g) ?? []).length >= 2); +}); + +test("birth-context activity migration stays out of the identity migration tree", () => { + assert.equal( + existsSync(birthContextActivityMigrationCopy), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); +}); diff --git a/frontend/tests/rectification-v9-status-security.test.ts b/frontend/tests/rectification-v9-status-security.test.ts index 3e10e0a9..8173124d 100644 --- a/frontend/tests/rectification-v9-status-security.test.ts +++ b/frontend/tests/rectification-v9-status-security.test.ts @@ -174,7 +174,7 @@ test("baseline change invalidates results and forces needs_rebaseline", () => { assert.equal(evidenceWritesAllowed("needs_rebaseline"), true); }); -test("tool outputs never leak birth data, raw scores or the baseline snapshot", async () => { +test("read-case gives the Agent authoritative birth context without raw internals", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ @@ -191,13 +191,24 @@ test("tool outputs never leak birth data, raw scores or the baseline snapshot", const projection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise>; }).execute({ caseId: CASE_ID }); + assert.deepEqual(projection.birth_context, { + birth_date: "1997-08-08", + reported_birth_time: "05:00", + active_birth_time: null, + candidate_range: CANDIDATE_RANGE, + birthplace: { + label: "河北省邯郸市", + latitude: 36.420487, + longitude: 114.209936, + }, + timezone: { + id: "Asia/Shanghai", + offset: 8, + }, + }); const serialized = JSON.stringify(projection); - assert.doesNotMatch(serialized, /birth_date/); - assert.doesNotMatch(serialized, /1997-08-08/); - assert.doesNotMatch(serialized, /latitude/); - assert.doesNotMatch(serialized, /longitude/); assert.doesNotMatch(serialized, /baseline_birth_snapshot/); - assert.doesNotMatch(serialized, /reported_birth_time/); + assert.doesNotMatch(serialized, /event_contribution_matrix|rule_ids|raw_score|weight/); }); test("safe tool error mapping downgrades unknown engine failures", () => { diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index c61745aa..004a3895 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -20,7 +20,15 @@ import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/ca type StreamChunk = { type: string; - payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown }; + payload?: { + toolName?: unknown; + text?: unknown; + args?: unknown; + error?: unknown; + result?: unknown; + output?: unknown; + }; + object?: unknown; }; function chunk(type: string, payload?: Record): StreamChunk { @@ -28,7 +36,7 @@ function chunk(type: string, payload?: Record): StreamChunk { } test("fullStream chunks map to the allowlisted NDJSON phases only", () => { - assert.deepEqual(mapStreamChunkToPhase(chunk("start") as never), { type: "run.started" }); + assert.equal(mapStreamChunkToPhase(chunk("start") as never), null); assert.deepEqual( mapStreamChunkToPhase(chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }) as never), { type: "skill.started" }, @@ -39,11 +47,39 @@ test("fullStream chunks map to the allowlisted NDJSON phases only", () => { ); assert.deepEqual( mapStreamChunkToPhase(chunk("tool-call", { toolName: "rectification-compare-candidates" }) as never), - { type: "candidates.comparing" }, + { type: "candidates.comparing", tool: "rectification-compare-candidates" }, ); assert.deepEqual( - mapStreamChunkToPhase(chunk("tool-result", { toolName: "rectification-compare-candidates" }) as never), - { type: "candidates.updated" }, + mapStreamChunkToPhase(chunk("tool-result", { + toolName: "rectification-compare-candidates", + result: { + executed_methods: [ + "d1-rashi", + "vimshottari-dasha", + "internal-secret-technique", + "d1-rashi", + ], + birth_context: { birth_date: "1997-08-08" }, + event_contribution_matrix: { secret: true }, + rule_ids: ["internal-rule"], + }, + }) as never), + { + type: "candidates.updated", + tool: "rectification-compare-candidates", + methods: ["d1-rashi", "vimshottari-dasha"], + }, + ); + assert.equal( + mapStreamChunkToPhase(chunk("tool-call", { toolName: "rectification-read-case" }) as never), + null, + ); + assert.deepEqual( + mapStreamChunkToPhase(chunk("tool-result", { + toolName: "rectification-read-case", + result: { birth_context: { birth_date: "1997-08-08", latitude: 36.4 } }, + }) as never), + { type: "case.loaded", tool: "rectification-read-case" }, ); assert.deepEqual( mapStreamChunkToPhase(chunk("text-delta", { text: "你好" }) as never), @@ -76,6 +112,29 @@ test("streamToolNames exposes only allowlisted rectification tools", () => { test("safePublicEvent drops anything outside the allowlist", () => { assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" }); assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" }); + assert.deepEqual( + safePublicEvent({ + type: "case.loaded", + tool: "rectification-read-case", + text: "1997-08-08 河北省邯郸市", + methods: ["d1-rashi"], + birth_context: { latitude: 36.4 }, + }), + { type: "case.loaded", tool: "rectification-read-case" }, + ); + assert.deepEqual( + safePublicEvent({ + type: "candidates.updated", + tool: "rectification-compare-candidates", + methods: ["d10-dashamsa", "private-method", "d10-dashamsa"], + rule_ids: ["private-rule"], + }), + { + type: "candidates.updated", + tool: "rectification-compare-candidates", + methods: ["d10-dashamsa"], + }, + ); assert.equal(safePublicEvent({ type: "provider.reasoning", text: "内部" }), null); assert.equal(safePublicEvent({ type: "tool.payload", text: "秘密" }), null); assert.equal(safePublicEvent({ type: "raw" }), null); diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index 3946649a..7d047e1c 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -114,8 +114,10 @@ export function computeFixture(overrides: { baseline_profile_fingerprint: overrides.baselineProfileFingerprint ?? "a".repeat(64), baseline_birth_snapshot: overrides.baselineBirthSnapshot ?? { birth_date: "1997-08-08", + birth_place_label: "河北省邯郸市", latitude: 36.420487, longitude: 114.209936, + timezone_id: "Asia/Shanghai", timezone_offset: 8, birth_time_source: "family_exact", reported_birth_time: "05:00",