Compare commits

...

7 Commits

Author SHA1 Message Date
Jesse_Chen 15a9e3f248 test(rectification): include birth context in entry fixture
Staging Backend Quality Gate / validate (pull_request) Successful in 14m59s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 09:46:54 +08:00
Jesse_Chen 397b6ef7c2 fix(rectification): surface real activity context
Staging Backend Quality Gate / validate (pull_request) Failing after 12m4s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 09:28:47 +08:00
jesse 689799102e Merge pull request #30: fix(rectification): skip settlement for free turns
Staging Backend Quality Gate / validate (push) Successful in 11m53s
Staging Backend Quality Gate / publish (push) Successful in 8m24s
Free opening/read_only turns no longer invoke complete_usage without a reservation.
2026-08-12 03:16:38 +08:00
Jesse_Chen da384357d0 fix(rectification): skip settlement for free turns
Staging Backend Quality Gate / validate (pull_request) Successful in 15m26s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 02:58:35 +08:00
jesse 863f65d090 Merge pull request #29: fix(rectification): pass server case id to agent
Staging Backend Quality Gate / validate (push) Successful in 11m48s
Staging Backend Quality Gate / publish (push) Successful in 8m11s
Pass the server-validated Case ID into V9 Agent messages so rectification tools do not guess an invalid case reference.
2026-08-12 02:19:59 +08:00
Jesse_Chen 32b65cfaf3 fix(rectification): pass server case id to agent
Staging Backend Quality Gate / validate (pull_request) Successful in 16m22s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
2026-08-12 02:00:08 +08:00
jesse db9e7ba2a4 Merge pull request #28 from codex/fix-rectification-session-model-20260811
Staging Backend Quality Gate / validate (push) Successful in 11m38s
Staging Backend Quality Gate / publish (push) Successful in 7m18s
fix(rectification): bind default session model
2026-08-12 01:25:01 +08:00
24 changed files with 979 additions and 100 deletions
+46
View File
@@ -2914,3 +2914,49 @@
- 防复发:任何服务器端创建 `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,
};
+48
View File
@@ -2811,3 +2811,51 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
flex-direction: column;
}
}
.rectification-message-wrap { display: grid; gap: var(--space-2); }
.rectification-activity {
width: min(720px, 100%);
margin: 0;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-canvas-muted) 72%, var(--color-canvas));
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-activity summary {
display: flex;
min-height: 36px;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
color: var(--color-ink-secondary);
cursor: pointer;
font-weight: 600;
list-style: none;
}
.rectification-activity summary::-webkit-details-marker { display: none; }
.rectification-activity summary::before { content: "+"; color: var(--color-ink-tertiary); font-family: var(--font-mono); }
.rectification-activity[open] summary::before { content: ""; }
.rectification-activity summary:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
.rectification-activity ol {
display: grid;
gap: var(--space-1);
margin: 0;
padding: 0 var(--space-4) var(--space-3) calc(var(--space-4) + 18px);
}
.rectification-activity-methods {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border-top: 1px solid var(--color-border);
}
.rectification-activity-methods > span { color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 600; }
.rectification-activity-methods ul { display: flex; flex-wrap: wrap; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
.rectification-activity-methods li {
padding: 3px 7px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
}
@@ -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),
};
+35 -7
View File
@@ -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 核心)。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
"用当前已确认证据运行候选比较。服务端按事件的实际领域选择分盘,并结合 VimshottariNarayana 与稳健性诊断。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
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,
@@ -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 () => {
@@ -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({
@@ -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", () => {
+64 -5
View File
@@ -20,7 +20,15 @@ import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/ca
type StreamChunk = {
type: string;
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
payload?: {
toolName?: unknown;
text?: unknown;
args?: unknown;
error?: unknown;
result?: unknown;
output?: unknown;
};
object?: unknown;
};
function chunk(type: string, payload?: Record<string, unknown>): StreamChunk {
@@ -28,7 +36,7 @@ function chunk(type: string, payload?: Record<string, unknown>): StreamChunk {
}
test("fullStream chunks map to the allowlisted NDJSON phases only", () => {
assert.deepEqual(mapStreamChunkToPhase(chunk("start") as never), { type: "run.started" });
assert.equal(mapStreamChunkToPhase(chunk("start") as never), null);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }) as never),
{ type: "skill.started" },
@@ -39,11 +47,39 @@ test("fullStream chunks map to the allowlisted NDJSON phases only", () => {
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "rectification-compare-candidates" }) as never),
{ type: "candidates.comparing" },
{ type: "candidates.comparing", tool: "rectification-compare-candidates" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-result", { toolName: "rectification-compare-candidates" }) as never),
{ type: "candidates.updated" },
mapStreamChunkToPhase(chunk("tool-result", {
toolName: "rectification-compare-candidates",
result: {
executed_methods: [
"d1-rashi",
"vimshottari-dasha",
"internal-secret-technique",
"d1-rashi",
],
birth_context: { birth_date: "1997-08-08" },
event_contribution_matrix: { secret: true },
rule_ids: ["internal-rule"],
},
}) as never),
{
type: "candidates.updated",
tool: "rectification-compare-candidates",
methods: ["d1-rashi", "vimshottari-dasha"],
},
);
assert.equal(
mapStreamChunkToPhase(chunk("tool-call", { toolName: "rectification-read-case" }) as never),
null,
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("tool-result", {
toolName: "rectification-read-case",
result: { birth_context: { birth_date: "1997-08-08", latitude: 36.4 } },
}) as never),
{ type: "case.loaded", tool: "rectification-read-case" },
);
assert.deepEqual(
mapStreamChunkToPhase(chunk("text-delta", { text: "你好" }) as never),
@@ -76,6 +112,29 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
assert.deepEqual(
safePublicEvent({
type: "case.loaded",
tool: "rectification-read-case",
text: "1997-08-08 河北省邯郸市",
methods: ["d1-rashi"],
birth_context: { latitude: 36.4 },
}),
{ type: "case.loaded", tool: "rectification-read-case" },
);
assert.deepEqual(
safePublicEvent({
type: "candidates.updated",
tool: "rectification-compare-candidates",
methods: ["d10-dashamsa", "private-method", "d10-dashamsa"],
rule_ids: ["private-rule"],
}),
{
type: "candidates.updated",
tool: "rectification-compare-candidates",
methods: ["d10-dashamsa"],
},
);
assert.equal(safePublicEvent({ type: "provider.reasoning", text: "内部" }), null);
assert.equal(safePublicEvent({ type: "tool.payload", text: "秘密" }), null);
assert.equal(safePublicEvent({ type: "raw" }), null);
@@ -114,8 +114,10 @@ export function computeFixture(overrides: {
baseline_profile_fingerprint: overrides.baselineProfileFingerprint ?? "a".repeat(64),
baseline_birth_snapshot: overrides.baselineBirthSnapshot ?? {
birth_date: "1997-08-08",
birth_place_label: "河北省邯郸市",
latitude: 36.420487,
longitude: 114.209936,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
birth_time_source: "family_exact",
reported_birth_time: "05:00",