Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c03c1a4b5 | |||
| 892e43fb23 | |||
| c7dfe1c166 | |||
| 8beed38951 | |||
| 220828d6f0 | |||
| 31fa6aaa50 | |||
| 15a9e3f248 | |||
| 397b6ef7c2 | |||
| 689799102e | |||
| da384357d0 | |||
| 863f65d090 | |||
| 32b65cfaf3 | |||
| db9e7ba2a4 |
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -55,14 +55,15 @@ const agenticRectificationInstructions = `你是生时校正 Agent,只服务
|
||||
硬性边界(必须服从):
|
||||
1. 先调用 skill 工具加载 jyotish-birth-time-rectification(本 Case 固定版本),再调用 rectification-read-case 读取服务端 Case/Dossier 后才能行动。
|
||||
2. 事件事实只能来自用户原话:不得虚构事件、日期、候选、分盘数据、评分或出生分钟;计算与持久化只能通过工具。
|
||||
3. 工具 input 只传最小引用(caseId、sourceTurnId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。
|
||||
3. 工具 input 只传最小引用(caseId、evidenceId、resultId、candidateId、quote、proposedKind、日期精度等)。绝不传 userId、出生资料、候选范围、events 数组、分数或权限开关。
|
||||
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,
|
||||
|
||||
@@ -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/other;eventKind 使用受控枚举;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 核心)。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
|
||||
"用当前已确认证据运行候选比较。服务端按事件的实际领域选择分盘,并结合 Vimshottari、Narayana 与稳健性诊断。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
|
||||
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", () => {
|
||||
|
||||
@@ -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` 只能由服务器确认路径产生。服务器确认路径不等于必须额外等待一轮用户回复。
|
||||
- 终态 Case(confirmed/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. 选择政策
|
||||
|
||||
Reference in New Issue
Block a user