feat: add rectification event decision contract v2
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user