Compare commits

...

13 Commits

Author SHA1 Message Date
Jesse_Chen 7c03c1a4b5 fix(rectification): confirm clear events in same turn
Staging Backend Quality Gate / validate (pull_request) Successful in 17m59s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 14:18:48 +08:00
jesse 892e43fb23 Merge pull request #33: fix(rectification): bind evidence to current server turn
Staging Backend Quality Gate / validate (push) Successful in 12m11s
Staging Backend Quality Gate / publish (push) Successful in 8m49s
Bind V9 evidence and consent to the server-owned current turn.
2026-08-12 12:30:51 +08:00
Jesse_Chen c7dfe1c166 fix(rectification): bind evidence to current server turn
Staging Backend Quality Gate / validate (pull_request) Successful in 16m51s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 12:12:10 +08:00
jesse 8beed38951 Merge pull request #32: fix(rectification): hydrate persisted case turns
Staging Backend Quality Gate / validate (push) Successful in 11m16s
Staging Backend Quality Gate / publish (push) Successful in 7m33s
Restore asynchronously loaded persisted rectification turns and preserve receipt methods.
2026-08-12 11:03:09 +08:00
Jesse_Chen 220828d6f0 fix(rectification): hydrate persisted case turns
Staging Backend Quality Gate / validate (pull_request) Successful in 15m6s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 10:45:49 +08:00
jesse 31fa6aaa50 Merge pull request #31 from codex/rectification-activity-birth-context-20260812
Staging Backend Quality Gate / validate (push) Successful in 10m45s
Staging Backend Quality Gate / publish (push) Successful in 7m31s
fix(rectification): surface real activity context
2026-08-12 10:03:13 +08:00
Jesse_Chen 15a9e3f248 test(rectification): include birth context in entry fixture
Staging Backend Quality Gate / validate (pull_request) Successful in 14m59s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 09:46:54 +08:00
Jesse_Chen 397b6ef7c2 fix(rectification): surface real activity context
Staging Backend Quality Gate / validate (pull_request) Failing after 12m4s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 09:28:47 +08:00
jesse 689799102e Merge pull request #30: fix(rectification): skip settlement for free turns
Staging Backend Quality Gate / validate (push) Successful in 11m53s
Staging Backend Quality Gate / publish (push) Successful in 8m24s
Free opening/read_only turns no longer invoke complete_usage without a reservation.
2026-08-12 03:16:38 +08:00
Jesse_Chen da384357d0 fix(rectification): skip settlement for free turns
Staging Backend Quality Gate / validate (pull_request) Successful in 15m26s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 02:58:35 +08:00
jesse 863f65d090 Merge pull request #29: fix(rectification): pass server case id to agent
Staging Backend Quality Gate / validate (push) Successful in 11m48s
Staging Backend Quality Gate / publish (push) Successful in 8m11s
Pass the server-validated Case ID into V9 Agent messages so rectification tools do not guess an invalid case reference.
2026-08-12 02:19:59 +08:00
Jesse_Chen 32b65cfaf3 fix(rectification): pass server case id to agent
Staging Backend Quality Gate / validate (pull_request) Successful in 16m22s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 02:00:08 +08:00
jesse db9e7ba2a4 Merge pull request #28 from codex/fix-rectification-session-model-20260811
Staging Backend Quality Gate / validate (push) Successful in 11m38s
Staging Backend Quality Gate / publish (push) Successful in 7m18s
fix(rectification): bind default session model
2026-08-12 01:25:01 +08:00
33 changed files with 1211 additions and 182 deletions
+91
View File
@@ -2914,3 +2914,94 @@
- 防复发:任何服务器端创建 `birth_time_rectification` Session 的路径都必须在同一事务内得到可解析的持久化模型与版本;前端显示的默认模型不能替代数据库绑定。
- 相关记录:BUG-060、BUG-163、BUG-168
- 修复版本:待提交
## BUG-170 | V9 Agent 开场未收到 Case ID 导致工具调用失败
- 状态:resolved(本地候选)
- 首次发现:2026-08-11
- 最近更新:2026-08-11
- 影响面:`POST /api/rectification/agent` 的 opening、message、read-only Agent 消息,以及所有要求 `caseId` 的 V9 rectification 工具调用。
- 用户现象:接口已返回 `200 application/x-ndjson`Skill 与 Case 均已加载,但回答正文报告 `invalid_case_id`,最后事件为 `run.failed`
- 触发条件:Agent 按指令调用 `rectification-read-case`,但服务端构造的 Agent 消息没有提供当前 Case ID。
- 根因:请求路由和 `runV9AgentTurn` 已验证 Case/Session 绑定,但 `buildAgentMessages` 只传时间与用户消息;模型只能猜测工具所需的 `caseId`
- 修复:在共享 Agent 消息构造处加入服务端已验证的唯一 Case ID,并明确所有 rectification 工具必须原样使用;不放宽 UUID、所有权或 Case/Session 绑定校验。
- 验证:新增回归测试捕获实际传给 Agent 的 opening 消息,要求包含精确服务端 Case ID;staging 需以原请求确认流以 `run.completed` 结束。
- 防复发:任何由模型调用、但值由服务端拥有的工具引用,都必须在 Agent 上下文中显式提供,不能要求模型猜测。
- 相关记录:BUG-163、BUG-169
- 修复版本:待提交
## BUG-171 | V9 免费开场生成成功后仍执行 usage settlement 导致 run.failed
- 状态:resolved(本地候选)
- 首次发现:2026-08-11
- 最近更新:2026-08-11
- 影响面:`POST /api/rectification/agent``opening``read_only` 免费 Turn,以及回答完成后的 Turn 最终状态与 assistant message 持久化。
- 用户现象:Agent 已加载 Skill 与正确 Case,并输出完整回答,但 NDJSON 最终事件仍为 `run.failed`;数据库 Turn 为 `retryable` 且没有持久化 assistant message。
- 触发条件:免费 `opening``read_only` Turn 正常生成回答并进入成功收尾。
- 根因:路由的 `billing.reserve()` 对免费 Turn 不创建 `usage_reservations`,但 `billing.complete()` 仍调用 `complete_usage`;数据库因找不到 reservation 返回 `request_missing`Agent runner 将已成功回答降级为 `usage_settlement_failed`
- 修复:免费 Turn 在 settlement adapter 中直接成功返回;只有 `message` Turn 才执行 reservation 与 settlement,保留原有付费消息的计费、幂等与失败保护。
- 验证:staging 数据库确认目标 Case 没有 usage reservation,直接调用同一结算函数稳定返回 `request_missing`;新增合同回归要求免费 Turn 同时绕过 reservation 与 settlement。部署后需以真实 opening 确认最终 `run.completed`、Turn `completed` 且 assistant message 已持久化。
- 防复发:任何声明为免费的 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 重建固定步骤且 `<details>` 没有专用样式;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
- 修复版本:待提交
## BUG-173 | 恢复生时校正会话时历史消息未随异步 Case GET 渲染
- 状态:resolved(本地候选)
- 首次发现:2026-08-12
- 最近更新:2026-08-12
- 影响面:V9 生时校正恢复入口、历史 turns 和持久化 Activity 技法展示。
- 用户现象:点击“继续上次校正”后只看到页面壳层,Case GET 已返回 assistant turn,但消息区为空。
- 触发条件:恢复已有 Case;聊天组件先以空 `initialTurns` 挂载,Case GET 稍后返回历史 turns。
- 根因:`RectificationAgenticChat` 仅在 `useState` 初始化时投影 `initialTurns`,未在异步 props 更新后同步;同时 `page.tsx` 映射持久化 receipt 时漏掉 `methods`
- 修复:复用同一个 turns→messages 投影函数,并让父层在持久化 turns 到达时用最后一个 Turn ID 重挂载该局部聊天组件;补齐 receipt `methods` 的客户端类型和安全映射。
- 验证:新增静态回归检查覆盖异步 hydration 和 methods 映射;部署后以真实登录浏览器确认历史 Agent 消息及 Activity 渲染。
- 防复发:任何异步加载后传入的初始化数据不能只依赖子组件首次 state 初始化;公开 receipt 新字段必须贯通 API projection、page mapping 与组件类型。
- 相关记录:BUG-172
- 修复版本:待提交
## BUG-174 | V9 当前用户事件无法绑定服务端 Turn,Agent 要求重复发送
- 状态:resolved(本地候选)
- 首次发现:2026-08-12
- 最近更新:2026-08-12
- 影响面:V9 生时校正事件证据提出与最终出生时间确认的用户原话绑定。
- 用户现象:用户输入“2016 年 9 月上大学”后,Agent 能识别事件并调用“整理事件证据”,但声称当前轮锚点没有接通,要求用户重复发送同一句话。
- 触发条件:Agent 在当前消息轮调用 `rectification-propose-evidence`;工具 schema 要求模型回传 `sourceTurnId`,但消息上下文和 `rectification-read-case` 安全投影都不公开当前 Turn UUID。
- 根因:服务端 runner 已在 Agent 执行前创建并持有可信 `turnId`,工具仍错误地把该内部引用交给模型提供,形成模型无法满足的参数合同;重复发送不会修复这一合同缺口。
- 修复:`rectification-propose-evidence``rectification-confirm-birth-time` 不再接受模型提供的 `sourceTurnId`,统一使用工具上下文中的服务端当前 `turnId`;保留数据库 Case ownership、Turn existence 与 quote/consent 原文匹配校验。
- 验证:回归断言 propose/confirm schema 拒绝模型传入 Turn ID,并确认事件证据与最终确认 RPC 的 `p_source_turn_id` 均等于服务端当前 Turn;聚焦测试通过,staging 部署证据见本轮发布记录。
- 防复发:当前请求已经由服务器掌握的内部 ID 不得再要求模型猜测或回传;原文真实性继续在数据库信任边界验证,不能以放宽 quote 校验规避绑定问题。
- 相关记录:BUG-170、BUG-172、BUG-173
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
## BUG-175 | V9 明确事件被强制要求额外二次确认
- 状态:resolved(本地候选)
- 首次发现:2026-08-12
- 最近更新:2026-08-12
- 影响面:V9 生时校正事件证据写入、Agent 访谈连续性与候选评分输入。
- 用户现象:用户已经明确说出“2016 年 9 月上大学”后,Agent 仍要求再回答一次“对/确认”,否则事件不进入评分账本。
- 触发条件:当前轮包含日期、主体和事件语义均明确的新事件,Agent 完成 `rectification-propose-evidence` 后继续按旧 Prompt/Skill 等待下一轮确认。
- 根因:Prompt、工具描述、Skill 文档与 TypeScript 状态机把“confirmed 只能由服务器确认路径产生”错误等同于“必须额外等待一轮用户同意”;同时 `scorableEvidence()` 又把 `pending_confirmation` 纳入正式评分,导致确认语义与评分边界不一致。数据库确认 RPC 实际已支持 `draft -> confirmed`
- 修复:当前轮主动、明确、单一且无歧义的用户事件由 Agent 在同一个 run 内依次调用 propose 与服务器 confirm;只有模糊、冲突、修订或需要补充原文外信息时追问。评分输入统一只接受 `confirmed` 且有日期的证据,保留 quote grounding、Case/Turn ownership、幂等与 append-only 修订链。
- 验证:回归测试覆盖同轮 `propose -> confirm` 工具顺序、`draft -> confirmed` 合法迁移、Prompt 不再要求重复确认,以及 pending/draft 不进入正式评分。
- 防复发:服务器确认路径与额外对话轮次必须分开建模;任何 pending 状态不得隐式参与正式候选评分。
- 相关记录:BUG-170、BUG-174
- 修复版本:本次提交(staging 精确 SHA 以发布记录为准)
@@ -209,7 +209,7 @@ export async function POST(request: Request) {
async reserve() {
// opening / read-only turns are free; the first substantive run
// reserves once, and resume/retry reuse the same case-bound request.
if (action === "opening" || action === "read_only") {
if (action !== "message") {
return { success: true, status: 200 };
}
try {
@@ -233,6 +233,7 @@ export async function POST(request: Request) {
}
},
async complete(usage) {
if (action !== "message") return true;
try {
const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId);
const settlement = await completeUsage(accounting, userId, billingRequestId, {
@@ -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,
};
+48
View File
@@ -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);
}
+2 -1
View File
@@ -2372,6 +2372,7 @@ export default function Home() {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
} : null,
@@ -3480,7 +3481,7 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}`}
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.at(-1)?.id ?? "loading"}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
@@ -13,6 +13,7 @@ export type PersistedRectificationTurn = Readonly<{
status: string;
phases: readonly string[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
@@ -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,74 @@ 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<Partial<Record<PublicRectificationPhase, string>>> = {
"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<Record<PublicRectificationTool, string>> = {
"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<Record<PublicRectificationMethod, string>> = {
"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 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]))],
};
}
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 messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
return initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
receiptActivity: activityFromReceipt(turn.receipt),
receiptStatus: turn.receipt?.status,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
});
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
@@ -111,27 +153,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onRestart,
} = props;
const [messages, setMessages] = useState<RenderMessage[]>(() =>
initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
receiptActivity: activityFromReceipt(turn.receipt),
receiptStatus: turn.receipt?.status,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
}),
);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -213,13 +235,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<string>();
let liveActivity: ReceiptActivity = { steps: [], methods: [] };
const activitySteps = new Set<string>();
const activityMethods = new Set<string>();
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
@@ -268,9 +291,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 +312,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 +415,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
{messages.map((message) => (
<div key={message.renderKey} className="rectification-message-wrap">
<ChatMessageRow message={message} />
{message.receiptActivity && message.receiptActivity.length > 0 && (
{message.receiptActivity && (message.receiptActivity.steps.length > 0 || message.receiptActivity.methods.length > 0) && (
<details className="rectification-activity">
<summary></summary>
<ol>
{message.receiptActivity.map((step) => <li key={step}>{step}</li>)}
</ol>
{message.receiptActivity.steps.length > 0 && (
<ol>
{message.receiptActivity.steps.map((step) => <li key={step}>{step}</li>)}
</ol>
)}
{message.receiptActivity.methods.length > 0 && (
<div className="rectification-activity-methods">
<span>使</span>
<ul>{message.receiptActivity.methods.map((method) => <li key={method}>{method}</li>)}</ul>
</div>
)}
</details>
)}
<ChatMessageRow message={message} />
</div>
))}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
@@ -154,6 +154,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let answerText = "";
const phases: string[] = [];
const toolsUsed = new Set<string>();
const emittedActivity = new Set<string>();
const repeatedCalls = new Map<string, number>();
const startedAt = Date.now();
@@ -182,8 +183,11 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let streamFailed = false;
let finished = false;
try {
await emit({ type: "run.started" });
await persistPhase("run.started", null);
if (!emittedActivity.has("run.started")) {
emittedActivity.add("run.started");
await emit({ type: "run.started" });
await persistPhase("run.started", null);
}
const result = await (agent as unknown as {
stream(
messages: unknown[],
@@ -212,8 +216,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
answerText += phaseEvent.text ?? "";
await emit(phaseEvent);
} else {
await emit(phaseEvent);
await persistPhase(phaseEvent.type, null);
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
if (!emittedActivity.has(key)) {
emittedActivity.add(key);
await emit(phaseEvent);
await persistPhase(phaseEvent.type, phaseEvent.tool ?? null);
}
}
}
for (const toolName of streamToolNames(chunk as never)) {
@@ -337,18 +345,20 @@ function buildAgentMessages(options: V9AgentRunOptions, _attempt: number): unkno
void _attempt;
const timeContext = options.timeContext
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
const caseContext = `【服务端 Case ID】${options.caseId}。所有 rectification 工具调用的 caseId 必须原样使用此值。`;
if (options.action === "opening") {
return [{
role: "user",
content: [
timeContext,
caseContext,
"【服务端开场指令】这是本校正 Case 的首次开场,还没有用户输入。请先调用 skill 工具加载 jyotish-birth-time-rectification,再调用 rectification-read-case 读取服务端 Case 与证据摘要,然后用简体中文自然开场:说明你会通过已发生的人生事件来校正出生时间,并自然地提出第一个最有用的问题(只需一个问题)。",
].join("\n"),
}];
}
return [{
role: "user",
content: [timeContext, options.message ?? ""].join("\n"),
content: [timeContext, caseContext, options.message ?? ""].join("\n"),
}];
}
@@ -26,8 +26,10 @@ type AccountingClient = SupabaseClient;
export type V9BaselineSnapshot = Readonly<{
birth_date: string;
birth_place_label: string;
latitude: number;
longitude: number;
timezone_id: string;
timezone_offset: number;
birth_time_source: string;
birth_time_period: string | null;
@@ -136,8 +138,10 @@ function deriveCandidateRange(input: {
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
const canonical = [
baseline.birth_date,
baseline.birth_place_label,
String(baseline.latitude),
String(baseline.longitude),
baseline.timezone_id,
String(baseline.timezone_offset),
baseline.birth_time_source,
baseline.birth_time_period ?? "",
@@ -156,7 +160,7 @@ export async function loadV9RectificationProfile(
const { data, error } = await accounting
.from("profiles")
.select(
"birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset",
"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",
)
.eq("id", userId)
.single();
@@ -166,6 +170,8 @@ export async function loadV9RectificationProfile(
const birthDate = normalizePersistedBirthDate(row.birth_date);
const latitude = numberOrNull(row.latitude);
const longitude = numberOrNull(row.longitude);
const birthPlaceLabel = typeof row.birth_place_label === "string" ? row.birth_place_label.trim() : "";
const timezoneId = typeof row.timezone_id === "string" ? row.timezone_id.trim() : "";
const timezoneOffset = numberOrNull(row.timezone_offset);
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
const reportedTime = timeValue(row.reported_birth_time);
@@ -173,14 +179,16 @@ export async function loadV9RectificationProfile(
const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null;
const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes);
const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes);
if (!birthDate || latitude === null || longitude === null || timezoneOffset === null || !source) {
if (!birthDate || !birthPlaceLabel || latitude === null || longitude === null || !timezoneId || timezoneOffset === null || !source) {
throw new RectificationCaseServiceError("profile_incomplete");
}
const baseline: V9BaselineSnapshot = {
birth_date: birthDate,
birth_place_label: birthPlaceLabel,
latitude,
longitude,
timezone_id: timezoneId,
timezone_offset: timezoneOffset,
birth_time_source: source,
birth_time_period: period,
@@ -22,6 +22,8 @@
* confirmation gate is bound to the engine's own can_confirm_exact_minute.
*/
import type { PublicRectificationMethod } from "./public-receipt";
export class RectificationEngineError extends Error {
readonly code: string;
@@ -59,6 +61,7 @@ export type V9EngineScoreResult = Readonly<{
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
executedMethods: readonly PublicRectificationMethod[];
}>;
export type V9EngineDiagnostics = Readonly<{
@@ -67,10 +70,55 @@ export type V9EngineDiagnostics = Readonly<{
diagnostics: Readonly<Record<string, unknown>>;
missingLayers: readonly string[];
canConfirmExactMinute: boolean;
executedMethods: readonly PublicRectificationMethod[];
}>;
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const DOMAIN_METHODS: Readonly<Record<string, readonly PublicRectificationMethod[]>> = {
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<string, unknown>).flatMap((eventRows) => {
if (!eventRows || typeof eventRows !== "object") return [];
return Object.values(eventRows as Record<string, unknown>).flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const layers = (candidate as Record<string, unknown>).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<PublicRectificationMethod>([
"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),
};
}
@@ -91,12 +91,12 @@ export const DISTINCT_KIND_GROUPS: readonly (readonly EvidenceKind[])[] = [
/**
* Legal evidence status transitions. Only the server confirmation path may
* produce `confirmed`; an agent may only ever create `draft` rows.
* produce `confirmed`; a grounded draft may use that server path in the same run.
*/
export const EVIDENCE_STATUS_TRANSITIONS: Readonly<
Record<EvidenceStatus, readonly EvidenceStatus[]>
> = {
draft: ["pending_confirmation", "rejected", "superseded"],
draft: ["pending_confirmation", "confirmed", "rejected", "superseded"],
pending_confirmation: ["confirmed", "rejected", "superseded"],
confirmed: ["superseded"],
superseded: [],
@@ -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<string>(PUBLIC_RECTIFICATION_PHASES);
const TOOL_SET = new Set<string>(PUBLIC_RECTIFICATION_TOOLS);
const METHOD_SET = new Set<string>(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.
@@ -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<Record<string, PublicRectificationPhase>> = {
"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<Partial<Record<PublicRectificationTool, PublicRectificationPhase>>> = {
"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<Record<string, PublicRectificationPhase>> = {
@@ -55,8 +50,27 @@ const TOOL_PHASE_ON_RESULT: Readonly<Record<string, PublicRectificationPhase>> =
"rectification-close-case": "run.completed",
};
const METHOD_TOOLS = new Set<PublicRectificationTool>([
"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<string, unknown>
: {};
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<string, unknown>).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 } : {}),
};
}
@@ -15,8 +15,10 @@ import {
type EvidenceKind,
} from "./evidence-model";
import {
isPublicRectificationMethod,
isPublicRectificationPhase,
isPublicRectificationTool,
type PublicRectificationMethod,
type PublicRectificationPhase,
type PublicRectificationTool,
} from "./public-receipt";
@@ -322,7 +324,7 @@ export function scorableEvidence(
): V9CaseDossier["evidence"] {
return evidence.filter(
(item) =>
(item.status === "confirmed" || item.status === "pending_confirmation")
item.status === "confirmed"
&& item.datePrecision !== "unknown"
&& (item.occurredFrom || item.occurredTo),
);
@@ -442,6 +444,7 @@ export async function insertV9ToolReceipt(
resultFingerprint?: string | null;
engineVersion?: string | null;
safeErrorCode?: string | null;
executedMethods?: readonly PublicRectificationMethod[];
},
): Promise<void> {
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<unknown>(
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),
};
+7 -6
View File
@@ -55,14 +55,15 @@ const agenticRectificationInstructions = `你是生时校正 Agent,只服务
1. skill jyotish-birth-time-rectification Case rectification-read-case Case/Dossier
2.
3. input caseIdsourceTurnIdevidenceIdresultIdcandidateIdquoteproposedKind userIdevents
3. input caseIdevidenceIdresultIdcandidateIdquoteproposedKind userIdevents
4. year
5. candidate=accepted=confirmed=accepted confirmed
6. / pending draft revise revision
7. truth/consent/selection policy
8. //
9.
10. Skill /`;
6. propose-evidence confirm-evidence/
7. revise revision
8. truth/consent/selection policy
9. //
10.
11. Skill /`;
export function getRectificationV9Agent(
model: ResolvedLanguageModel,
+42 -25
View File
@@ -1,8 +1,8 @@
/**
* V9 rectification tools (Case-ref only).
*
* The model may only pass minimal references: caseId, sourceTurnId, quote,
* proposedKind, evidenceId, resultId, candidateId (plus optional date fields
* The model may only pass minimal references: caseId, quote, proposedKind,
* evidenceId, resultId, candidateId (plus optional date fields
* the model grounded in the user's own words). userId, birth data, candidate
* ranges, event arrays, scores and permission booleans never appear in tool
* inputs. Every tool authenticates through the Case row, validates ownership
@@ -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<typeof parseDossierForTools>): Record<string, unknown> {
function safeBirthContext(compute: Awaited<ReturnType<typeof loadV9CaseCompute>>): Record<string, unknown> {
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<typeof parseDossierForTools>,
compute: Awaited<ReturnType<typeof loadV9CaseCompute>>,
): Record<string, unknown> {
const caseRow = dossier.case;
const latest = dossier.latestResult;
return {
@@ -81,6 +104,7 @@ function safeCaseProjection(dossier: ReturnType<typeof parseDossierForTools>): R
by_kind: dossier.kindCounts,
scorable: dossier.scorable.length,
},
birth_context: safeBirthContext(compute),
latest_result: latest
? {
result_id: latest.resultId,
@@ -190,13 +214,6 @@ function assertEvidenceRef(input: { evidenceId?: unknown }): string {
return input.evidenceId;
}
function assertTurnRef(input: { sourceTurnId?: unknown }): string {
if (typeof input.sourceTurnId !== "string" || !uuidPattern.test(input.sourceTurnId)) {
throw new RectificationToolServiceError("invalid_source_turn_id");
}
return input.sourceTurnId;
}
function assertCandidateRef(input: { resultId?: unknown; candidateId?: unknown }): { resultId: string; candidateId: string } {
if (
typeof input.resultId !== "string" || !uuidPattern.test(input.resultId)
@@ -224,7 +241,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 +252,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 +272,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 +280,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) {
@@ -278,7 +297,6 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
"提出一条事件证据草稿。quote 必须能规范化匹配当前用户轮次原文;日期只保留用户说过的精度(只说年份则 year);subject 只能 self/family/othereventKind 使用受控枚举;summary 只复述用户原话,不得补充月份、原因、主动被动或人物关系。返回 evidenceId(服务器生成)。",
inputSchema: z.object({
caseId: z.string().uuid(),
sourceTurnId: z.string().uuid(),
quote: z.string().trim().min(2).max(400),
proposedKind: z.string().trim().min(1).max(48),
subject: z.enum(["self", "family", "other"]).default("self"),
@@ -290,13 +308,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const sourceTurnId = assertTurnRef(input);
if (!isEvidenceKind(input.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind");
if (!isEvidenceDomain(input.domain)) throw new RectificationToolServiceError("invalid_domain");
if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision");
const inputFingerprint = canonicalToolInputFingerprint("rectification-propose-evidence", {
caseId: input.caseId,
sourceTurnId,
sourceTurnId: turnId,
quote: input.quote,
proposedKind: input.proposedKind,
});
@@ -305,7 +322,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null;
const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null;
const result = await proposeV9Evidence(accounting, userId, input.caseId, {
sourceTurnId,
sourceTurnId: turnId,
quote: input.quote,
subject: input.subject,
eventKind: input.proposedKind,
@@ -323,7 +340,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
evidence_id: result.evidenceId,
status: "draft",
idempotent: result.idempotent,
note: "草稿证据已记录;只有用户明确确认后才进入评分账本。",
note: "草稿证据已记录。当前轮主动、明确、单一且无歧义的事件应继续调用 rectification-confirm-evidence;模糊、冲突或修订事件才等待用户补充或确认。",
};
} catch (error) {
await receipt("rectification-propose-evidence", "evidence.proposed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
@@ -335,7 +352,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const confirmEvidenceTool = createTool({
id: "rectification-confirm-evidence",
description:
"确认当前待确认的证据草稿。仅当用户本轮明确说“是/对/确认”且存在 pending draft 时调用;如果用户说“是”但没有 pending draft,本工具会拒绝并提示先补日期或先提出证据。不会把聊天文本自动升级为已确认事实。",
"通过服务器确认路径确认既有证据。当前轮主动、明确、单一且无歧义的用户事件在 propose-evidence 成功后应同轮调用;用户明确确认既有 pending draft 时也可调用。不得确认助手文本、模型推断、历史摘要、模糊或冲突事实。",
inputSchema: z.object({
caseId: z.string().uuid(),
evidenceId: z.string().uuid(),
@@ -422,7 +439,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const compareCandidatesTool = createTool({
id: "rectification-compare-candidates",
description:
"用当前已确认证据运行候选比较Vimshottari + Narayana 双轨,D9/D10 核心)。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
"用当前已确认证据运行候选比较。服务端按事件的实际领域选择分盘,并结合 VimshottariNarayana 与稳健性诊断。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
@@ -481,8 +498,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 +545,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,
@@ -623,13 +642,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
caseId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(timePattern),
sourceTurnId: z.string().uuid(),
consentQuote: z.string().trim().min(2).max(400),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId });
const sourceTurnId = assertTurnRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-birth-time", {
caseId: input.caseId,
resultId,
@@ -641,7 +658,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
resultId,
time: input.candidateId,
consentQuote: input.consentQuote,
sourceTurnId,
sourceTurnId: turnId,
});
const projection = {
saved_time: result.savedTime,
@@ -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;
@@ -21,6 +21,11 @@ test("Agentic rectification reuses one case-level usage authorization and the se
assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/);
});
test("free Agentic rectification turns bypass both usage reservation and settlement", () => {
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 2);
assert.match(rectificationRoute, /async complete\(usage\) \{\s*if \(action !== "message"\) return true;/);
});
test("standard consultation resolves and settles the session-pinned model version", () => {
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
@@ -29,6 +29,14 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => {
assert.match(component, /initialTurns:/);
});
test("persisted rectification turns hydrate after the async Case refresh", () => {
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
assert.match(chat, /useState<RenderMessage\[\]>\(\(\) => messagesFromTurns\(initialTurns\)\)/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
assert.match(component, /methods\?: readonly string\[\]/);
});
test("opening is server-owned: shouldStartOpening drives the first turn, never client history", () => {
assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/);
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
@@ -100,7 +108,7 @@ test("agent tool calls never end silently; the runner owns completion and failur
test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => {
assert.match(chat, /initialTurns/);
assert.match(chat, /const openingStarted = useRef\(false\)/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
});
@@ -169,6 +177,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("<ChatMessageRow message={message} />"));
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, /可以先采用一个作为当前排盘时间,也可以继续补充事件/);
@@ -181,6 +221,18 @@ test("candidate state renders from the snapshot API and never from sentinels", (
assert.match(chat, /已采用/);
});
test("clear current-turn events are proposed and confirmed in the same Agent run", () => {
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
"utf8",
);
assert.match(agent, /同一轮依次调用 propose-evidence 和 confirm-evidence/);
assert.match(agent, /不得要求用户重复发送或再回答.*确认/);
assert.doesNotMatch(agent, /“是\/对”只能确认当前 pending draft/);
assert.match(tools, /当前轮主动、明确、单一且无歧义/);
assert.doesNotMatch(tools, /只有用户明确确认后才进入评分账本/);
});
test("the Agent prompt cannot offer candidates while asking for more evidence", () => {
// The hard boundary lives in the prompt; no tool input carries an
// offer_selection boolean anymore.
@@ -173,6 +173,35 @@ function runOptions(overrides: Partial<V9AgentRunOptions> = {}): {
return { options: optionsValue, emitted, billing };
}
test("agent receives the exact server-owned case id for tool calls", async () => {
let observedMessages: unknown[] = [];
const agent = fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好,我是生时校正助手。" }),
chunk("finish"),
]);
const { options } = runOptions({
action: "opening",
message: null,
buildAgent: async () => ({
...agent,
stream: async (messages: unknown[]) => {
observedMessages = messages;
return agent.stream();
},
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(JSON.stringify(observedMessages), new RegExp(CASE_ID));
});
test("first turn with no real skill evidence retries once then fails without saving success", async () => {
const { options, emitted, billing } = runOptions({
accounting: fakeAccounting({
@@ -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 () => {
@@ -139,7 +139,7 @@ test("only the server confirmation path may produce confirmed evidence", () => {
assert.equal(canTransitEvidenceStatus("confirmed", "superseded"), true);
assert.equal(canTransitEvidenceStatus("superseded", "confirmed"), false);
assert.equal(canTransitEvidenceStatus("rejected", "confirmed"), false);
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), false);
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), true);
});
test("quote grounding normalizes whitespace and punctuation", () => {
@@ -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<string, unknown>;
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", {
@@ -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();
}
@@ -214,6 +214,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
};
const profile = {
birth_date: "1997-08-08",
birth_place_label: "河北省邯郸市",
reported_birth_time: "05:00",
active_birth_time: null,
birth_time_source: "family_exact",
@@ -222,6 +223,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
uncertainty_after_minutes: 10,
latitude: 36.420487,
longitude: 114.209936,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
};
const accounting = fakeAccounting({
@@ -11,7 +11,10 @@ import {
DISTINCT_KIND_GROUPS,
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import { RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
RectificationToolServiceError,
scorableEvidence,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
EVIDENCE_ID,
@@ -22,7 +25,6 @@ import {
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777";
function toolContext(overrides: {
accounting?: ReturnType<typeof fakeAccounting>;
@@ -35,6 +37,11 @@ function toolContext(overrides: {
evidence_id: EVIDENCE_ID,
idempotent: false,
}),
confirm_agentic_rectification_evidence: () => ({
evidence_id: EVIDENCE_ID,
status: "confirmed",
idempotent: false,
}),
});
return {
accounting,
@@ -47,14 +54,13 @@ function toolContext(overrides: {
};
}
test("propose-evidence schema rejects model-provided ids, birth data and ranges", async () => {
test("propose-evidence binds the server-owned current turn and rejects model-provided ids, birth data and ranges", async () => {
const { tools } = toolContext();
const schema = (tools as Record<string, { inputSchema?: { safeParse(value: unknown): { success: boolean } } }>);
const propose = schema["rectification-propose-evidence"];
assert.ok(propose?.inputSchema);
const valid = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -65,9 +71,21 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
});
assert.equal(valid.success, true);
const withTurnId = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: "77777777-7777-4777-8777-777777777777",
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
});
assert.equal(withTurnId.success, false);
const withModelId = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
@@ -78,7 +96,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
const withBirthData = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
@@ -89,7 +106,6 @@ test("propose-evidence schema rejects model-provided ids, birth data and ranges"
const withRange = propose.inputSchema!.safeParse({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
datePrecision: "month",
@@ -113,7 +129,6 @@ test("quote must be grounded in the source turn's own message", async () => {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "这段话根本不在用户消息里",
proposedKind: "career_entry",
subject: "self",
@@ -133,7 +148,6 @@ test("year-only evidence keeps year precision and normalizes to a year start", a
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年离开家去北京开始工作",
proposedKind: "career_entry",
subject: "self",
@@ -144,30 +158,48 @@ test("year-only evidence keeps year precision and normalizes to a year start", a
});
const proposeCall = accounting.calls.find((call) => call.fn === "propose_agentic_rectification_evidence");
assert.ok(proposeCall);
assert.equal(proposeCall.args.p_source_turn_id, TURN_ID);
assert.equal(proposeCall.args.p_date_precision, "year");
assert.equal(proposeCall.args.p_occurred_from, "2016-01-01");
// The model cannot supply an evidence id; the server generates it.
assert.equal("evidence_id" in proposeCall.args, false);
});
test("\"是的\" can only confirm the pending draft; a new event requires a new proposal", async () => {
const { tools } = toolContext();
test("a clear event can be proposed and confirmed through server tools in the same run", async () => {
const { accounting, tools } = toolContext();
const proposal = await (tools["rectification-propose-evidence"] as unknown as {
execute(input: unknown): Promise<{ evidence_id: string }>;
}).execute({
caseId: CASE_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2016-09",
summary: "2016年9月离家去北京工作",
});
const result = await (tools["rectification-confirm-evidence"] as unknown as {
execute(input: unknown): Promise<{ evidence_id: string; status: string }>;
}).execute({ caseId: CASE_ID, evidenceId: proposal.evidence_id });
assert.equal(result.status, "confirmed");
assert.deepEqual(
accounting.calls
.filter((call) => call.fn === "propose_agentic_rectification_evidence" || call.fn === "confirm_agentic_rectification_evidence")
.map((call) => call.fn),
["propose_agentic_rectification_evidence", "confirm_agentic_rectification_evidence"],
);
const confirmSchema = (tools["rectification-confirm-evidence"] as unknown as {
inputSchema: { safeParse(value: unknown): { success: boolean } };
}).inputSchema;
const valid = confirmSchema.safeParse({
caseId: CASE_ID,
evidenceId: EVIDENCE_ID,
});
assert.equal(valid.success, true);
// The confirm tool takes only refs; it can never create a new event.
const withQuote = confirmSchema.safeParse({
assert.equal(confirmSchema.safeParse({
caseId: CASE_ID,
evidenceId: EVIDENCE_ID,
quote: "是的",
proposedKind: "career_entry",
});
assert.equal(withQuote.success, false);
}).success, false);
});
test("revision is append-only: revise supersedes and never overwrites history", async () => {
@@ -227,7 +259,6 @@ test("propose is idempotent: replay returns the existing draft without a second
execute(input: unknown): Promise<{ idempotent: boolean }>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -242,12 +273,35 @@ test("propose is idempotent: replay returns the existing draft without a second
test("unknown date precision is allowed but still requires quote grounding", () => {
assert.equal(isDatePrecision("unknown"), true);
assert.equal(isDatePrecision("exact_minute"), false);
// Unknown-precision evidence carries no scorable date and never becomes
// confirmed from chat text alone.
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), false);
// The server confirmation path may confirm a grounded draft in the same run,
// but unknown-precision evidence still carries no scorable date.
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), true);
assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true);
});
test("only confirmed dated evidence enters scoring", () => {
const confirmed = {
id: EVIDENCE_ID,
sourceTurnId: TURN_ID,
subject: "self",
eventKind: "career_entry",
domain: "career",
occurredFrom: "2016-09-01",
occurredTo: null,
datePrecision: "month",
summary: "2016年9月离家去北京开始工作",
status: "confirmed",
supersedesEvidenceId: null,
createdAt: "2026-08-12T10:00:06.000Z",
};
const pending = { ...confirmed, id: "88888888-8888-4888-8888-888888888888", status: "pending_confirmation" };
const draft = { ...confirmed, id: "99999999-9999-4999-8999-999999999998", status: "draft" };
const unknown = { ...confirmed, id: "99999999-9999-4999-8999-999999999997", datePrecision: "unknown", occurredFrom: null };
assert.deepEqual(scorableEvidence([confirmed, pending, draft, unknown]).map((item) => item.id), [EVIDENCE_ID]);
});
test("terminal cases reject evidence writes", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
@@ -262,7 +316,6 @@ test("terminal cases reject evidence writes", async () => {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
sourceTurnId: SOURCE_TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -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)",
);
});
@@ -94,6 +94,43 @@ test("confirmed requires the engine gate plus explicit grounded consent", async
);
});
test("confirm-birth-time binds consent to the server-owned current turn", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
confirm_agentic_rectification_birth_time: () => ({
success: true,
saved_time: "05:02",
status: "confirmed",
result_id: RESULT_ID,
case_status: "confirmed",
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const confirm = tools["rectification-confirm-birth-time"] as unknown as {
inputSchema: { safeParse(value: unknown): { success: boolean } };
execute(input: unknown): Promise<unknown>;
};
const input = {
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: "05:02",
consentQuote: "就用05:02",
};
assert.equal(confirm.inputSchema.safeParse(input).success, true);
assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false);
await confirm.execute(input);
const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_birth_time");
assert.ok(call);
assert.equal(call.args.p_source_turn_id, TURN_ID);
});
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case: () => ({
@@ -152,7 +189,6 @@ test("terminal cases reject evidence writes and candidate actions", async () =>
await assert.rejects(
(tools["rectification-propose-evidence"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({
caseId: CASE_ID,
sourceTurnId: TURN_ID,
quote: "2016年9月离开家去北京工作",
proposedKind: "career_entry",
subject: "self",
@@ -174,7 +210,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 +227,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<Record<string, unknown>>;
}).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", () => {
+64 -5
View File
@@ -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<string, unknown>): StreamChunk {
@@ -28,7 +36,7 @@ function chunk(type: string, payload?: Record<string, unknown>): 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);
@@ -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",
@@ -42,7 +42,7 @@ description: "生时校正专用 Skill(V9)。以用户原话事件 + 服务
## 4. 可调用工具与边界
只调用服务器提供的 `rectification-*` 工具(read-case / propose-evidence / confirm-evidence / revise-evidence / compare-candidates / read-diagnostics / offer-candidates / accept-candidate / confirm-birth-time / close-case)。工具 input 只含最小引用(caseId、evidenceId、turnId、quote、proposedKind 等),**绝不**传:
只调用服务器提供的 `rectification-*` 工具(read-case / propose-evidence / confirm-evidence / revise-evidence / compare-candidates / read-diagnostics / offer-candidates / accept-candidate / confirm-birth-time / close-case)。工具 input 只含最小引用(caseId、evidenceId、quote、proposedKind 等),**绝不**传:
- userId、出生日期/时间/地点/时区、candidate range、完整 events 数组、分数与阈值、confirmationAllowed/selectionAllowed、profile 写入目标。
@@ -59,7 +59,8 @@ description: "生时校正专用 Skill(V9)。以用户原话事件 + 服务
## 6. 事件事实与日期真实性
- 每条证据必须有用户原话 `quote` 且能在对应轮次消息中找到规范化匹配;没有来源不得成稿。
- Agent 只能提出 evidence draft`confirmed` 只能由服务器确认路径产生。
- Agent 只能提出 evidence draft`confirmed` 只能由服务器确认路径产生。当前轮用户主动、明确、单一且无歧义的事件,在 proposal 通过原文绑定后应同轮走服务器确认路径,不要求用户再回复一次“对/确认”。
- 日期或主体不清、语义多解、与既有证据冲突、修订旧证据或需要补充原文没有的信息时才追问;修订产生的 pending evidence 不自动确认。
- 修改事实必须生成 superseding revision**不得覆盖历史**。
- 日期精度真实保留:只说年份就保留 `year`,不得诱导用户编造月份/日期。
- 禁止模型补充月份、日期、原因、主动/被动、人物关系等原文没有的信息。
@@ -65,19 +65,20 @@ other
## 6. 状态迁移
```text
draft -> pending_confirmation (服务器收到 proposal,等待确认
draft -> confirmed (当前轮明确事件:proposal 通过原文绑定后,同轮走服务器确认路径
draft -> pending_confirmation (事实模糊、冲突或需要用户补充)
pending_confirmation -> confirmed (用户明确确认 + 服务器确认路径)
pending_confirmation -> superseded(用户更正,产生修订)
confirmed -> superseded (后续修订使旧事实失效)
draft / pending_confirmation -> rejected (用户否认,保留只读历史)
```
- Agent 只能产生 `draft``confirmed` 只能由服务器确认路径产生。
- Agent 只能产生 `draft``confirmed` 只能由服务器确认路径产生。服务器确认路径不等于必须额外等待一轮用户回复。
- 终态 Caseconfirmed/closed/abandoned/superseded)禁止新增或修订证据。
- 同一请求重放不得重复写证据(幂等键 = case + source_turn + quote + kind + summary)。
## 7. 评分输入边界
- 只有 `confirmed`(或服务器明确放行的 pending)证据进入评分账本
- 只有 `confirmed` 证据进入评分账本;`draft``pending_confirmation` 都不参与评分
- `family_event` / `other` 只作背景,不推进评分覆盖计数。
- 证据变化才触发重算;相同证据指纹复用缓存,不重复评分。
@@ -13,7 +13,7 @@
- 保存 profile 需要用户明确同意 + 服务器确认门。
- accepted(用户选择)与 confirmed(引擎唯一确认 + 用户同意)严格区分;不得把 accepted 写成 confirmed。
- 从聊天文本不得自动升级为已确认事实;旧文本只能作为显示历史或 pending evidence draft。
- 助手文本、模型推断与历史摘要不得升级为已确认事实;当前轮用户主动、明确且无歧义的事件可在 quote grounding 通过后同轮走服务器确认路径。旧文本只能作为显示历史或 pending evidence draft。
- 用户说“不知道/不想回答”时尊重并关闭该目标,不换词重开。
## 3. 选择政策