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:
@@ -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;
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user