Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15a9e3f248 | |||
| 397b6ef7c2 | |||
| 689799102e | |||
| da384357d0 | |||
| 863f65d090 | |||
| 32b65cfaf3 | |||
| db9e7ba2a4 | |||
| b276bc8a8f | |||
| 752c8f4239 | |||
| d19d398221 |
@@ -2884,3 +2884,79 @@
|
||||
- 防复发:账号重置只允许使用普通管理员 mutation guard;共享高风险 guard 不做全局放松,并由合同测试锁定角色变更仍需邮箱复核。
|
||||
- 相关记录:无
|
||||
- 修复版本:本次 staging 候选
|
||||
|
||||
## BUG-168 | 生时校正运行时开关被 RLS 静默隐藏并误报服务未开放
|
||||
|
||||
- 状态:resolved(本地回归已通过,待 staging 迁移与真实接口验收)
|
||||
- 首次发现:2026-08-11
|
||||
- 最近更新:2026-08-11
|
||||
- 影响面:`POST /api/rectification/agent`、共享 `loadRuntimeFeatureFlags` 的运行时开关读取,以及管理端 feature flag 列表。
|
||||
- 用户现象:staging 的 V9 生时校正接口返回 `503 rectification_runtime_disabled`,即使数据库中的 `rectification_runtime_version` 已是 `published`、`enabled=true`、`rollout_percentage=100`。
|
||||
- 触发条件:self-hosted Web 通过 `ADMIN_DATABASE_URL` 以 `admin_runtime` 查询启用了 RLS 的 `public.feature_flags`。
|
||||
- 根因:`20260806050000_operations_feature_flags.sql` 给 `admin_runtime` 授予了表级 SELECT,但启用 RLS 后没有创建对应 SELECT policy。PostgreSQL 因此不报权限错误而是返回零行;`loadRuntimeFeatureFlags` 将缺失记录安全降级为 disabled,路由遂返回“生时校正服务暂未开放”。
|
||||
- 修复:新增向前迁移 `20260811030000_feature_flags_admin_runtime_read_policy.sql`,保留最小 SELECT grant,并为 `admin_runtime` 创建 `feature_flags_admin_read` RLS SELECT policy;不放宽匿名、普通用户或其它运行时角色权限。
|
||||
- 验证:Docker PostgreSQL 回归先在修复前稳定得到空结果,新增迁移后要求 `admin_runtime` 能读取 `true:100:published`;staging 还需验证迁移账本、角色可见性及真实 Agent 接口不再返回 runtime disabled。
|
||||
- 防复发:任何对启用 RLS 的表新增 runtime grant 时,必须同时测试对应运行时角色的真实可见行,而不能只断言 `has_table_privilege=true`;feature flag 种子测试必须以 Web 实际使用的 `admin_runtime` 读取。
|
||||
- 相关记录:BUG-151、BUG-166
|
||||
- 修复版本:待提交
|
||||
|
||||
## BUG-169 | V9 新建生时校正 Session 未绑定模型导致 Agent 立即返回模型不可用
|
||||
|
||||
- 状态:resolved(本地候选)
|
||||
- 首次发现:2026-08-11
|
||||
- 最近更新:2026-08-11
|
||||
- 影响面:V9 `open_agentic_rectification_case` 新建会话、既有 `model_id is null` 的生时校正会话,以及 `POST /api/rectification/agent` 的模型解析。
|
||||
- 用户现象:运行时开关恢复后,原请求继续返回 `409 模型暂不可用`;请求体包含有效 `modelId`,管理端模型及供应商也均为 published/enabled。
|
||||
- 触发条件:V9 Open Case RPC 原子创建 `birth_time_rectification` Session 后立即发送 opening。
|
||||
- 根因:RPC 插入 `chat_sessions` 时没有写入 `model_id`;前端仅在内存中把目录默认模型显示为当前选择,而 Agent 路由按安全合同只解析服务端持久化的 `chatSession.model_id/model_config_version`,不会信任请求体覆盖会话模型。
|
||||
- 修复:新增向前迁移 `20260811040000_rectification_session_default_model.sql`;数据库触发器为缺少模型的生时校正 Session 绑定当前 published/enabled 默认模型,由既有 pin trigger 固定配置版本,并一次性回填同类历史 Session。普通咨询 Session 与已有明确模型选择均不改变。
|
||||
- 验证:V9 PostgreSQL fixture 在创建 Case 前播种默认模型,要求 RPC 新建 Session 后持久化为 `v9-default-model:1`;staging 还需核对迁移账本、原 Session 回填结果和真实 opening 请求。
|
||||
- 防复发:任何服务器端创建 `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
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { membershipHref } from "@/lib/membership";
|
||||
import { PUBLIC_RECTIFICATION_PHASES, type PublicRectificationPhase } from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import {
|
||||
isPublicRectificationMethod,
|
||||
isPublicRectificationTool,
|
||||
type PublicRectificationMethod,
|
||||
type PublicRectificationTool,
|
||||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
@@ -21,6 +26,7 @@ type PersistedTurn = Readonly<{
|
||||
status: string;
|
||||
phases: readonly string[];
|
||||
tools: readonly string[];
|
||||
methods?: readonly string[];
|
||||
skill_name?: string;
|
||||
skill_version?: string;
|
||||
}> | null;
|
||||
@@ -58,38 +64,52 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
type RenderMessage = ChatMessageView & {
|
||||
renderKey: string;
|
||||
/** V9 execution-receipt steps; separate from shared consultation activity. */
|
||||
receiptActivity?: readonly string[];
|
||||
receiptActivity?: ReceiptActivity;
|
||||
receiptStatus?: string;
|
||||
};
|
||||
|
||||
const PHASE_LABELS: Readonly<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 labelForPhase(phase: string): string {
|
||||
return isPublicPhase(phase) ? (PHASE_LABELS[phase] ?? phase) : phase;
|
||||
}
|
||||
|
||||
function activityFromReceipt(receipt: PersistedTurn["receipt"]): string[] {
|
||||
if (!receipt) return [];
|
||||
const phases = (receipt.phases ?? []).filter(isPublicPhase);
|
||||
return phases.map(labelForPhase).slice(0, 12);
|
||||
function activityFromReceipt(receipt: PersistedTurn["receipt"]): ReceiptActivity {
|
||||
if (!receipt) return { steps: [], methods: [] };
|
||||
return {
|
||||
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool).map((tool) => TOOL_LABELS[tool]))],
|
||||
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod).map((method) => METHOD_LABELS[method]))],
|
||||
};
|
||||
}
|
||||
|
||||
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
@@ -213,13 +233,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", receiptActivity: [] },
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", receiptActivity: { steps: [], methods: [] } },
|
||||
]);
|
||||
setDraft("");
|
||||
|
||||
let raw = "";
|
||||
let liveActivity: string[] = [];
|
||||
const activitySet = new Set<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 +289,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let event: { type?: unknown; text?: unknown; message?: unknown };
|
||||
let event: { type?: unknown; text?: unknown; message?: unknown; tool?: unknown; methods?: unknown };
|
||||
try {
|
||||
event = JSON.parse(line) as { type?: unknown; text?: unknown; message?: unknown };
|
||||
event = JSON.parse(line) as { type?: unknown; text?: unknown; message?: unknown; tool?: unknown; methods?: unknown };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -289,10 +310,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。");
|
||||
} else if (event.type === "run.completed") {
|
||||
completed = true;
|
||||
} else if (isPublicPhase(event.type)) {
|
||||
if (!activitySet.has(event.type)) {
|
||||
activitySet.add(event.type);
|
||||
liveActivity = [...liveActivity, labelForPhase(event.type)];
|
||||
} else {
|
||||
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
|
||||
if (tool) activitySteps.add(TOOL_LABELS[tool]);
|
||||
if (Array.isArray(event.methods)) {
|
||||
for (const method of event.methods) {
|
||||
if (isPublicRectificationMethod(method)) activityMethods.add(METHOD_LABELS[method]);
|
||||
}
|
||||
}
|
||||
if (tool || activityMethods.size !== liveActivity.methods.length) {
|
||||
liveActivity = { steps: [...activitySteps], methods: [...activityMethods] };
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, receiptActivity: liveActivity }
|
||||
: message));
|
||||
@@ -386,15 +413,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -224,7 +248,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
toolName: string,
|
||||
publicPhase: string,
|
||||
status: "started" | "completed" | "failed" | "skipped",
|
||||
extra: { inputFingerprint?: string | null; resultFingerprint?: string | null; engineVersion?: string | null; safeErrorCode?: string | null } = {},
|
||||
extra: { inputFingerprint?: string | null; resultFingerprint?: string | null; engineVersion?: string | null; safeErrorCode?: string | null; executedMethods?: readonly PublicRectificationMethod[] } = {},
|
||||
) => {
|
||||
try {
|
||||
await insertV9ToolReceipt(accounting, userId, caseId, turnId, {
|
||||
@@ -235,6 +259,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
resultFingerprint: extra.resultFingerprint ?? null,
|
||||
engineVersion: extra.engineVersion ?? null,
|
||||
safeErrorCode: extra.safeErrorCode ?? null,
|
||||
executedMethods: extra.executedMethods ?? [],
|
||||
});
|
||||
} catch (error) {
|
||||
// Receipt persistence must never break the tool result; it is auditable
|
||||
@@ -254,7 +279,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const readCaseTool = createTool({
|
||||
id: "rectification-read-case",
|
||||
description:
|
||||
"读取服务端 Case 与证据摘要(状态、Skill 版本、候选范围、已确认/待确认事件、最近候选快照)。任何一轮开始必须先调用本工具。input 只允许 caseId。",
|
||||
"读取服务端 Case、证据摘要和权威出生上下文。birth_context 中的日期、地点、经纬度、IANA 时区、偏移、填报/当前时间及候选范围均来自服务端,必须原样使用,不得猜测或替换。任何一轮开始必须先调用本工具。input 只允许 caseId。",
|
||||
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
|
||||
execute: async (input) => {
|
||||
assertCaseRef(input);
|
||||
@@ -262,7 +287,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
await receipt("rectification-read-case", "case.loaded", "started", { inputFingerprint });
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
const projection = safeCaseProjection(parseDossierForTools(dossier));
|
||||
const compute = await loadV9CaseCompute(accounting, userId, input.caseId);
|
||||
const projection = safeCaseProjection(parseDossierForTools(dossier), compute);
|
||||
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
|
||||
return projection;
|
||||
} catch (error) {
|
||||
@@ -422,7 +448,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const compareCandidatesTool = createTool({
|
||||
id: "rectification-compare-candidates",
|
||||
description:
|
||||
"用当前已确认证据运行候选比较(Vimshottari + Narayana 双轨,D9/D10 核心)。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
|
||||
"用当前已确认证据运行候选比较。服务端按事件的实际领域选择分盘,并结合 Vimshottari、Narayana 与稳健性诊断。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
|
||||
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
|
||||
execute: async (input) => {
|
||||
assertCaseRef(input);
|
||||
@@ -481,8 +507,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
inputFingerprint,
|
||||
resultFingerprint: hashResult(projection),
|
||||
engineVersion: persisted.algorithmVersion ?? engineVersion,
|
||||
executedMethods: score.executedMethods,
|
||||
});
|
||||
return projection;
|
||||
return { ...projection, executed_methods: score.executedMethods };
|
||||
} catch (error) {
|
||||
await receipt("rectification-compare-candidates", "candidates.comparing", "failed", {
|
||||
inputFingerprint,
|
||||
@@ -527,8 +554,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
inputFingerprint,
|
||||
resultFingerprint: hashResult(projection),
|
||||
engineVersion: diagnostics.algorithmVersion,
|
||||
executedMethods: diagnostics.executedMethods,
|
||||
});
|
||||
return projection;
|
||||
return { ...projection, executed_methods: diagnostics.executedMethods };
|
||||
} catch (error) {
|
||||
await receipt("rectification-read-diagnostics", "diagnostics.completed", "failed", {
|
||||
inputFingerprint,
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
begin;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
|
||||
grant select on table public.feature_flags to admin_runtime;
|
||||
|
||||
drop policy if exists feature_flags_admin_read on public.feature_flags;
|
||||
create policy feature_flags_admin_read on public.feature_flags
|
||||
for select to admin_runtime using (true);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,49 @@
|
||||
begin;
|
||||
|
||||
create or replace function public.default_rectification_chat_session_model()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if new.session_type = 'birth_time_rectification' and new.model_id is null then
|
||||
select c.model_id into new.model_id
|
||||
from public.model_config_versions v
|
||||
join public.model_configs c on c.id = v.config_id
|
||||
join public.model_providers p on p.id = v.provider_id
|
||||
where v.status = 'published'
|
||||
and v.enabled
|
||||
and v.is_default
|
||||
and p.enabled
|
||||
limit 1;
|
||||
end if;
|
||||
return new;
|
||||
end
|
||||
$$;
|
||||
|
||||
revoke all on function public.default_rectification_chat_session_model() from public, anon, authenticated;
|
||||
|
||||
drop trigger if exists chat_sessions_default_rectification_model on public.chat_sessions;
|
||||
create trigger chat_sessions_default_rectification_model
|
||||
before insert or update of session_type, model_id on public.chat_sessions
|
||||
for each row execute function public.default_rectification_chat_session_model();
|
||||
|
||||
with default_model as (
|
||||
select c.model_id
|
||||
from public.model_config_versions v
|
||||
join public.model_configs c on c.id = v.config_id
|
||||
join public.model_providers p on p.id = v.provider_id
|
||||
where v.status = 'published'
|
||||
and v.enabled
|
||||
and v.is_default
|
||||
and p.enabled
|
||||
limit 1
|
||||
)
|
||||
update public.chat_sessions s
|
||||
set model_id = default_model.model_id
|
||||
from default_model
|
||||
where s.session_type = 'birth_time_rectification'
|
||||
and s.model_id is null;
|
||||
|
||||
commit;
|
||||
@@ -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\(\)/);
|
||||
|
||||
@@ -169,6 +169,38 @@ test("Agentic rectification scrolls the conversation container as streamed messa
|
||||
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
|
||||
});
|
||||
|
||||
test("rectification activity renders real completed work above the Agent message", () => {
|
||||
const activityHelper = chat.slice(
|
||||
chat.indexOf("function activityFromReceipt"),
|
||||
chat.indexOf("export function RectificationAgenticChat"),
|
||||
);
|
||||
assert.match(activityHelper, /receipt\.tools/);
|
||||
assert.doesNotMatch(activityHelper, /receipt\.phases/);
|
||||
assert.match(activityHelper, /filter\(isPublicRectificationTool\)/);
|
||||
assert.match(activityHelper, /TOOL_LABELS\[tool\]/);
|
||||
assert.match(activityHelper, /receipt\.methods/);
|
||||
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
|
||||
assert.match(activityHelper, /METHOD_LABELS\[method\]/);
|
||||
assert.match(chat, /"rectification-read-case": "读取校正记录"/);
|
||||
|
||||
const messageRender = chat.slice(
|
||||
chat.indexOf('{messages.map((message) => ('),
|
||||
chat.indexOf('{candidateResult?.selectionAllowed'),
|
||||
);
|
||||
assert.ok(messageRender.indexOf('className="rectification-activity"') < messageRender.indexOf("<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, /可以先采用一个作为当前排盘时间,也可以继续补充事件/);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -101,6 +101,23 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip:
|
||||
where id = '${userId}';
|
||||
`);
|
||||
|
||||
fixture.psql(`
|
||||
with provider as (
|
||||
insert into public.model_providers (code, name, provider_type, encrypted_api_key, enabled)
|
||||
values ('v9-test', 'V9 Test', 'openai', 'test-ciphertext', true)
|
||||
returning id
|
||||
), config as (
|
||||
insert into public.model_configs (model_id)
|
||||
values ('v9-default-model')
|
||||
returning id
|
||||
)
|
||||
insert into public.model_config_versions (
|
||||
config_id, version, provider_id, label, provider_model, enabled, is_default, status, published_at
|
||||
)
|
||||
select config.id, 1, provider.id, 'V9 Default', 'gpt-test', true, true, 'published', now()
|
||||
from config cross join provider;
|
||||
`);
|
||||
|
||||
const service = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
@@ -134,6 +151,10 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip:
|
||||
fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`),
|
||||
"1",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select model_id || ':' || model_config_version from public.chat_sessions where id = '${sessionId}'`),
|
||||
"v9-default-model:1",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(
|
||||
`select count(*) from public.agentic_rectification_open_ledger where user_id = '${userId}'`,
|
||||
@@ -798,6 +819,8 @@ 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.
|
||||
assert.equal(
|
||||
@@ -805,6 +828,16 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent"
|
||||
from public.feature_flags where flag_key = 'rectification_runtime_version'`),
|
||||
"true:100:published",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psqlAs(
|
||||
"admin_runtime",
|
||||
"admin-runtime-test-password",
|
||||
`select enabled || ':' || rollout_percentage || ':' || status
|
||||
from public.feature_flags
|
||||
where flag_key = 'rectification_runtime_version'`,
|
||||
),
|
||||
"true:100:published",
|
||||
);
|
||||
|
||||
// Run phases table exists with RLS.
|
||||
assert.equal(
|
||||
@@ -827,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(`
|
||||
@@ -843,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
|
||||
);
|
||||
`);
|
||||
@@ -877,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",
|
||||
@@ -886,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({
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -174,7 +174,7 @@ test("baseline change invalidates results and forces needs_rebaseline", () => {
|
||||
assert.equal(evidenceWritesAllowed("needs_rebaseline"), true);
|
||||
});
|
||||
|
||||
test("tool outputs never leak birth data, raw scores or the baseline snapshot", async () => {
|
||||
test("read-case gives the Agent authoritative birth context without raw internals", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
@@ -191,13 +191,24 @@ test("tool outputs never leak birth data, raw scores or the baseline snapshot",
|
||||
const projection = await (tools["rectification-read-case"] as unknown as {
|
||||
execute(input: unknown): Promise<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",
|
||||
|
||||
Reference in New Issue
Block a user