Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/case-service.ts
T
Jesse_Chen f708edf365
Independent Staging Quality Gate / validate (push) Successful in 10m5s
Independent Staging Quality Gate / publish (push) Successful in 7m4s
fix(rectification): ingest first-turn events without dropping day precision or claiming a unique minute
SQL kinds now match the TypeScript ledger so batch ingest can confirm dated events. Confirm no longer burns the opening focus, recap uses server date labels, and the Agent sees indistinguishable width instead of a fake unique minute.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 07:44:23 +08:00

601 lines
25 KiB
TypeScript

/**
* 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 { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts";
import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts";
import {
resolveActiveSkillPackage,
resolveSkillPackageVersion,
} from "../../skill-package-registry.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;
birth_place_label: string;
latitude: number;
longitude: number;
timezone_id: string;
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 RectificationSkillIdentityStatus = "verified" | "legacy_unverifiable";
export type RectificationSkillIdentityStatusView = Readonly<{
skillName: string;
skillVersion: string;
skillSha256: string | null;
sourceCommit: string | null;
status: RectificationSkillIdentityStatus;
requiresSkillAdoption: boolean;
}>;
export type RectificationCaseView = Readonly<{
caseId: string;
sessionId: string;
status: string;
skillName: string;
skillVersion: string;
skillIdentityStatus: RectificationSkillIdentityStatus;
requiresSkillAdoption: boolean;
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;
}
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")}`;
}
// This is an engine execution boundary, not a user-declared uncertainty.
// Exact-time Cases receive a movable search radius; imprecise declarations keep
// the honest server-owned range instead of inventing a baseline minute.
const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15;
const PERIOD_CANDIDATE_RANGES = {
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" },
} as const;
function deriveCandidateRange(input: {
reportedTime: string | null;
source: string;
period: string | null;
}): { start_time: string; end_time: string } {
if (input.reportedTime) {
return {
start_time: shiftedTime(input.reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
end_time: shiftedTime(input.reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const periodRange = input.period && Object.hasOwn(PERIOD_CANDIDATE_RANGES, input.period)
? PERIOD_CANDIDATE_RANGES[input.period as keyof typeof PERIOD_CANDIDATE_RANGES]
: null;
if (periodRange) return periodRange;
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,
baseline.birth_place_label,
String(baseline.latitude),
String(baseline.longitude),
baseline.timezone_id,
String(baseline.timezone_offset),
baseline.birth_time_source,
baseline.birth_time_period ?? "",
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,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset",
)
.eq("id", userId)
.single();
if (error || !data) throw new RectificationCaseServiceError("profile_unavailable");
let resolvedData: unknown;
try {
resolvedData = await resolveMissingBirthTimezoneOffset(data);
} catch {
throw new RectificationCaseServiceError("profile_unavailable");
}
const row = resolvedData as Record<string, unknown>;
const birthDate = normalizePersistedBirthDate(row.birth_date);
const latitude = numberOrNull(row.latitude);
const longitude = numberOrNull(row.longitude);
const birthPlaceLabel = typeof row.birth_place_label === "string" ? row.birth_place_label.trim() : "";
const timezoneId = typeof row.timezone_id === "string" ? row.timezone_id.trim() : "";
const timezoneOffset = numberOrNull(row.timezone_offset);
const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : "";
const reportedTime = timeValue(row.reported_birth_time);
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 || !birthPlaceLabel || latitude === null || longitude === null || !timezoneId || timezoneOffset === null || !source) {
throw new RectificationCaseServiceError("profile_incomplete");
}
const baseline: V9BaselineSnapshot = {
birth_date: birthDate,
birth_place_label: birthPlaceLabel,
latitude,
longitude,
timezone_id: timezoneId,
timezone_offset: timezoneOffset,
birth_time_source: source,
birth_time_period: period,
reported_birth_time: reportedTime,
// A fresh Case must never inherit an accepted/active minute as its new baseline.
active_birth_time: null,
uncertainty_before_minutes: uncertaintyBefore,
uncertainty_after_minutes: uncertaintyAfter,
};
return {
userId,
baseline,
baselineFingerprint: baselineFingerprint(baseline),
candidateRange: deriveCandidateRange({ reportedTime, source, period }),
};
}
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_skill_identity_missing", { status: 409, code: "skill_identity_missing", message: "该校正绑定的 Skill 版本不可用,请联系支持人员" }],
["agentic_rectification_legacy_skill_identity_unverifiable", { status: 409, code: "skill_identity_unverifiable", message: "该校正绑定的是无法核验的历史 Skill,请先采用当前注册版本" }],
["agentic_rectification_skill_identity_already_verified", { status: 409, code: "skill_identity_already_verified", message: "该校正已经绑定可核验的 Skill,不能重复采用历史 Skill" }],
["agentic_rectification_invalid_skill_identity", { status: 400, code: "invalid_skill_identity", message: "Skill 版本身份不正确" }],
["agentic_rectification_skill_name_mismatch", { status: 409, code: "skill_name_mismatch", message: "不能将校正记录升级到其他 Skill" }],
["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: "该事件当前不能修订" }],
["agentic_rectification_precision_downgrade", { status: 422, code: "precision_downgrade", 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) || message.includes(view.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 activeSkill = resolveActiveSkillPackage(RECTIFICATION_SKILL_NAME);
if (activeSkill.version !== RECTIFICATION_SKILL_VERSION) {
throw new RectificationCaseServiceError("skill_registry_version_mismatch");
}
const { data, error } = await accounting.rpc("open_agentic_rectification_case_v2", {
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: activeSkill.name,
p_skill_version: activeSkill.version,
p_skill_sha256: activeSkill.sha256,
p_skill_source_commit: activeSkill.sourceCommit,
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 ?? ""),
skillIdentityStatus: row.skill_identity_status === "legacy_unverifiable" ? "legacy_unverifiable" : "verified",
requiresSkillAdoption: row.requires_skill_adoption === true,
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 getRectificationSkillIdentityStatus(
accounting: AccountingClient,
userId: string,
caseId: string,
): Promise<RectificationSkillIdentityStatusView> {
const { data, error } = await accounting.rpc("get_agentic_rectification_skill_identity_status", {
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_skill_identity_status");
}
const value = row as Record<string, unknown>;
const status = value.skill_identity_status;
const skillSha256 = value.skill_sha256 === null ? null : typeof value.skill_sha256 === "string" ? value.skill_sha256 : null;
const sourceCommit = value.skill_source_commit === null ? null : typeof value.skill_source_commit === "string" ? value.skill_source_commit : null;
if (typeof value.skill_name !== "string" || !value.skill_name
|| typeof value.skill_version !== "string" || !value.skill_version
|| (status !== "verified" && status !== "legacy_unverifiable")
|| (skillSha256 !== null && !/^[0-9a-f]{64}$/.test(skillSha256))
|| (sourceCommit !== null && !/^[0-9a-f]{40}$/.test(sourceCommit))
|| (status === "verified" && skillSha256 === null)
|| (status === "legacy_unverifiable" && skillSha256 !== null)
|| (status === "legacy_unverifiable" && value.requires_skill_adoption !== true)) {
throw new RectificationCaseServiceError("invalid_skill_identity_status");
}
return {
skillName: value.skill_name,
skillVersion: value.skill_version,
skillSha256,
sourceCommit,
status,
requiresSkillAdoption: value.requires_skill_adoption === true,
};
}
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;
previousSkillSha256: string | null;
skillVersion: string;
skillSha256: string;
sourceCommit: string | null;
receiptId: string | null;
idempotent: boolean;
}>;
export type RectificationSkillAdoptionResult = Readonly<{
success: true;
caseId: string;
previousSkillName: string;
previousSkillVersion: string;
previousSkillSha256: null;
previousSourceCommit: string | null;
previousIdentityStatus: "legacy_unverifiable";
upgradeKind: "legacy_adoption";
skillName: string;
skillVersion: string;
skillSha256: string;
sourceCommit: string | null;
receiptId: string;
idempotent: boolean;
}>;
export async function adoptLegacyRectificationSkill(
accounting: AccountingClient,
userId: string,
caseId: string,
skillVersion: string,
): Promise<RectificationSkillAdoptionResult> {
const target = resolveSkillPackageVersion(RECTIFICATION_SKILL_NAME, skillVersion);
const { data, error } = await accounting.rpc("adopt_agentic_rectification_skill_v1", {
p_user_id: userId,
p_case_id: caseId,
p_skill_name: target.name,
p_skill_version: target.version,
p_skill_sha256: target.sha256,
p_skill_source_commit: target.sourceCommit,
});
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
const row = readRpcData(data);
if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_adoption_result");
const value = row as Record<string, unknown>;
const previousSourceCommit = value.previous_source_commit === null
? null
: typeof value.previous_source_commit === "string"
? value.previous_source_commit
: null;
const sourceCommit = value.source_commit === null
? null
: typeof value.source_commit === "string"
? value.source_commit
: null;
const receiptId = typeof value.receipt_id === "string" ? value.receipt_id : "";
if (
value.success !== true
|| value.case_id !== caseId
|| typeof value.previous_skill_name !== "string"
|| !value.previous_skill_name
|| typeof value.previous_skill_version !== "string"
|| !value.previous_skill_version
|| value.previous_skill_sha256 !== null
|| (previousSourceCommit !== null && !/^[0-9a-f]{40}$/.test(previousSourceCommit))
|| value.previous_identity_status !== "legacy_unverifiable"
|| value.upgrade_kind !== "legacy_adoption"
|| value.skill_name !== target.name
|| value.skill_version !== target.version
|| value.skill_sha256 !== target.sha256
|| sourceCommit !== target.sourceCommit
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(receiptId)
) {
throw new RectificationCaseServiceError("invalid_adoption_result");
}
return {
success: true,
caseId,
previousSkillName: value.previous_skill_name,
previousSkillVersion: value.previous_skill_version,
previousSkillSha256: null,
previousSourceCommit,
previousIdentityStatus: "legacy_unverifiable",
upgradeKind: "legacy_adoption",
skillName: target.name,
skillVersion: target.version,
skillSha256: target.sha256,
sourceCommit,
receiptId,
idempotent: value.idempotent === true,
};
}
export async function upgradeRectificationSkill(
accounting: AccountingClient,
userId: string,
caseId: string,
skillVersion: string,
): Promise<RectificationUpgradeResult> {
const target = resolveSkillPackageVersion(RECTIFICATION_SKILL_NAME, skillVersion);
const { data, error } = await accounting.rpc("upgrade_agentic_rectification_skill_v2", {
p_user_id: userId,
p_case_id: caseId,
p_skill_name: target.name,
p_skill_version: target.version,
p_skill_sha256: target.sha256,
p_skill_source_commit: target.sourceCommit,
});
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 ?? ""),
previousSkillSha256: value.previous_skill_sha256 === null || value.previous_skill_sha256 === undefined
? null
: String(value.previous_skill_sha256),
skillVersion: String(value.skill_version ?? skillVersion),
skillSha256: String(value.skill_sha256 ?? target.sha256),
sourceCommit: typeof value.source_commit === "string" ? value.source_commit : null,
receiptId: typeof value.receipt_id === "string" ? value.receipt_id : null,
idempotent: value.idempotent === true,
};
}