feat(rectification): stream agent execution and redesign entry routing

Replace the Direct Agentic textStream relay with a durable V9 agent runtime:

- agentic-rectification.ts: short boundary-only system prompt (no gate->scan
  ->score->diagnostics copy); pins skills/jyotish-birth-time-rectification;
  per-action bounded maxSteps (opening/read-only 6, evidence 8, rescore 12,
  accept/confirm 6) with a hard ceiling and repeated-tool-call detection.
- rectification-v9-tools.ts: ten Case-ref tools (read-case, propose/confirm/
  revise-evidence, compare-candidates, read-diagnostics, offer-candidates,
  accept-candidate, confirm-birth-time, close-case). Inputs are minimal refs
  only; RPC-backed evidence ledger, fingerprint cache reuse, receipts, and
  accepted!=confirmed semantics; confirm requires gate + grounded consent.
- /api/rectification/agent: caseId/sessionId/requestId/action/message; exact
  Case<->Session binding verified server-side; client history never overrides
  the durable dossier; pending turn -> completed/failed/retryable; consumes
  result.fullStream and emits allowlisted NDJSON only (reasoning/raw/provider
  metadata/tool payloads/birth data/scores never forwarded); first-turn real
  skill.started/skill.loaded gate with one controlled retry; billing bound to
  rectification:case:{caseId}.
- New forward migration 20260813010000_agentic_rectification_v9_agent_api.sql:
  case dossier/compute, turn finalize, fingerprint-cached candidate persist,
  case-scoped accept, consent-gated confirm, guarded transitions,
  needs_rebaseline profile guard, run_phases receipt table, and the
  rectification_runtime_version feature flag (v9 default, legacy read-only).
- Frontend: homepage/sidebar entry routing now uses the server Case open API
  (openRectificationFromHomepage/openRectificationSession/startNewRectification)
  with exact sessionId/caseId and server-owned shouldStartOpening; CTA driven
  by entry-summary; chat restores from persisted turns, candidate cards from
  the Candidate Snapshot API, activity from real NDJSON + persisted receipts;
  direct durable candidate-accept endpoint for the UI cards.
This commit is contained in:
Jesse
2026-08-11 17:17:56 +08:00
parent d394dd0585
commit ca7484666f
14 changed files with 4367 additions and 558 deletions
@@ -0,0 +1,355 @@
/**
* V9 agent turn runner.
*
* Durable per-turn execution: verifies the Case/Session exact binding, reads
* the server-side dossier (client history can never override it), atomically
* creates a pending turn, streams the agent's fullStream, maps chunks to the
* allowlisted NDJSON phases, persists receipts, and only on success finalizes
* the turn as completed. Failures become failed/retryable and never look like
* settled history. Billing is bound to the caseId.
*/
import type { Agent } from "@mastra/core/agent";
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
import {
insertV9RunPhase,
loadV9CaseDossier,
RectificationToolServiceError,
type RectificationRpcClient,
} from "./tool-service";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-status";
import {
mapStreamChunkToPhase,
streamToolNames,
isPublicRectificationToolName,
type PublicStreamEvent,
} from "./stream-mapping";
export type V9RunBilling = Readonly<{
reserve(): Promise<{ success: boolean; reason?: string; status: number }>;
complete(input: { inputTokens: number; outputTokens: number; durationMs: number }): Promise<boolean>;
release(): Promise<boolean>;
}>;
export type V9AgentRunOptions = Readonly<{
userId: string;
caseId: string;
sessionId: string;
requestId: string;
action: RectificationAgentAction;
message: string | null;
modelName: string;
skillName?: string;
skillVersion?: string;
accounting: RectificationRpcClient;
buildAgent(turnId: string): Promise<Agent>;
billing: V9RunBilling;
emit(event: PublicStreamEvent): Promise<void> | void;
signal?: AbortSignal;
timeContext?: string;
}>;
export type V9AgentRunResult = Readonly<{
ok: boolean;
turnId: string;
turnStatus: "completed" | "failed" | "retryable";
skillLoaded: boolean;
answerText: string;
phases: readonly string[];
toolsUsed: readonly string[];
errorCode: string | null;
}>;
const REPEATED_TOOL_CALL_LIMIT = 3;
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;
}
async function rpcOf(
accounting: RectificationRpcClient,
fn: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { data, error } = await accounting.rpc(fn, args);
if (error) throw new RectificationToolServiceError(error.message);
return first(data);
}
function safeErrorCode(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("agentic_rectification_case_terminal")) return "case_terminal";
if (message.includes("agentic_rectification_case_not_found")) return "case_not_found";
if (message.includes("agentic_rectification_case_session_mismatch")) return "case_session_mismatch";
if (message.includes("repeated_tool_call")) return "repeated_tool_call";
if (message.includes("skill_not_loaded")) return "skill_not_loaded";
return "run_failed";
}
export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9AgentRunResult> {
const {
userId, caseId, sessionId, action, message, modelName,
accounting, buildAgent, billing, emit, signal,
} = options;
const skillName = options.skillName ?? RECTIFICATION_SKILL_NAME;
const reserve = await billing.reserve();
if (!reserve.success) {
throw new RectificationToolServiceError(reserve.reason ?? "billing_denied");
}
// Exact Case/Session binding: the sessionId in the request must be the
// case's bound session; client history is never trusted.
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
if (dossier.case.status === "confirmed" || dossier.case.status === "closed"
|| dossier.case.status === "abandoned" || dossier.case.status === "superseded") {
throw new RectificationToolServiceError("agentic_rectification_case_terminal");
}
// First turn = no completed turn yet. The skill gate stays active until a
// genuine successful answer is on record, so a failed opening does not let
// the next turn skip real skill loading.
const isFirstTurn = dossier.turns.every((turn) => turn.status !== "completed");
const turnRow = await rpcOf(accounting, "append_agentic_rectification_turn", {
p_user_id: userId,
p_case_id: caseId,
p_user_message: message,
p_assistant_message: null,
p_model_name: modelName,
p_model_version: null,
p_status: "pending",
});
const turnId = turnRow && typeof turnRow === "object"
&& typeof (turnRow as { turn_id?: unknown }).turn_id === "string"
? (turnRow as { turn_id: string }).turn_id
: "";
if (!turnId) throw new RectificationToolServiceError("agentic_rectification_turn_incomplete");
const phaseSequence = { value: 0 };
const persistPhase = async (phase: string, toolName: string | null) => {
if (phase === "answer.delta") return;
try {
phaseSequence.value += 1;
await insertV9RunPhase(
accounting,
userId,
caseId,
turnId,
phase,
toolName,
phaseSequence.value,
);
} catch {
// Receipt persistence must never break the run.
}
};
let skillLoaded = false;
let answerText = "";
const phases: string[] = [];
const toolsUsed = new Set<string>();
const repeatedCalls = new Map<string, number>();
const startedAt = Date.now();
const streamTurn = async (attempt: number): Promise<{ ok: boolean; status: "completed" | "failed" | "retryable"; errorCode: string | null; usage: { inputTokens: number; outputTokens: number } }> => {
const agent = await buildAgent(turnId);
// Framework-level skill verification: agent.getSkill() loads the SKILL.md
// through the workspace; null means the skill is not registered.
let frameworkSkill: unknown = null;
try {
frameworkSkill = await (agent as unknown as { getSkill(name: string): Promise<unknown> }).getSkill(skillName);
} catch {
frameworkSkill = null;
}
if (!frameworkSkill) {
const status = attempt < 2 ? "retryable" : "failed";
return { ok: false, status, errorCode: "skill_not_loaded", usage: { inputTokens: 0, outputTokens: 0 } };
}
const messages = buildAgentMessages(options, attempt);
const maxSteps = resolveRectificationStepBudget(action);
const abortController = new AbortController();
const onAbort = () => abortController.abort();
signal?.addEventListener("abort", onAbort, { once: true });
const timeout = setTimeout(() => abortController.abort(), 105_000);
let streamFailed = false;
let finished = false;
try {
await emit({ type: "run.started" });
await persistPhase("run.started", null);
const result = await (agent as unknown as {
stream(
messages: unknown[],
streamOptions: { maxSteps: number; abortSignal: AbortSignal; instructions?: string },
): Promise<{
fullStream: AsyncIterable<{
type: string;
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
object?: unknown;
}>;
text?: Promise<string>;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
}>;
}).stream(messages, {
maxSteps,
abortSignal: abortController.signal,
...(attempt === 2 ? { instructions: "你必须先调用 skill 工具加载 jyotish-birth-time-rectification,再调用 rectification-read-case,然后才能继续。" } : {}),
});
for await (const chunk of result.fullStream) {
const phaseEvent = mapStreamChunkToPhase(chunk as never);
if (phaseEvent) {
phases.push(phaseEvent.type);
if (phaseEvent.type === "skill.loaded") skillLoaded = true;
if (phaseEvent.type === "answer.delta") {
answerText += phaseEvent.text ?? "";
await emit(phaseEvent);
} else {
await emit(phaseEvent);
await persistPhase(phaseEvent.type, null);
}
}
for (const toolName of streamToolNames(chunk as never)) {
toolsUsed.add(toolName);
}
if (chunk.type === "tool-call" && isPublicRectificationToolName(chunk.payload?.toolName)) {
const key = `${String(chunk.payload.toolName)}:${JSON.stringify(chunk.payload?.args ?? {})}`;
const count = (repeatedCalls.get(key) ?? 0) + 1;
repeatedCalls.set(key, count);
if (count > REPEATED_TOOL_CALL_LIMIT) {
abortController.abort();
throw new Error("repeated_tool_call");
}
}
if (chunk.type === "error") streamFailed = true;
if (chunk.type === "abort") streamFailed = true;
if (chunk.type === "finish") finished = true;
}
// First-turn gate: real skill.started/skill.loaded evidence is required
// for a fresh case. A retry is allowed once with a stronger instruction.
if (isFirstTurn && !skillLoaded) {
const status = attempt < 2 ? "retryable" : "failed";
return { ok: false, status, errorCode: "skill_not_loaded", usage: { inputTokens: 0, outputTokens: 0 } };
}
if (streamFailed || abortController.signal.aborted) {
return { ok: false, status: "retryable", errorCode: "stream_aborted", usage: { inputTokens: 0, outputTokens: 0 } };
}
if (!finished) {
return { ok: false, status: "retryable", errorCode: "stream_unfinished", usage: { inputTokens: 0, outputTokens: 0 } };
}
if (!answerText.trim()) {
return { ok: false, status: "retryable", errorCode: "empty_stream", usage: { inputTokens: 0, outputTokens: 0 } };
}
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
return {
ok: true,
status: "completed",
errorCode: null,
usage: {
inputTokens: Math.max(0, Math.trunc(usage.inputTokens ?? 0)),
outputTokens: Math.max(0, Math.trunc(usage.outputTokens ?? 0)),
},
};
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
}
};
let outcome: Awaited<ReturnType<typeof streamTurn>> | null = null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
outcome = await streamTurn(attempt);
} catch (error) {
// Abort signals, repeated-tool detection and engine errors must convert
// into a failed/retryable turn, never an unhandled rejection.
outcome = {
ok: false,
status: "retryable",
errorCode: safeErrorCode(error),
usage: { inputTokens: 0, outputTokens: 0 },
};
}
if (outcome.ok || outcome.status === "failed") break;
if (attempt === 1 && outcome.errorCode === "skill_not_loaded") continue;
break;
}
const finalOutcome = outcome ?? { ok: false, status: "failed" as const, errorCode: "run_failed", usage: { inputTokens: 0, outputTokens: 0 } };
const durationMs = Date.now() - startedAt;
if (finalOutcome.ok) {
const completed = await billing.complete({
inputTokens: finalOutcome.usage.inputTokens,
outputTokens: finalOutcome.usage.outputTokens,
durationMs,
});
if (!completed) {
await finalize("retryable", answerText);
await emit({ type: "run.failed" });
return {
ok: false, turnId, turnStatus: "retryable", skillLoaded,
answerText, phases, toolsUsed: [...toolsUsed], errorCode: "usage_settlement_failed",
};
}
await finalize("completed", answerText);
await persistPhase("run.completed", null);
await emit({ type: "run.completed" });
return {
ok: true, turnId, turnStatus: "completed", skillLoaded,
answerText, phases, toolsUsed: [...toolsUsed], errorCode: null,
};
}
await billing.release();
await finalize(finalOutcome.status, answerText);
await persistPhase("run.failed", null);
await emit({ type: "run.failed" });
return {
ok: false, turnId, turnStatus: finalOutcome.status, skillLoaded,
answerText, phases, toolsUsed: [...toolsUsed], errorCode: finalOutcome.errorCode,
};
async function finalize(status: "completed" | "failed" | "retryable", assistantText: string) {
try {
await rpcOf(accounting, "finalize_agentic_rectification_turn", {
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_status: status,
p_assistant_message: status === "completed" ? assistantText : null,
});
} catch (error) {
console.warn(`[rectification-v9] turn finalize failed turn=${turnId} status=${status} reason=${safeErrorCode(error)}`);
}
}
}
function buildAgentMessages(options: V9AgentRunOptions, _attempt: number): unknown[] {
void _attempt;
const timeContext = options.timeContext
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
if (options.action === "opening") {
return [{
role: "user",
content: [
timeContext,
"【服务端开场指令】这是本校正 Case 的首次开场,还没有用户输入。请先调用 skill 工具加载 jyotish-birth-time-rectification,再调用 rectification-read-case 读取服务端 Case 与证据摘要,然后用简体中文自然开场:说明你会通过已发生的人生事件来校正出生时间,并自然地提出第一个最有用的问题(只需一个问题)。",
].join("\n"),
}];
}
return [{
role: "user",
content: [timeContext, options.message ?? ""].join("\n"),
}];
}
export { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION };
@@ -0,0 +1,248 @@
/**
* V9 deterministic engine client (Python, server-side only).
*
* Builds engine payloads exclusively from the Case's baseline birth snapshot
* and the durable evidence ledger. The model never supplies birth data,
* candidate ranges or event arrays. Responses are compacted to safe,
* allowlisted projections before they reach the tool layer.
*/
export class RectificationEngineError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "RectificationEngineError";
this.code = code;
}
}
export type V9EngineEvent = Readonly<{
id: string;
domain: string;
event_kind: string;
date_start: string;
date_end: string;
precision: "day" | "month" | "quarter" | "year" | "range";
summary: string;
}>;
export type V9EngineCandidate = Readonly<{
rank: number;
time: string;
relative_support: number;
tied_minute_count: number;
}>;
export type V9EngineScoreResult = Readonly<{
engineResultId: string;
algorithmVersion: string;
candidateRange: { start_time: string; end_time: string };
candidates: readonly V9EngineCandidate[];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
}>;
export type V9EngineDiagnostics = Readonly<{
algorithmVersion: string;
engineResultId: string;
diagnostics: Readonly<Record<string, unknown>>;
missingLayers: readonly string[];
canConfirmExactMinute: boolean;
}>;
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
function clockMinute(value: string): number {
const [hour = 0, minute = 0] = value.split(":").map(Number);
return hour * 60 + minute;
}
function timeInRange(time: string, range: { start_time: string; end_time: string }): boolean {
const value = clockMinute(time);
const start = clockMinute(range.start_time);
const end = clockMinute(range.end_time);
return end >= start ? value >= start && value <= end : value >= start || value <= end;
}
function readCandidates(value: unknown, range: { start_time: string; end_time: string }): V9EngineCandidate[] {
if (!Array.isArray(value)) return [];
const rows = value.flatMap((item): Array<{ rank: number; time: string; score: number; tied: number }> => {
if (!item || typeof item !== "object") return [];
const row = item as Record<string, unknown>;
const time = typeof row.time === "string" ? row.time : "";
const rank = typeof row.rank === "number" ? Math.trunc(row.rank) : 0;
const score = typeof row.score === "number" && Number.isFinite(row.score) ? row.score : 0;
const tied = typeof row.tied_minute_count === "number" ? Math.max(1, Math.trunc(row.tied_minute_count)) : 1;
if (!timePattern.test(time) || rank < 1 || !timeInRange(time, range)) return [];
return [{ rank, time, score, tied }];
}).sort((left, right) => left.rank - right.rank).slice(0, 3);
if (rows.length === 0) return [];
const weights = rows.map((row) => Math.max(0, row.score));
const total = weights.reduce((sum, weight) => sum + weight, 0);
const supports = weights.map((weight) => total > 0 ? Math.round((weight / total) * 100) : Math.floor(100 / rows.length));
supports[0] += 100 - supports.reduce((sum, support) => sum + support, 0);
return rows.map((row, index) => ({
rank: row.rank,
time: row.time,
relative_support: supports[index] ?? 0,
tied_minute_count: row.tied,
}));
}
/** Map a V9 evidence date precision to the engine's precision vocabulary. */
export function enginePrecision(precision: string): V9EngineEvent["precision"] {
if (precision === "day") return "day";
if (precision === "month") return "month";
if (precision === "range") return "range";
return "year";
}
export function toEngineEvents(
evidence: readonly Readonly<{
id: string;
eventKind: string;
domain: string;
occurredFrom: string | null;
occurredTo: string | null;
datePrecision: string;
summary: string;
}>[],
): V9EngineEvent[] {
return evidence.flatMap((item): V9EngineEvent[] => {
const start = item.occurredFrom ?? item.occurredTo;
const end = item.occurredTo ?? item.occurredFrom;
if (!start) return [];
return [{
id: item.id,
domain: item.domain,
event_kind: item.eventKind,
date_start: start.slice(0, 10),
date_end: end ? end.slice(0, 10) : start.slice(0, 10),
precision: enginePrecision(item.datePrecision),
summary: item.summary,
}];
});
}
function engineBase(): string {
return process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200";
}
async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Promise<Record<string, unknown>> {
const response = await fetch(`${engineBase()}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data?.error || data?.message || `Jyotish API ${path} returned ${response.status}`;
throw new RectificationEngineError("engine_http_error", String(message));
}
if (!data || typeof data !== "object") {
throw new RectificationEngineError("engine_invalid_response", `Jyotish API ${path} returned an invalid response`);
}
return data as Record<string, unknown>;
}
function engineNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export async function runV9CandidateScore(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
}): Promise<V9EngineScoreResult> {
const snapshot = input.baselineBirthSnapshot;
const birthDate = String(snapshot.birth_date ?? "");
const lat = engineNumber(snapshot.latitude);
const lon = engineNumber(snapshot.longitude);
const tz = engineNumber(snapshot.timezone_offset);
if (!birthDate || lat === null || lon === null || tz === null) {
throw new RectificationEngineError("engine_profile_incomplete", "server profile snapshot is incomplete");
}
const data = await postEngine("/api/rectification/v5/score", {
birth_date: birthDate,
start_time: input.candidateRange.start_time,
end_time: input.candidateRange.end_time,
lat,
lon,
tz,
events: input.events,
});
const candidates = readCandidates(data.candidate_scores, input.candidateRange);
if (candidates.length === 0) {
throw new RectificationEngineError("engine_no_candidates", "the engine returned no usable candidates");
}
const representativeTime =
typeof data.representative_time === "string" && timePattern.test(data.representative_time)
? data.representative_time.slice(0, 5)
: null;
const confidence: "low" | "medium" | "high" =
data.confidence === "high" || data.confidence === "medium" ? data.confidence : "low";
return {
engineResultId: String(data.result_id ?? ""),
algorithmVersion: String(data.algorithm_version ?? "rectification-v5"),
candidateRange: input.candidateRange,
candidates,
overallConfidence: confidence,
marginPercent: engineNumber(data.margin_percent),
selectionAllowed: data.selection_allowed === true || data.can_apply === true,
confirmationAllowed: data.confirmation_allowed === true,
representativeTime,
};
}
export async function runV9Diagnostics(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
}): Promise<V9EngineDiagnostics> {
const snapshot = input.baselineBirthSnapshot;
const birthDate = String(snapshot.birth_date ?? "");
const lat = engineNumber(snapshot.latitude);
const lon = engineNumber(snapshot.longitude);
const tz = engineNumber(snapshot.timezone_offset);
if (!birthDate || lat === null || lon === null || tz === null) {
throw new RectificationEngineError("engine_profile_incomplete", "server profile snapshot is incomplete");
}
const data = await postEngine("/api/rectification/v5/diagnostics", {
birth_date: birthDate,
start_time: input.candidateRange.start_time,
end_time: input.candidateRange.end_time,
lat,
lon,
tz,
events: input.events,
});
const diagnostics = data.diagnostics && typeof data.diagnostics === "object"
? data.diagnostics as Record<string, unknown>
: {};
const missingLayers = Array.isArray(data.missing_layers) ? data.missing_layers as string[] : [];
return {
algorithmVersion: String(data.algorithm_version ?? "rectification-v5"),
engineResultId: String(data.result_id ?? ""),
diagnostics: {
primary_cluster_retention_rate: diagnostics.primary_cluster_retention_rate,
leave_one_event_out_retention_rate: diagnostics.leave_one_event_out_retention_rate,
leave_one_domain_out_retention_rate: diagnostics.leave_one_domain_out_retention_rate,
date_sensitivity_retention_rate: diagnostics.date_sensitivity_retention_rate,
neighbor_support_minutes: diagnostics.neighbor_support_minutes,
primary_secondary_margin_percent: diagnostics.primary_secondary_margin_percent,
unstable_event_ids: diagnostics.unstable_event_ids,
most_discriminating_layers: diagnostics.most_discriminating_layers,
candidate_splits: diagnostics.candidate_splits,
},
missingLayers,
canConfirmExactMinute: data.can_confirm_exact_minute === true,
};
}
export const v9EngineVersion = (): string =>
process.env.RECTIFICATION_ENGINE_VERSION?.trim() || "rectification-v5";
@@ -0,0 +1,117 @@
/**
* V9 fullStream → public NDJSON mapping.
*
* Consumes the Mastra agent's fullStream (AgentChunkType) and emits only the
* allowlisted public phases from public-receipt.ts. Reasoning, raw payloads,
* provider metadata, tool args/results, system/skill source text, birth data
* and internal errors are dropped at this boundary.
*/
import type { AgentChunkType } from "@mastra/core/stream";
import {
PUBLIC_RECTIFICATION_TOOLS,
safeActivityEvent,
type PublicRectificationPhase,
type PublicRectificationTool,
} from "./public-receipt";
export type PublicStreamEvent = Readonly<{
type: PublicRectificationPhase;
text?: string;
}>;
export type StreamObservation = Readonly<{
phases: readonly PublicRectificationPhase[];
toolsUsed: readonly PublicRectificationTool[];
skillLoaded: boolean;
answerText: string;
errored: boolean;
aborted: boolean;
finished: boolean;
}>;
const TOOL_PHASE_ON_CALL: Readonly<Record<string, PublicRectificationPhase>> = {
"rectification-read-case": "case.loaded",
"rectification-propose-evidence": "evidence.proposed",
"rectification-confirm-evidence": "evidence.confirmed",
"rectification-revise-evidence": "evidence.proposed",
"rectification-compare-candidates": "candidates.comparing",
"rectification-read-diagnostics": "diagnostics.completed",
"rectification-offer-candidates": "candidates.updated",
"rectification-accept-candidate": "candidate.accepted",
"rectification-confirm-birth-time": "birth_time.confirmed",
"rectification-close-case": "run.completed",
};
const TOOL_PHASE_ON_RESULT: Readonly<Record<string, PublicRectificationPhase>> = {
"rectification-read-case": "case.loaded",
"rectification-propose-evidence": "evidence.proposed",
"rectification-confirm-evidence": "evidence.confirmed",
"rectification-revise-evidence": "evidence.proposed",
"rectification-compare-candidates": "candidates.updated",
"rectification-read-diagnostics": "diagnostics.completed",
"rectification-offer-candidates": "candidates.updated",
"rectification-accept-candidate": "candidate.accepted",
"rectification-confirm-birth-time": "birth_time.confirmed",
"rectification-close-case": "run.completed",
};
export function isPublicRectificationToolName(value: unknown): value is PublicRectificationTool {
return typeof value === "string" && (PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes(value);
}
/**
* Map a single fullStream chunk to a public phase (or null to drop). The
* skill tool is the framework's auditable skill loader: its tool-call proves
* skill.started and its tool-result proves skill.loaded.
*/
export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicStreamEvent | null {
switch (chunk.type) {
case "start":
return { type: "run.started" };
case "tool-call": {
const toolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
if (toolName === "skill") return { type: "skill.started" };
const phase = TOOL_PHASE_ON_CALL[toolName];
return phase ? { type: phase } : null;
}
case "tool-result": {
const toolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
if (toolName === "skill") return { type: "skill.loaded" };
const phase = TOOL_PHASE_ON_RESULT[toolName];
return phase ? { type: phase } : null;
}
case "text-delta": {
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
if (!text) return null;
return { type: "answer.delta", text };
}
case "finish":
// Completion is decided by the runner after the skill/first-turn gates;
// a finish chunk alone never proves a settled answer.
return null;
case "error":
case "abort":
// Failure is decided by the runner so receipts stay accurate.
return null;
default:
// reasoning-*, raw, step-*, source, file, response-metadata and any
// future chunk type are never forwarded.
return null;
}
}
export function streamToolNames(chunk: AgentChunkType): PublicRectificationTool[] {
if (chunk.type !== "tool-call" && chunk.type !== "tool-result") return [];
const toolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
return isPublicRectificationToolName(toolName) ? [toolName] : [];
}
/** Safe activity event for the web client; drops anything not allowlisted. */
export function safePublicEvent(value: unknown): PublicStreamEvent | null {
if (!value || typeof value !== "object") return null;
const event = value as { type?: unknown; text?: unknown };
const type = safeActivityEvent(event.type);
if (!type) return null;
const text = typeof event.text === "string" ? event.text.slice(0, 4_000) : undefined;
return { type, ...(text !== undefined ? { text } : {}) };
}
@@ -0,0 +1,853 @@
/**
* 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 {
isPublicRectificationPhase,
isPublicRectificationTool,
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.status === "pending_confirmation")
&& 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;
},
): Promise<void> {
if (!isPublicRectificationTool(input.toolName)) {
throw new RectificationToolServiceError("tool_not_allowlisted");
}
if (!isPublicRectificationPhase(input.publicPhase)) {
throw new RectificationToolServiceError("phase_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,
},
);
}
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[];
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)),
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;
+144
View File
@@ -0,0 +1,144 @@
/**
* Client-side rectification entry routing contracts.
*
* Every disposition decision comes from the server Case open API. The browser
* never guesses "resume vs create" from session lists or message counts.
*/
export type RectificationEntrySummary = Readonly<{
hasResumableCase: boolean;
hasTerminalCaseWithTime: boolean;
latestResumable: Readonly<{
caseId: string;
status: string;
lastActivityAt: string;
}> | null;
latestTerminal: Readonly<{
caseId: string;
status: string;
hasUsableTime: boolean;
}> | null;
}>;
export type RectificationCardAction = "start" | "resume" | "restart";
export const rectificationEntryLabels: Readonly<Record<RectificationCardAction, string>> = {
start: "开始生时校正",
resume: "继续上次校正",
restart: "再次校正",
};
export function resolveRectificationEntryAction(
summary: RectificationEntrySummary,
): RectificationCardAction {
if (summary.hasResumableCase) return "resume";
if (summary.hasTerminalCaseWithTime) return "restart";
return "start";
}
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
export type OpenRectificationCaseResponse = Readonly<{
disposition: OpenRectificationDisposition;
caseId: string;
sessionId: string;
status: string;
shouldStartOpening: boolean;
skillVersion: string;
}>;
const RESUMABLE_STATUSES = new Set([
"draft",
"collecting_evidence",
"candidate_ready",
"candidate_accepted",
"needs_rebaseline",
"paused",
]);
const TERMINAL_STATUSES = new Set([
"confirmed",
"closed",
"abandoned",
"superseded",
]);
export function isResumableRectificationStatus(status: string): boolean {
return RESUMABLE_STATUSES.has(status);
}
export function isTerminalRectificationStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}
export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary {
if (!value || typeof value !== "object") {
return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null };
}
const row = value as Record<string, unknown>;
const latestResumable = row.latest_resumable && typeof row.latest_resumable === "object"
? row.latest_resumable as Record<string, unknown>
: null;
const latestTerminal = row.latest_terminal && typeof row.latest_terminal === "object"
? row.latest_terminal as Record<string, unknown>
: null;
return {
hasResumableCase: row.has_resumable_case === true,
hasTerminalCaseWithTime: row.has_terminal_case_with_time === true,
latestResumable: latestResumable && typeof latestResumable.case_id === "string"
? {
caseId: latestResumable.case_id,
status: String(latestResumable.status ?? ""),
lastActivityAt: String(latestResumable.last_activity_at ?? ""),
}
: null,
latestTerminal: latestTerminal && typeof latestTerminal.case_id === "string"
? {
caseId: latestTerminal.case_id,
status: String(latestTerminal.status ?? ""),
hasUsableTime: latestTerminal.has_usable_time === true,
}
: null,
};
}
export function openResponseFromPayload(value: unknown): OpenRectificationCaseResponse | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const disposition = row.disposition;
const caseId = typeof row.caseId === "string" ? row.caseId : "";
const sessionId = typeof row.sessionId === "string" ? row.sessionId : "";
const status = typeof row.status === "string" ? row.status : "";
if (
(disposition !== "created" && disposition !== "resumed" && disposition !== "readonly")
|| !caseId || !sessionId || !status
) {
return null;
}
return {
disposition,
caseId,
sessionId,
status,
shouldStartOpening: row.shouldStartOpening === true,
skillVersion: typeof row.skillVersion === "string" ? row.skillVersion : "",
};
}
export type RectificationEntryOpenIntent = "homepage" | "session" | "new";
/** Build the server open request body; the browser never adds business state. */
export function openRectificationRequestBody(
intent: RectificationEntryOpenIntent,
sessionId: string | null,
): { intent: RectificationEntryOpenIntent; requestId: string; sessionId?: string } {
const requestId = globalThis.crypto?.randomUUID?.() ?? fallbackUuid();
if (intent === "session" && sessionId) {
return { intent, requestId, sessionId };
}
return { intent, requestId };
}
function fallbackUuid(): string {
return "00000000-0000-4000-8000-000000000000";
}