864 lines
28 KiB
TypeScript
864 lines
28 KiB
TypeScript
/**
|
|
* V9 rectification tool service.
|
|
*
|
|
* Server-owned fact layer behind the ten `rectification-*` tools. Every tool
|
|
* call authenticates through the Case row, validates ownership/status/skill
|
|
* version, reads the durable evidence ledger, derives canonical fingerprints,
|
|
* invokes the deterministic Python engine when evidence effectively changed,
|
|
* persists a candidate snapshot or receipt, and returns a compact safe
|
|
* projection. The model never supplies userId, birth data, candidate ranges,
|
|
* event arrays, scores or permission decisions.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
EVIDENCE_KINDS,
|
|
type EvidenceKind,
|
|
} from "./evidence-model";
|
|
import {
|
|
isPublicRectificationMethod,
|
|
isPublicRectificationPhase,
|
|
isPublicRectificationTool,
|
|
type PublicRectificationMethod,
|
|
type PublicRectificationPhase,
|
|
type PublicRectificationTool,
|
|
} from "./public-receipt";
|
|
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
|
|
|
|
/**
|
|
* Minimal structural RPC client. SupabaseClient satisfies this; tests can
|
|
* pass a fake with a single rpc() method.
|
|
*/
|
|
export type RectificationRpcClient = {
|
|
rpc(
|
|
fn: string,
|
|
args: Record<string, unknown>,
|
|
): PromiseLike<{ data: unknown; error: { message: string } | null }>;
|
|
};
|
|
|
|
export type AccountingClient = RectificationRpcClient;
|
|
|
|
export class RectificationToolServiceError extends Error {
|
|
readonly code: string;
|
|
|
|
constructor(code: string) {
|
|
super(`Rectification tool service error: ${code}`);
|
|
this.name = "RectificationToolServiceError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function first(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;
|
|
}
|
|
|
|
function rpc<T>(
|
|
accounting: AccountingClient,
|
|
fn: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<T> {
|
|
return Promise.resolve(accounting.rpc(fn, args)).then(({ data, error }) => {
|
|
if (error) throw new RectificationToolServiceError(error.message);
|
|
return first(data) as T;
|
|
});
|
|
}
|
|
|
|
export type V9CaseDossier = Readonly<{
|
|
case: 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;
|
|
completedAt: string | null;
|
|
closedReason: string | null;
|
|
lastActivityAt: string;
|
|
evidenceCount: number;
|
|
turnCount: number;
|
|
}>;
|
|
turns: readonly Readonly<{
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
text: string | null;
|
|
status: string;
|
|
createdAt: string;
|
|
}>[];
|
|
evidence: readonly Readonly<{
|
|
id: string;
|
|
sourceTurnId: string;
|
|
subject: string;
|
|
eventKind: string;
|
|
domain: string;
|
|
occurredFrom: string | null;
|
|
occurredTo: string | null;
|
|
datePrecision: string;
|
|
summary: string;
|
|
status: string;
|
|
supersedesEvidenceId: string | null;
|
|
createdAt: string;
|
|
}>[];
|
|
latestResult: V9CandidateSnapshot | null;
|
|
}>;
|
|
|
|
export type V9CandidateSnapshot = Readonly<{
|
|
resultId: string;
|
|
candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[];
|
|
overallConfidence: "low" | "medium" | "high";
|
|
selectionAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
representativeTime: string | null;
|
|
selectedTime: string | null;
|
|
selectionKind: string | null;
|
|
evidenceLedgerFingerprint: string | null;
|
|
candidateRangeFingerprint: string | null;
|
|
skillVersion: string | null;
|
|
algorithmVersion: string | null;
|
|
createdAt: string;
|
|
invalidatedAt: string | null;
|
|
}>;
|
|
|
|
function timeValue(value: unknown): string | null {
|
|
const time = typeof value === "string" ? value.slice(0, 5) : "";
|
|
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
|
|
}
|
|
|
|
function rowText(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function rowNumber(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function rowBoolean(value: unknown): boolean {
|
|
return value === true;
|
|
}
|
|
|
|
function rowArray(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
export function parseV9CaseDossier(value: unknown): V9CaseDossier | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const root = value as Record<string, unknown>;
|
|
const caseRow = root.case && typeof root.case === "object"
|
|
? root.case as Record<string, unknown>
|
|
: null;
|
|
if (!caseRow || typeof caseRow.case_id !== "string") return null;
|
|
const range = caseRow.candidate_range && typeof caseRow.candidate_range === "object"
|
|
? caseRow.candidate_range as { start_time?: unknown; end_time?: unknown }
|
|
: null;
|
|
const candidateRange =
|
|
range && typeof range.start_time === "string" && typeof range.end_time === "string"
|
|
? { start_time: range.start_time, end_time: range.end_time }
|
|
: null;
|
|
|
|
const turns = rowArray(root.turns).flatMap((item): V9CaseDossier["turns"] => {
|
|
if (!item || typeof item !== "object") return [];
|
|
const turn = item as Record<string, unknown>;
|
|
if (typeof turn.id !== "string") return [];
|
|
return [{
|
|
id: turn.id,
|
|
role: turn.role === "user" ? "user" : "assistant",
|
|
text: rowText(turn.text),
|
|
status: String(turn.status ?? ""),
|
|
createdAt: String(turn.created_at ?? ""),
|
|
}];
|
|
});
|
|
|
|
const evidence = rowArray(root.evidence).flatMap((item): V9CaseDossier["evidence"] => {
|
|
if (!item || typeof item !== "object") return [];
|
|
const row = item as Record<string, unknown>;
|
|
if (typeof row.id !== "string") return [];
|
|
return [{
|
|
id: row.id,
|
|
sourceTurnId: String(row.source_turn_id ?? ""),
|
|
subject: String(row.subject ?? ""),
|
|
eventKind: String(row.event_kind ?? ""),
|
|
domain: String(row.domain ?? ""),
|
|
occurredFrom: rowText(row.occurred_from),
|
|
occurredTo: rowText(row.occurred_to),
|
|
datePrecision: String(row.date_precision ?? ""),
|
|
summary: String(row.summary ?? ""),
|
|
status: String(row.status ?? ""),
|
|
supersedesEvidenceId: rowText(row.supersedes_evidence_id),
|
|
createdAt: String(row.created_at ?? ""),
|
|
}];
|
|
});
|
|
|
|
const latestResult = parseV9CandidateSnapshot(root.latest_result);
|
|
|
|
return {
|
|
case: {
|
|
caseId: caseRow.case_id,
|
|
sessionId: String(caseRow.session_id ?? ""),
|
|
status: String(caseRow.status ?? ""),
|
|
skillName: String(caseRow.skill_name ?? ""),
|
|
skillVersion: String(caseRow.skill_version ?? ""),
|
|
candidateRange,
|
|
acceptedTime: timeValue(caseRow.accepted_time),
|
|
confirmedTime: timeValue(caseRow.confirmed_time),
|
|
completedAt: rowText(caseRow.completed_at),
|
|
closedReason: rowText(caseRow.closed_reason),
|
|
lastActivityAt: String(caseRow.last_activity_at ?? ""),
|
|
evidenceCount: rowNumber(caseRow.evidence_count) ?? 0,
|
|
turnCount: rowNumber(caseRow.turn_count) ?? 0,
|
|
},
|
|
turns,
|
|
evidence,
|
|
latestResult,
|
|
};
|
|
}
|
|
|
|
export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const row = value as Record<string, unknown>;
|
|
const resultId = typeof row.result_id === "string" ? row.result_id : "";
|
|
if (!resultId) return null;
|
|
const candidates = rowArray(row.candidates).flatMap((item) => {
|
|
if (!item || typeof item !== "object") return [];
|
|
const candidate = item as Record<string, unknown>;
|
|
const time = timeValue(candidate.time);
|
|
if (!time || typeof candidate.rank !== "number") return [];
|
|
return [{
|
|
rank: Math.trunc(candidate.rank),
|
|
time,
|
|
relative_support: typeof candidate.relative_support === "number" ? Math.max(0, Math.min(100, Math.trunc(candidate.relative_support))) : 0,
|
|
tied_minute_count: typeof candidate.tied_minute_count === "number" ? Math.max(1, Math.trunc(candidate.tied_minute_count)) : 1,
|
|
}];
|
|
});
|
|
return {
|
|
resultId,
|
|
candidates,
|
|
overallConfidence: row.overall_confidence === "high" || row.overall_confidence === "medium" ? row.overall_confidence : "low",
|
|
selectionAllowed: rowBoolean(row.selection_allowed),
|
|
confirmationAllowed: rowBoolean(row.confirmation_allowed),
|
|
representativeTime: timeValue(row.representative_time),
|
|
selectedTime: timeValue(row.selected_time),
|
|
selectionKind: rowText(row.selection_kind),
|
|
evidenceLedgerFingerprint: rowText(row.evidence_ledger_fingerprint),
|
|
candidateRangeFingerprint: rowText(row.candidate_range_fingerprint),
|
|
skillVersion: rowText(row.skill_version),
|
|
algorithmVersion: rowText(row.algorithm_version),
|
|
createdAt: String(row.created_at ?? ""),
|
|
invalidatedAt: rowText(row.invalidated_at),
|
|
};
|
|
}
|
|
|
|
export type V9ComputeProjection = Readonly<{
|
|
caseId: string;
|
|
skillVersion: string;
|
|
baselineProfileFingerprint: string;
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
}>;
|
|
|
|
export function parseV9ComputeProjection(value: unknown): V9ComputeProjection | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const row = value as Record<string, unknown>;
|
|
const range = row.candidate_range && typeof row.candidate_range === "object"
|
|
? row.candidate_range as { start_time?: unknown; end_time?: unknown }
|
|
: null;
|
|
const snapshot = row.baseline_birth_snapshot && typeof row.baseline_birth_snapshot === "object"
|
|
? row.baseline_birth_snapshot as Record<string, unknown>
|
|
: null;
|
|
if (
|
|
typeof row.case_id !== "string"
|
|
|| typeof row.skill_version !== "string"
|
|
|| typeof row.baseline_profile_fingerprint !== "string"
|
|
|| !snapshot
|
|
|| !range
|
|
|| typeof range.start_time !== "string"
|
|
|| typeof range.end_time !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
caseId: row.case_id,
|
|
skillVersion: row.skill_version,
|
|
baselineProfileFingerprint: row.baseline_profile_fingerprint,
|
|
baselineBirthSnapshot: snapshot,
|
|
candidateRange: { start_time: range.start_time, end_time: range.end_time },
|
|
};
|
|
}
|
|
|
|
export async function loadV9CaseDossier(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<V9CaseDossier> {
|
|
const row = await rpc<unknown>(
|
|
accounting,
|
|
"get_agentic_rectification_case_dossier",
|
|
{ p_user_id: userId, p_case_id: caseId },
|
|
);
|
|
const dossier = parseV9CaseDossier(row);
|
|
if (!dossier) throw new RectificationToolServiceError("invalid_case_dossier");
|
|
return dossier;
|
|
}
|
|
|
|
export async function loadV9CaseCompute(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<V9ComputeProjection> {
|
|
const row = await rpc<unknown>(
|
|
accounting,
|
|
"get_agentic_rectification_case_compute",
|
|
{ p_user_id: userId, p_case_id: caseId },
|
|
);
|
|
const projection = parseV9ComputeProjection(row);
|
|
if (!projection) throw new RectificationToolServiceError("invalid_case_compute");
|
|
return projection;
|
|
}
|
|
|
|
/** Evidence rows that actually carry a scorable date. */
|
|
export function scorableEvidence(
|
|
evidence: V9CaseDossier["evidence"],
|
|
): V9CaseDossier["evidence"] {
|
|
return evidence.filter(
|
|
(item) =>
|
|
item.status === "confirmed"
|
|
&& item.datePrecision !== "unknown"
|
|
&& (item.occurredFrom || item.occurredTo),
|
|
);
|
|
}
|
|
|
|
function hashParts(parts: readonly string[]): string {
|
|
return createHash("sha256").update(parts.join("|")).digest("hex");
|
|
}
|
|
|
|
/** Canonical evidence ledger fingerprint over scorable evidence rows. */
|
|
export function evidenceLedgerFingerprint(
|
|
evidence: V9CaseDossier["evidence"],
|
|
): string {
|
|
const rows = [...scorableEvidence(evidence)]
|
|
.sort((left, right) => left.id.localeCompare(right.id))
|
|
.map((item) =>
|
|
[
|
|
item.id,
|
|
item.eventKind,
|
|
item.domain,
|
|
item.occurredFrom ?? "",
|
|
item.occurredTo ?? "",
|
|
item.datePrecision,
|
|
item.summary,
|
|
].join("::"),
|
|
);
|
|
return hashParts(["v9-evidence-ledger-v1", ...rows]);
|
|
}
|
|
|
|
/** Candidate range fingerprint: range + baseline fingerprint. */
|
|
export function candidateRangeFingerprint(
|
|
range: { start_time: string; end_time: string },
|
|
baselineProfileFingerprint: string,
|
|
): string {
|
|
return hashParts([
|
|
"v9-candidate-range-v1",
|
|
range.start_time,
|
|
range.end_time,
|
|
baselineProfileFingerprint,
|
|
]);
|
|
}
|
|
|
|
/** Canonical tool input fingerprint for receipts. */
|
|
export function canonicalToolInputFingerprint(
|
|
toolName: string,
|
|
args: Readonly<Record<string, unknown>>,
|
|
): string {
|
|
const safe = Object.fromEntries(
|
|
Object.entries(args)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, value]) => [key, typeof value === "string" ? value : JSON.stringify(value)]),
|
|
);
|
|
return hashParts([`v9-tool:${toolName}`, JSON.stringify(safe)]);
|
|
}
|
|
|
|
export type V9TurnAppendResult = Readonly<{ turnId: string }>;
|
|
|
|
export async function appendV9Turn(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
userMessage: string | null;
|
|
modelName: string;
|
|
modelVersion?: string;
|
|
},
|
|
): Promise<V9TurnAppendResult> {
|
|
const row = await rpc<{ turn_id?: unknown }>(
|
|
accounting,
|
|
"append_agentic_rectification_turn",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_user_message: input.userMessage,
|
|
p_assistant_message: null,
|
|
p_model_name: input.modelName,
|
|
p_model_version: input.modelVersion ?? null,
|
|
p_status: "pending",
|
|
},
|
|
);
|
|
const turnId = typeof row?.turn_id === "string" ? row.turn_id : "";
|
|
if (!turnId) throw new RectificationToolServiceError("invalid_turn_id");
|
|
return { turnId };
|
|
}
|
|
|
|
export async function finalizeV9Turn(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
status: "completed" | "failed" | "retryable",
|
|
assistantMessage: string | null,
|
|
): Promise<void> {
|
|
await rpc<unknown>(
|
|
accounting,
|
|
"finalize_agentic_rectification_turn",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_turn_id: turnId,
|
|
p_status: status,
|
|
p_assistant_message: assistantMessage,
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function insertV9ToolReceipt(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
input: {
|
|
toolName: string;
|
|
publicPhase: string;
|
|
status: "started" | "completed" | "failed" | "skipped";
|
|
inputFingerprint?: string | null;
|
|
resultFingerprint?: string | null;
|
|
engineVersion?: string | null;
|
|
safeErrorCode?: string | null;
|
|
executedMethods?: readonly PublicRectificationMethod[];
|
|
},
|
|
): Promise<void> {
|
|
if (!isPublicRectificationTool(input.toolName)) {
|
|
throw new RectificationToolServiceError("tool_not_allowlisted");
|
|
}
|
|
if (!isPublicRectificationPhase(input.publicPhase)) {
|
|
throw new RectificationToolServiceError("phase_not_allowlisted");
|
|
}
|
|
const executedMethods = [...new Set(input.executedMethods ?? [])];
|
|
if (!executedMethods.every(isPublicRectificationMethod)) {
|
|
throw new RectificationToolServiceError("method_not_allowlisted");
|
|
}
|
|
await rpc<unknown>(
|
|
accounting,
|
|
"insert_agentic_rectification_tool_receipt",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_turn_id: turnId,
|
|
p_tool_name: input.toolName,
|
|
p_public_phase: input.publicPhase,
|
|
p_status: input.status,
|
|
p_input_fingerprint: input.inputFingerprint ?? null,
|
|
p_result_fingerprint: input.resultFingerprint ?? null,
|
|
p_engine_version: input.engineVersion ?? null,
|
|
p_safe_error_code: input.safeErrorCode ?? null,
|
|
p_executed_methods: executedMethods,
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function insertV9RunPhase(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
phase: string,
|
|
toolName: string | null,
|
|
sequence: number,
|
|
): Promise<void> {
|
|
if (!isPublicRectificationPhase(phase)) {
|
|
throw new RectificationToolServiceError("phase_not_allowlisted");
|
|
}
|
|
await rpc<unknown>(
|
|
accounting,
|
|
"insert_agentic_rectification_run_phase",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_turn_id: turnId,
|
|
p_phase: phase,
|
|
p_tool_name: toolName,
|
|
p_sequence: sequence,
|
|
},
|
|
);
|
|
}
|
|
|
|
export type V9TurnReceipt = Readonly<{
|
|
turnId: string;
|
|
status: string;
|
|
skillName: string;
|
|
skillVersion: string;
|
|
engineVersion: string | null;
|
|
phases: readonly Readonly<{ phase: string; tool: string | null }>[];
|
|
tools: readonly string[];
|
|
methods: readonly PublicRectificationMethod[];
|
|
startedAt: string;
|
|
completedAt: string | null;
|
|
}>;
|
|
|
|
export async function loadV9TurnReceipt(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
): Promise<V9TurnReceipt | null> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"get_agentic_rectification_turn_receipt",
|
|
{ p_user_id: userId, p_case_id: caseId, p_turn_id: turnId },
|
|
);
|
|
if (!row || typeof row.turn_id !== "string") return null;
|
|
const phases = rowArray(row.phases).flatMap((item) => {
|
|
if (!item || typeof item !== "object") return [];
|
|
const phaseRow = item as Record<string, unknown>;
|
|
return [{ phase: String(phaseRow.phase ?? ""), tool: rowText(phaseRow.tool) }];
|
|
});
|
|
return {
|
|
turnId: row.turn_id,
|
|
status: String(row.status ?? ""),
|
|
skillName: String(row.skill_name ?? ""),
|
|
skillVersion: String(row.skill_version ?? ""),
|
|
engineVersion: rowText(row.engine_version),
|
|
phases,
|
|
tools: rowArray(row.tools).map((item) => String(item)),
|
|
methods: rowArray(row.methods).filter(isPublicRectificationMethod),
|
|
startedAt: String(row.started_at ?? ""),
|
|
completedAt: rowText(row.completed_at),
|
|
};
|
|
}
|
|
|
|
/** Map a persisted turn status to the public receipt status vocabulary. */
|
|
export function receiptStatusFromTurn(status: string): "completed" | "degraded" | "blocked" | "failed" {
|
|
if (status === "completed") return "completed";
|
|
if (status === "retryable") return "degraded";
|
|
return "failed";
|
|
}
|
|
|
|
export async function proposeV9Evidence(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
sourceTurnId: string;
|
|
quote: string;
|
|
subject: "self" | "family" | "other";
|
|
eventKind: EvidenceKind;
|
|
domain: string;
|
|
occurredFrom: string | null;
|
|
occurredTo: string | null;
|
|
datePrecision: string;
|
|
summary: string;
|
|
},
|
|
): Promise<Readonly<{ evidenceId: string; idempotent: boolean }>> {
|
|
const row = await rpc<{ evidence_id?: unknown; idempotent?: unknown }>(
|
|
accounting,
|
|
"propose_agentic_rectification_evidence",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_source_turn_id: input.sourceTurnId,
|
|
p_user_quote: input.quote,
|
|
p_subject: input.subject,
|
|
p_event_kind: input.eventKind,
|
|
p_domain: input.domain,
|
|
p_occurred_from: input.occurredFrom,
|
|
p_occurred_to: input.occurredTo,
|
|
p_date_precision: input.datePrecision,
|
|
p_summary: input.summary,
|
|
},
|
|
);
|
|
const evidenceId = typeof row?.evidence_id === "string" ? row.evidence_id : "";
|
|
if (!evidenceId) throw new RectificationToolServiceError("invalid_evidence_id");
|
|
return { evidenceId, idempotent: row?.idempotent === true };
|
|
}
|
|
|
|
export async function confirmV9Evidence(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
evidenceId: string,
|
|
): Promise<Readonly<{ evidenceId: string; status: string; idempotent: boolean }>> {
|
|
const row = await rpc<{ evidence_id?: unknown; status?: unknown; idempotent?: unknown }>(
|
|
accounting,
|
|
"confirm_agentic_rectification_evidence",
|
|
{ p_user_id: userId, p_case_id: caseId, p_evidence_id: evidenceId },
|
|
);
|
|
const confirmedId = typeof row?.evidence_id === "string" ? row.evidence_id : "";
|
|
if (!confirmedId) throw new RectificationToolServiceError("invalid_evidence_id");
|
|
return {
|
|
evidenceId: confirmedId,
|
|
status: String(row?.status ?? "confirmed"),
|
|
idempotent: row?.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export async function reviseV9Evidence(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
evidenceId: string;
|
|
quote: string;
|
|
occurredFrom: string | null;
|
|
occurredTo: string | null;
|
|
datePrecision: string;
|
|
summary: string;
|
|
},
|
|
): Promise<Readonly<{ evidenceId: string; supersedesEvidenceId: string; idempotent: boolean }>> {
|
|
const row = await rpc<{ evidence_id?: unknown; supersedes_evidence_id?: unknown; idempotent?: unknown }>(
|
|
accounting,
|
|
"revise_agentic_rectification_evidence",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_evidence_id: input.evidenceId,
|
|
p_user_quote: input.quote,
|
|
p_occurred_from: input.occurredFrom,
|
|
p_occurred_to: input.occurredTo,
|
|
p_date_precision: input.datePrecision,
|
|
p_summary: input.summary,
|
|
},
|
|
);
|
|
const evidenceId = typeof row?.evidence_id === "string" ? row.evidence_id : "";
|
|
const supersedes = typeof row?.supersedes_evidence_id === "string" ? row.supersedes_evidence_id : "";
|
|
if (!evidenceId) throw new RectificationToolServiceError("invalid_evidence_id");
|
|
return { evidenceId, supersedesEvidenceId: supersedes, idempotent: row?.idempotent === true };
|
|
}
|
|
|
|
export async function transitionV9CaseStatus(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
status: "draft" | "collecting_evidence" | "candidate_ready" | "candidate_accepted" | "needs_rebaseline" | "paused",
|
|
): Promise<Readonly<{ status: string; idempotent: boolean }>> {
|
|
const row = await rpc<{ status?: unknown; idempotent?: unknown }>(
|
|
accounting,
|
|
"transition_agentic_rectification_case_status",
|
|
{ p_user_id: userId, p_case_id: caseId, p_status: status },
|
|
);
|
|
return { status: String(row?.status ?? status), idempotent: row?.idempotent === true };
|
|
}
|
|
|
|
export type V9PersistedCandidate = Readonly<{
|
|
resultId: string;
|
|
cached: boolean;
|
|
candidates: V9CandidateSnapshot["candidates"];
|
|
overallConfidence: "low" | "medium" | "high";
|
|
marginPercent: number | null;
|
|
selectionAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
representativeTime: string | null;
|
|
algorithmVersion: string | null;
|
|
}>;
|
|
|
|
export async function persistV9Candidate(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
engineResultId: string;
|
|
algorithmVersion: string;
|
|
evidenceFingerprint: string;
|
|
rangeFingerprint: string;
|
|
skillVersion: string;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
candidates: V9CandidateSnapshot["candidates"];
|
|
overallConfidence: "low" | "medium" | "high";
|
|
marginPercent: number | null;
|
|
selectionAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
representativeTime: string | null;
|
|
},
|
|
): Promise<V9PersistedCandidate> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"persist_agentic_rectification_candidate",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_engine_result_id: input.engineResultId,
|
|
p_algorithm_version: input.algorithmVersion,
|
|
p_evidence_ledger_fingerprint: input.evidenceFingerprint,
|
|
p_candidate_range_fingerprint: input.rangeFingerprint,
|
|
p_skill_version: input.skillVersion,
|
|
p_candidate_range: input.candidateRange,
|
|
p_candidates: input.candidates,
|
|
p_overall_confidence: input.overallConfidence,
|
|
p_margin_percent: input.marginPercent,
|
|
p_selection_allowed: input.selectionAllowed,
|
|
p_confirmation_allowed: input.confirmationAllowed,
|
|
p_representative_time: input.representativeTime,
|
|
},
|
|
);
|
|
const resultId = typeof row?.result_id === "string" ? row.result_id : "";
|
|
if (!resultId) throw new RectificationToolServiceError("invalid_candidate_result");
|
|
return {
|
|
resultId,
|
|
cached: row?.cached === true,
|
|
candidates: parseV9CandidateSnapshot(row)?.candidates ?? input.candidates,
|
|
overallConfidence: row?.overall_confidence === "high" || row?.overall_confidence === "medium" ? row.overall_confidence : "low",
|
|
marginPercent: typeof row?.margin_percent === "number" ? row.margin_percent : input.marginPercent,
|
|
selectionAllowed: row?.selection_allowed === true,
|
|
confirmationAllowed: row?.confirmation_allowed === true,
|
|
representativeTime: timeValue(row?.representative_time),
|
|
algorithmVersion: typeof row?.algorithm_version === "string" ? row.algorithm_version : input.algorithmVersion,
|
|
};
|
|
}
|
|
|
|
export type V9AcceptResult = Readonly<{
|
|
success: boolean;
|
|
savedTime: string;
|
|
status: "accepted" | "confirmed";
|
|
resultId: string;
|
|
caseStatus: string;
|
|
idempotent: boolean;
|
|
}>;
|
|
|
|
export async function acceptV9Candidate(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
resultId: string,
|
|
time: string,
|
|
): Promise<V9AcceptResult> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"accept_agentic_rectification_candidate_for_case",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_result_id: resultId,
|
|
p_time: time,
|
|
},
|
|
);
|
|
const savedTime = timeValue(row?.saved_time);
|
|
if (row?.success !== true || !savedTime) throw new RectificationToolServiceError("invalid_accept_result");
|
|
return {
|
|
success: true,
|
|
savedTime,
|
|
status: row?.status === "confirmed" ? "confirmed" : "accepted",
|
|
resultId: String(row?.result_id ?? resultId),
|
|
caseStatus: String(row?.case_status ?? "candidate_accepted"),
|
|
idempotent: row?.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export type V9ConfirmResult = Readonly<{
|
|
success: boolean;
|
|
savedTime: string;
|
|
status: "confirmed";
|
|
resultId: string;
|
|
caseStatus: string;
|
|
idempotent: boolean;
|
|
}>;
|
|
|
|
export async function confirmV9BirthTime(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
resultId: string;
|
|
time: string;
|
|
consentQuote: string;
|
|
sourceTurnId: string;
|
|
},
|
|
): Promise<V9ConfirmResult> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"confirm_agentic_rectification_birth_time",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_result_id: input.resultId,
|
|
p_time: input.time,
|
|
p_consent_quote: input.consentQuote,
|
|
p_source_turn_id: input.sourceTurnId,
|
|
},
|
|
);
|
|
const savedTime = timeValue(row?.saved_time);
|
|
if (row?.success !== true || !savedTime) throw new RectificationToolServiceError("invalid_confirm_result");
|
|
return {
|
|
success: true,
|
|
savedTime,
|
|
status: "confirmed",
|
|
resultId: String(row?.result_id ?? input.resultId),
|
|
caseStatus: String(row?.case_status ?? "confirmed"),
|
|
idempotent: row?.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export async function closeV9Case(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
reason: "completed_by_user" | "abandoned_by_user" | "other",
|
|
): Promise<Readonly<{ success: boolean; caseId: string; status: string; idempotent: boolean }>> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"close_agentic_rectification_case",
|
|
{ p_user_id: userId, p_case_id: caseId, p_reason: reason },
|
|
);
|
|
if (row?.success !== true) throw new RectificationToolServiceError("invalid_close_result");
|
|
return {
|
|
success: true,
|
|
caseId: String(row?.case_id ?? caseId),
|
|
status: String(row?.status ?? "closed"),
|
|
idempotent: row?.idempotent === true,
|
|
};
|
|
}
|
|
|
|
/** Map an RPC error message back to a safe tool error code. */
|
|
export function safeToolErrorCode(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const known = [
|
|
"quote_not_grounded",
|
|
"consent_not_grounded",
|
|
"evidence_not_found",
|
|
"evidence_not_confirmable",
|
|
"evidence_not_revisable",
|
|
"case_terminal",
|
|
"case_not_found",
|
|
"candidate_not_found",
|
|
"candidate_expired",
|
|
"candidate_selection_blocked",
|
|
"candidate_time_not_allowed",
|
|
"candidate_already_selected",
|
|
"candidate_superseded",
|
|
"candidate_profile_changed",
|
|
"confirmation_blocked",
|
|
"confirm_time_mismatch",
|
|
"case_already_confirmed",
|
|
"skill_version_mismatch",
|
|
"turn_not_found",
|
|
"invalid_input",
|
|
];
|
|
for (const code of known) {
|
|
if (message.includes(`agentic_rectification_${code}`)) return code;
|
|
}
|
|
return "tool_failed";
|
|
}
|
|
|
|
export const V9_EVIDENCE_KINDS = EVIDENCE_KINDS;
|
|
export const V9_SKILL_VERSION = RECTIFICATION_SKILL_VERSION;
|
|
export type V9PublicTool = PublicRectificationTool;
|
|
export type V9PublicPhase = PublicRectificationPhase;
|