fix(web): persist rectification C/D answers on an append-only inference ledger
Engine result rows stay immutable. Choice answers append transitions, and reads overlay the latest revision instead of patching the cached receipt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,11 +50,12 @@ export function buildInferenceState(input: {
|
||||
input.range_end,
|
||||
input.candidates.map((item) => item.time),
|
||||
);
|
||||
const previous = input.previous?.candidate_set_id === setId ? input.previous : null;
|
||||
const sameSet = input.previous?.candidate_set_id === setId;
|
||||
const previous = sameSet ? input.previous : null;
|
||||
const events = splitHoldoutEvents(input.events);
|
||||
const prior = Object.fromEntries(input.candidates.map((item) => [item.id, item.relative_support]));
|
||||
const trainingPrior = subtractHoldout(prior, input.candidates, events, input.event_ledger);
|
||||
const answers = mergeAnswers(previous?.answered_probes ?? [], input.answered_probes ?? []);
|
||||
const answers = mergeAnswers(input.previous?.answered_probes ?? [], input.answered_probes ?? []);
|
||||
const seenRoundIds = new Set((previous?.rounds ?? []).map((item) => item.probe_id));
|
||||
const eliminated = new Set(
|
||||
(previous?.candidates ?? []).filter((item) => item.status === "eliminated").map((item) => item.id),
|
||||
@@ -65,7 +66,9 @@ export function buildInferenceState(input: {
|
||||
for (const answer of answers) {
|
||||
if (answer.answer_class === "yes") continue;
|
||||
const probe = input.probes.find((item) => item.id === answer.probe_id)
|
||||
?? previous?.probes.find((item) => item.id === answer.probe_id);
|
||||
?? input.probes.find((item) => item.semantic_key === answer.semantic_key)
|
||||
?? input.previous?.probes.find((item) => item.id === answer.probe_id)
|
||||
?? input.previous?.probes.find((item) => item.semantic_key === answer.semantic_key);
|
||||
if (!probe) continue;
|
||||
const before = { ...scores };
|
||||
const applied = applyProbeOutcome(scores, probe, answer.answer_class, { eliminatedIds: eliminated });
|
||||
@@ -130,13 +133,13 @@ export function buildInferenceState(input: {
|
||||
&& item.cluster_range[1] === top.cluster_range[1]
|
||||
)),
|
||||
);
|
||||
const alreadyAnswered = new Set((previous?.answered_probes ?? []).map((item) => item.probe_id));
|
||||
const alreadyAnswered = new Set((input.previous?.answered_probes ?? []).map((item) => item.probe_id));
|
||||
const newAnswerCount = answers.filter((item) => !alreadyAnswered.has(item.probe_id)).length;
|
||||
const draft: InferenceState = {
|
||||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||||
candidate_set_id: setId,
|
||||
revision: Math.max(1, (previous?.revision ?? 0) + (newAnswerCount > 0 ? 1 : 0)),
|
||||
phase: input.phase ?? previous?.phase ?? "discrimination",
|
||||
revision: Math.max(1, (input.previous?.revision ?? 0) + (newAnswerCount > 0 ? 1 : 0)),
|
||||
phase: input.phase ?? input.previous?.phase ?? "discrimination",
|
||||
result_status: "discriminating",
|
||||
range_start: input.range_start,
|
||||
range_end: input.range_end,
|
||||
@@ -171,6 +174,45 @@ export function applyAnswerToState(
|
||||
): InferenceState {
|
||||
const probe = state.probes.find((item) => item.id === probeId);
|
||||
if (!probe) return state;
|
||||
return rebuildWithAnswers(state, [{
|
||||
probe_id: probe.id,
|
||||
semantic_key: probe.semantic_key,
|
||||
candidate_split_hash: probe.candidate_split_hash,
|
||||
answer_class: answer,
|
||||
classified_from: "choice",
|
||||
}]);
|
||||
}
|
||||
|
||||
export function applySupersedeAnswer(
|
||||
state: InferenceState,
|
||||
probeId: string,
|
||||
answer: AnswerClass,
|
||||
): InferenceState {
|
||||
const live = state.probes.find((item) => item.id === probeId);
|
||||
const previousAnswer = state.answered_probes.find((item) => item.probe_id === probeId);
|
||||
const semanticKey = live?.semantic_key ?? previousAnswer?.semantic_key;
|
||||
const splitHash = live?.candidate_split_hash ?? previousAnswer?.candidate_split_hash;
|
||||
if (!semanticKey || !splitHash) return state;
|
||||
const remaining = state.answered_probes.filter((item) => (
|
||||
item.probe_id !== probeId && item.semantic_key !== semanticKey
|
||||
));
|
||||
const rounds = state.rounds.filter((item) => item.probe_id !== probeId);
|
||||
return rebuildWithAnswers(
|
||||
{ ...state, answered_probes: remaining, rounds },
|
||||
[{
|
||||
probe_id: probeId,
|
||||
semantic_key: semanticKey,
|
||||
candidate_split_hash: splitHash,
|
||||
answer_class: answer,
|
||||
classified_from: "choice",
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
export function replayInferenceState(
|
||||
state: InferenceState,
|
||||
answers: readonly ProbeAnswer[],
|
||||
): InferenceState {
|
||||
return buildInferenceState({
|
||||
range_start: state.range_start,
|
||||
range_end: state.range_end,
|
||||
@@ -181,14 +223,8 @@ export function applyAnswerToState(
|
||||
})),
|
||||
events: state.events,
|
||||
probes: state.probes,
|
||||
previous: state,
|
||||
answered_probes: [{
|
||||
probe_id: probe.id,
|
||||
semantic_key: probe.semantic_key,
|
||||
candidate_split_hash: probe.candidate_split_hash,
|
||||
answer_class: answer,
|
||||
classified_from: "choice",
|
||||
}],
|
||||
previous: { ...state, answered_probes: [], rounds: [] },
|
||||
answered_probes: answers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,6 +255,22 @@ export function classifyChoiceAnswer(key: string): AnswerClass {
|
||||
return "unsure";
|
||||
}
|
||||
|
||||
function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswer[]): InferenceState {
|
||||
return buildInferenceState({
|
||||
range_start: state.range_start,
|
||||
range_end: state.range_end,
|
||||
candidates: state.candidates.map((item) => ({
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
relative_support: item.prior_score,
|
||||
})),
|
||||
events: state.events,
|
||||
probes: state.probes,
|
||||
previous: state,
|
||||
answered_probes: incoming,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeAnswers(previous: readonly ProbeAnswer[], incoming: readonly ProbeAnswer[]): ProbeAnswer[] {
|
||||
const rows = [...previous];
|
||||
for (const item of incoming) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { InferenceState } from "./types.ts";
|
||||
|
||||
export type InferenceTransitionSnapshot = Readonly<{
|
||||
id?: string;
|
||||
resultId: string;
|
||||
revision: number;
|
||||
probeId: string | null;
|
||||
reason: string;
|
||||
idempotent?: boolean;
|
||||
decisionStateFingerprint: string;
|
||||
inferenceState: InferenceState;
|
||||
posteriorBefore: Readonly<Record<string, number>>;
|
||||
posteriorAfter: Readonly<Record<string, number>>;
|
||||
scoreDeltas: Readonly<Record<string, number>>;
|
||||
}>;
|
||||
|
||||
export function asInferenceState(value: unknown): InferenceState | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const state = value as InferenceState;
|
||||
return state.algorithm_version && Array.isArray(state.candidates) ? state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay the latest inference revision onto an immutable engine receipt.
|
||||
* Only apply when the transition was recorded against this engine result.
|
||||
*/
|
||||
export function composeInferenceReceipt(
|
||||
engineReceipt: Readonly<Record<string, unknown>> | null | undefined,
|
||||
transition: InferenceTransitionSnapshot | null | undefined,
|
||||
resultId?: string | null,
|
||||
): Record<string, unknown> {
|
||||
const receipt = engineReceipt && typeof engineReceipt === "object" && !Array.isArray(engineReceipt)
|
||||
? { ...engineReceipt }
|
||||
: {};
|
||||
if (!transition) return receipt;
|
||||
if (resultId && transition.resultId && transition.resultId !== resultId) return receipt;
|
||||
return {
|
||||
...receipt,
|
||||
inference_state: transition.inferenceState,
|
||||
decision_state_fingerprint: transition.decisionStateFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
export function previousInferenceFromReceipt(
|
||||
receipt: Readonly<Record<string, unknown>> | null | undefined,
|
||||
): InferenceState | null {
|
||||
return asInferenceState(receipt?.inference_state);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const DECISION_STATE_FINGERPRINT_VERSION = "v9-decision-state-v1";
|
||||
|
||||
export type DecisionStateFingerprintInput = Readonly<{
|
||||
caseId: string;
|
||||
evidenceLedgerFingerprint: string;
|
||||
candidateSetId: string;
|
||||
inferenceRevision: number;
|
||||
answeredProbeIds: readonly string[];
|
||||
scoringPolicyVersion: string;
|
||||
}>;
|
||||
|
||||
export function decisionStateFingerprint(input: DecisionStateFingerprintInput): string {
|
||||
return createHash("sha256")
|
||||
.update([
|
||||
DECISION_STATE_FINGERPRINT_VERSION,
|
||||
input.caseId,
|
||||
input.evidenceLedgerFingerprint,
|
||||
input.candidateSetId,
|
||||
String(input.inferenceRevision),
|
||||
[...input.answeredProbeIds].sort().join(","),
|
||||
input.scoringPolicyVersion,
|
||||
].join("|"))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function posteriorMap(
|
||||
candidates: readonly Readonly<{ id: string; posterior_score: number }>[],
|
||||
): Record<string, number> {
|
||||
return Object.fromEntries(candidates.map((item) => [item.id, item.posterior_score]));
|
||||
}
|
||||
|
||||
export function scoreDeltas(
|
||||
before: Readonly<Record<string, number>>,
|
||||
after: Readonly<Record<string, number>>,
|
||||
): Record<string, number> {
|
||||
const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
return Object.fromEntries(
|
||||
[...ids].map((id) => [id, (after[id] ?? 0) - (before[id] ?? 0)]),
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,5 @@ export * from "./split-holdout.ts";
|
||||
export * from "./convergence-evaluator.ts";
|
||||
export * from "./build-state.ts";
|
||||
export * from "./probes-from-engine.ts";
|
||||
export * from "./decision-fingerprint.ts";
|
||||
export * from "./compose-receipt.ts";
|
||||
|
||||
@@ -261,6 +261,9 @@ const KNOWN_RPC_ERROR_CODES = new Map<string, { status: number; code: string; me
|
||||
["agentic_rectification_evidence_not_confirmable", { status: 409, code: "evidence_not_confirmable", message: "该事件当前不能确认" }],
|
||||
["agentic_rectification_evidence_not_revisable", { status: 409, code: "evidence_not_revisable", message: "该事件当前不能修订" }],
|
||||
["agentic_rectification_precision_downgrade", { status: 422, code: "precision_downgrade", message: "不能把已确认的更细日期精度改粗" }],
|
||||
["agentic_rectification_stale_probe", { status: 409, code: "stale_probe", message: "这道区分题已经过期,请回答当前问题" }],
|
||||
["agentic_rectification_revision_conflict", { status: 409, code: "revision_conflict", message: "推断状态已更新,请刷新后再试" }],
|
||||
["agentic_rectification_inference_patch_retired", { status: 409, code: "inference_patch_retired", message: "不能再原地修改推断回执" }],
|
||||
]);
|
||||
|
||||
export type RectificationServiceErrorView = {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
answersFromEvidence,
|
||||
applyAnswerToState,
|
||||
applySupersedeAnswer,
|
||||
buildInferenceState,
|
||||
classifyChoiceAnswer,
|
||||
nextProbe,
|
||||
type EngineEventInput,
|
||||
} from "../core/build-state.ts";
|
||||
import { isDuplicateProbe } from "../core/duplicate-probes.ts";
|
||||
@@ -44,14 +46,7 @@ export function askedProbeKeysFromReceipt(
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function previousInferenceFromReceipt(
|
||||
receipt: Readonly<Record<string, unknown>> | null | undefined,
|
||||
): InferenceState | null {
|
||||
const value = receipt?.inference_state;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const state = value as InferenceState;
|
||||
return state.algorithm_version && Array.isArray(state.candidates) ? state : null;
|
||||
}
|
||||
export { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
|
||||
|
||||
export function compactInferenceProjection(state: InferenceState | null | undefined): Record<string, unknown> | null {
|
||||
if (!state) return null;
|
||||
@@ -179,8 +174,7 @@ export function matchProbeForChoice(
|
||||
const splitHash = asText(row?.candidate_split_hash);
|
||||
const probes = state.probes;
|
||||
if (probeId) {
|
||||
const found = probes.find((item) => item.id === probeId);
|
||||
if (found) return found;
|
||||
return probes.find((item) => item.id === probeId) ?? null;
|
||||
}
|
||||
if (semanticKey) {
|
||||
const found = probes.find((item) => item.semantic_key === semanticKey);
|
||||
@@ -215,7 +209,7 @@ export function stampChoiceSchemaWithProbe(
|
||||
|
||||
export type ChoiceWithoutEvidenceResult = Readonly<{
|
||||
applied: boolean;
|
||||
reason: "applied" | "no_choice" | "holdout" | "no_probe" | "already_answered";
|
||||
reason: "applied" | "no_choice" | "holdout" | "no_probe" | "already_answered" | "stale_probe" | "superseded";
|
||||
state: InferenceState;
|
||||
answerClass: AnswerClass | null;
|
||||
probeId: string | null;
|
||||
@@ -242,14 +236,41 @@ export function applyChoiceWithoutEvidence(
|
||||
if (!choiceKey) {
|
||||
return { applied: false, reason: "no_choice", state, answerClass: null, probeId: null };
|
||||
}
|
||||
const submittedProbeId = asText(asRecord(input.schema)?.probe_id);
|
||||
const probe = matchProbeForChoice(state, input.schema, input.domain);
|
||||
if (!probe) {
|
||||
return { applied: false, reason: "no_probe", state, answerClass: null, probeId: null };
|
||||
return { applied: false, reason: submittedProbeId ? "stale_probe" : "no_probe", state, answerClass: null, probeId: submittedProbeId };
|
||||
}
|
||||
const openProbeId = nextProbe(state)?.id ?? null;
|
||||
const lastAnsweredId = state.answered_probes.at(-1)?.probe_id ?? null;
|
||||
const answerClass = classifyChoiceAnswer(choiceKey);
|
||||
if (
|
||||
(submittedProbeId && submittedProbeId !== openProbeId && submittedProbeId !== lastAnsweredId)
|
||||
|| (openProbeId && probe.id !== openProbeId && probe.id !== lastAnsweredId)
|
||||
) {
|
||||
return { applied: false, reason: "stale_probe", state, answerClass, probeId: probe.id };
|
||||
}
|
||||
const existing = state.answered_probes.find((item) => (
|
||||
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
|
||||
));
|
||||
if (existing) {
|
||||
if (existing.answer_class === answerClass) {
|
||||
return { applied: false, reason: "already_answered", state, answerClass, probeId: probe.id };
|
||||
}
|
||||
if (probe.id === lastAnsweredId) {
|
||||
return {
|
||||
applied: true,
|
||||
reason: "superseded",
|
||||
state: applySupersedeAnswer(state, probe.id, answerClass),
|
||||
answerClass,
|
||||
probeId: probe.id,
|
||||
};
|
||||
}
|
||||
return { applied: false, reason: "stale_probe", state, answerClass, probeId: probe.id };
|
||||
}
|
||||
if (isDuplicateProbe(probe, state.answered_probes)) {
|
||||
return { applied: false, reason: "already_answered", state, answerClass: classifyChoiceAnswer(choiceKey), probeId: probe.id };
|
||||
return { applied: false, reason: "already_answered", state, answerClass, probeId: probe.id };
|
||||
}
|
||||
const answerClass = classifyChoiceAnswer(choiceKey);
|
||||
return {
|
||||
applied: true,
|
||||
reason: "applied",
|
||||
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
type PublicRectificationTool,
|
||||
} from "./public-receipt";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
|
||||
import {
|
||||
asInferenceState,
|
||||
type InferenceTransitionSnapshot,
|
||||
} from "../core/compose-receipt";
|
||||
import { INFERENCE_ALGORITHM_VERSION } from "../core/types";
|
||||
import { decisionStateFingerprint } from "../core/decision-fingerprint";
|
||||
|
||||
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;
|
||||
|
||||
@@ -1325,27 +1331,124 @@ export async function persistV9Candidate(
|
||||
};
|
||||
}
|
||||
|
||||
function parseTransitionSnapshot(value: unknown): InferenceTransitionSnapshot | null {
|
||||
const row = rowObject(value);
|
||||
const inference = asInferenceState(row?.inference_state);
|
||||
const resultId = rowText(row?.result_id);
|
||||
const fingerprint = rowText(row?.decision_state_fingerprint);
|
||||
const revision = rowNumber(row?.revision);
|
||||
if (!row || !inference || !resultId || !fingerprint || revision === null) return null;
|
||||
return {
|
||||
id: rowText(row.id) ?? undefined,
|
||||
resultId,
|
||||
revision,
|
||||
probeId: rowText(row.probe_id),
|
||||
reason: String(row.reason ?? "choice"),
|
||||
idempotent: row.idempotent === true,
|
||||
decisionStateFingerprint: fingerprint,
|
||||
inferenceState: inference,
|
||||
posteriorBefore: rowObject(row.posterior_before) as Record<string, number> ?? {},
|
||||
posteriorAfter: rowObject(row.posterior_after) as Record<string, number> ?? {},
|
||||
scoreDeltas: rowObject(row.score_deltas) as Record<string, number> ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadLatestInferenceTransition(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
): Promise<InferenceTransitionSnapshot | null> {
|
||||
const row = await rpc<unknown>(
|
||||
accounting,
|
||||
"get_agentic_rectification_latest_inference_transition",
|
||||
{ p_user_id: userId, p_case_id: caseId },
|
||||
);
|
||||
return parseTransitionSnapshot(row);
|
||||
}
|
||||
|
||||
export type PersistInferenceTransitionInput = Readonly<{
|
||||
expectedRevision: number;
|
||||
probeId: string;
|
||||
openProbeId: string;
|
||||
semanticKey: string;
|
||||
candidateSplitHash: string;
|
||||
answerClass: string;
|
||||
rawAnswer: string;
|
||||
inferenceState: Readonly<Record<string, unknown>>;
|
||||
posteriorBefore: Readonly<Record<string, number>>;
|
||||
posteriorAfter: Readonly<Record<string, number>>;
|
||||
scoreDeltas: Readonly<Record<string, number>>;
|
||||
decisionStateFingerprint: string;
|
||||
reason: "choice" | "supersede" | "already_answered";
|
||||
idempotencyKey: string;
|
||||
candidateSetId: string;
|
||||
}>;
|
||||
|
||||
export async function persistV9InferenceState(
|
||||
accounting: AccountingClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
inferenceState: Readonly<Record<string, unknown>>,
|
||||
): Promise<Readonly<{ resultId: string; decisionReceipt: Readonly<Record<string, unknown>> }>> {
|
||||
input: PersistInferenceTransitionInput,
|
||||
): Promise<Readonly<{
|
||||
resultId: string;
|
||||
revision: number;
|
||||
idempotent: boolean;
|
||||
decisionReceipt: Readonly<Record<string, unknown>>;
|
||||
decisionStateFingerprint: string;
|
||||
}>> {
|
||||
const row = await rpc<Record<string, unknown>>(
|
||||
accounting,
|
||||
"patch_agentic_rectification_inference_state",
|
||||
"append_agentic_rectification_inference_transition",
|
||||
{
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_inference_state: inferenceState,
|
||||
p_expected_revision: input.expectedRevision,
|
||||
p_probe_id: input.probeId,
|
||||
p_open_probe_id: input.openProbeId,
|
||||
p_semantic_key: input.semanticKey,
|
||||
p_candidate_split_hash: input.candidateSplitHash,
|
||||
p_answer_class: input.answerClass,
|
||||
p_raw_answer: input.rawAnswer,
|
||||
p_inference_state: input.inferenceState,
|
||||
p_posterior_before: input.posteriorBefore,
|
||||
p_posterior_after: input.posteriorAfter,
|
||||
p_score_deltas: input.scoreDeltas,
|
||||
p_decision_state_fingerprint: input.decisionStateFingerprint,
|
||||
p_reason: input.reason,
|
||||
p_idempotency_key: input.idempotencyKey,
|
||||
p_candidate_set_id: input.candidateSetId,
|
||||
},
|
||||
);
|
||||
const resultId = rowText(row.result_id);
|
||||
const decisionReceipt = rowObject(row.decision_receipt);
|
||||
if (!resultId || !decisionReceipt) {
|
||||
throw new RectificationToolServiceError("invalid_inference_patch");
|
||||
const revision = rowNumber(row.revision);
|
||||
const fingerprint = rowText(row.decision_state_fingerprint) ?? input.decisionStateFingerprint;
|
||||
if (!resultId || !decisionReceipt || revision === null) {
|
||||
throw new RectificationToolServiceError("invalid_inference_transition");
|
||||
}
|
||||
return { resultId, decisionReceipt };
|
||||
return {
|
||||
resultId,
|
||||
revision,
|
||||
idempotent: row.idempotent === true,
|
||||
decisionReceipt,
|
||||
decisionStateFingerprint: fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferenceFingerprintForState(
|
||||
caseId: string,
|
||||
evidenceLedgerFingerprint: string,
|
||||
state: { candidate_set_id: string; revision: number; answered_probes: readonly { probe_id: string }[] },
|
||||
scoringPolicyVersion = INFERENCE_ALGORITHM_VERSION,
|
||||
): string {
|
||||
return decisionStateFingerprint({
|
||||
caseId,
|
||||
evidenceLedgerFingerprint,
|
||||
candidateSetId: state.candidate_set_id,
|
||||
inferenceRevision: state.revision,
|
||||
answeredProbeIds: state.answered_probes.map((item) => item.probe_id),
|
||||
scoringPolicyVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export type V9AcceptResult = Readonly<{
|
||||
@@ -1504,6 +1607,9 @@ export function safeToolErrorCode(error: unknown): string {
|
||||
"precision_downgrade",
|
||||
"offer_not_allowed",
|
||||
"no_candidate_result",
|
||||
"stale_probe",
|
||||
"revision_conflict",
|
||||
"inference_patch_retired",
|
||||
];
|
||||
if (error instanceof RectificationToolServiceError && known.includes(error.code)) {
|
||||
return error.code;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
transitionV9CaseStatus,
|
||||
persistV9Candidate,
|
||||
persistV9InferenceState,
|
||||
inferenceFingerprintForState,
|
||||
acceptV9Candidate,
|
||||
confirmV9BirthTime,
|
||||
closeV9Case,
|
||||
@@ -71,6 +72,10 @@ import {
|
||||
previousInferenceFromReceipt,
|
||||
stampChoiceSchemaWithProbe,
|
||||
} from "@/lib/rectification-agentic/v9/inference-adapter";
|
||||
import {
|
||||
posteriorMap,
|
||||
scoreDeltas,
|
||||
} from "@/lib/rectification-agentic/core/decision-fingerprint";
|
||||
import { buildSkillVerificationReport } from "@/lib/rectification-agentic/v9/skill-verification-report";
|
||||
import {
|
||||
internalObservationsFromWindowScan,
|
||||
@@ -827,19 +832,64 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
questionId: focus.questionId,
|
||||
domain: focus.targetDomain,
|
||||
});
|
||||
if (applied.applied) {
|
||||
if (applied.reason === "stale_probe") {
|
||||
throw new RectificationToolServiceError("stale_probe");
|
||||
}
|
||||
const persistable = applied.applied
|
||||
|| applied.reason === "already_answered"
|
||||
|| applied.reason === "superseded";
|
||||
if (persistable && applied.probeId && applied.answerClass) {
|
||||
const probe = applied.state.probes.find((item) => item.id === applied.probeId)
|
||||
?? previous.probes.find((item) => item.id === applied.probeId);
|
||||
const schemaProbeId = typeof focus.expectedAnswerSchema?.probe_id === "string"
|
||||
? focus.expectedAnswerSchema.probe_id
|
||||
: applied.probeId;
|
||||
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
|
||||
?? evidenceLedgerFingerprint(dossier.evidence);
|
||||
const fingerprint = inferenceFingerprintForState(
|
||||
input.caseId,
|
||||
evidenceFp,
|
||||
applied.state,
|
||||
);
|
||||
const persisted = await persistV9InferenceState(
|
||||
accounting,
|
||||
userId,
|
||||
input.caseId,
|
||||
applied.state as unknown as Record<string, unknown>,
|
||||
{
|
||||
expectedRevision: previous.revision,
|
||||
probeId: applied.probeId,
|
||||
openProbeId: schemaProbeId,
|
||||
semanticKey: probe?.semantic_key ?? "",
|
||||
candidateSplitHash: probe?.candidate_split_hash ?? "",
|
||||
answerClass: applied.answerClass,
|
||||
rawAnswer: input.choiceKey ?? applied.answerClass,
|
||||
inferenceState: applied.state as unknown as Record<string, unknown>,
|
||||
posteriorBefore: posteriorMap(previous.candidates),
|
||||
posteriorAfter: posteriorMap(applied.state.candidates),
|
||||
scoreDeltas: scoreDeltas(
|
||||
posteriorMap(previous.candidates),
|
||||
posteriorMap(applied.state.candidates),
|
||||
),
|
||||
decisionStateFingerprint: fingerprint,
|
||||
reason: applied.reason === "superseded"
|
||||
? "supersede"
|
||||
: applied.reason === "already_answered"
|
||||
? "already_answered"
|
||||
: "choice",
|
||||
idempotencyKey: `${applied.reason === "superseded" ? "supersede" : "choice"}:${applied.probeId}:${applied.answerClass}`,
|
||||
candidateSetId: applied.state.candidate_set_id,
|
||||
},
|
||||
);
|
||||
inferenceProjection = compactInferenceProjection(
|
||||
previousInferenceFromReceipt(persisted.decisionReceipt) ?? applied.state,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof RectificationToolServiceError) {
|
||||
const code = safeToolErrorCode(error);
|
||||
if (code === "stale_probe" || code === "revision_conflict") throw error;
|
||||
}
|
||||
inferenceProjection = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user