feat(rectification): add durable case and evidence runtime
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user