feat(rectification): add durable case and evidence runtime

This commit is contained in:
Jesse
2026-08-11 16:18:53 +08:00
parent 60e2ce4fa4
commit d394dd0585
13 changed files with 3794 additions and 0 deletions
@@ -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,
};
}