feat(rectification): add durable case and evidence runtime
This commit is contained in:
@@ -2744,3 +2744,30 @@
|
||||
- 相关记录:BUG-159、ERR-020、ERR-021、ERR-022、ERR-024、ERR-025、ERR-026、ERR-104
|
||||
- 复发自:无
|
||||
- 修复版本:本地 staging 候选(未 push / deploy)
|
||||
|
||||
## BUG-162 | 生时校正入口没有服务端 Case 状态,复用第一条校正 Session 且并发会重复创建
|
||||
|
||||
- 状态:resolved(local candidate;真实 PostgreSQL fixture 待远端 runner 执行)
|
||||
- 首次发现:2026-08-12
|
||||
- 最近更新:2026-08-12
|
||||
- 影响面:生时校正首页入口、侧边栏校正 Session 恢复、Case/Session 绑定、并发打开、旧 Direct Agentic 数据映射
|
||||
- 用户现象:点击首页“生时校正”时,只要历史存在任意校正 Session 就进入“继续”,不管它是进行中、已采用、已确认还是已关闭;侧边栏点击某条校正 Session 可能被复用逻辑带向另一条记录;双击或多标签并发点击可能创建多组 Case/Session;已完成校正 Session 还会阻止用户重新开始。
|
||||
- 触发条件:使用 `hasRectificationSession` + `sessions.find(sessionType === "birth_time_rectification")` 的客户端猜测路径;历史 Direct Agentic Session 没有 V9 Case 状态。
|
||||
- 根因:旧入口把“存在任意校正 Session”当作“可继续”,把客户端数组第一条校正 Session 当作权威;没有服务端 Case 状态机(resumable/terminal 由消息数、是否有候选推断),Session 与 Case 没有数据库级双向绑定与精确恢复,open/create 没有 requestId 幂等账本和同用户串行化,旧 Agentic 数据也没有一次性的 Case 回填与结果映射。
|
||||
- 修复(V9 Agentic Domain lane):
|
||||
1. 新增独立 Skill `skills/jyotish-birth-time-rectification`(SKILL.md ≤200 行 + 5 个 references),方法源只在 Skill,系统提示词不再复制完整方法。
|
||||
2. 新增 V9 领域 contracts:Case 状态机(10 状态 + resumable/terminal 谓词 + 单向终态)、Evidence kind/date precision/append-only lineage、public receipt/activity allowlist、open request/response schemas;`supersedeActive=true` 被 schema 与 RPC 双重禁止。
|
||||
3. 新增向前业务迁移 `20260812010000_agentic_rectification_v9_runtime.sql`(只进 `frontend/supabase/migrations`,不进 `frontend/db/migrations`):`agentic_rectification_cases`(每用户最多一个 resumable 由 partial unique index 强制;Case/Session 双向一致由触发器+RPC+唯一索引保证)、`agentic_rectification_evidence`(原文 grounding、日期精度、revision lineage、confirmed 只能由服务器确认路径产生)、`agentic_rectification_turns`(pending/completed/failed/retryable,禁止 reasoning)、`agentic_rectification_tool_receipts`(只存 fingerprint/phase/tool/status)、`agentic_rectification_open_ledger`(requestId 幂等);`agentic_rectification_results` 前向扩展 `case_id/evidence_ledger_fingerprint/candidate_range_fingerprint/skill_version`;所有新表 RLS + 仅 service_role 授权,全部 RPC security definer + `search_path=''` 且仅 service_role 可执行;浏览器不传 userId、出生快照、range 或权限决定。
|
||||
4. 一次性幂等 legacy backfill(迁移内执行,应用启动不隐式批量跑):engine_confirmed→confirmed、user_accepted only→candidate_accepted、有消息无选择→collecting_evidence/candidate_ready、重复空/旧→abandoned/superseded、每用户只保留最新实际 active(窗口函数+部分唯一索引),旧文本绝不生成 confirmed evidence;提供 `backfill_agentic_rectification_legacy_cases()` 与可复跑的 `verify_agentic_rectification_backfill()`。
|
||||
5. 新增 Case Service + 五个 API:`POST /api/rectification/cases/open`(homepage resume-or-create / session 精确恢复 / new 安全冲突)、`GET entry-summary`(首页 CTA 真值)、`GET cases/[caseId]`(脱敏投影,绝不返回 baseline_birth_snapshot)、`POST close`、`POST upgrade-skill`;profile 不完整不建案;错误不泄露身份、出生资料或 DB 原文。
|
||||
- 验证:新增 `rectification-v9-contracts.test.ts`(12)、`rectification-v9-case-service.test.ts`(14)、`rectification-v9-migration.test.ts`(17 静态合同)本地共 43 项全部通过;`rectification-v9-database.test.ts`(真实 PostgreSQL 迁移 apply/re-apply、open 原子+幂等+resume、profile 门禁、所有权、terminal 只读、evidence 生命周期、backfill 状态分布/单 active/结果映射/幂等)在本机因无 Docker 按环境 skip(5 项),待远端完整 runner 执行;既有生时校正相关测试 60/60 通过;`tsc --noEmit` 对本 lane 文件零错误(全量 6 个既有错误全部位于未触碰文件);目标 ESLint 0 error 0 warning;`git diff --check` 通过;`database-local-business.test.ts` 精确 public 表清单同步新增 5 张 v9 表并断言新迁移 applied(BUG-127/144 防复发)。
|
||||
- 防复发:Case 状态、resumable/terminal、shouldStartOpening 必须只由服务端决定;侧边栏必须以精确 sessionId 恢复并校验所有权;open/create 必须走 requestId 幂等账本 + 同用户 advisory 串行化;新增 self-hosted 业务表必须同时更新全迁移 applied ledger 与精确 public 表集合,且只进 `frontend/supabase/migrations`;新页面/新 API 必须同步能力审计;浏览器任何入参都不允许携带 userId、出生资料、range 或权限决定。
|
||||
- 相关记录:BUG-004、BUG-027、BUG-033、BUG-067、BUG-068、BUG-069、BUG-072、BUG-074、BUG-085、BUG-086、BUG-095、BUG-112、BUG-113、BUG-114、BUG-115、BUG-116、BUG-117、BUG-118、BUG-119、BUG-120、BUG-121、BUG-127、BUG-143、BUG-144
|
||||
- 复发自:无(新 Agentic Domain 主链;旧防线缺失的直接原因见下)
|
||||
- 修复版本:本地 staging 候选(未 push / deploy)
|
||||
|
||||
### 旧防线为何没拦住(hasRectificationSession + sessions.find())
|
||||
|
||||
- `hasRectificationSession` 只断言“存在任意校正 Session”,不区分 draft / collecting / candidate_ready / candidate_accepted / confirmed / closed,因此已完成会话始终被当作可继续,且没有服务端 Case 状态可被测试断言。
|
||||
- `sessions.find(sessionType === "birth_time_rectification")` 取客户端数组第一条,既不保证精确 sessionId,也不校验 Case 绑定;排序、缓存或刷新差异都会改变打开哪条记录。
|
||||
- 旧测试只覆盖“能找到一条校正 Session”的客户端行为,没有服务端 Case 状态机、没有“点击指定 Session 必须精确恢复”的契约、没有并发/双击/多标签幂等断言,也没有 legacy→V9 一次性回填的数据库级验证,因此这些缺陷在回归中被遗漏。
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
RectificationCaseServiceError,
|
||||
closeRectificationCase,
|
||||
mapRectificationRpcError,
|
||||
} from "@/lib/rectification-agentic/v9/case-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = { params: Promise<{ caseId: string }> };
|
||||
|
||||
const closeRequestSchema = z
|
||||
.object({
|
||||
reason: z.enum(["completed_by_user", "abandoned_by_user", "other"]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* POST /api/rectification/cases/[caseId]/close
|
||||
*
|
||||
* Explicit business close only; never wraps as engine confirmed.
|
||||
*/
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json(
|
||||
{ error: "请求内容不正确", code: "invalid_case_id" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const parsed = closeRequestSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "请求内容不正确", code: "invalid_close_request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await closeRectificationCase(
|
||||
accounting,
|
||||
user.id,
|
||||
caseId,
|
||||
parsed.data.reason,
|
||||
);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationCaseServiceError) {
|
||||
const view = mapRectificationRpcError(
|
||||
new Error(`agentic_rectification_${error.code}`),
|
||||
);
|
||||
return NextResponse.json({ error: view.message, code: view.code }, { status: view.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "校正服务暂时不可用", code: "rectification_service_failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
RectificationCaseServiceError,
|
||||
getRectificationCase,
|
||||
mapRectificationRpcError,
|
||||
} from "@/lib/rectification-agentic/v9/case-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = { params: Promise<{ caseId: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/rectification/cases/[caseId]
|
||||
*
|
||||
* Sanitized case projection (never the baseline birth snapshot). Terminal
|
||||
* cases are served read-only.
|
||||
*/
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json(
|
||||
{ error: "请求内容不正确", code: "invalid_case_id" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(
|
||||
await getRectificationCase(accounting, user.id, caseId),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationCaseServiceError) {
|
||||
const view = mapRectificationRpcError(
|
||||
new Error(`agentic_rectification_${error.code}`),
|
||||
);
|
||||
return NextResponse.json({ error: view.message, code: view.code }, { status: view.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "校正服务暂时不可用", code: "rectification_service_failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
RectificationCaseServiceError,
|
||||
mapRectificationRpcError,
|
||||
upgradeRectificationSkill,
|
||||
} from "@/lib/rectification-agentic/v9/case-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = { params: Promise<{ caseId: string }> };
|
||||
|
||||
const upgradeRequestSchema = z
|
||||
.object({
|
||||
skillVersion: z.string().trim().min(1).max(64),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* POST /api/rectification/cases/[caseId]/upgrade-skill
|
||||
*
|
||||
* Explicit skill-version migration for a running case; terminal cases are
|
||||
* rejected and new cases pin the current default version.
|
||||
*/
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json(
|
||||
{ error: "请求内容不正确", code: "invalid_case_id" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const parsed = upgradeRequestSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "请求内容不正确", code: "invalid_upgrade_request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await upgradeRectificationSkill(
|
||||
accounting,
|
||||
user.id,
|
||||
caseId,
|
||||
parsed.data.skillVersion,
|
||||
);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationCaseServiceError) {
|
||||
const view = mapRectificationRpcError(
|
||||
new Error(`agentic_rectification_${error.code}`),
|
||||
);
|
||||
return NextResponse.json({ error: view.message, code: view.code }, { status: view.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "校正服务暂时不可用", code: "rectification_service_failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
RectificationCaseServiceError,
|
||||
getRectificationEntrySummary,
|
||||
mapRectificationRpcError,
|
||||
} from "@/lib/rectification-agentic/v9/case-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/rectification/cases/entry-summary
|
||||
*
|
||||
* Server-truth homepage CTA: "开始生时校正" / "继续上次校正" / "再次校正".
|
||||
*/
|
||||
export async function GET() {
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(await getRectificationEntrySummary(accounting, user.id));
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationCaseServiceError) {
|
||||
const view = mapRectificationRpcError(
|
||||
new Error(`agentic_rectification_${error.code}`),
|
||||
);
|
||||
return NextResponse.json({ error: view.message, code: view.code }, { status: view.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "校正服务暂时不可用", code: "rectification_service_failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
RectificationCaseServiceError,
|
||||
mapRectificationRpcError,
|
||||
openRectificationCase,
|
||||
} from "@/lib/rectification-agentic/v9/case-service";
|
||||
import { parseOpenRectificationCaseRequest } from "@/lib/rectification-agentic/v9/open-request";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function serviceErrorResponse(error: unknown) {
|
||||
if (error instanceof RectificationCaseServiceError) {
|
||||
const view = mapRectificationRpcError(
|
||||
new Error(`agentic_rectification_${error.code}`),
|
||||
);
|
||||
return NextResponse.json({ error: view.message, code: view.code }, { status: view.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "校正服务暂时不可用", code: "rectification_service_failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/rectification/cases/open
|
||||
*
|
||||
* Browser sends only { intent, requestId } (+ sessionId for intent=session).
|
||||
* The server derives the user from the session, normalizes the profile and
|
||||
* decides resume-or-create. Double-click / multi-tab are idempotent.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let supabase;
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = parseOpenRectificationCaseRequest(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ error: "请求内容不正确", code: "invalid_open_request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await openRectificationCase(accounting, user.id, parsed);
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
return serviceErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* V9 rectification Case Service.
|
||||
*
|
||||
* Server-owned facts: profile normalization, baseline snapshot/fingerprint,
|
||||
* candidate range derivation and every disposition decision. The browser only
|
||||
* ever supplies an intent + requestId (+ exact sessionId); it never passes
|
||||
* userId, birth data, candidate range or permission decisions.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts";
|
||||
import {
|
||||
isRectificationCaseStatus,
|
||||
RECTIFICATION_SKILL_NAME,
|
||||
RECTIFICATION_SKILL_VERSION,
|
||||
type RectificationCaseStatus,
|
||||
} from "./case-status.ts";
|
||||
import {
|
||||
openResponse,
|
||||
type OpenRectificationCaseRequest,
|
||||
type OpenRectificationCaseResponse,
|
||||
type RectificationEntrySummary,
|
||||
} from "./open-request.ts";
|
||||
|
||||
type AccountingClient = SupabaseClient;
|
||||
|
||||
export type V9BaselineSnapshot = Readonly<{
|
||||
birth_date: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezone_offset: number;
|
||||
birth_time_source: string;
|
||||
birth_time_period: string | null;
|
||||
reported_birth_time: string | null;
|
||||
active_birth_time: string | null;
|
||||
uncertainty_before_minutes: number | null;
|
||||
uncertainty_after_minutes: number | null;
|
||||
}>;
|
||||
|
||||
export type V9RectificationProfile = Readonly<{
|
||||
userId: string;
|
||||
baseline: V9BaselineSnapshot;
|
||||
baselineFingerprint: string;
|
||||
candidateRange: { start_time: string; end_time: string };
|
||||
}>;
|
||||
|
||||
export type RectificationCaseView = Readonly<{
|
||||
caseId: string;
|
||||
sessionId: string;
|
||||
status: string;
|
||||
skillName: string;
|
||||
skillVersion: string;
|
||||
candidateRange: { start_time: string; end_time: string } | null;
|
||||
acceptedTime: string | null;
|
||||
confirmedTime: string | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
completedAt: string | null;
|
||||
closedReason: string | null;
|
||||
evidenceCount: number;
|
||||
turnCount: number;
|
||||
latestResult: unknown;
|
||||
}>;
|
||||
|
||||
export class RectificationCaseServiceError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string) {
|
||||
super(`Rectification case service error: ${code}`);
|
||||
this.name = "RectificationCaseServiceError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const clockTime = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
function timeValue(value: unknown): string | null {
|
||||
const time = typeof value === "string" ? value.slice(0, 5) : "";
|
||||
return clockTime.test(time) ? time : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const periodRanges: Readonly<Record<string, { start_time: string; end_time: string }>> = {
|
||||
early_morning: { start_time: "04:00", end_time: "07:59" },
|
||||
morning: { start_time: "08:00", end_time: "11:59" },
|
||||
afternoon: { start_time: "12:00", end_time: "17:59" },
|
||||
evening: { start_time: "18:00", end_time: "22:59" },
|
||||
late_night: { start_time: "23:00", end_time: "03:59" },
|
||||
};
|
||||
|
||||
function shiftedTime(time: string, offsetMinutes: number): string {
|
||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||
const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440;
|
||||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fallbackUncertainty(source: string): number {
|
||||
if (source === "hospital_record" || source === "hospital") return 2;
|
||||
if (source === "family_exact" || source === "family_clear") return 15;
|
||||
if (source === "approximate" || source === "family_vague") return 60;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function deriveCandidateRange(input: {
|
||||
activeTime: string | null;
|
||||
reportedTime: string | null;
|
||||
source: string;
|
||||
period: string | null;
|
||||
uncertaintyBefore: number | null;
|
||||
uncertaintyAfter: number | null;
|
||||
}): { start_time: string; end_time: string } {
|
||||
const referenceTime = input.activeTime ?? input.reportedTime;
|
||||
if (referenceTime) {
|
||||
const fallback = fallbackUncertainty(input.source);
|
||||
return {
|
||||
start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)),
|
||||
end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback),
|
||||
};
|
||||
}
|
||||
if (input.source === "period_only" || input.source === "legacy_import") {
|
||||
const period = input.period ? periodRanges[input.period] : undefined;
|
||||
if (period) return period;
|
||||
if (input.source === "period_only") {
|
||||
throw new RectificationCaseServiceError("profile_incomplete");
|
||||
}
|
||||
}
|
||||
if (input.source === "unknown" || input.source === "legacy_import") {
|
||||
return { start_time: "00:00", end_time: "23:59" };
|
||||
}
|
||||
throw new RectificationCaseServiceError("profile_incomplete");
|
||||
}
|
||||
|
||||
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
|
||||
const canonical = [
|
||||
baseline.birth_date,
|
||||
String(baseline.latitude),
|
||||
String(baseline.longitude),
|
||||
String(baseline.timezone_offset),
|
||||
baseline.birth_time_source,
|
||||
baseline.birth_time_period ?? "",
|
||||
baseline.reported_birth_time ?? "",
|
||||
baseline.active_birth_time ?? "",
|
||||
String(baseline.uncertainty_before_minutes ?? ""),
|
||||
String(baseline.uncertainty_after_minutes ?? ""),
|
||||
].join("|");
|
||||
return createHash("sha256").update(canonical).digest("hex");
|
||||
}
|
||||
|
||||
export async function loadV9RectificationProfile(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
): Promise<V9RectificationProfile> {
|
||||
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",
|
||||
)
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
if (error || !data) throw new RectificationCaseServiceError("profile_unavailable");
|
||||
|
||||
const row = data as Record<string, unknown>;
|
||||
const birthDate = normalizePersistedBirthDate(row.birth_date);
|
||||
const latitude = numberOrNull(row.latitude);
|
||||
const longitude = numberOrNull(row.longitude);
|
||||
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);
|
||||
const activeTime = timeValue(row.active_birth_time);
|
||||
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) {
|
||||
throw new RectificationCaseServiceError("profile_incomplete");
|
||||
}
|
||||
|
||||
const baseline: V9BaselineSnapshot = {
|
||||
birth_date: birthDate,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone_offset: timezoneOffset,
|
||||
birth_time_source: source,
|
||||
birth_time_period: period,
|
||||
reported_birth_time: reportedTime,
|
||||
active_birth_time: activeTime,
|
||||
uncertainty_before_minutes: uncertaintyBefore,
|
||||
uncertainty_after_minutes: uncertaintyAfter,
|
||||
};
|
||||
|
||||
return {
|
||||
userId,
|
||||
baseline,
|
||||
baselineFingerprint: baselineFingerprint(baseline),
|
||||
candidateRange: deriveCandidateRange({
|
||||
activeTime,
|
||||
reportedTime,
|
||||
source,
|
||||
period,
|
||||
uncertaintyBefore,
|
||||
uncertaintyAfter,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readRpcData(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value[0] ?? null;
|
||||
if (value && typeof value === "object" && "value" in value) {
|
||||
return (value as { value?: unknown }).value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const KNOWN_RPC_ERROR_CODES = new Map<string, { status: number; code: string; message: string }>([
|
||||
["agentic_rectification_profile_incomplete", { status: 422, code: "profile_incomplete", message: "出生资料不完整" }],
|
||||
["agentic_rectification_active_case_conflict", { status: 409, code: "active_case_conflict", message: "仍有未完成的校正,请先继续或明确结束当前校正" }],
|
||||
["agentic_rectification_session_not_found", { status: 404, code: "case_session_not_found", message: "校正会话不存在或无权访问" }],
|
||||
["agentic_rectification_session_not_rectification", { status: 400, code: "session_not_rectification", message: "该会话不是生时校正会话" }],
|
||||
["agentic_rectification_case_not_found", { status: 404, code: "case_not_found", message: "校正记录不存在或无权访问" }],
|
||||
["agentic_rectification_case_terminal", { status: 409, code: "case_terminal", message: "该校正已结束,不能继续修改" }],
|
||||
["agentic_rectification_case_session_mismatch", { status: 409, code: "case_session_mismatch", message: "校正记录与会话绑定不一致" }],
|
||||
["agentic_rectification_case_owner_mismatch", { status: 403, code: "case_owner_mismatch", message: "无权访问该校正记录" }],
|
||||
["agentic_rectification_invalid_input", { status: 400, code: "invalid_input", message: "请求内容不正确" }],
|
||||
["agentic_rectification_invalid_intent", { status: 400, code: "invalid_intent", message: "请求内容不正确" }],
|
||||
["agentic_rectification_invalid_range", { status: 400, code: "invalid_range", message: "候选范围不正确" }],
|
||||
["agentic_rectification_turn_incomplete", { status: 400, code: "turn_incomplete", message: "回合内容不完整" }],
|
||||
["agentic_rectification_quote_not_grounded", { status: 422, code: "quote_not_grounded", message: "事件引用未能在本轮消息中找到" }],
|
||||
["agentic_rectification_evidence_not_found", { status: 404, code: "evidence_not_found", message: "事件记录不存在或无权访问" }],
|
||||
["agentic_rectification_evidence_not_confirmable", { status: 409, code: "evidence_not_confirmable", message: "该事件当前不能确认" }],
|
||||
["agentic_rectification_evidence_not_revisable", { status: 409, code: "evidence_not_revisable", message: "该事件当前不能修订" }],
|
||||
]);
|
||||
|
||||
export type RectificationServiceErrorView = {
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function mapRectificationRpcError(error: unknown): RectificationServiceErrorView {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "object" && error !== null && "message" in error
|
||||
? String((error as { message?: unknown }).message ?? "")
|
||||
: "";
|
||||
for (const [code, view] of KNOWN_RPC_ERROR_CODES) {
|
||||
if (message.includes(code)) return view;
|
||||
}
|
||||
return { status: 500, code: "rectification_service_failed", message: "校正服务暂时不可用" };
|
||||
}
|
||||
|
||||
export async function openRectificationCase(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
request: OpenRectificationCaseRequest,
|
||||
): Promise<OpenRectificationCaseResponse> {
|
||||
let profile: V9RectificationProfile | null = null;
|
||||
if (request.intent === "homepage" || request.intent === "new") {
|
||||
profile = await loadV9RectificationProfile(accounting, userId);
|
||||
}
|
||||
|
||||
const { data, error } = await accounting.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: request.requestId,
|
||||
p_intent: request.intent,
|
||||
p_session_id: request.intent === "session" ? request.sessionId : null,
|
||||
p_skill_name: RECTIFICATION_SKILL_NAME,
|
||||
p_skill_version: RECTIFICATION_SKILL_VERSION,
|
||||
p_baseline_profile_fingerprint: profile?.baselineFingerprint ?? "session-view",
|
||||
p_baseline_birth_snapshot: profile?.baseline ?? {},
|
||||
p_candidate_range: profile?.candidateRange ?? { start_time: "00:00", end_time: "23:59" },
|
||||
});
|
||||
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
||||
const response = openResponse(readRpcData(data));
|
||||
if (!response) throw new RectificationCaseServiceError("invalid_open_response");
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getRectificationEntrySummary(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
): Promise<RectificationEntrySummary> {
|
||||
const { data, error } = await accounting.rpc("get_agentic_rectification_entry_summary", {
|
||||
p_user_id: userId,
|
||||
});
|
||||
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
||||
const row = readRpcData(data);
|
||||
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_entry_summary");
|
||||
const value = row as Record<string, unknown>;
|
||||
const latestResumable =
|
||||
value.latest_resumable && typeof value.latest_resumable === "object"
|
||||
? (value.latest_resumable as Record<string, unknown>)
|
||||
: null;
|
||||
const latestTerminal =
|
||||
value.latest_terminal && typeof value.latest_terminal === "object"
|
||||
? (value.latest_terminal as Record<string, unknown>)
|
||||
: null;
|
||||
return {
|
||||
hasResumableCase: value.has_resumable_case === true,
|
||||
hasTerminalCaseWithTime: value.has_terminal_case_with_time === true,
|
||||
latestResumable:
|
||||
latestResumable && typeof latestResumable.case_id === "string"
|
||||
? {
|
||||
caseId: latestResumable.case_id,
|
||||
status: isRectificationCaseStatus(latestResumable.status)
|
||||
? latestResumable.status
|
||||
: "draft",
|
||||
lastActivityAt:
|
||||
typeof latestResumable.last_activity_at === "string" ? latestResumable.last_activity_at : "",
|
||||
}
|
||||
: null,
|
||||
latestTerminal:
|
||||
latestTerminal && typeof latestTerminal.case_id === "string"
|
||||
? {
|
||||
caseId: latestTerminal.case_id,
|
||||
status: isRectificationCaseStatus(latestTerminal.status)
|
||||
? latestTerminal.status
|
||||
: ("closed" as RectificationCaseStatus),
|
||||
hasUsableTime: latestTerminal.has_usable_time === true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function caseView(row: Record<string, unknown>): RectificationCaseView {
|
||||
const range = row.candidate_range && typeof row.candidate_range === "object"
|
||||
? (row.candidate_range as { start_time?: unknown; end_time?: unknown })
|
||||
: null;
|
||||
return {
|
||||
caseId: String(row.case_id ?? ""),
|
||||
sessionId: String(row.session_id ?? ""),
|
||||
status: String(row.status ?? ""),
|
||||
skillName: String(row.skill_name ?? ""),
|
||||
skillVersion: String(row.skill_version ?? ""),
|
||||
candidateRange:
|
||||
range && typeof range.start_time === "string" && typeof range.end_time === "string"
|
||||
? { start_time: range.start_time, end_time: range.end_time }
|
||||
: null,
|
||||
acceptedTime: typeof row.accepted_time === "string" ? row.accepted_time : null,
|
||||
confirmedTime: typeof row.confirmed_time === "string" ? row.confirmed_time : null,
|
||||
createdAt: String(row.created_at ?? ""),
|
||||
lastActivityAt: String(row.last_activity_at ?? ""),
|
||||
completedAt: typeof row.completed_at === "string" ? row.completed_at : null,
|
||||
closedReason: typeof row.closed_reason === "string" ? row.closed_reason : null,
|
||||
evidenceCount: typeof row.evidence_count === "number" ? row.evidence_count : 0,
|
||||
turnCount: typeof row.turn_count === "number" ? row.turn_count : 0,
|
||||
latestResult: row.latest_result ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRectificationCase(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
): Promise<RectificationCaseView> {
|
||||
const { data, error } = await accounting.rpc("get_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
});
|
||||
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
||||
const row = readRpcData(data);
|
||||
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_case_view");
|
||||
return caseView(row as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export type RectificationCloseResult = Readonly<{
|
||||
success: boolean;
|
||||
caseId: string;
|
||||
status: string;
|
||||
idempotent: boolean;
|
||||
}>;
|
||||
|
||||
export async function closeRectificationCase(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
reason: "completed_by_user" | "abandoned_by_user" | "other",
|
||||
): Promise<RectificationCloseResult> {
|
||||
const { data, error } = await accounting.rpc("close_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_reason: reason,
|
||||
});
|
||||
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
||||
const row = readRpcData(data);
|
||||
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_close_result");
|
||||
const value = row as Record<string, unknown>;
|
||||
return {
|
||||
success: value.success === true,
|
||||
caseId: String(value.case_id ?? caseId),
|
||||
status: String(value.status ?? "closed"),
|
||||
idempotent: value.idempotent === true,
|
||||
};
|
||||
}
|
||||
|
||||
export type RectificationUpgradeResult = Readonly<{
|
||||
success: boolean;
|
||||
caseId: string;
|
||||
previousSkillVersion: string;
|
||||
skillVersion: string;
|
||||
idempotent: boolean;
|
||||
}>;
|
||||
|
||||
export async function upgradeRectificationSkill(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
skillVersion: string,
|
||||
): Promise<RectificationUpgradeResult> {
|
||||
const { data, error } = await accounting.rpc("upgrade_agentic_rectification_skill", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_skill_version: skillVersion,
|
||||
});
|
||||
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
|
||||
const row = readRpcData(data);
|
||||
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_upgrade_result");
|
||||
const value = row as Record<string, unknown>;
|
||||
return {
|
||||
success: value.success === true,
|
||||
caseId: String(value.case_id ?? caseId),
|
||||
previousSkillVersion: String(value.previous_skill_version ?? ""),
|
||||
skillVersion: String(value.skill_version ?? skillVersion),
|
||||
idempotent: value.idempotent === true,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.match(migration.stdout, /applied 20260806040000_model_configuration\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260806050000_operations_feature_flags\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260811010000_consultation_status_service_role_read\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260812010000_agentic_rectification_v9_runtime\.sql/);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
@@ -108,7 +109,12 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
"admin_session_revocations",
|
||||
"admin_user_roles",
|
||||
"admin_users",
|
||||
"agentic_rectification_cases",
|
||||
"agentic_rectification_evidence",
|
||||
"agentic_rectification_open_ledger",
|
||||
"agentic_rectification_results",
|
||||
"agentic_rectification_tool_receipts",
|
||||
"agentic_rectification_turns",
|
||||
"billing_products",
|
||||
"birth_time_rectification_action_receipts",
|
||||
"birth_time_rectification_agent_runs",
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
RectificationCaseServiceError,
|
||||
closeRectificationCase,
|
||||
getRectificationCase,
|
||||
getRectificationEntrySummary,
|
||||
loadV9RectificationProfile,
|
||||
mapRectificationRpcError,
|
||||
openRectificationCase,
|
||||
upgradeRectificationSkill,
|
||||
} from "../src/lib/rectification-agentic/v9/case-service.ts";
|
||||
import type { OpenRectificationCaseRequest } from "../src/lib/rectification-agentic/v9/open-request.ts";
|
||||
|
||||
type RpcHandler = (fn: string, args: Record<string, unknown>) => Promise<{
|
||||
data: unknown;
|
||||
error: { message: string } | null;
|
||||
}>;
|
||||
|
||||
function fakeAccounting(overrides: {
|
||||
profile?: Record<string, unknown> | null;
|
||||
rpc?: RpcHandler;
|
||||
}) {
|
||||
const rpc =
|
||||
overrides.rpc ??
|
||||
(async () => ({ data: null, error: { message: "agentic_rectification_case_not_found" } }));
|
||||
return {
|
||||
from: (table: string) => {
|
||||
if (table !== "profiles") throw new Error(`unexpected table ${table}`);
|
||||
const builder = {
|
||||
select: () => builder,
|
||||
eq: () => builder,
|
||||
single: async () => {
|
||||
if (!overrides.profile) return { data: null, error: { message: "not found" } };
|
||||
return { data: overrides.profile, error: null };
|
||||
},
|
||||
};
|
||||
return builder;
|
||||
},
|
||||
rpc,
|
||||
} as never;
|
||||
}
|
||||
|
||||
const completeProfile = {
|
||||
birth_date: "1997-08-08",
|
||||
reported_birth_time: "05:00",
|
||||
active_birth_time: null,
|
||||
birth_time_source: "family_exact",
|
||||
birth_time_period: null,
|
||||
uncertainty_before_minutes: 10,
|
||||
uncertainty_after_minutes: 10,
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezone_offset: 8,
|
||||
};
|
||||
|
||||
const requestId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
const caseId = "11111111-1111-4111-8111-111111111111";
|
||||
const sessionId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function openRpc(data: unknown): RpcHandler {
|
||||
return async () => ({ data, error: null });
|
||||
}
|
||||
|
||||
test("loadV9RectificationProfile derives baseline snapshot, fingerprint and range server-side", async () => {
|
||||
const accounting = fakeAccounting({ profile: completeProfile });
|
||||
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.baselineFingerprint.length, 64);
|
||||
assert.deepEqual(profile.candidateRange, { start_time: "04:50", end_time: "05:10" });
|
||||
assert.ok(!("password" in profile.baseline));
|
||||
});
|
||||
|
||||
test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async () => {
|
||||
const dateProfile = {
|
||||
...completeProfile,
|
||||
birth_date: new Date("1997-08-08T00:00:00.000Z"),
|
||||
};
|
||||
const accounting = fakeAccounting({ profile: dateProfile });
|
||||
const profile = await loadV9RectificationProfile(accounting, "user-1");
|
||||
assert.equal(profile.baseline.birth_date, "1997-08-08");
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
test("period-only profiles derive a period range; unknown derives the full day", async () => {
|
||||
const period = fakeAccounting({
|
||||
profile: {
|
||||
...completeProfile,
|
||||
reported_birth_time: null,
|
||||
birth_time_source: "period_only",
|
||||
birth_time_period: "morning",
|
||||
},
|
||||
});
|
||||
const periodProfile = await loadV9RectificationProfile(period, "user-1");
|
||||
assert.deepEqual(periodProfile.candidateRange, { start_time: "08:00", end_time: "11:59" });
|
||||
|
||||
const unknown = fakeAccounting({
|
||||
profile: {
|
||||
...completeProfile,
|
||||
reported_birth_time: null,
|
||||
birth_time_source: "unknown",
|
||||
birth_time_period: null,
|
||||
},
|
||||
});
|
||||
const unknownProfile = await loadV9RectificationProfile(unknown, "user-1");
|
||||
assert.deepEqual(unknownProfile.candidateRange, { start_time: "00:00", end_time: "23:59" });
|
||||
});
|
||||
|
||||
test("homepage open with no history creates one case and one session", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
profile: completeProfile,
|
||||
rpc: openRpc({
|
||||
disposition: "created",
|
||||
case_id: caseId,
|
||||
session_id: sessionId,
|
||||
status: "draft",
|
||||
should_start_opening: true,
|
||||
skill_version: "9.0.0",
|
||||
}),
|
||||
});
|
||||
const request: OpenRectificationCaseRequest = { intent: "homepage", requestId };
|
||||
const response = await openRectificationCase(accounting, "user-1", request);
|
||||
assert.equal(response.disposition, "created");
|
||||
assert.equal(response.caseId, caseId);
|
||||
assert.equal(response.sessionId, sessionId);
|
||||
assert.equal(response.shouldStartOpening, true);
|
||||
});
|
||||
|
||||
test("homepage open with a resumable case resumes without creating a session", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
profile: completeProfile,
|
||||
rpc: openRpc({
|
||||
disposition: "resumed",
|
||||
case_id: caseId,
|
||||
session_id: sessionId,
|
||||
status: "collecting_evidence",
|
||||
should_start_opening: false,
|
||||
skill_version: "9.0.0",
|
||||
}),
|
||||
});
|
||||
const response = await openRectificationCase(accounting, "user-1", {
|
||||
intent: "homepage",
|
||||
requestId,
|
||||
});
|
||||
assert.equal(response.disposition, "resumed");
|
||||
assert.equal(response.shouldStartOpening, false);
|
||||
});
|
||||
|
||||
test("session intent passes the exact sessionId and never the profile snapshot", async () => {
|
||||
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
|
||||
const accounting = fakeAccounting({
|
||||
profile: null,
|
||||
rpc: async (fn, args) => {
|
||||
capturedArgs.value = args;
|
||||
return {
|
||||
data: {
|
||||
disposition: "readonly",
|
||||
case_id: caseId,
|
||||
session_id: sessionId,
|
||||
status: "closed",
|
||||
should_start_opening: false,
|
||||
skill_version: "9.0.0",
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const response = await openRectificationCase(accounting, "user-1", {
|
||||
intent: "session",
|
||||
requestId,
|
||||
sessionId,
|
||||
});
|
||||
assert.equal(response.disposition, "readonly");
|
||||
assert.equal(capturedArgs.value?.p_session_id, sessionId);
|
||||
assert.deepEqual(capturedArgs.value?.p_baseline_birth_snapshot, {});
|
||||
});
|
||||
|
||||
test("non-owner session opens surface a safe not-found error", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
profile: null,
|
||||
rpc: async () => ({ data: null, error: { message: "agentic_rectification_session_not_found" } }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
openRectificationCase(accounting, "user-1", {
|
||||
intent: "session",
|
||||
requestId,
|
||||
sessionId: "99999999-9999-4999-8999-999999999999",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof RectificationCaseServiceError && error.code === "case_session_not_found",
|
||||
);
|
||||
});
|
||||
|
||||
test("intent new with an active case surfaces a safe conflict, never silent abandon", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
profile: completeProfile,
|
||||
rpc: async () => ({ data: null, error: { message: "agentic_rectification_active_case_conflict" } }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => openRectificationCase(accounting, "user-1", { intent: "new", requestId }),
|
||||
(error: unknown) =>
|
||||
error instanceof RectificationCaseServiceError && error.code === "active_case_conflict",
|
||||
);
|
||||
});
|
||||
|
||||
test("entry summary projects resume-or-create truth for the homepage card", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
rpc: async (fn) => {
|
||||
assert.equal(fn, "get_agentic_rectification_entry_summary");
|
||||
return {
|
||||
data: {
|
||||
has_resumable_case: true,
|
||||
has_terminal_case_with_time: false,
|
||||
latest_resumable: {
|
||||
case_id: caseId,
|
||||
status: "collecting_evidence",
|
||||
last_activity_at: "2026-08-12T00:00:00.000Z",
|
||||
},
|
||||
latest_terminal: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const summary = await getRectificationEntrySummary(accounting, "user-1");
|
||||
assert.equal(summary.hasResumableCase, true);
|
||||
assert.equal(summary.hasTerminalCaseWithTime, false);
|
||||
assert.equal(summary.latestResumable?.caseId, caseId);
|
||||
});
|
||||
|
||||
test("get case returns a sanitized projection without the birth snapshot", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
rpc: async () => ({
|
||||
data: {
|
||||
case_id: caseId,
|
||||
session_id: sessionId,
|
||||
status: "candidate_ready",
|
||||
skill_name: "jyotish-birth-time-rectification",
|
||||
skill_version: "9.0.0",
|
||||
candidate_range: { start_time: "04:50", end_time: "05:10" },
|
||||
accepted_time: null,
|
||||
confirmed_time: null,
|
||||
created_at: "2026-08-12T00:00:00.000Z",
|
||||
last_activity_at: "2026-08-12T00:00:00.000Z",
|
||||
completed_at: null,
|
||||
closed_reason: null,
|
||||
evidence_count: 3,
|
||||
turn_count: 4,
|
||||
latest_result: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
const view = await getRectificationCase(accounting, "user-1", caseId);
|
||||
assert.equal(view.caseId, caseId);
|
||||
assert.equal(view.evidenceCount, 3);
|
||||
assert.deepEqual(view.candidateRange, { start_time: "04:50", end_time: "05:10" });
|
||||
assert.ok(!("baseline_birth_snapshot" in view));
|
||||
assert.ok(!("birth_date" in view));
|
||||
});
|
||||
|
||||
test("close is idempotent and never wraps as engine confirmed", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
rpc: async (fn, args) => {
|
||||
assert.equal(args.p_reason, "completed_by_user");
|
||||
return { data: { success: true, case_id: caseId, status: "closed", idempotent: false }, error: null };
|
||||
},
|
||||
});
|
||||
const result = await closeRectificationCase(accounting, "user-1", caseId, "completed_by_user");
|
||||
assert.equal(result.status, "closed");
|
||||
assert.equal(result.idempotent, false);
|
||||
});
|
||||
|
||||
test("upgrade skill migrates a running case to a pinned version", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
rpc: async (fn, args) => {
|
||||
assert.equal(args.p_skill_version, "9.1.0");
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
case_id: caseId,
|
||||
previous_skill_version: "9.0.0",
|
||||
skill_version: "9.1.0",
|
||||
idempotent: false,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await upgradeRectificationSkill(accounting, "user-1", caseId, "9.1.0");
|
||||
assert.equal(result.skillVersion, "9.1.0");
|
||||
});
|
||||
|
||||
test("rpc errors map to safe public views without leaking database text", async () => {
|
||||
const generic = mapRectificationRpcError(new Error("syntax error at or near \"profiles\""));
|
||||
assert.equal(generic.status, 500);
|
||||
assert.equal(generic.code, "rectification_service_failed");
|
||||
assert.doesNotMatch(generic.message, /syntax error/);
|
||||
|
||||
const conflict = mapRectificationRpcError(new Error("agentic_rectification_active_case_conflict"));
|
||||
assert.equal(conflict.status, 409);
|
||||
assert.equal(conflict.code, "active_case_conflict");
|
||||
assert.doesNotMatch(conflict.message, /agentic_rectification/);
|
||||
|
||||
const notFound = mapRectificationRpcError(new Error("agentic_rectification_case_not_found"));
|
||||
assert.equal(notFound.status, 404);
|
||||
assert.equal(notFound.code, "case_not_found");
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
MAX_RESUMABLE_CASES_PER_USER,
|
||||
RECTIFICATION_CASE_STATUSES,
|
||||
RECTIFICATION_SKILL_NAME,
|
||||
RECTIFICATION_SKILL_VERSION,
|
||||
RESUMABLE_CASE_STATUSES,
|
||||
TERMINAL_CASE_STATUSES,
|
||||
canTransitToTerminal,
|
||||
evidenceWritesAllowed,
|
||||
isRectificationCaseStatus,
|
||||
isResumableStatus,
|
||||
isTerminalStatus,
|
||||
} from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import {
|
||||
BACKGROUND_ONLY_KINDS,
|
||||
DISTINCT_KIND_GROUPS,
|
||||
EVIDENCE_KINDS,
|
||||
canTransitEvidenceStatus,
|
||||
isBackgroundEvidenceKind,
|
||||
isDatePrecision,
|
||||
isEvidenceDomain,
|
||||
isEvidenceKind,
|
||||
isEvidenceStatus,
|
||||
normalizeQuote,
|
||||
quoteIsGroundedInMessage,
|
||||
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||||
import {
|
||||
PUBLIC_ACTIVITY_EVENTS,
|
||||
PUBLIC_RECTIFICATION_PHASES,
|
||||
PUBLIC_RECTIFICATION_TOOLS,
|
||||
safeActivityEvent,
|
||||
} from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
import {
|
||||
openRectificationCaseRequestSchema,
|
||||
shouldStartOpening,
|
||||
} from "../src/lib/rectification-agentic/v9/open-request.ts";
|
||||
|
||||
const skillDirectory = fileURLToPath(
|
||||
new URL("../../skills/jyotish-birth-time-rectification", import.meta.url),
|
||||
);
|
||||
const skill = readFileSync(`${skillDirectory}/SKILL.md`, "utf8");
|
||||
const references = [
|
||||
"evidence-model.md",
|
||||
"conversation-strategy.md",
|
||||
"candidate-comparison.md",
|
||||
"technique-routing.md",
|
||||
"truth-consent-boundaries.md",
|
||||
];
|
||||
|
||||
test("v9 case status machine separates resumable from terminal statuses", () => {
|
||||
assert.deepEqual([...RESUMABLE_CASE_STATUSES], [
|
||||
"draft",
|
||||
"collecting_evidence",
|
||||
"candidate_ready",
|
||||
"candidate_accepted",
|
||||
"needs_rebaseline",
|
||||
"paused",
|
||||
]);
|
||||
assert.deepEqual([...TERMINAL_CASE_STATUSES], [
|
||||
"confirmed",
|
||||
"closed",
|
||||
"abandoned",
|
||||
"superseded",
|
||||
]);
|
||||
for (const status of RECTIFICATION_CASE_STATUSES) {
|
||||
assert.equal(isRectificationCaseStatus(status), true);
|
||||
assert.equal(isResumableStatus(status), RESUMABLE_CASE_STATUSES.includes(status));
|
||||
assert.equal(isTerminalStatus(status), TERMINAL_CASE_STATUSES.includes(status));
|
||||
}
|
||||
assert.equal(isRectificationCaseStatus("mystery"), false);
|
||||
});
|
||||
|
||||
test("terminal transitions are one-way and evidence writes stop at terminal", () => {
|
||||
assert.equal(canTransitToTerminal("candidate_accepted", "confirmed"), true);
|
||||
assert.equal(canTransitToTerminal("collecting_evidence", "closed"), true);
|
||||
assert.equal(canTransitToTerminal("confirmed", "closed"), false);
|
||||
assert.equal(canTransitToTerminal("closed", "candidate_ready"), false);
|
||||
assert.equal(evidenceWritesAllowed("collecting_evidence"), true);
|
||||
assert.equal(evidenceWritesAllowed("candidate_accepted"), true);
|
||||
assert.equal(evidenceWritesAllowed("needs_rebaseline"), true);
|
||||
assert.equal(evidenceWritesAllowed("confirmed"), false);
|
||||
assert.equal(evidenceWritesAllowed("closed"), false);
|
||||
assert.equal(evidenceWritesAllowed("superseded"), false);
|
||||
assert.equal(MAX_RESUMABLE_CASES_PER_USER, 1);
|
||||
});
|
||||
|
||||
test("the v9 skill pins its name and version and lives in the right directory", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "9.0.0");
|
||||
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
|
||||
for (const reference of references) {
|
||||
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
|
||||
assert.ok(content.length > 0, `${reference} must be non-empty`);
|
||||
}
|
||||
assert.ok(skill.split("\n").length <= 200, "SKILL.md must stay within 200 lines");
|
||||
});
|
||||
|
||||
test("skill keeps the method in the skill, not a hard-coded questionnaire", () => {
|
||||
// The skill may only mention the old questionnaire as an explicit
|
||||
// prohibition, never as a requirement.
|
||||
assert.match(skill, /不再有固定 10[–-]15 个事件/);
|
||||
assert.match(skill, /固定 80%\/60% 匹配率/);
|
||||
assert.match(skill, /固定 A\/B\/C\/D 问卷/);
|
||||
assert.match(skill, /不得在文本中伪造出生分钟/);
|
||||
assert.match(skill, /日期精度真实保留/);
|
||||
assert.match(skill, /candidate[\s\S]*accepted[\s\S]*confirmed/);
|
||||
});
|
||||
|
||||
test("evidence model exposes the full kind/domain/precision/status sets", () => {
|
||||
for (const kind of EVIDENCE_KINDS) assert.equal(isEvidenceKind(kind), true);
|
||||
assert.equal(isEvidenceKind("career"), false);
|
||||
assert.equal(isEvidenceDomain("career"), true);
|
||||
assert.equal(isEvidenceDomain("nope"), false);
|
||||
for (const precision of ["year", "month", "day", "range", "unknown"]) {
|
||||
assert.equal(isDatePrecision(precision), true);
|
||||
}
|
||||
assert.equal(isDatePrecision("exact_minute"), false);
|
||||
for (const status of ["draft", "pending_confirmation", "confirmed", "superseded", "rejected"]) {
|
||||
assert.equal(isEvidenceStatus(status), true);
|
||||
}
|
||||
assert.equal(isEvidenceStatus("collected"), false);
|
||||
});
|
||||
|
||||
test("semantically distinct kinds are never folded together", () => {
|
||||
const flat = DISTINCT_KIND_GROUPS.flat();
|
||||
assert.ok(flat.includes("career_entry") && flat.includes("career_pressure") && flat.includes("career_exit"));
|
||||
assert.ok(flat.includes("relationship_start") && flat.includes("relationship_commitment") && flat.includes("relationship_separation"));
|
||||
assert.equal(new Set(flat).size, flat.length);
|
||||
});
|
||||
|
||||
test("only the server confirmation path may produce confirmed evidence", () => {
|
||||
assert.equal(canTransitEvidenceStatus("draft", "pending_confirmation"), true);
|
||||
assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true);
|
||||
assert.equal(canTransitEvidenceStatus("confirmed", "superseded"), true);
|
||||
assert.equal(canTransitEvidenceStatus("superseded", "confirmed"), false);
|
||||
assert.equal(canTransitEvidenceStatus("rejected", "confirmed"), false);
|
||||
assert.equal(canTransitEvidenceStatus("draft", "confirmed"), false);
|
||||
});
|
||||
|
||||
test("quote grounding normalizes whitespace and punctuation", () => {
|
||||
assert.equal(
|
||||
normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"),
|
||||
"2016年9月,我离开家去北京开始工作。".replace(/[\s\u3000,。!?、;:“”‘’()《》·—…]/g, ""),
|
||||
);
|
||||
assert.equal(quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"), true);
|
||||
assert.equal(quoteIsGroundedInMessage("我去了上海", "去了北京"), false);
|
||||
assert.equal(quoteIsGroundedInMessage("", "任意"), false);
|
||||
});
|
||||
|
||||
test("family/other are background-only kinds that never advance scoring", () => {
|
||||
assert.equal(isBackgroundEvidenceKind("family_event"), true);
|
||||
assert.equal(isBackgroundEvidenceKind("other"), true);
|
||||
assert.equal(isBackgroundEvidenceKind("career_entry"), false);
|
||||
assert.equal(BACKGROUND_ONLY_KINDS.size, 2);
|
||||
});
|
||||
|
||||
test("public receipt allowlists are exact and deny unknown values", () => {
|
||||
for (const phase of PUBLIC_RECTIFICATION_PHASES) {
|
||||
assert.equal(safeActivityEvent(phase), phase);
|
||||
}
|
||||
assert.equal(safeActivityEvent("provider.reasoning"), null);
|
||||
assert.equal(safeActivityEvent("tool.payload"), null);
|
||||
assert.equal(PUBLIC_ACTIVITY_EVENTS.length, PUBLIC_RECTIFICATION_PHASES.length);
|
||||
assert.ok(PUBLIC_RECTIFICATION_TOOLS.includes("rectification-read-case"));
|
||||
assert.ok(!(PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes("rectification-scan"));
|
||||
});
|
||||
|
||||
test("open request schemas reject userId, birth data and range from the browser", () => {
|
||||
const homepage = openRectificationCaseRequestSchema.safeParse({
|
||||
intent: "homepage",
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
});
|
||||
assert.equal(homepage.success, true);
|
||||
const withUserId = openRectificationCaseRequestSchema.safeParse({
|
||||
intent: "homepage",
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
userId: "ffffffff-0000-4aaa-9bbb-cccccccccccc",
|
||||
});
|
||||
assert.equal(withUserId.success, false);
|
||||
const withBirthData = openRectificationCaseRequestSchema.safeParse({
|
||||
intent: "homepage",
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
birth_date: "1997-08-08",
|
||||
});
|
||||
assert.equal(withBirthData.success, false);
|
||||
const withRange = openRectificationCaseRequestSchema.safeParse({
|
||||
intent: "homepage",
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
candidate_range: { start_time: "04:00", end_time: "06:00" },
|
||||
});
|
||||
assert.equal(withRange.success, false);
|
||||
const supersedeTrue = openRectificationCaseRequestSchema.safeParse({
|
||||
intent: "new",
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
supersedeActive: true,
|
||||
});
|
||||
assert.equal(supersedeTrue.success, false);
|
||||
});
|
||||
|
||||
test("shouldStartOpening is server-owned: only freshly created never-started cases", () => {
|
||||
assert.equal(shouldStartOpening("created", 0), true);
|
||||
assert.equal(shouldStartOpening("created", 1), false);
|
||||
assert.equal(shouldStartOpening("resumed", 0), false);
|
||||
assert.equal(shouldStartOpening("resumed", 5), false);
|
||||
assert.equal(shouldStartOpening("readonly", 0), false);
|
||||
});
|
||||
@@ -0,0 +1,761 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
function dockerAvailable(): boolean {
|
||||
const probe = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
});
|
||||
return probe.status === 0;
|
||||
}
|
||||
|
||||
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
|
||||
|
||||
const snapshot = {
|
||||
birth_date: "1997-08-08",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezone_offset: 8,
|
||||
birth_time_source: "family_exact",
|
||||
reported_birth_time: "05:00",
|
||||
active_birth_time: null,
|
||||
birth_time_period: null,
|
||||
uncertainty_before_minutes: 10,
|
||||
uncertainty_after_minutes: 10,
|
||||
};
|
||||
const fingerprint = "a".repeat(64);
|
||||
const range = { start_time: "04:50", end_time: "05:10" };
|
||||
const skillName = "jyotish-birth-time-rectification";
|
||||
const skillVersion = "9.0.0";
|
||||
|
||||
function rpcError(error: unknown): string {
|
||||
if (!error || typeof error !== "object") return "";
|
||||
const value = error as { message?: unknown };
|
||||
return typeof value.message === "string" ? value.message : "";
|
||||
}
|
||||
|
||||
test("v9 migration applies on a fresh database and re-applies idempotently", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
const migrate = () =>
|
||||
spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
try {
|
||||
const firstRun = migrate();
|
||||
assert.equal(firstRun.status, 0, firstRun.stderr);
|
||||
assert.match(firstRun.stdout, /applied 20260812010000_agentic_rectification_v9_runtime\.sql/);
|
||||
const secondRun = migrate();
|
||||
assert.equal(secondRun.status, 0, secondRun.stderr);
|
||||
assert.match(
|
||||
secondRun.stdout,
|
||||
/already applied 20260812010000_agentic_rectification_v9_runtime\.sql/,
|
||||
);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
values ('V9 User', 'v9-user@example.com', true, now())
|
||||
`,
|
||||
);
|
||||
const userId = fixture.psql(
|
||||
"select id from identity.users where email = 'v9-user@example.com'",
|
||||
);
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
reported_birth_time = '05:00',
|
||||
birth_time_source = 'family_exact',
|
||||
uncertainty_before_minutes = 10,
|
||||
uncertainty_after_minutes = 10,
|
||||
latitude = 36.420487,
|
||||
longitude = 114.209936,
|
||||
timezone_offset = 8,
|
||||
birth_time_status = 'reported'
|
||||
where id = '${userId}';
|
||||
`);
|
||||
|
||||
const service = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
|
||||
const first = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "11111111-1111-4111-8111-111111111111",
|
||||
p_intent: "homepage",
|
||||
p_session_id: null,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.equal(first.error, null, rpcError(first.error));
|
||||
const created = first.data as Record<string, unknown>;
|
||||
assert.equal(created.disposition, "created");
|
||||
assert.equal(created.should_start_opening, true);
|
||||
assert.equal(created.status, "draft");
|
||||
const caseId = String(created.case_id);
|
||||
const sessionId = String(created.session_id);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${userId}'`),
|
||||
"1",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`),
|
||||
"1",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(
|
||||
`select count(*) from public.agentic_rectification_open_ledger where user_id = '${userId}'`,
|
||||
),
|
||||
"1",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(
|
||||
`select agentic_rectification_case_id from public.chat_sessions where id = '${sessionId}'`,
|
||||
),
|
||||
caseId,
|
||||
);
|
||||
|
||||
// Replaying the same requestId must not create anything new.
|
||||
const replay = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "11111111-1111-4111-8111-111111111111",
|
||||
p_intent: "homepage",
|
||||
p_session_id: null,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.equal(replay.error, null, rpcError(replay.error));
|
||||
assert.equal((replay.data as Record<string, unknown>).case_id, caseId);
|
||||
assert.equal((replay.data as Record<string, unknown>).session_id, sessionId);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
// A second request with a fresh requestId resumes the same case.
|
||||
const resume = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "22222222-2222-4222-8222-222222222222",
|
||||
p_intent: "homepage",
|
||||
p_session_id: null,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.equal(resume.error, null, rpcError(resume.error));
|
||||
assert.equal((resume.data as Record<string, unknown>).disposition, "resumed");
|
||||
assert.equal((resume.data as Record<string, unknown>).should_start_opening, false);
|
||||
assert.equal((resume.data as Record<string, unknown>).case_id, caseId);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
// intent new while an active case exists must fail safely.
|
||||
const conflict = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "33333333-3333-4333-8333-333333333333",
|
||||
p_intent: "new",
|
||||
p_session_id: null,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.match(rpcError(conflict.error), /agentic_rectification_active_case_conflict/);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
// Session intent opens the exact session.
|
||||
const sessionOpen = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "44444444-4444-4444-8444-444444444444",
|
||||
p_intent: "session",
|
||||
p_session_id: sessionId,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: "session-view",
|
||||
p_baseline_birth_snapshot: {},
|
||||
p_candidate_range: { start_time: "00:00", end_time: "23:59" },
|
||||
});
|
||||
assert.equal(sessionOpen.error, null, rpcError(sessionOpen.error));
|
||||
assert.equal((sessionOpen.data as Record<string, unknown>).case_id, caseId);
|
||||
assert.equal((sessionOpen.data as Record<string, unknown>).session_id, sessionId);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 enforces profile gating, ownership and terminal read-only", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at) values
|
||||
('Incomplete', 'incomplete@example.com', true, now()),
|
||||
('Owner', 'owner@example.com', true, now());
|
||||
`,
|
||||
);
|
||||
const incompleteId = fixture.psql(
|
||||
"select id from identity.users where email = 'incomplete@example.com'",
|
||||
);
|
||||
const ownerId = fixture.psql(
|
||||
"select id from identity.users where email = 'owner@example.com'",
|
||||
);
|
||||
fixture.psql(`
|
||||
update public.profiles set birth_date = '1997-08-08' where id = '${incompleteId}';
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
reported_birth_time = '05:00',
|
||||
birth_time_source = 'family_exact',
|
||||
latitude = 36.420487, longitude = 114.209936, timezone_offset = 8,
|
||||
birth_time_status = 'reported'
|
||||
where id = '${ownerId}';
|
||||
`);
|
||||
|
||||
const service = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
|
||||
// Incomplete profile: no case is created.
|
||||
const incomplete = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: incompleteId,
|
||||
p_request_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
p_intent: "homepage",
|
||||
p_session_id: null,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: { birth_date: "1997-08-08" },
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.match(rpcError(incomplete.error), /agentic_rectification_profile_incomplete/);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${incompleteId}'`),
|
||||
"0",
|
||||
);
|
||||
|
||||
// Owner opens a case, closes it, then reopens it read-only.
|
||||
const opened = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: ownerId,
|
||||
p_request_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
p_intent: "homepage",
|
||||
p_session_id: null,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.equal(opened.error, null, rpcError(opened.error));
|
||||
const ownerCaseId = String((opened.data as Record<string, unknown>).case_id);
|
||||
const ownerSessionId = String((opened.data as Record<string, unknown>).session_id);
|
||||
|
||||
const closed = await service.rpc("close_agentic_rectification_case", {
|
||||
p_user_id: ownerId,
|
||||
p_case_id: ownerCaseId,
|
||||
p_reason: "completed_by_user",
|
||||
});
|
||||
assert.equal(closed.error, null, rpcError(closed.error));
|
||||
assert.equal((closed.data as Record<string, unknown>).status, "closed");
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where id = '${ownerCaseId}'`),
|
||||
"closed",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select completed_at is not null from public.agentic_rectification_cases where id = '${ownerCaseId}'`),
|
||||
"t",
|
||||
);
|
||||
|
||||
const readonly = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: ownerId,
|
||||
p_request_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
p_intent: "session",
|
||||
p_session_id: ownerSessionId,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: "session-view",
|
||||
p_baseline_birth_snapshot: {},
|
||||
p_candidate_range: { start_time: "00:00", end_time: "23:59" },
|
||||
});
|
||||
assert.equal(readonly.error, null, rpcError(readonly.error));
|
||||
assert.equal((readonly.data as Record<string, unknown>).disposition, "readonly");
|
||||
assert.equal((readonly.data as Record<string, unknown>).should_start_opening, false);
|
||||
|
||||
// A different user cannot open the owner's session.
|
||||
const foreign = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: incompleteId,
|
||||
p_request_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
p_intent: "session",
|
||||
p_session_id: ownerSessionId,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: "session-view",
|
||||
p_baseline_birth_snapshot: {},
|
||||
p_candidate_range: { start_time: "00:00", end_time: "23:59" },
|
||||
});
|
||||
assert.match(rpcError(foreign.error), /agentic_rectification_session_not_found/);
|
||||
|
||||
// Terminal cases reject evidence proposals and turn appends.
|
||||
const turn = await service.rpc("append_agentic_rectification_turn", {
|
||||
p_user_id: ownerId,
|
||||
p_case_id: ownerCaseId,
|
||||
p_user_message: "我2016年9月离开家去北京工作",
|
||||
p_assistant_message: "已记录这段经历。",
|
||||
p_model_name: "test-model",
|
||||
p_model_version: "1",
|
||||
p_status: "completed",
|
||||
});
|
||||
assert.match(rpcError(turn.error), /agentic_rectification_case_terminal/);
|
||||
|
||||
const proposal = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: ownerId,
|
||||
p_case_id: ownerCaseId,
|
||||
p_source_turn_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
p_user_quote: "离开家去北京",
|
||||
p_subject: "self",
|
||||
p_event_kind: "relocation",
|
||||
p_domain: "relocation",
|
||||
p_occurred_from: "2016-09-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "month",
|
||||
p_summary: "2016年9月离开家去北京",
|
||||
});
|
||||
assert.match(rpcError(proposal.error), /agentic_rectification_case_terminal/);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 evidence lifecycle: quote grounding, idempotency, confirm and revision lineage", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
values ('Evidence', 'evidence@example.com', true, now())
|
||||
`,
|
||||
);
|
||||
const userId = fixture.psql(
|
||||
"select id from identity.users where email = 'evidence@example.com'",
|
||||
);
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
reported_birth_time = '05:00',
|
||||
birth_time_source = 'family_exact',
|
||||
latitude = 36.420487, longitude = 114.209936, timezone_offset = 8,
|
||||
birth_time_status = 'reported'
|
||||
where id = '${userId}';
|
||||
`);
|
||||
|
||||
const service = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
|
||||
const opened = await service.rpc("open_agentic_rectification_case", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||
p_intent: "homepage",
|
||||
p_session_id: null,
|
||||
p_skill_name: skillName,
|
||||
p_skill_version: skillVersion,
|
||||
p_baseline_profile_fingerprint: fingerprint,
|
||||
p_baseline_birth_snapshot: snapshot,
|
||||
p_candidate_range: range,
|
||||
});
|
||||
assert.equal(opened.error, null, rpcError(opened.error));
|
||||
const caseId = String((opened.data as Record<string, unknown>).case_id);
|
||||
|
||||
const turn = await service.rpc("append_agentic_rectification_turn", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_user_message: "2016年9月我离开家去北京开始工作",
|
||||
p_assistant_message: "好的,已记录。",
|
||||
p_model_name: "test-model",
|
||||
p_model_version: "1",
|
||||
p_status: "completed",
|
||||
});
|
||||
assert.equal(turn.error, null, rpcError(turn.error));
|
||||
const turnId = String((turn.data as Record<string, unknown>).turn_id);
|
||||
|
||||
const proposed = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_source_turn_id: turnId,
|
||||
p_user_quote: "离开家去北京",
|
||||
p_subject: "self",
|
||||
p_event_kind: "relocation",
|
||||
p_domain: "relocation",
|
||||
p_occurred_from: "2016-09-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "month",
|
||||
p_summary: "2016年9月离开家去北京",
|
||||
});
|
||||
assert.equal(proposed.error, null, rpcError(proposed.error));
|
||||
const evidenceId = String((proposed.data as Record<string, unknown>).evidence_id);
|
||||
assert.equal((proposed.data as Record<string, unknown>).idempotent, false);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_evidence where id = '${evidenceId}'`),
|
||||
"draft",
|
||||
);
|
||||
|
||||
// Replay must not create a second evidence row.
|
||||
const replay = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_source_turn_id: turnId,
|
||||
p_user_quote: "离开家去北京",
|
||||
p_subject: "self",
|
||||
p_event_kind: "relocation",
|
||||
p_domain: "relocation",
|
||||
p_occurred_from: "2016-09-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "month",
|
||||
p_summary: "2016年9月离开家去北京",
|
||||
});
|
||||
assert.equal(replay.error, null, rpcError(replay.error));
|
||||
assert.equal((replay.data as Record<string, unknown>).evidence_id, evidenceId);
|
||||
assert.equal((replay.data as Record<string, unknown>).idempotent, true);
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.agentic_rectification_evidence where case_id = '${caseId}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
// Quotes not present in the source turn are rejected.
|
||||
const ungrounded = await service.rpc("propose_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_source_turn_id: turnId,
|
||||
p_user_quote: "去了上海",
|
||||
p_subject: "self",
|
||||
p_event_kind: "relocation",
|
||||
p_domain: "relocation",
|
||||
p_occurred_from: "2020-01-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "year",
|
||||
p_summary: "2020年去了上海",
|
||||
});
|
||||
assert.match(rpcError(ungrounded.error), /agentic_rectification_quote_not_grounded/);
|
||||
|
||||
// Confirm transitions draft -> confirmed with a timestamp.
|
||||
const confirmed = await service.rpc("confirm_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_evidence_id: evidenceId,
|
||||
});
|
||||
assert.equal(confirmed.error, null, rpcError(confirmed.error));
|
||||
assert.equal((confirmed.data as Record<string, unknown>).status, "confirmed");
|
||||
assert.equal(
|
||||
fixture.psql(`select confirmed_at is not null from public.agentic_rectification_evidence where id = '${evidenceId}'`),
|
||||
"t",
|
||||
);
|
||||
|
||||
// Revision creates a superseding row and never overwrites history.
|
||||
const revised = await service.rpc("revise_agentic_rectification_evidence", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_evidence_id: evidenceId,
|
||||
p_user_quote: "离开家去北京",
|
||||
p_occurred_from: "2016-10-01",
|
||||
p_occurred_to: null,
|
||||
p_date_precision: "month",
|
||||
p_summary: "2016年10月离开家去北京",
|
||||
});
|
||||
assert.equal(revised.error, null, rpcError(revised.error));
|
||||
const revisionId = String((revised.data as Record<string, unknown>).evidence_id);
|
||||
assert.equal((revised.data as Record<string, unknown>).supersedes_evidence_id, evidenceId);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_evidence where id = '${evidenceId}'`),
|
||||
"superseded",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_evidence where id = '${revisionId}'`),
|
||||
"pending_confirmation",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(
|
||||
`select count(*) from public.agentic_rectification_evidence where case_id = '${caseId}'`,
|
||||
),
|
||||
"2",
|
||||
);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 legacy backfill maps statuses, keeps one resumable per user and is idempotent", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at) values
|
||||
('Legacy', 'legacy@example.com', true, now()),
|
||||
('LegacyConfirmed', 'legacy-confirmed@example.com', true, now()),
|
||||
('LegacyCollect', 'legacy-collect@example.com', true, now()),
|
||||
('LegacyDraft', 'legacy-draft@example.com', true, now()),
|
||||
('LegacyDup', 'legacy-dup@example.com', true, now());
|
||||
`,
|
||||
);
|
||||
const legacyId = fixture.psql("select id from identity.users where email = 'legacy@example.com'");
|
||||
const confirmedId = fixture.psql("select id from identity.users where email = 'legacy-confirmed@example.com'");
|
||||
const collectId = fixture.psql("select id from identity.users where email = 'legacy-collect@example.com'");
|
||||
const draftId = fixture.psql("select id from identity.users where email = 'legacy-draft@example.com'");
|
||||
const dupId = fixture.psql("select id from identity.users where email = 'legacy-dup@example.com'");
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08', reported_birth_time = '05:00',
|
||||
birth_time_source = 'family_exact',
|
||||
latitude = 36.420487, longitude = 114.209936, timezone_offset = 8,
|
||||
birth_time_status = 'reported'
|
||||
where id in ('${legacyId}', '${confirmedId}', '${collectId}', '${draftId}', '${dupId}');
|
||||
|
||||
-- Legacy session with activity + a user-accepted result (newest -> active).
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at)
|
||||
values (
|
||||
'aaaa1111-1111-4111-8111-111111111111', '${legacyId}', '旧校正A', 'general',
|
||||
'birth_time_rectification',
|
||||
'[{"role":"user","text":"2016年9月我离开家去北京工作"},{"role":"assistant","text":"已记录"}]',
|
||||
now() - interval '1 day'
|
||||
);
|
||||
-- Legacy session with activity but no selection (older -> superseded).
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at)
|
||||
values (
|
||||
'bbbb1111-1111-4111-8111-111111111111', '${legacyId}', '旧校正B', 'general',
|
||||
'birth_time_rectification',
|
||||
'[{"role":"user","text":"2020年我开始担任管理职责"}]',
|
||||
now() - interval '3 days'
|
||||
);
|
||||
-- Repeated empty legacy session (old -> abandoned).
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at)
|
||||
values (
|
||||
'cccc1111-1111-4111-8111-111111111111', '${legacyId}', '空校正', 'general',
|
||||
'birth_time_rectification', '[]', now() - interval '10 days'
|
||||
);
|
||||
-- Engine-confirmed legacy session for the second user.
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at)
|
||||
values (
|
||||
'dddd1111-1111-4111-8111-111111111111', '${confirmedId}', '已确认校正', 'general',
|
||||
'birth_time_rectification',
|
||||
'[{"role":"user","text":"2015年我进入大学"}]',
|
||||
now() - interval '2 days'
|
||||
);
|
||||
-- Messages but no results -> collecting_evidence (active).
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at)
|
||||
values (
|
||||
'eeee1111-1111-4111-8111-111111111111', '${collectId}', '收集校正', 'general',
|
||||
'birth_time_rectification',
|
||||
'[{"role":"user","text":"2018年我换了城市"}]',
|
||||
now() - interval '1 day'
|
||||
);
|
||||
-- Single empty session -> draft.
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at)
|
||||
values (
|
||||
'ffff1111-1111-4111-8111-111111111111', '${draftId}', '新空校正', 'general',
|
||||
'birth_time_rectification', '[]', now() - interval '1 day'
|
||||
);
|
||||
-- Duplicate empty sessions: newest -> draft, older -> abandoned.
|
||||
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at)
|
||||
values
|
||||
('10111111-1111-4111-8111-111111111111', '${dupId}', '重复空1', 'general',
|
||||
'birth_time_rectification', '[]', now() - interval '1 day'),
|
||||
('20222222-2222-4222-8222-222222222222', '${dupId}', '重复空2', 'general',
|
||||
'birth_time_rectification', '[]', now() - interval '2 days');
|
||||
|
||||
insert into public.agentic_rectification_results (
|
||||
user_id, session_id, engine_result_id, canonical_input_hash, algorithm_version,
|
||||
candidate_range, candidates, overall_confidence, selection_allowed, confirmation_allowed,
|
||||
representative_time, selected_time, selection_kind, selected_at,
|
||||
baseline_birth_date, baseline_reported_birth_time, baseline_birth_time_source,
|
||||
baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes,
|
||||
baseline_latitude, baseline_longitude, baseline_timezone_offset
|
||||
) values (
|
||||
'${legacyId}', 'aaaa1111-1111-4111-8111-111111111111',
|
||||
'legacy-engine-1', 'legacy-hash-1', 'legacy-v1',
|
||||
'{"start_time":"04:00","end_time":"06:00"}',
|
||||
'[{"rank":1,"time":"05:00","relative_support":70,"tied_minute_count":1}]',
|
||||
'medium', true, false, '05:00', '05:00', 'user_accepted', now() - interval '1 day',
|
||||
'1997-08-08', '05:00', 'family_exact', 10, 10, 36.420487, 114.209936, 8
|
||||
);
|
||||
insert into public.agentic_rectification_results (
|
||||
user_id, session_id, engine_result_id, canonical_input_hash, algorithm_version,
|
||||
candidate_range, candidates, overall_confidence, selection_allowed, confirmation_allowed,
|
||||
representative_time, selected_time, selection_kind, selected_at,
|
||||
baseline_birth_date, baseline_reported_birth_time, baseline_birth_time_source,
|
||||
baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes,
|
||||
baseline_latitude, baseline_longitude, baseline_timezone_offset
|
||||
) values (
|
||||
'${confirmedId}', 'dddd1111-1111-4111-8111-111111111111',
|
||||
'legacy-engine-2', 'legacy-hash-2', 'legacy-v1',
|
||||
'{"start_time":"04:00","end_time":"06:00"}',
|
||||
'[{"rank":1,"time":"05:00","relative_support":90,"tied_minute_count":1}]',
|
||||
'high', true, true, '05:00', '05:00', 'engine_confirmed', now() - interval '2 days',
|
||||
'1997-08-08', '05:00', 'family_exact', 10, 10, 36.420487, 114.209936, 8
|
||||
);
|
||||
`);
|
||||
|
||||
const service = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
|
||||
// Preflight count: 8 legacy sessions without a V9 case.
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select count(*) from public.chat_sessions
|
||||
where session_type = 'birth_time_rectification'
|
||||
and not exists (
|
||||
select 1 from public.agentic_rectification_cases c where c.session_id = public.chat_sessions.id
|
||||
)
|
||||
`),
|
||||
"8",
|
||||
);
|
||||
|
||||
const backfill = await service.rpc("backfill_agentic_rectification_legacy_cases", {});
|
||||
assert.equal(backfill.error, null, rpcError(backfill.error));
|
||||
const stats = backfill.data as Record<string, unknown>;
|
||||
assert.equal(stats.cases_created, 8);
|
||||
assert.equal(stats.results_mapped, 2);
|
||||
assert.equal(stats.confirmed, 1);
|
||||
assert.equal(stats.candidate_accepted, 1);
|
||||
assert.equal(stats.collecting_evidence, 1);
|
||||
assert.equal(stats.draft, 2);
|
||||
assert.equal(stats.abandoned, 2);
|
||||
assert.equal(stats.superseded, 1);
|
||||
|
||||
// Statuses landed correctly.
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'dddd1111-1111-4111-8111-111111111111'`),
|
||||
"confirmed",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'ffff1111-1111-4111-8111-111111111111'`),
|
||||
"draft",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'eeee1111-1111-4111-8111-111111111111'`),
|
||||
"collecting_evidence",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'aaaa1111-1111-4111-8111-111111111111'`),
|
||||
"candidate_accepted",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'bbbb1111-1111-4111-8111-111111111111'`),
|
||||
"superseded",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where session_id = '10111111-1111-4111-8111-111111111111'`),
|
||||
"draft",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where session_id = '20222222-2222-4222-8222-222222222222'`),
|
||||
"abandoned",
|
||||
);
|
||||
|
||||
// Exactly one resumable case per user.
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select count(*) from (
|
||||
select user_id from public.agentic_rectification_cases
|
||||
where status in ('draft','collecting_evidence','candidate_ready','candidate_accepted','needs_rebaseline','paused')
|
||||
group by user_id having count(*) > 1
|
||||
) conflicts
|
||||
`),
|
||||
"0",
|
||||
);
|
||||
|
||||
// Results are mapped to their cases.
|
||||
assert.equal(
|
||||
fixture.psql(`select count(*) from public.agentic_rectification_results where case_id is null`),
|
||||
"0",
|
||||
);
|
||||
|
||||
// Verification report is clean.
|
||||
const verify = await service.rpc("verify_agentic_rectification_backfill", {});
|
||||
assert.equal(verify.error, null, rpcError(verify.error));
|
||||
const report = verify.data as Record<string, unknown>;
|
||||
assert.equal(report.orphan_cases, 0);
|
||||
assert.equal(report.resumable_conflicts, 0);
|
||||
assert.equal(report.legacy_sessions_without_case, 0);
|
||||
|
||||
// Re-running the backfill creates nothing new (idempotent).
|
||||
const again = await service.rpc("backfill_agentic_rectification_legacy_cases", {});
|
||||
assert.equal(again.error, null, rpcError(again.error));
|
||||
assert.equal((again.data as Record<string, unknown>).cases_created, 0);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const migration = readFileSync(
|
||||
new URL(
|
||||
"../supabase/migrations/20260812010000_agentic_rectification_v9_runtime.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const dbMigrationsCopy = fileURLToPath(
|
||||
new URL("../db/migrations/20260812010000_agentic_rectification_v9_runtime.sql", import.meta.url),
|
||||
);
|
||||
|
||||
test("v9 runtime migration sorts after the newest business migration and stays unique", () => {
|
||||
assert.ok(
|
||||
"20260812010000_agentic_rectification_v9_runtime.sql" >
|
||||
"20260811010000_consultation_status_service_role_read.sql",
|
||||
);
|
||||
assert.match(migration, /^begin;[\s\S]*^commit;$/m);
|
||||
});
|
||||
|
||||
test("v9 runtime migration must never be duplicated into the identity foundation", () => {
|
||||
assert.equal(
|
||||
existsSync(dbMigrationsCopy),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
test("v9 runtime migration creates the five durable tables with required columns", () => {
|
||||
assert.match(migration, /create table if not exists public\.agentic_rectification_cases \(/);
|
||||
assert.match(migration, /user_id uuid not null references auth\.users\(id\) on delete cascade/);
|
||||
assert.match(migration, /session_id uuid not null unique references public\.chat_sessions\(id\) on delete cascade/);
|
||||
assert.match(migration, /baseline_profile_fingerprint text not null/);
|
||||
assert.match(migration, /baseline_birth_snapshot jsonb not null/);
|
||||
assert.match(migration, /skill_version text not null/);
|
||||
assert.match(migration, /accepted_time time without time zone/);
|
||||
assert.match(migration, /confirmed_time time without time zone/);
|
||||
assert.match(migration, /completed_at timestamptz/);
|
||||
assert.match(migration, /closed_reason text/);
|
||||
|
||||
assert.match(migration, /create table if not exists public\.agentic_rectification_evidence \(/);
|
||||
assert.match(migration, /source_turn_id uuid not null references public\.agentic_rectification_turns\(id\)/);
|
||||
assert.match(migration, /user_quote text not null/);
|
||||
assert.match(migration, /event_kind text not null/);
|
||||
assert.match(migration, /date_precision text not null check \(date_precision in \('year', 'month', 'day', 'range', 'unknown'\)\)/);
|
||||
assert.match(migration, /supersedes_evidence_id uuid references public\.agentic_rectification_evidence\(id\)/);
|
||||
|
||||
assert.match(migration, /create table if not exists public\.agentic_rectification_turns \(/);
|
||||
assert.match(migration, /status text not null check \(status in \('pending', 'completed', 'failed', 'retryable'\)\)/);
|
||||
assert.match(migration, /model_name text not null/);
|
||||
const turnsTable = migration.slice(
|
||||
migration.indexOf("create table if not exists public.agentic_rectification_turns"),
|
||||
migration.indexOf("-- 3. Evidence"),
|
||||
);
|
||||
assert.doesNotMatch(turnsTable, /reasoning/);
|
||||
|
||||
assert.match(migration, /create table if not exists public\.agentic_rectification_tool_receipts \(/);
|
||||
assert.match(migration, /input_fingerprint text/);
|
||||
assert.match(migration, /result_fingerprint text/);
|
||||
assert.match(migration, /safe_error_code text/);
|
||||
assert.doesNotMatch(migration, /create table if not exists public\.agentic_rectification_tool_receipts \([\s\S]*payload/);
|
||||
|
||||
assert.match(migration, /create table if not exists public\.agentic_rectification_open_ledger \(/);
|
||||
assert.match(migration, /primary key \(user_id, request_id\)/);
|
||||
});
|
||||
|
||||
test("v9 runtime migration enforces one resumable case per user at the database level", () => {
|
||||
assert.match(
|
||||
migration,
|
||||
/create unique index if not exists agentic_rectification_cases_one_resumable_per_user[\s\S]*where status in \([\s\S]*'draft'[\s\S]*'collecting_evidence'[\s\S]*'candidate_ready'[\s\S]*'candidate_accepted'[\s\S]*'needs_rebaseline'[\s\S]*'paused'[\s\S]*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("v9 runtime migration keeps Case/Session bidirectional consistency", () => {
|
||||
assert.match(migration, /alter table public\.chat_sessions\s+add column if not exists agentic_rectification_case_id uuid/);
|
||||
assert.match(migration, /create unique index if not exists chat_sessions_agentic_rectification_case_unique/);
|
||||
assert.match(migration, /agentic_rectification_cases_sync_session/);
|
||||
assert.match(migration, /agentic_rectification_case_session_mismatch/);
|
||||
assert.match(migration, /agentic_rectification_case_owner_mismatch/);
|
||||
});
|
||||
|
||||
test("v9 runtime migration extends results with case_id and fingerprints", () => {
|
||||
assert.match(migration, /add column if not exists case_id uuid/);
|
||||
assert.match(migration, /add column if not exists evidence_ledger_fingerprint text/);
|
||||
assert.match(migration, /add column if not exists candidate_range_fingerprint text/);
|
||||
assert.match(migration, /add column if not exists skill_version text/);
|
||||
});
|
||||
|
||||
test("v9 runtime migration grants only service_role on the new tables", () => {
|
||||
for (const table of [
|
||||
"agentic_rectification_cases",
|
||||
"agentic_rectification_turns",
|
||||
"agentic_rectification_evidence",
|
||||
"agentic_rectification_tool_receipts",
|
||||
"agentic_rectification_open_ledger",
|
||||
]) {
|
||||
assert.match(migration, new RegExp(`revoke all on table public\\.${table} from public, anon, authenticated, service_role`));
|
||||
assert.match(migration, new RegExp(`grant all on table public\\.${table} to service_role`));
|
||||
assert.doesNotMatch(migration, new RegExp(`grant select on table public\\.${table} to authenticated`));
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 RPCs are security definer and service-role only", () => {
|
||||
for (const functionName of [
|
||||
"open_agentic_rectification_case",
|
||||
"get_agentic_rectification_entry_summary",
|
||||
"get_agentic_rectification_case",
|
||||
"close_agentic_rectification_case",
|
||||
"upgrade_agentic_rectification_skill",
|
||||
"append_agentic_rectification_turn",
|
||||
"insert_agentic_rectification_tool_receipt",
|
||||
"propose_agentic_rectification_evidence",
|
||||
"confirm_agentic_rectification_evidence",
|
||||
"revise_agentic_rectification_evidence",
|
||||
]) {
|
||||
assert.match(migration, new RegExp(`create or replace function public\\.${functionName}\\(`));
|
||||
assert.match(migration, new RegExp(`grant execute on function public\\.${functionName}\\([\\s\\S]*?to service_role`));
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
migration,
|
||||
/grant execute on function public\.open_agentic_rectification_case\([\s\S]*?to authenticated/,
|
||||
);
|
||||
});
|
||||
|
||||
test("open RPC serializes same-user requests and forbids silent supersede", () => {
|
||||
assert.match(migration, /pg_catalog\.pg_advisory_xact_lock\(/);
|
||||
assert.match(migration, /hashtext\('agentic_rectification_open:' \|\| p_user_id::text\)/);
|
||||
assert.match(migration, /agentic_rectification_active_case_conflict/);
|
||||
assert.doesNotMatch(migration, /supersede_active/);
|
||||
assert.doesNotMatch(migration, /p_supersede/);
|
||||
});
|
||||
|
||||
test("open RPC creates the case and session atomically and derives shouldStartOpening", () => {
|
||||
const open = migration.slice(
|
||||
migration.indexOf("create or replace function public.open_agentic_rectification_case"),
|
||||
migration.indexOf("-- 12. Entry summary"),
|
||||
);
|
||||
assert.match(open, /insert into public\.chat_sessions/);
|
||||
assert.match(open, /insert into public\.agentic_rectification_cases/);
|
||||
assert.match(open, /insert into public\.agentic_rectification_open_ledger/);
|
||||
assert.match(open, /exception when unique_violation/);
|
||||
assert.match(open, /'should_start_opening', true/);
|
||||
// Snapshot/range validation lives inside the create block, before the
|
||||
// inserts -- a session/view-only open never needs a complete profile.
|
||||
const createBlock = open.slice(
|
||||
open.indexOf("Create a new case + a new session atomically"),
|
||||
open.lastIndexOf("return jsonb_build_object("),
|
||||
);
|
||||
assert.match(createBlock, /agentic_rectification_profile_incomplete/);
|
||||
assert.match(createBlock, /agentic_rectification_invalid_range/);
|
||||
});
|
||||
|
||||
test("open RPC never creates a case for an incomplete profile", () => {
|
||||
assert.match(migration, /agentic_rectification_profile_incomplete/);
|
||||
assert.match(migration, /p_baseline_birth_snapshot ->> 'birth_date' is null/);
|
||||
});
|
||||
|
||||
test("terminal cases reject evidence and turn writes", () => {
|
||||
const propose = migration.slice(
|
||||
migration.indexOf("create or replace function public.propose_agentic_rectification_evidence"),
|
||||
migration.indexOf("create or replace function public.confirm_agentic_rectification_evidence"),
|
||||
);
|
||||
assert.match(propose, /agentic_rectification_case_terminal/);
|
||||
const append = migration.slice(
|
||||
migration.indexOf("create or replace function public.append_agentic_rectification_turn"),
|
||||
migration.indexOf("-- 17. Tool receipt"),
|
||||
);
|
||||
assert.match(append, /agentic_rectification_case_terminal/);
|
||||
});
|
||||
|
||||
test("evidence proposals require quote grounding in the source turn", () => {
|
||||
assert.match(migration, /agentic_rectification_quote_not_grounded/);
|
||||
assert.match(migration, /agentic_rectification_normalize_quote\(p_user_quote\)/);
|
||||
assert.match(migration, /agentic_rectification_normalize_quote\(v_turn\.user_message\)/);
|
||||
});
|
||||
|
||||
test("evidence revisions are append-only and never generate confirmed evidence from text", () => {
|
||||
assert.match(migration, /set status = 'superseded'/);
|
||||
assert.match(migration, /supersedes_evidence_id[\s\S]*'pending_confirmation', v_target\.id/);
|
||||
const backfill = migration.slice(
|
||||
migration.indexOf("create or replace function public.backfill_agentic_rectification_legacy_cases"),
|
||||
migration.indexOf("-- 20. Backfill verification"),
|
||||
);
|
||||
assert.doesNotMatch(backfill, /insert into public\.agentic_rectification_evidence/);
|
||||
});
|
||||
|
||||
test("legacy backfill maps every documented status and keeps one resumable per user", () => {
|
||||
const backfill = migration.slice(
|
||||
migration.indexOf("create or replace function public.backfill_agentic_rectification_legacy_cases"),
|
||||
migration.indexOf("-- 20. Backfill verification"),
|
||||
);
|
||||
assert.match(backfill, /when result_kind = 'engine_confirmed' then 'confirmed'/);
|
||||
assert.match(backfill, /when result_kind = 'user_accepted' then 'candidate_accepted'/);
|
||||
assert.match(backfill, /when has_messages then case when has_results then 'candidate_ready' else 'collecting_evidence' end/);
|
||||
assert.match(backfill, /then 'superseded'/);
|
||||
assert.match(backfill, /then 'draft'/);
|
||||
assert.match(backfill, /agentic_rectification_legacy_fingerprint/);
|
||||
assert.match(backfill, /update public\.agentic_rectification_results\s+set case_id = v_case_id/);
|
||||
assert.match(backfill, /not exists \(\s*select 1 from public\.agentic_rectification_cases c where c\.session_id = s\.id\s*\)/);
|
||||
});
|
||||
|
||||
test("backfill apply runs inside the migration and verification is re-runnable", () => {
|
||||
assert.match(migration, /select public\.backfill_agentic_rectification_legacy_cases\(\) into v_result/);
|
||||
assert.match(migration, /create or replace function public\.verify_agentic_rectification_backfill\(\)/);
|
||||
assert.match(migration, /resumable_conflicts/);
|
||||
assert.match(migration, /orphan_cases/);
|
||||
});
|
||||
|
||||
test("v9 fingerprint uses the already-installed pgcrypto digest", () => {
|
||||
assert.match(migration, /public\.digest\(/);
|
||||
assert.match(migration, /'sha256'/);
|
||||
});
|
||||
Reference in New Issue
Block a user