feat: add rectification event decision contract v2

This commit is contained in:
Jesse_Chen
2026-08-15 00:56:06 +08:00
parent 21fce9513c
commit 83fef19779
24 changed files with 3637 additions and 562 deletions
@@ -11,7 +11,7 @@ type RouteContext = { params: Promise<{ caseId: string }> };
const acceptSchema = z.object({
sessionId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
candidateId: z.string().uuid(),
requestId: z.string().uuid(),
}).strict();
@@ -90,24 +90,29 @@ export async function POST(request: Request, context: RouteContext) {
try {
const { data, error } = await accounting.rpc(
"accept_agentic_rectification_candidate_for_case",
"accept_agentic_rectification_candidate_for_case_v2",
{
p_user_id: user.id,
p_case_id: caseId,
p_result_id: parsed.data.resultId,
p_time: parsed.data.candidateId,
p_candidate_id: parsed.data.candidateId,
p_request_id: parsed.data.requestId,
},
);
if (error) throw new RectificationToolServiceError(error.message);
const row = Array.isArray(data) ? data[0] : data;
if (!row || typeof row !== "object" || (row as { success?: unknown }).success !== true) {
if (
!row || typeof row !== "object"
|| (row as { success?: unknown }).success !== true
|| (row as { status?: unknown }).status !== "accepted"
) {
return NextResponse.json({ error: "暂时无法采用该候选时间", code: "candidate_accept_rejected" }, { status: 409 });
}
const result = row as Record<string, unknown>;
return NextResponse.json({
ok: true,
saved_time: result.saved_time,
status: result.status === "confirmed" ? "confirmed" : "accepted",
status: "accepted",
result_id: result.result_id,
case_status: result.case_status,
idempotent: result.idempotent === true,
@@ -162,7 +162,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
@@ -384,10 +384,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void send("opening", "");
}, [readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (time: string) => {
if (!candidateResult || acceptingTime || readonly) return;
const acceptCandidate = useCallback(async (candidateId: string) => {
if (!candidateResult || acceptingCandidateId || readonly) return;
setError("");
setAcceptingTime(time);
setAcceptingCandidateId(candidateId);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
@@ -397,27 +397,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
body: JSON.stringify({
sessionId,
resultId: candidateResult.resultId,
candidateId: time,
candidateId,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true) {
if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") {
throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
}
const status = payload.status === "confirmed" ? "confirmed" : "accepted";
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: status === "confirmed" ? "engine_confirmed" : "user_accepted" } : current);
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: "user_accepted" } : current);
setSavedTime(payload.saved_time);
setSavedStatus(status);
onSaved?.(payload.saved_time, status);
setSavedStatus("accepted");
onSaved?.(payload.saved_time, "accepted");
onCompleted?.();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingTime(null);
setAcceptingCandidateId(null);
}
}, [acceptingTime, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]);
}, [acceptingCandidateId, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]);
async function copyMessage(message: RenderMessage) {
try {
@@ -562,7 +561,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
{candidateResult?.selectionAllowed && (
<section className="rectification-candidates" aria-label="生时校正候选时间">
<div className="rectification-candidates-heading">
<strong>{candidateResult.confirmationAllowed ? "确认校正时间" : "当前可能的出生时间"}</strong>
@@ -577,9 +576,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<button
type="button"
className={`rectification-candidate${selected ? " is-selected" : ""}`}
key={`${candidateResult.resultId}-${candidate.time}`}
disabled={selected || Boolean(acceptingTime) || readonly}
onClick={() => void acceptCandidate(candidate.time)}
key={candidate.candidateId}
disabled={selected || Boolean(acceptingCandidateId) || readonly}
onClick={() => void acceptCandidate(candidate.candidateId)}
>
<span className="rectification-candidate-time">
<strong>{candidate.time}</strong>
@@ -588,7 +587,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</span>
<span className="rectification-candidate-support"> {candidate.relativeSupport}</span>
<span className="rectification-candidate-action">
{selected ? "已采用" : acceptingTime === candidate.time ? "正在采用…" : candidateResult.selectedTime ? "改选为此时间" : "采用此时间"}
{selected ? "已采用" : acceptingCandidateId === candidate.candidateId ? "正在采用…" : candidateResult.selectedTime ? "改选为此时间" : "采用此时间"}
</span>
</button>
);
@@ -1,28 +1,22 @@
/**
* 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.
*
* Contract notes (verified against scripts/jyotish_api_server.py +
* scripts/rectification/api_service.py):
* * The engine's SCOREABLE_EVENT_KINDS is a coarse vocabulary
* (education_milestone / relocation / relationship_start|change /
* career_change / finance_change / self_health_event under
* health_pressure). V9 evidence kinds are mapped onto that vocabulary;
* non-scoreable kinds (family_event, other) stay in the ledger but never
* reach the engine.
* * /api/rectification/v5/score returns candidate_scores as
* [{time, score, supporting_event_ids, conflicting_event_ids}] without
* rank/tied/representative/confidence fields. Rank and tie counts are
* derived deterministically here; relative support is normalized from
* scores; representative time is the top-ranked candidate; the
* confirmation gate is bound to the engine's own can_confirm_exact_minute.
* The Python service owns Event Contract v2 normalization, candidate ranking,
* decision policy and the execution ledger. This client validates and projects
* that server contract; it never reconstructs rank, support, ties, confidence,
* permissions or executed methods from raw scores or event domains.
*/
import type { PublicRectificationMethod } from "./public-receipt";
import {
isBackgroundEvidenceKind,
isEvidenceDomain,
isEvidenceKind,
type EvidenceKind,
} from "./evidence-model";
import {
isPublicRectificationMethod,
type PublicRectificationMethod,
} from "./public-receipt";
export class RectificationEngineError extends Error {
readonly code: string;
@@ -42,81 +36,75 @@ export type V9EngineEvent = Readonly<{
date_end: string;
precision: "day" | "month" | "quarter" | "year" | "range";
summary: string;
date_source?: string | null;
date_reliability?: string | null;
date_corroboration?: string | null;
date_conflict_status?: string | null;
source_turn_id?: string | null;
subject?: string | null;
}>;
export type V9EngineCandidate = Readonly<{
rank: number;
candidateId: string;
time: string;
relative_support: number;
tied_minute_count: number;
rank: number;
relativeSupport: number;
tiedMinuteCount: number;
}>;
export type V9DecisionReceipt = Readonly<Record<string, unknown>>;
export type V9ExecutionLedger = readonly Readonly<Record<string, unknown>>[];
export type V9EngineScoreResult = Readonly<{
engineResultId: string;
algorithmVersion: string;
eventContractVersion: string;
policyVersion: string;
candidateRange: { start_time: string; end_time: string };
candidates: readonly V9EngineCandidate[];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeCandidateId: string | null;
representativeTime: string | null;
decisionReceipt: V9DecisionReceipt;
executionLedger: V9ExecutionLedger;
executedMethods: readonly PublicRectificationMethod[];
}>;
export type V9EngineDiagnostics = Readonly<{
algorithmVersion: string;
engineResultId: string;
eventContractVersion: string;
policyVersion: string;
diagnostics: Readonly<Record<string, unknown>>;
missingLayers: readonly string[];
canConfirmExactMinute: boolean;
decisionReceipt: V9DecisionReceipt;
executionLedger: V9ExecutionLedger;
executedMethods: readonly PublicRectificationMethod[];
}>;
const EVENT_CONTRACT_VERSION = "rectification-event-contract-v2";
const RECEIPT_VERSION = "candidate-decision-receipt-v2";
const EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2";
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const DOMAIN_METHODS: Readonly<Record<string, readonly PublicRectificationMethod[]>> = {
education: ["d24-chaturvimshamsha"],
relocation: ["d4-chaturthamsha"],
relationship: ["d9-navamsa"],
career: ["d10-dashamsa"],
finance: ["d2-hora", "d11-labhamsha"],
health_pressure: ["d30-trimshamsha"],
};
function techniqueLayers(value: unknown): string[] {
if (!value || typeof value !== "object") return [];
return Object.values(value as Record<string, unknown>).flatMap((eventRows) => {
if (!eventRows || typeof eventRows !== "object") return [];
return Object.values(eventRows as Record<string, unknown>).flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const layers = (candidate as Record<string, unknown>).technique_layers;
return Array.isArray(layers) ? layers.filter((item): item is string => typeof item === "string") : [];
});
});
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function executedMethods(
events: readonly V9EngineEvent[],
layers: readonly string[],
): PublicRectificationMethod[] {
const methods = new Set<PublicRectificationMethod>([
"d1-rashi",
"vimshottari-dasha",
"narayana-dasha",
]);
for (const event of events) {
for (const method of DOMAIN_METHODS[event.domain] ?? []) methods.add(method);
function engineNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
const normalized = layers.map((layer) => layer.toLowerCase());
if (normalized.some((layer) => layer.includes("controlled_transit") || layer.includes("gochara"))) methods.add("gochara");
if (normalized.some((layer) => layer.includes("ashtakavarga"))) methods.add("ashtakavarga");
if (normalized.some((layer) => layer.includes("shadbala"))) methods.add("shadbala");
if (normalized.some((layer) => layer.includes("arudha"))) methods.add("arudha-pada");
if (normalized.some((layer) => layer.includes("functional_benefic") || layer.includes("functional_malefic"))) {
methods.add("functional-benefic-malefic");
}
return [...methods];
return null;
}
function clockMinute(value: string): number {
@@ -131,47 +119,11 @@ function timeInRange(time: string, range: { start_time: string; end_time: string
return end >= start ? value >= start && value <= end : value >= start || value <= end;
}
/**
* The engine's scoreable (domain, kind) vocabulary (contracts.py
* SCOREABLE_EVENT_KINDS). V9 evidence kinds are mapped kind-aware so
* relationship_start/change keep their distinct engine semantics. Rows that
* map to null (family/other or unknown domains) are excluded from scoring;
* they remain evidence in the ledger.
*/
export function toEngineScoreableEvent(
item: Readonly<{
eventKind: string;
domain: string;
}>,
): { domain: string; event_kind: string } | null {
const kind = item.eventKind;
switch (item.domain) {
case "education":
return { domain: "education", event_kind: "education_milestone" };
case "career":
return { domain: "career", event_kind: "career_change" };
case "relationship":
if (kind === "relationship_start" || kind === "relationship_commitment") {
return { domain: "relationship", event_kind: "relationship_start" };
}
return { domain: "relationship", event_kind: "relationship_change" };
case "relocation":
return { domain: "relocation", event_kind: "relocation" };
case "finance":
return { domain: "finance", event_kind: "finance_change" };
case "health":
return { domain: "health_pressure", event_kind: "self_health_event" };
default:
// family, other and unknown domains are background evidence only.
return null;
}
}
/** Map a V9 evidence date precision to the engine's precision vocabulary. */
/** Map a V9 evidence date precision to the Event Contract v2 vocabulary. */
export function enginePrecision(precision: string): V9EngineEvent["precision"] {
if (precision === "day") return "day";
if (precision === "month") return "month";
if (precision === "range") return "range";
if (precision === "day" || precision === "month" || precision === "quarter" || precision === "range") {
return precision;
}
return "year";
}
@@ -184,22 +136,34 @@ export function toEngineEvents(
occurredTo: string | null;
datePrecision: string;
summary: string;
dateSource?: string | null;
dateReliability?: string | null;
dateCorroboration?: string | null;
dateConflictStatus?: string | null;
sourceTurnId?: string | null;
subject?: string | null;
}>[],
): V9EngineEvent[] {
return evidence.flatMap((item): V9EngineEvent[] => {
const scoreable = toEngineScoreableEvent(item);
if (!scoreable) return [];
if (!isEvidenceKind(item.eventKind) || !isEvidenceDomain(item.domain)) return [];
if (isBackgroundEvidenceKind(item.eventKind as EvidenceKind)) return [];
const start = item.occurredFrom ?? item.occurredTo;
const end = item.occurredTo ?? item.occurredFrom;
if (!start) return [];
return [{
id: item.id,
domain: scoreable.domain,
event_kind: scoreable.event_kind,
domain: item.domain,
event_kind: item.eventKind,
date_start: start.slice(0, 10),
date_end: end ? end.slice(0, 10) : start.slice(0, 10),
date_end: (end ?? start).slice(0, 10),
precision: enginePrecision(item.datePrecision),
summary: item.summary,
date_source: item.dateSource ?? null,
date_reliability: item.dateReliability ?? null,
date_corroboration: item.dateCorroboration ?? null,
date_conflict_status: item.dateConflictStatus ?? null,
source_turn_id: item.sourceTurnId ?? null,
subject: item.subject ?? null,
}];
});
}
@@ -217,60 +181,180 @@ async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Prom
});
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));
const message = data && typeof data === "object"
? String((data as Record<string, unknown>).error ?? (data as Record<string, unknown>).message ?? `Jyotish API ${response.status}`)
: `Jyotish API ${response.status}`;
throw new RectificationEngineError("engine_request_failed", message);
}
if (!data || typeof data !== "object") {
throw new RectificationEngineError("engine_invalid_response", `Jyotish API ${path} returned an invalid response`);
throw new RectificationEngineError("engine_invalid_response", "Jyotish API 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;
function readCandidates(
value: unknown,
range: { start_time: string; end_time: string },
): V9EngineCandidate[] {
if (!Array.isArray(value) || value.length === 0) {
throw new RectificationEngineError("engine_invalid_candidate_decisions", "engine candidate decisions are missing");
}
const seenIds = new Set<string>();
const seenTimes = new Set<string>();
const candidates: V9EngineCandidate[] = [];
for (const item of value) {
const row = record(item);
const candidateId = row?.candidate_id;
const time = row?.time;
const rank = row?.rank;
const relativeSupport = row?.relative_support;
const tiedMinuteCount = row?.tied_minute_count;
if (
typeof candidateId !== "string" || !uuidPattern.test(candidateId)
|| typeof time !== "string" || !timePattern.test(time) || !timeInRange(time, range)
|| typeof rank !== "number" || !Number.isInteger(rank) || rank < 1
|| typeof relativeSupport !== "number" || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100
|| typeof tiedMinuteCount !== "number" || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
|| seenIds.has(candidateId) || seenTimes.has(time)
) {
throw new RectificationEngineError("engine_invalid_candidate_decisions", "engine candidate decisions are invalid");
}
seenIds.add(candidateId);
seenTimes.add(time);
candidates.push({ candidateId, time, rank, relativeSupport, tiedMinuteCount });
}
return candidates;
}
/**
* Derive ranked candidates from the engine's [{time, score}] rows. The engine
* does not rank; rank = score-descending order and tied_minute_count = how
* many candidate minutes in the scan share the same score.
*/
function readCandidates(value: unknown, range: { start_time: string; end_time: string }): V9EngineCandidate[] {
if (!Array.isArray(value)) return [];
const scored = value.flatMap((item): Array<{ time: string; score: number }> => {
if (!item || typeof item !== "object") return [];
const row = item as Record<string, unknown>;
const time = typeof row.time === "string" ? row.time : "";
const score = typeof row.score === "number" && Number.isFinite(row.score) ? row.score : 0;
if (!timePattern.test(time) || !timeInRange(time, range)) return [];
return [{ time, score }];
type ParsedReceipt = Readonly<{
raw: V9DecisionReceipt;
eventContractVersion: string;
policyVersion: string;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeCandidateId: string | null;
representativeTime: string | null;
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
}>;
function invalidReceipt(): never {
throw new RectificationEngineError("engine_invalid_v2_receipt", "engine decision receipt is missing or invalid");
}
function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandidate[]): ParsedReceipt {
const row = record(value);
if (!row) return invalidReceipt();
const representativeCandidateId = row.representative_candidate_id;
const representativeTime = row.representative_time;
const confidence = row.overall_confidence;
const marginPercent = row.margin_percent === null || row.margin_percent === undefined
? null
: engineNumber(row.margin_percent);
if (
row.receipt_version !== RECEIPT_VERSION
|| row.contract_version !== "v2"
|| row.event_contract_version !== EVENT_CONTRACT_VERSION
|| typeof row.policy_version !== "string" || !row.policy_version
|| typeof row.decision_policy_version !== "string" || row.decision_policy_version !== row.policy_version
|| typeof row.display_allowed !== "boolean"
|| typeof row.selection_allowed !== "boolean"
|| typeof row.acceptance_allowed !== "boolean"
|| typeof row.confirmation_allowed !== "boolean"
|| typeof row.accept_allowed !== "boolean"
|| typeof row.confirm_allowed !== "boolean"
|| row.selection_allowed !== row.acceptance_allowed
|| row.selection_allowed !== row.accept_allowed
|| row.confirmation_allowed !== row.confirm_allowed
|| (representativeCandidateId !== null && (typeof representativeCandidateId !== "string" || !uuidPattern.test(representativeCandidateId)))
|| (representativeTime !== null && (typeof representativeTime !== "string" || !timePattern.test(representativeTime)))
|| (confidence !== "low" && confidence !== "medium" && confidence !== "high")
|| (row.margin_percent !== null && row.margin_percent !== undefined && marginPercent === null)
) {
return invalidReceipt();
}
const representative = representativeCandidateId === null
? null
: candidates.find((candidate) => candidate.candidateId === representativeCandidateId) ?? null;
if (
(representativeCandidateId === null) !== (representativeTime === null)
|| (representativeCandidateId !== null && (!representative || representative.time !== representativeTime))
|| (row.confirmation_allowed === true && row.selection_allowed !== true)
|| (row.selection_allowed === true && row.display_allowed !== true)
) {
return invalidReceipt();
}
return {
raw: row,
eventContractVersion: EVENT_CONTRACT_VERSION,
policyVersion: row.policy_version,
selectionAllowed: row.selection_allowed,
confirmationAllowed: row.confirmation_allowed,
representativeCandidateId,
representativeTime,
overallConfidence: confidence,
marginPercent,
};
}
function readExecutionLedger(version: unknown, value: unknown): V9ExecutionLedger {
if (version !== EXECUTION_LEDGER_VERSION || !Array.isArray(value) || value.length === 0) {
throw new RectificationEngineError("engine_invalid_execution_ledger", "engine execution ledger is missing or invalid");
}
return value.map((item) => {
const row = record(item);
if (
!row
|| row.ledger_version !== EXECUTION_LEDGER_VERSION
|| typeof row.stage !== "string" || !row.stage
|| (row.status !== "executed" && row.status !== "not_executed" && row.status !== "retained_not_scored")
|| typeof row.source !== "string" || !row.source
) {
throw new RectificationEngineError("engine_invalid_execution_ledger", "engine execution ledger is missing or invalid");
}
return row;
});
if (scored.length === 0) return [];
scored.sort((left, right) => right.score - left.score);
const top = scored.slice(0, 3);
const weights = top.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 / top.length));
supports[0] += 100 - supports.reduce((sum, support) => sum + support, 0);
return top.map((row, index) => ({
rank: index + 1,
time: row.time,
relative_support: supports[index] ?? 0,
tied_minute_count: scored.filter((candidate) => candidate.score === row.score).length,
}));
}
function engineDiagnostics(data: Record<string, unknown>): Record<string, unknown> {
return data.diagnostics && typeof data.diagnostics === "object"
? data.diagnostics as Record<string, unknown>
: {};
function publicMethodFromLedger(value: unknown): PublicRectificationMethod | null {
if (isPublicRectificationMethod(value)) return value;
if (typeof value !== "string") return null;
const normalized = value.toLowerCase();
if (normalized.includes("controlled_transit") || normalized.includes("gochara")) return "gochara";
if (normalized.includes("ashtakavarga")) return "ashtakavarga";
if (normalized.includes("shadbala")) return "shadbala";
if (normalized.includes("arudha")) return "arudha-pada";
if (normalized.includes("functional_benefic") || normalized.includes("functional_malefic")) return "functional-benefic-malefic";
if (normalized.includes("vimshottari") || normalized.includes("vimsottari")) return "vimshottari-dasha";
if (normalized.includes("narayana")) return "narayana-dasha";
return null;
}
export async function runV9CandidateScore(input: {
function executedMethods(ledger: V9ExecutionLedger): PublicRectificationMethod[] {
const methods = new Set<PublicRectificationMethod>();
for (const entry of ledger) {
if (entry.status !== "executed") continue;
const direct = publicMethodFromLedger(entry.method);
if (direct) methods.add(direct);
if (Array.isArray(entry.technique_layers)) {
for (const layer of entry.technique_layers) {
const method = publicMethodFromLedger(layer);
if (method) methods.add(method);
}
}
}
return [...methods];
}
function engineDiagnostics(data: Record<string, unknown>): Readonly<Record<string, unknown>> {
return record(data.diagnostics) ?? {};
}
function engineRequestBody(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
}): Promise<V9EngineScoreResult> {
}): Record<string, unknown> {
const snapshot = input.baselineBirthSnapshot;
const birthDate = String(snapshot.birth_date ?? "");
const lat = engineNumber(snapshot.latitude);
@@ -282,7 +366,7 @@ export async function runV9CandidateScore(input: {
if (input.events.length === 0) {
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
}
const data = await postEngine("/api/rectification/v5/score", {
return {
birth_date: birthDate,
start_time: input.candidateRange.start_time,
end_time: input.candidateRange.end_time,
@@ -290,36 +374,44 @@ export async function runV9CandidateScore(input: {
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");
birth_time_source: snapshot.birth_time_source,
timezone_id: snapshot.timezone_id,
timezone_source: snapshot.timezone_source,
local_time_status: snapshot.local_time_status,
};
}
export async function runV9CandidateScore(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
}): Promise<V9EngineScoreResult> {
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
const receipt = readDecisionReceipt(data.decision_receipt, candidates);
const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger);
if (
data.event_contract_version !== receipt.eventContractVersion
|| data.decision_policy_version !== receipt.policyVersion
) {
return invalidReceipt();
}
const diagnostics = engineDiagnostics(data);
const methods = executedMethods(input.events, techniqueLayers(data.event_contribution_matrix));
const marginPercent = engineNumber(diagnostics.primary_secondary_margin_percent)
?? engineNumber(data.margin_percent)
?? null;
const retention = engineNumber(diagnostics.leave_one_event_out_retention_rate);
const confidence: "low" | "medium" | "high" =
marginPercent !== null && marginPercent >= 40 && retention !== null && retention >= 0.8
? "high"
: marginPercent !== null && marginPercent >= 20
? "medium"
: data.confidence === "high" || data.confidence === "medium"
? data.confidence
: "low";
return {
engineResultId: String(data.result_id ?? ""),
algorithmVersion: String(data.algorithm_version ?? "rectification-v5"),
algorithmVersion: String(data.algorithm_version ?? ""),
eventContractVersion: receipt.eventContractVersion,
policyVersion: receipt.policyVersion,
candidateRange: input.candidateRange,
candidates,
overallConfidence: confidence,
marginPercent,
selectionAllowed: candidates.length > 0,
confirmationAllowed: data.can_confirm_exact_minute === true,
representativeTime: candidates[0]?.time ?? null,
executedMethods: methods,
overallConfidence: receipt.overallConfidence,
marginPercent: receipt.marginPercent,
selectionAllowed: receipt.selectionAllowed,
confirmationAllowed: receipt.confirmationAllowed,
representativeCandidateId: receipt.representativeCandidateId,
representativeTime: receipt.representativeTime,
decisionReceipt: receipt.raw,
executionLedger: ledger,
executedMethods: executedMethods(ledger),
};
}
@@ -328,34 +420,25 @@ export async function runV9Diagnostics(input: {
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", engineRequestBody(input));
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
const receipt = readDecisionReceipt(data.decision_receipt, candidates);
const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger);
if (
data.event_contract_version !== receipt.eventContractVersion
|| data.decision_policy_version !== receipt.policyVersion
) {
return invalidReceipt();
}
if (input.events.length === 0) {
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
}
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 = engineDiagnostics(data);
const missingLayers = Array.isArray(data.missing_layers) ? data.missing_layers as string[] : [];
const discriminatingLayers = Array.isArray(diagnostics.most_discriminating_layers)
? diagnostics.most_discriminating_layers.filter((item): item is string => typeof item === "string")
const missingLayers = Array.isArray(data.missing_layers)
? data.missing_layers.filter((item): item is string => typeof item === "string")
: [];
return {
algorithmVersion: String(data.algorithm_version ?? "rectification-v5"),
algorithmVersion: String(data.algorithm_version ?? ""),
engineResultId: String(data.result_id ?? ""),
eventContractVersion: receipt.eventContractVersion,
policyVersion: receipt.policyVersion,
diagnostics: {
primary_cluster_retention_rate: diagnostics.primary_cluster_retention_rate,
leave_one_event_out_retention_rate: diagnostics.leave_one_event_out_retention_rate,
@@ -368,8 +451,10 @@ export async function runV9Diagnostics(input: {
candidate_splits: diagnostics.candidate_splits,
},
missingLayers,
canConfirmExactMinute: data.can_confirm_exact_minute === true,
executedMethods: executedMethods(input.events, discriminatingLayers),
canConfirmExactMinute: receipt.confirmationAllowed,
decisionReceipt: receipt.raw,
executionLedger: ledger,
executedMethods: executedMethods(ledger),
};
}
@@ -7,18 +7,30 @@ export const EVIDENCE_KINDS = [
"education_start",
"education_completion",
"education_interruption",
"education_change",
"education_milestone",
"career_entry",
"career_change",
"promotion",
"career_pressure",
"career_exit",
"business_start",
"relationship_start",
"relationship_commitment",
"relationship_separation",
"relationship_end",
"relationship_change",
"relocation",
"foreign_move",
"return",
"home_change",
"finance_gain",
"finance_loss",
"income_change",
"asset_change",
"finance_change",
"self_health_event",
"pressure_period",
"family_event",
"other",
] as const;
@@ -32,6 +44,7 @@ export const EVIDENCE_DOMAINS = [
"relocation",
"finance",
"health",
"health_pressure",
"family",
"other",
] as const;
@@ -41,6 +54,7 @@ export type EvidenceDomain = (typeof EVIDENCE_DOMAINS)[number];
export const DATE_PRECISIONS = [
"year",
"month",
"quarter",
"day",
"range",
"unknown",
@@ -24,6 +24,8 @@ import {
} from "./public-receipt";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Minimal structural RPC client. SupabaseClient satisfies this; tests can
* pass a fake with a single rpc() method.
@@ -135,6 +137,10 @@ export type V9CaseDossier = Readonly<{
status: string;
supersedesEvidenceId: string | null;
createdAt: string;
dateSource?: string | null;
dateReliability?: string | null;
dateCorroboration?: string | null;
dateConflictStatus?: string | null;
}>[];
conversationSummary: CaseConversationSummary;
latestResult: V9CandidateSnapshot | null;
@@ -248,9 +254,17 @@ export async function insertV9SkillRunReceipt(
);
}
export type V9Candidate = Readonly<{
candidateId: string;
time: string;
rank: number;
relativeSupport: number;
tiedMinuteCount: number;
}>;
export type V9CandidateSnapshot = Readonly<{
resultId: string;
candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[];
candidates: readonly V9Candidate[];
overallConfidence: "low" | "medium" | "high";
selectionAllowed: boolean;
confirmationAllowed: boolean;
@@ -261,6 +275,10 @@ export type V9CandidateSnapshot = Readonly<{
candidateRangeFingerprint: string | null;
skillVersion: string | null;
algorithmVersion: string | null;
eventContractVersion: string | null;
policyVersion: string | null;
decisionReceipt: Readonly<Record<string, unknown>> | null;
executionLedger: readonly Readonly<Record<string, unknown>>[] | null;
createdAt: string;
invalidatedAt: string | null;
}>;
@@ -384,6 +402,10 @@ export function parseV9CaseDossier(value: unknown): V9CaseDossier | null {
status: String(row.status ?? ""),
supersedesEvidenceId: rowText(row.supersedes_evidence_id),
createdAt: String(row.created_at ?? ""),
dateSource: rowText(row.date_source),
dateReliability: rowText(row.date_reliability),
dateCorroboration: rowText(row.date_corroboration),
dateConflictStatus: rowText(row.date_conflict_status),
}];
});
@@ -414,22 +436,33 @@ export function parseV9CaseDossier(value: unknown): V9CaseDossier | null {
}
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,
}];
});
const row = rowObject(value);
const resultId = rowText(row?.result_id);
if (!row || !resultId || !Array.isArray(row.candidates) || row.candidates.length === 0) return null;
const candidates: V9Candidate[] = [];
const seenIds = new Set<string>();
for (const item of row.candidates) {
const candidate = rowObject(item);
const candidateId = rowText(candidate?.candidate_id);
const candidateTime = timeValue(candidate?.time);
const rank = rowNumber(candidate?.rank);
const relativeSupport = rowNumber(candidate?.relative_support);
const tiedMinuteCount = rowNumber(candidate?.tied_minute_count);
if (
!candidate || !candidateId || !uuidPattern.test(candidateId) || seenIds.has(candidateId)
|| !candidateTime
|| rank === null || !Number.isInteger(rank) || rank < 1
|| relativeSupport === null || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100
|| tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
) return null;
seenIds.add(candidateId);
candidates.push({ candidateId, time: candidateTime, rank, relativeSupport, tiedMinuteCount });
}
const decisionReceipt = rowObject(row.decision_receipt);
const executionLedger = Array.isArray(row.execution_ledger)
&& row.execution_ledger.every((item) => rowObject(item) !== null)
? row.execution_ledger as readonly Readonly<Record<string, unknown>>[]
: null;
return {
resultId,
candidates,
@@ -443,6 +476,10 @@ export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot |
candidateRangeFingerprint: rowText(row.candidate_range_fingerprint),
skillVersion: rowText(row.skill_version),
algorithmVersion: rowText(row.algorithm_version),
eventContractVersion: rowText(row.event_contract_version),
policyVersion: rowText(row.decision_policy_version) ?? rowText(decisionReceipt?.policy_version),
decisionReceipt,
executionLedger,
createdAt: String(row.created_at ?? ""),
invalidatedAt: rowText(row.invalidated_at),
};
@@ -1167,11 +1204,14 @@ export type V9PersistedCandidate = Readonly<{
cached: boolean;
candidates: V9CandidateSnapshot["candidates"];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
algorithmVersion: string | null;
eventContractVersion: string | null;
policyVersion: string | null;
decisionReceipt: Readonly<Record<string, unknown>>;
executionLedger: readonly Readonly<Record<string, unknown>>[];
}>;
export async function persistV9Candidate(
@@ -1184,54 +1224,61 @@ export async function persistV9Candidate(
evidenceFingerprint: string;
rangeFingerprint: string;
skillVersion: string;
eventContractVersion: string;
policyVersion: 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;
decisionReceipt: Readonly<Record<string, unknown>>;
executionLedger: readonly Readonly<Record<string, unknown>>[];
},
): Promise<V9PersistedCandidate> {
const row = await rpc<Record<string, unknown>>(
accounting,
"persist_agentic_rectification_candidate",
"persist_agentic_rectification_candidate_v2",
{
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_algorithm_version: input.algorithmVersion,
p_event_contract_version: input.eventContractVersion,
p_decision_policy_version: input.policyVersion,
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,
p_candidates: input.candidates.map((candidate) => ({
candidate_id: candidate.candidateId,
time: candidate.time,
rank: candidate.rank,
relative_support: candidate.relativeSupport,
tied_minute_count: candidate.tiedMinuteCount,
})),
p_decision_receipt: input.decisionReceipt,
p_execution_ledger: input.executionLedger,
},
);
const resultId = typeof row?.result_id === "string" ? row.result_id : "";
if (!resultId) throw new RectificationToolServiceError("invalid_candidate_result");
const snapshot = parseV9CandidateSnapshot(row);
if (!snapshot) 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,
resultId: snapshot.resultId,
cached: row.cached === true,
candidates: snapshot.candidates,
overallConfidence: snapshot.overallConfidence,
selectionAllowed: snapshot.selectionAllowed,
confirmationAllowed: snapshot.confirmationAllowed,
representativeTime: snapshot.representativeTime,
algorithmVersion: snapshot.algorithmVersion ?? input.algorithmVersion,
eventContractVersion: snapshot.eventContractVersion ?? input.eventContractVersion,
policyVersion: snapshot.policyVersion ?? input.policyVersion,
decisionReceipt: snapshot.decisionReceipt ?? input.decisionReceipt,
executionLedger: snapshot.executionLedger ?? input.executionLedger,
};
}
export type V9AcceptResult = Readonly<{
success: boolean;
savedTime: string;
status: "accepted" | "confirmed";
status: "accepted";
resultId: string;
caseStatus: string;
idempotent: boolean;
@@ -1242,27 +1289,34 @@ export async function acceptV9Candidate(
userId: string,
caseId: string,
resultId: string,
time: string,
candidateId: string,
requestId: string,
): Promise<V9AcceptResult> {
if (!uuidPattern.test(candidateId) || !uuidPattern.test(requestId)) {
throw new RectificationToolServiceError("invalid_candidate_ref");
}
const row = await rpc<Record<string, unknown>>(
accounting,
"accept_agentic_rectification_candidate_for_case",
"accept_agentic_rectification_candidate_for_case_v2",
{
p_user_id: userId,
p_case_id: caseId,
p_result_id: resultId,
p_time: time,
p_candidate_id: candidateId,
p_request_id: requestId,
},
);
const savedTime = timeValue(row?.saved_time);
if (row?.success !== true || !savedTime) throw new RectificationToolServiceError("invalid_accept_result");
const savedTime = timeValue(row.saved_time);
if (row.success !== true || row.status !== "accepted" || !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,
status: "accepted",
resultId: String(row.result_id ?? resultId),
caseStatus: String(row.case_status ?? "candidate_accepted"),
idempotent: row.idempotent === true,
};
}
@@ -1281,32 +1335,39 @@ export async function confirmV9BirthTime(
caseId: string,
input: {
resultId: string;
time: string;
candidateId: string;
requestId: string;
consentQuote: string;
sourceTurnId: string;
},
): Promise<V9ConfirmResult> {
if (!uuidPattern.test(input.candidateId) || !uuidPattern.test(input.requestId)) {
throw new RectificationToolServiceError("invalid_candidate_ref");
}
const row = await rpc<Record<string, unknown>>(
accounting,
"confirm_agentic_rectification_birth_time",
"confirm_agentic_rectification_candidate_for_case_v2",
{
p_user_id: userId,
p_case_id: caseId,
p_result_id: input.resultId,
p_time: input.time,
p_candidate_id: input.candidateId,
p_request_id: input.requestId,
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");
const savedTime = timeValue(row.saved_time);
if (row.success !== true || row.status !== "confirmed" || !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,
resultId: String(row.result_id ?? input.resultId),
caseStatus: String(row.case_status ?? "confirmed"),
idempotent: row.idempotent === true,
};
}
@@ -1,4 +1,5 @@
export type RectificationCandidate = Readonly<{
candidateId: string;
rank: number;
time: string;
relativeSupport: number;
@@ -34,24 +35,30 @@ function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null {
const snapshot = record(value);
if (!snapshot || typeof snapshot.resultId !== "string") return null;
if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length === 0) return null;
const candidates = Array.isArray(snapshot.candidates)
? snapshot.candidates.flatMap((value): RectificationCandidate[] => {
const candidate = record(value);
const candidateTime = time(candidate?.time);
const rank = finiteNumber(candidate?.rank);
if (!candidate || !candidateTime || rank === null) return [];
return [{
rank: Math.trunc(rank),
time: candidateTime,
relativeSupport: Math.max(0, Math.trunc(finiteNumber(candidate.relative_support) ?? 0)),
tiedMinuteCount: Math.max(1, Math.trunc(finiteNumber(candidate.tied_minute_count) ?? 1)),
}];
})
: [];
const candidates: RectificationCandidate[] = [];
for (const value of snapshot.candidates) {
const candidate = record(value);
const candidateId = candidate?.candidateId;
const candidateTime = time(candidate?.time);
const rank = finiteNumber(candidate?.rank);
const relativeSupport = finiteNumber(candidate?.relativeSupport);
const tiedMinuteCount = finiteNumber(candidate?.tiedMinuteCount);
if (
!candidate || typeof candidateId !== "string" || !uuidPattern.test(candidateId)
|| !candidateTime
|| rank === null || !Number.isInteger(rank) || rank < 1
|| relativeSupport === null || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100
|| tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
) return null;
candidates.push({ candidateId, rank, time: candidateTime, relativeSupport, tiedMinuteCount });
}
return {
resultId: snapshot.resultId,
+11 -11
View File
@@ -151,7 +151,7 @@ type DossierForTools = {
kindCounts: Record<string, number>;
latestResult: {
resultId: string;
candidates: readonly { rank: number; time: string; relative_support: number; tied_minute_count: number }[];
candidates: NonNullable<V9CaseDossier["latestResult"]>["candidates"];
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
@@ -300,7 +300,7 @@ function assertEvidenceRef(input: { evidenceId?: unknown }): string {
function assertCandidateRef(input: { resultId?: unknown; candidateId?: unknown }): { resultId: string; candidateId: string } {
if (
typeof input.resultId !== "string" || !uuidPattern.test(input.resultId)
|| typeof input.candidateId !== "string" || !timePattern.test(input.candidateId)
|| typeof input.candidateId !== "string" || !uuidPattern.test(input.candidateId)
) {
throw new RectificationToolServiceError("invalid_candidate_ref");
}
@@ -759,13 +759,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
evidenceFingerprint,
rangeFingerprint,
skillVersion: parsed.case.skillVersion,
eventContractVersion: score.eventContractVersion,
policyVersion: score.policyVersion,
candidateRange: parsed.case.candidateRange,
candidates: score.candidates,
overallConfidence: score.overallConfidence,
marginPercent: score.marginPercent,
selectionAllowed: score.selectionAllowed,
confirmationAllowed: score.confirmationAllowed,
representativeTime: score.representativeTime,
decisionReceipt: score.decisionReceipt,
executionLedger: score.executionLedger,
});
const projection = {
result_id: persisted.resultId,
@@ -892,7 +891,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
inputSchema: z.object({
caseId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(timePattern),
candidateId: z.string().uuid(),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
@@ -900,7 +899,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const inputFingerprint = canonicalToolInputFingerprint("rectification-accept-candidate", input);
await receipt("rectification-accept-candidate", "candidate.accepted", "started", { inputFingerprint });
try {
const result = await acceptV9Candidate(accounting, userId, input.caseId, resultId, input.candidateId);
const result = await acceptV9Candidate(accounting, userId, input.caseId, resultId, input.candidateId, turnId);
const projection = {
saved_time: result.savedTime,
status: result.status,
@@ -927,7 +926,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
inputSchema: z.object({
caseId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(timePattern),
candidateId: z.string().uuid(),
consentQuote: z.string().trim().min(2).max(400),
}).strict(),
execute: async (input) => {
@@ -942,7 +941,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
const result = await confirmV9BirthTime(accounting, userId, input.caseId, {
resultId,
time: input.candidateId,
candidateId: input.candidateId,
requestId: turnId,
consentQuote: input.consentQuote,
sourceTurnId: turnId,
});