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:
+21
-5
@@ -5487,15 +5487,31 @@
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-23
|
||||
- 最近更新:2026-08-23
|
||||
- 最近更新:2026-08-24
|
||||
- 影响面:`rectification-agentic/core`、`method_followup_plan`、`discriminating_event_probes`、`rectification-v9-tools`、decision receipt `inference_state`
|
||||
- 用户现象:纠正会话一直在收集经历并提问,但候选时间不固定、不淘汰,轮次增加后仍给不出可信区间或代表性时间。覆盖齐后低信息探针不再挡出牌,高信息未答探针仍可能挡出牌。
|
||||
- 触发条件:出生时间范围为若干分钟;已有带日期事件;引擎给出多枚候选;Agent 继续用自然语言追问。
|
||||
- 根因:收集、提问、打分和收口都挤在对话 Agent 里。没有版本化候选集、事件×候选评分账本、由候选差异算出的探针,也没有统一收敛评估。Prompt 要求“提出冲突问题”并不更新后验;职业/领域覆盖被误当成收敛。
|
||||
- 修复:新增服务端推断状态机:固定 `candidate_set_id`、事件分层 holdout、等价分钟聚类、纯函数 `applyProbeOutcome`、按信息增益选探针、统一 `evaluateConvergence`。分数只由 reducer 更新。Python 探针带 `information_gain` / `expected_outcomes`;followup 只改写程序给出的探针。decision receipt 持久化 `inference_state`。点选 C/D/B 且没有新证据时,`rectification-resolve-focus` 当场把后验写入最新 result 的 `inference_state`,不再等下一次带日期事件重算。高信息未答探针在覆盖齐后仍可挡出牌;无 `information_gain` 的旧探针保持 BUG-361 不挡出牌。无法区分时返回可信区间,最大轮次不是 `converged`。
|
||||
- 验证:`frontend/tests/rectification-inference-machine.test.ts` 锁定有效回答降低熵、淘汰不可复活、重复切分禁用、最大轮次≠成功、等价分钟返回区间、holdout 不参与训练、赢家需两轮稳定、C 无新证据立刻更新后验、D 只记录已问切分、盘外核对与收集拒答不写探针;`frontend/tests/rectification-v10-conversation-focus.test.ts` 锁定 resolve-focus C 走 `patch_agentic_rectification_inference_state` 而不是候选缓存;`frontend/tests/rectification-eight-method.test.ts` 锁定同领域不同年份仍问冲突探针、高信息探针覆盖后仍挡出牌、无增益探针覆盖后不挡出牌。
|
||||
- 防复发:禁止 Agent 直接改候选分数或宣布收敛。禁止把方法覆盖或职业笔记当成 `result_status=converged`。同一 `semantic_key` / `candidate_split_hash` 不得连问。淘汰候选不得在同一 `candidate_set_id` 复活。最大轮次只能是 `max_rounds_reached`。点选 C/D/B 且没有新证据时必须走 `patch_agentic_rectification_inference_state` 当场写入 `decision_receipt.inference_state`,禁止走候选 persist RPC 的证据指纹缓存。盘外核对与收集阶段拒答不得写探针。下一轮 followup 必须读已答切分,避免把同一探针再问一遍。不得改已哈希 Skill `10.0.11`。
|
||||
- 相关记录:BUG-361、BUG-351
|
||||
- 修复:新增服务端推断状态机:固定 `candidate_set_id`、事件分层 holdout、等价分钟聚类、纯函数 `applyProbeOutcome`、按信息增益选探针、统一 `evaluateConvergence`。分数只由 reducer 更新。Python 探针带 `information_gain` / `expected_outcomes`;followup 只改写程序给出的探针。decision receipt 持久化 `inference_state`。点选 C/D/B 且没有新证据时,`rectification-resolve-focus` 当场把后验写入追加的推断 transition,读路径 overlay 到引擎回执,不再等下一次带日期事件重算。高信息未答探针在覆盖齐后仍可挡出牌;无 `information_gain` 的旧探针保持 BUG-361 不挡出牌。无法区分时返回可信区间,最大轮次不是 `converged`。
|
||||
- 验证:`frontend/tests/rectification-inference-machine.test.ts` 锁定有效回答降低熵、淘汰不可复活、重复切分禁用、最大轮次≠成功、等价分钟返回区间、holdout 不参与训练、赢家需两轮稳定、C 无新证据立刻更新后验、D 只记录已问切分、盘外核对与收集拒答不写探针;`frontend/tests/rectification-v10-conversation-focus.test.ts` 锁定 resolve-focus C 走 `append_agentic_rectification_inference_transition` 而不是候选缓存或原地 patch;`frontend/tests/rectification-eight-method.test.ts` 锁定同领域不同年份仍问冲突探针、高信息探针覆盖后仍挡出牌、无增益探针覆盖后不挡出牌。
|
||||
- 防复发:禁止 Agent 直接改候选分数或宣布收敛。禁止把方法覆盖或职业笔记当成 `result_status=converged`。同一 `semantic_key` / `candidate_split_hash` 不得连问。淘汰候选不得在同一 `candidate_set_id` 复活。最大轮次只能是 `max_rounds_reached`。点选 C/D/B 且没有新证据时必须走 `append_agentic_rectification_inference_transition` 追加不可变 transition,禁止 `jsonb_set` 已落库的引擎 `decision_receipt`,禁止走候选 persist RPC 的证据指纹缓存来写后验。读路径必须 overlay 最新 transition。盘外核对与收集阶段拒答不得写探针。下一轮 followup 必须读已答切分,避免把同一探针再问一遍。不得改已哈希 Skill `10.0.11`。
|
||||
- 相关记录:BUG-361、BUG-351、BUG-363
|
||||
- 复发自:BUG-361(覆盖门槛和出牌行为修了,但仍没有候选后验更新闭环)
|
||||
- 修复版本:`29750d38`
|
||||
|
||||
## BUG-363 | 生时纠正推断后验原地改 receipt,persist-v2 只认 evidence fingerprint
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-24
|
||||
- 最近更新:2026-08-24
|
||||
- 影响面:`agentic_rectification_inference_transitions`、`append_agentic_rectification_inference_transition`、`compose_agentic_rectification_decision_receipt`、`persist_agentic_rectification_candidate_v2`、`get_agentic_rectification_case_dossier`、`rectification-resolve-focus`
|
||||
- 用户现象:C/D 回答可以改后验,但数据库里只剩更新后的分数,无法回放哪次回答改变了它;证据没变时 persist-v2 仍可能把旧 receipt 当成当前世界。
|
||||
- 触发条件:点选 C/D 且没有新增带日期证据;随后读 Case、命中 persist-v2 缓存、刷新或重试同一回答。
|
||||
- 根因:BUG-362 热修复用 `patch_agentic_rectification_inference_state` 对最新 `decision_receipt.inference_state` 做 `jsonb_set`。执行回执被原地修改。缓存键仍是 evidence/range/policy fingerprint,不含推断修订。
|
||||
- 修复:追加不可变 `agentic_rectification_inference_transitions`。C/D 走 `append_agentic_rectification_inference_transition`(乐观锁 `expected_revision`、`stale_probe`、幂等键)。引擎结果行不再被 patch。persist-v2 HIT/MISS 与 dossier/get_case 用 `compose_agentic_rectification_decision_receipt` overlay 与当前 `result_id` 匹配的最新 transition。`decision_state_fingerprint` 存在 transition 上,不进入 persist-v2 等值匹配。`patch_agentic_rectification_inference_state` 改为 `inference_patch_retired`。不改已哈希 Skill `10.0.11`。P0 inference ledger implemented, verification pending。
|
||||
- 验证:`frontend/tests/rectification-inference-machine.test.ts` 锁定 C 立刻改后验且 `revision+1`、evidence fingerprint 不变时 decision-state fingerprint 与 posterior 变、重复 D 幂等、C→D 新 revision 且旧 transition 仍在、旧 probe 为 `stale_probe`、过期 revision 为 `revision_conflict`、reducer replay 等于当前 posterior、候选集变化的 persist-v2 MISS 仍 replay 已答探针、append SQL 不 UPDATE 引擎行;`frontend/tests/rectification-v10-conversation-focus.test.ts` 锁定 resolve-focus C 走 append RPC;`frontend/tests/rectification-v9-agent.test.ts` 锁定 persist-v2 查找键不含 `p_decision_state_fingerprint`,HIT 返回 overlay 后的 `inference_state`;`frontend/tests/rectification-v9-case-service.test.ts` 锁定 409 `stale_probe` / `revision_conflict` / `inference_patch_retired`。P0 inference ledger implemented, verification pending。
|
||||
- 防复发:禁止 `jsonb_set` 已落库引擎 `decision_receipt` 来写后验。禁止把 inference revision 加进 persist-v2 等值匹配。有效 C/D 必须追加 transition。读 `inference_state` 必须 overlay 最新且 `result_id` 匹配的 transition。重复提交必须幂等。过期 `probe_id` 必须 409,不得改当前 posterior。不得改已哈希 Skill `10.0.11`。
|
||||
- 相关记录:BUG-362
|
||||
- 复发自:BUG-362(C/D 写入后验修了,但用原地 patch 绕过缓存,receipt 不可审计)
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+892
@@ -0,0 +1,892 @@
|
||||
-- Append-only rectification inference ledger. Engine result rows stay immutable.
|
||||
-- C/D answers insert a transition and overlay inference_state on read.
|
||||
-- Business schema only; do not copy into frontend/db/migrations (BUG-127 / BUG-144).
|
||||
|
||||
begin;
|
||||
|
||||
create table if not exists public.agentic_rectification_inference_transitions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade,
|
||||
result_id uuid not null references public.agentic_rectification_results(id) on delete cascade,
|
||||
previous_revision integer not null check (previous_revision >= 0),
|
||||
revision integer not null check (revision > previous_revision),
|
||||
candidate_set_id text not null check (length(btrim(candidate_set_id)) > 0),
|
||||
probe_id text,
|
||||
semantic_key text,
|
||||
candidate_split_hash text,
|
||||
answer_class text check (answer_class is null or answer_class in ('yes', 'weak_yes', 'no', 'unsure')),
|
||||
raw_answer text,
|
||||
posterior_before jsonb not null default '{}'::jsonb check (jsonb_typeof(posterior_before) = 'object'),
|
||||
posterior_after jsonb not null default '{}'::jsonb check (jsonb_typeof(posterior_after) = 'object'),
|
||||
score_deltas jsonb not null default '{}'::jsonb check (jsonb_typeof(score_deltas) = 'object'),
|
||||
inference_state jsonb not null check (jsonb_typeof(inference_state) = 'object'),
|
||||
decision_state_fingerprint text not null check (decision_state_fingerprint ~ '^[0-9a-f]{64}$'),
|
||||
reason text not null check (reason in ('choice', 'supersede', 'already_answered')),
|
||||
idempotency_key text not null check (length(btrim(idempotency_key)) > 0),
|
||||
receipt_after jsonb not null check (jsonb_typeof(receipt_after) = 'object'),
|
||||
created_at timestamptz not null default pg_catalog.now(),
|
||||
unique (case_id, revision),
|
||||
unique (case_id, idempotency_key)
|
||||
);
|
||||
|
||||
create index if not exists agentic_rectification_inference_transitions_case_revision_idx
|
||||
on public.agentic_rectification_inference_transitions (case_id, revision desc);
|
||||
|
||||
create index if not exists agentic_rectification_inference_transitions_result_idx
|
||||
on public.agentic_rectification_inference_transitions (result_id, revision desc);
|
||||
|
||||
alter table public.agentic_rectification_inference_transitions enable row level security;
|
||||
|
||||
revoke all on table public.agentic_rectification_inference_transitions
|
||||
from public, anon, authenticated;
|
||||
grant all on table public.agentic_rectification_inference_transitions to service_role;
|
||||
|
||||
create or replace function public.compose_agentic_rectification_decision_receipt(
|
||||
p_case_id uuid,
|
||||
p_result_id uuid,
|
||||
p_receipt jsonb
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_transition public.agentic_rectification_inference_transitions%rowtype;
|
||||
v_receipt jsonb;
|
||||
begin
|
||||
v_receipt := case
|
||||
when p_receipt is not null and jsonb_typeof(p_receipt) = 'object' then p_receipt
|
||||
else '{}'::jsonb
|
||||
end;
|
||||
if p_case_id is null or p_result_id is null then
|
||||
return v_receipt;
|
||||
end if;
|
||||
select * into v_transition
|
||||
from public.agentic_rectification_inference_transitions
|
||||
where case_id = p_case_id
|
||||
and result_id = p_result_id
|
||||
order by revision desc
|
||||
limit 1;
|
||||
if not found then
|
||||
return v_receipt;
|
||||
end if;
|
||||
return jsonb_set(
|
||||
jsonb_set(v_receipt, '{inference_state}', v_transition.inference_state, true),
|
||||
'{decision_state_fingerprint}',
|
||||
to_jsonb(v_transition.decision_state_fingerprint),
|
||||
true
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.compose_agentic_rectification_decision_receipt(uuid, uuid, jsonb)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.compose_agentic_rectification_decision_receipt(uuid, uuid, jsonb)
|
||||
to service_role;
|
||||
|
||||
create or replace function public.append_agentic_rectification_inference_transition(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_revision integer,
|
||||
p_probe_id text,
|
||||
p_open_probe_id text,
|
||||
p_semantic_key text,
|
||||
p_candidate_split_hash text,
|
||||
p_answer_class text,
|
||||
p_raw_answer text,
|
||||
p_inference_state jsonb,
|
||||
p_posterior_before jsonb,
|
||||
p_posterior_after jsonb,
|
||||
p_score_deltas jsonb,
|
||||
p_decision_state_fingerprint text,
|
||||
p_reason text,
|
||||
p_idempotency_key text,
|
||||
p_candidate_set_id text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_result public.agentic_rectification_results%rowtype;
|
||||
v_existing public.agentic_rectification_inference_transitions%rowtype;
|
||||
v_current_revision integer;
|
||||
v_next_revision integer;
|
||||
v_receipt jsonb;
|
||||
v_id uuid;
|
||||
v_focus_probe_id text;
|
||||
v_last_probe_id text;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null
|
||||
or p_expected_revision is null or p_expected_revision < 0
|
||||
or p_inference_state is null or jsonb_typeof(p_inference_state) <> 'object'
|
||||
or p_posterior_before is null or jsonb_typeof(p_posterior_before) <> 'object'
|
||||
or p_posterior_after is null or jsonb_typeof(p_posterior_after) <> 'object'
|
||||
or p_score_deltas is null or jsonb_typeof(p_score_deltas) <> 'object'
|
||||
or p_decision_state_fingerprint is null
|
||||
or p_decision_state_fingerprint !~ '^[0-9a-f]{64}$'
|
||||
or p_reason is null or p_reason not in ('choice', 'supersede', 'already_answered')
|
||||
or length(btrim(coalesce(p_idempotency_key, ''))) = 0
|
||||
or length(btrim(coalesce(p_candidate_set_id, ''))) = 0
|
||||
or p_answer_class is null or p_answer_class not in ('yes', 'weak_yes', 'no', 'unsure')
|
||||
or length(btrim(coalesce(p_probe_id, ''))) = 0 then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
|
||||
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_result
|
||||
from public.agentic_rectification_results
|
||||
where case_id = p_case_id
|
||||
and invalidated_at is null
|
||||
order by created_at desc
|
||||
limit 1
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_result_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_result.decision_receipt is null
|
||||
or jsonb_typeof(v_result.decision_receipt) <> 'object' then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_existing
|
||||
from public.agentic_rectification_inference_transitions
|
||||
where case_id = p_case_id
|
||||
and idempotency_key = p_idempotency_key;
|
||||
if found then
|
||||
v_receipt := public.compose_agentic_rectification_decision_receipt(
|
||||
p_case_id, v_result.id, v_result.decision_receipt
|
||||
);
|
||||
return jsonb_build_object(
|
||||
'transition_id', v_existing.id,
|
||||
'result_id', v_result.id,
|
||||
'revision', v_existing.revision,
|
||||
'idempotent', true,
|
||||
'decision_receipt', v_receipt,
|
||||
'decision_state_fingerprint', v_existing.decision_state_fingerprint
|
||||
);
|
||||
end if;
|
||||
|
||||
select coalesce(max(revision), 0) into v_current_revision
|
||||
from public.agentic_rectification_inference_transitions
|
||||
where case_id = p_case_id;
|
||||
v_current_revision := greatest(
|
||||
v_current_revision,
|
||||
coalesce((v_result.decision_receipt -> 'inference_state' ->> 'revision')::integer, 0)
|
||||
);
|
||||
if p_expected_revision is distinct from v_current_revision then
|
||||
raise exception 'agentic_rectification_revision_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if p_probe_id is distinct from nullif(btrim(coalesce(p_open_probe_id, '')), '')
|
||||
and p_reason is distinct from 'supersede' then
|
||||
raise exception 'agentic_rectification_stale_probe' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select nullif(btrim(coalesce(v_focus.expected_answer_schema ->> 'probe_id', '')), '')
|
||||
into v_focus_probe_id
|
||||
from public.agentic_rectification_conversation_focuses v_focus
|
||||
where v_focus.case_id = p_case_id
|
||||
and v_focus.status = 'active'
|
||||
order by v_focus.asked_at desc, v_focus.id desc
|
||||
limit 1;
|
||||
if v_focus_probe_id is not null and p_probe_id is distinct from v_focus_probe_id then
|
||||
if p_reason is distinct from 'supersede' then
|
||||
raise exception 'agentic_rectification_stale_probe' using errcode = 'P0001';
|
||||
end if;
|
||||
select probe_id into v_last_probe_id
|
||||
from public.agentic_rectification_inference_transitions
|
||||
where case_id = p_case_id
|
||||
order by revision desc
|
||||
limit 1;
|
||||
if p_probe_id is distinct from v_last_probe_id then
|
||||
raise exception 'agentic_rectification_stale_probe' using errcode = 'P0001';
|
||||
end if;
|
||||
end if;
|
||||
|
||||
v_next_revision := v_current_revision + 1;
|
||||
v_receipt := jsonb_set(
|
||||
jsonb_set(v_result.decision_receipt, '{inference_state}', p_inference_state, true),
|
||||
'{decision_state_fingerprint}',
|
||||
to_jsonb(p_decision_state_fingerprint),
|
||||
true
|
||||
);
|
||||
|
||||
insert into public.agentic_rectification_inference_transitions (
|
||||
case_id, result_id, previous_revision, revision, candidate_set_id,
|
||||
probe_id, semantic_key, candidate_split_hash, answer_class, raw_answer,
|
||||
posterior_before, posterior_after, score_deltas, inference_state,
|
||||
decision_state_fingerprint, reason, idempotency_key, receipt_after
|
||||
) values (
|
||||
p_case_id, v_result.id, p_expected_revision, v_next_revision, p_candidate_set_id,
|
||||
p_probe_id, nullif(btrim(coalesce(p_semantic_key, '')), ''),
|
||||
nullif(btrim(coalesce(p_candidate_split_hash, '')), ''),
|
||||
p_answer_class, nullif(btrim(coalesce(p_raw_answer, '')), ''),
|
||||
p_posterior_before, p_posterior_after, p_score_deltas, p_inference_state,
|
||||
p_decision_state_fingerprint, p_reason, p_idempotency_key, v_receipt
|
||||
) returning id into v_id;
|
||||
|
||||
return jsonb_build_object(
|
||||
'transition_id', v_id,
|
||||
'result_id', v_result.id,
|
||||
'revision', v_next_revision,
|
||||
'idempotent', false,
|
||||
'decision_receipt', v_receipt,
|
||||
'decision_state_fingerprint', p_decision_state_fingerprint
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.append_agentic_rectification_inference_transition(
|
||||
uuid, uuid, integer, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text, text
|
||||
) from public, anon, authenticated;
|
||||
grant execute on function public.append_agentic_rectification_inference_transition(
|
||||
uuid, uuid, integer, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text, text
|
||||
) to service_role;
|
||||
|
||||
create or replace function public.get_agentic_rectification_latest_inference_transition(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_transition public.agentic_rectification_inference_transitions%rowtype;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_transition
|
||||
from public.agentic_rectification_inference_transitions
|
||||
where case_id = p_case_id
|
||||
order by revision desc
|
||||
limit 1;
|
||||
if not found then
|
||||
return null;
|
||||
end if;
|
||||
return jsonb_build_object(
|
||||
'id', v_transition.id,
|
||||
'result_id', v_transition.result_id,
|
||||
'previous_revision', v_transition.previous_revision,
|
||||
'revision', v_transition.revision,
|
||||
'probe_id', v_transition.probe_id,
|
||||
'reason', v_transition.reason,
|
||||
'decision_state_fingerprint', v_transition.decision_state_fingerprint,
|
||||
'inference_state', v_transition.inference_state,
|
||||
'posterior_before', v_transition.posterior_before,
|
||||
'posterior_after', v_transition.posterior_after,
|
||||
'score_deltas', v_transition.score_deltas
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.get_agentic_rectification_latest_inference_transition(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.get_agentic_rectification_latest_inference_transition(uuid, uuid)
|
||||
to service_role;
|
||||
|
||||
create or replace function public.list_agentic_rectification_inference_transitions(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_rows jsonb;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
select coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', item.id,
|
||||
'result_id', item.result_id,
|
||||
'previous_revision', item.previous_revision,
|
||||
'revision', item.revision,
|
||||
'probe_id', item.probe_id,
|
||||
'semantic_key', item.semantic_key,
|
||||
'candidate_split_hash', item.candidate_split_hash,
|
||||
'answer_class', item.answer_class,
|
||||
'reason', item.reason,
|
||||
'decision_state_fingerprint', item.decision_state_fingerprint,
|
||||
'inference_state', item.inference_state,
|
||||
'posterior_before', item.posterior_before,
|
||||
'posterior_after', item.posterior_after,
|
||||
'score_deltas', item.score_deltas
|
||||
) order by item.revision), '[]'::jsonb)
|
||||
into v_rows
|
||||
from public.agentic_rectification_inference_transitions item
|
||||
where item.case_id = p_case_id;
|
||||
return v_rows;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.list_agentic_rectification_inference_transitions(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.list_agentic_rectification_inference_transitions(uuid, uuid)
|
||||
to service_role;
|
||||
|
||||
create or replace function public.patch_agentic_rectification_inference_state(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_inference_state jsonb
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
raise exception 'agentic_rectification_inference_patch_retired' using errcode = 'P0001';
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.persist_agentic_rectification_candidate_v2(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_engine_result_id text,
|
||||
p_evidence_ledger_fingerprint text,
|
||||
p_candidate_range_fingerprint text,
|
||||
p_skill_version text,
|
||||
p_algorithm_version text,
|
||||
p_event_contract_version text,
|
||||
p_decision_policy_version text,
|
||||
p_candidate_range jsonb,
|
||||
p_candidates jsonb,
|
||||
p_decision_receipt jsonb,
|
||||
p_execution_ledger jsonb
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_cached public.agentic_rectification_results%rowtype;
|
||||
v_snapshot jsonb;
|
||||
v_result_id uuid;
|
||||
v_candidate jsonb;
|
||||
v_candidate_id uuid;
|
||||
v_candidate_time time without time zone;
|
||||
v_candidate_time_text text;
|
||||
v_candidate_ordinal integer;
|
||||
v_saved_candidate jsonb;
|
||||
v_saved_candidates jsonb := '[]'::jsonb;
|
||||
v_seen_times text[] := array[]::text[];
|
||||
v_display_allowed boolean;
|
||||
v_selection_allowed boolean;
|
||||
v_confirmation_allowed boolean;
|
||||
v_overall_confidence text;
|
||||
v_margin_percent numeric;
|
||||
v_representative_time_text text;
|
||||
v_representative_time time without time zone;
|
||||
v_representative_candidate_id uuid;
|
||||
v_representative_count integer := 0;
|
||||
v_saved_decision_receipt jsonb;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null
|
||||
or length(btrim(coalesce(p_engine_result_id, ''))) = 0
|
||||
or length(btrim(coalesce(p_evidence_ledger_fingerprint, ''))) = 0
|
||||
or length(btrim(coalesce(p_candidate_range_fingerprint, ''))) = 0
|
||||
or length(btrim(coalesce(p_skill_version, ''))) = 0
|
||||
or length(btrim(coalesce(p_algorithm_version, ''))) = 0
|
||||
or length(btrim(coalesce(p_event_contract_version, ''))) = 0
|
||||
or length(btrim(coalesce(p_decision_policy_version, ''))) = 0 then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_candidate_range is null or jsonb_typeof(p_candidate_range) <> 'object' then
|
||||
raise exception 'agentic_rectification_invalid_candidate_range' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_candidates is null or jsonb_typeof(p_candidates) <> 'array'
|
||||
or jsonb_array_length(p_candidates) = 0 then
|
||||
raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_decision_receipt is null or jsonb_typeof(p_decision_receipt) <> 'object'
|
||||
or jsonb_typeof(p_decision_receipt -> 'display_allowed') is distinct from 'boolean'
|
||||
or jsonb_typeof(p_decision_receipt -> 'accept_allowed') is distinct from 'boolean'
|
||||
or jsonb_typeof(p_decision_receipt -> 'confirm_allowed') is distinct from 'boolean'
|
||||
or jsonb_typeof(p_decision_receipt -> 'overall_confidence') is distinct from 'string' then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_execution_ledger is null or jsonb_typeof(p_execution_ledger) <> 'array' then
|
||||
raise exception 'agentic_rectification_invalid_execution_ledger' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
v_display_allowed := (p_decision_receipt ->> 'display_allowed')::boolean;
|
||||
v_selection_allowed := (p_decision_receipt ->> 'accept_allowed')::boolean;
|
||||
v_confirmation_allowed := (p_decision_receipt ->> 'confirm_allowed')::boolean;
|
||||
v_overall_confidence := p_decision_receipt ->> 'overall_confidence';
|
||||
v_saved_decision_receipt := p_decision_receipt - 'representative_candidate_id';
|
||||
if v_overall_confidence not in ('low', 'medium', 'high') then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
if (p_decision_receipt ? 'margin_percent')
|
||||
and jsonb_typeof(p_decision_receipt -> 'margin_percent') not in ('number', 'null') then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
v_margin_percent := case
|
||||
when jsonb_typeof(p_decision_receipt -> 'margin_percent') = 'number'
|
||||
then (p_decision_receipt ->> 'margin_percent')::numeric
|
||||
else null
|
||||
end;
|
||||
if v_selection_allowed and not v_display_allowed then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_confirmation_allowed and not v_selection_allowed then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
v_representative_time_text := nullif(btrim(coalesce(p_decision_receipt ->> 'representative_time', '')), '');
|
||||
if v_representative_time_text is not null then
|
||||
if v_representative_time_text !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
v_representative_time := v_representative_time_text::time without time zone;
|
||||
end if;
|
||||
if v_confirmation_allowed and v_representative_time is null then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
|
||||
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_case.skill_version is distinct from p_skill_version then
|
||||
raise exception 'agentic_rectification_skill_version_mismatch' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into v_cached
|
||||
from public.agentic_rectification_results
|
||||
where case_id = p_case_id
|
||||
and invalidated_at is null
|
||||
and evidence_ledger_fingerprint = p_evidence_ledger_fingerprint
|
||||
and candidate_range_fingerprint = p_candidate_range_fingerprint
|
||||
and skill_version = p_skill_version
|
||||
and algorithm_version = p_algorithm_version
|
||||
and event_contract_version = p_event_contract_version
|
||||
and decision_policy_version = p_decision_policy_version
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
|
||||
if found then
|
||||
return jsonb_build_object(
|
||||
'result_id', v_cached.id,
|
||||
'cached', true,
|
||||
'candidates', v_cached.candidates,
|
||||
'overall_confidence', v_cached.overall_confidence,
|
||||
'margin_percent', v_cached.margin_percent,
|
||||
'display_allowed', v_cached.display_allowed,
|
||||
'selection_allowed', v_cached.selection_allowed,
|
||||
'confirmation_allowed', v_cached.confirmation_allowed,
|
||||
'representative_time', v_cached.representative_time,
|
||||
'algorithm_version', v_cached.algorithm_version,
|
||||
'event_contract_version', v_cached.event_contract_version,
|
||||
'decision_policy_version', v_cached.decision_policy_version,
|
||||
'decision_receipt', public.compose_agentic_rectification_decision_receipt(p_case_id, v_cached.id, v_cached.decision_receipt),
|
||||
'execution_ledger', v_cached.execution_ledger
|
||||
);
|
||||
end if;
|
||||
|
||||
v_snapshot := v_case.baseline_birth_snapshot;
|
||||
|
||||
insert into public.agentic_rectification_results (
|
||||
user_id, session_id, case_id,
|
||||
engine_result_id, canonical_input_hash, algorithm_version,
|
||||
evidence_ledger_fingerprint, candidate_range_fingerprint, skill_version,
|
||||
event_contract_version, decision_policy_version,
|
||||
candidate_range, candidates,
|
||||
overall_confidence, margin_percent,
|
||||
display_allowed, selection_allowed, confirmation_allowed, representative_time,
|
||||
decision_receipt, execution_ledger,
|
||||
baseline_birth_date, baseline_reported_birth_time, baseline_active_birth_time,
|
||||
baseline_birth_time_source, baseline_birth_time_period,
|
||||
baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes,
|
||||
baseline_latitude, baseline_longitude, baseline_timezone_offset
|
||||
) values (
|
||||
v_case.user_id, v_case.session_id, v_case.id,
|
||||
p_engine_result_id, p_evidence_ledger_fingerprint, p_algorithm_version,
|
||||
p_evidence_ledger_fingerprint, p_candidate_range_fingerprint, p_skill_version,
|
||||
p_event_contract_version, p_decision_policy_version,
|
||||
p_candidate_range, '[]'::jsonb,
|
||||
v_overall_confidence, v_margin_percent,
|
||||
v_display_allowed, v_selection_allowed, v_confirmation_allowed, v_representative_time,
|
||||
v_saved_decision_receipt, p_execution_ledger,
|
||||
(v_snapshot ->> 'birth_date')::date,
|
||||
(v_snapshot ->> 'reported_birth_time')::time without time zone,
|
||||
(v_snapshot ->> 'active_birth_time')::time without time zone,
|
||||
v_snapshot ->> 'birth_time_source',
|
||||
v_snapshot ->> 'birth_time_period',
|
||||
(v_snapshot ->> 'uncertainty_before_minutes')::integer,
|
||||
(v_snapshot ->> 'uncertainty_after_minutes')::integer,
|
||||
(v_snapshot ->> 'latitude')::double precision,
|
||||
(v_snapshot ->> 'longitude')::double precision,
|
||||
(v_snapshot ->> 'timezone_offset')::double precision
|
||||
) returning id into v_result_id;
|
||||
|
||||
for v_candidate, v_candidate_ordinal in
|
||||
select item.value, item.ordinality::integer
|
||||
from pg_catalog.jsonb_array_elements(p_candidates) with ordinality as item(value, ordinality)
|
||||
loop
|
||||
if jsonb_typeof(v_candidate) <> 'object' then
|
||||
raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001';
|
||||
end if;
|
||||
v_candidate_time_text := nullif(btrim(coalesce(v_candidate ->> 'time', '')), '');
|
||||
if v_candidate_time_text is null
|
||||
or v_candidate_time_text !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
|
||||
or v_candidate_time_text = any(v_seen_times) then
|
||||
raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001';
|
||||
end if;
|
||||
v_seen_times := pg_catalog.array_append(v_seen_times, v_candidate_time_text);
|
||||
v_candidate_time := v_candidate_time_text::time without time zone;
|
||||
v_candidate_id := gen_random_uuid();
|
||||
if v_representative_time is not null
|
||||
and v_candidate_time is not distinct from v_representative_time then
|
||||
v_representative_count := v_representative_count + 1;
|
||||
v_representative_candidate_id := v_candidate_id;
|
||||
end if;
|
||||
|
||||
v_saved_candidate := jsonb_set(
|
||||
v_candidate - 'candidate_id',
|
||||
'{candidate_id}',
|
||||
to_jsonb(v_candidate_id::text),
|
||||
true
|
||||
);
|
||||
|
||||
insert into public.agentic_rectification_candidates (
|
||||
id, result_id, user_id, case_id, ordinal,
|
||||
candidate_time, candidate_payload, is_representative
|
||||
) values (
|
||||
v_candidate_id, v_result_id, p_user_id, p_case_id, v_candidate_ordinal,
|
||||
v_candidate_time, v_saved_candidate,
|
||||
v_representative_time is not null and v_candidate_time is not distinct from v_representative_time
|
||||
);
|
||||
v_saved_candidates := v_saved_candidates || jsonb_build_array(v_saved_candidate);
|
||||
end loop;
|
||||
|
||||
if v_representative_time is not null and v_representative_count <> 1 then
|
||||
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
|
||||
end if;
|
||||
if v_representative_candidate_id is not null then
|
||||
v_saved_decision_receipt := jsonb_set(
|
||||
v_saved_decision_receipt,
|
||||
'{representative_candidate_id}',
|
||||
to_jsonb(v_representative_candidate_id::text),
|
||||
true
|
||||
);
|
||||
end if;
|
||||
|
||||
update public.agentic_rectification_results
|
||||
set candidates = v_saved_candidates,
|
||||
decision_receipt = v_saved_decision_receipt,
|
||||
updated_at = pg_catalog.now()
|
||||
where id = v_result_id;
|
||||
|
||||
return jsonb_build_object(
|
||||
'result_id', v_result_id,
|
||||
'cached', false,
|
||||
'candidates', v_saved_candidates,
|
||||
'overall_confidence', v_overall_confidence,
|
||||
'margin_percent', v_margin_percent,
|
||||
'display_allowed', v_display_allowed,
|
||||
'selection_allowed', v_selection_allowed,
|
||||
'confirmation_allowed', v_confirmation_allowed,
|
||||
'representative_time', v_representative_time,
|
||||
'algorithm_version', p_algorithm_version,
|
||||
'event_contract_version', p_event_contract_version,
|
||||
'decision_policy_version', p_decision_policy_version,
|
||||
'decision_receipt', public.compose_agentic_rectification_decision_receipt(p_case_id, v_result_id, v_saved_decision_receipt),
|
||||
'execution_ledger', p_execution_ledger
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
revoke all on function public.persist_agentic_rectification_candidate_v2(uuid, uuid, text, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.persist_agentic_rectification_candidate_v2(uuid, uuid, text, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb)
|
||||
to service_role;
|
||||
|
||||
create or replace function public.get_agentic_rectification_case(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_result public.agentic_rectification_results%rowtype;
|
||||
v_evidence_count bigint;
|
||||
v_turn_count bigint;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select count(*) into v_evidence_count
|
||||
from public.agentic_rectification_evidence
|
||||
where case_id = v_case.id;
|
||||
|
||||
select count(*) into v_turn_count
|
||||
from public.agentic_rectification_turns
|
||||
where case_id = v_case.id;
|
||||
|
||||
select * into v_result
|
||||
from public.agentic_rectification_results
|
||||
where case_id = v_case.id
|
||||
and invalidated_at is null
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
|
||||
return jsonb_build_object(
|
||||
'case_id', v_case.id,
|
||||
'session_id', v_case.session_id,
|
||||
'status', v_case.status,
|
||||
'skill_name', v_case.skill_name,
|
||||
'skill_version', v_case.skill_version,
|
||||
'candidate_range', v_case.candidate_range,
|
||||
'accepted_time', v_case.accepted_time,
|
||||
'confirmed_time', v_case.confirmed_time,
|
||||
'created_at', v_case.created_at,
|
||||
'last_activity_at', v_case.last_activity_at,
|
||||
'completed_at', v_case.completed_at,
|
||||
'closed_reason', v_case.closed_reason,
|
||||
'evidence_count', v_evidence_count,
|
||||
'turn_count', v_turn_count,
|
||||
'latest_result', case
|
||||
when v_result.id is null then null
|
||||
else jsonb_build_object(
|
||||
'result_id', v_result.id,
|
||||
'candidates', v_result.candidates,
|
||||
'overall_confidence', v_result.overall_confidence,
|
||||
'display_allowed', v_result.display_allowed,
|
||||
'selection_allowed', v_result.selection_allowed,
|
||||
'confirmation_allowed', v_result.confirmation_allowed,
|
||||
'representative_time', v_result.representative_time,
|
||||
'selected_candidate_id', v_result.selected_candidate_id,
|
||||
'selected_time', v_result.selected_time,
|
||||
'selection_kind', v_result.selection_kind,
|
||||
'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint,
|
||||
'candidate_range_fingerprint', v_result.candidate_range_fingerprint,
|
||||
'skill_version', v_result.skill_version,
|
||||
'algorithm_version', v_result.algorithm_version,
|
||||
'event_contract_version', v_result.event_contract_version,
|
||||
'decision_policy_version', v_result.decision_policy_version,
|
||||
'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt),
|
||||
'execution_ledger', v_result.execution_ledger,
|
||||
'created_at', v_result.created_at,
|
||||
'invalidated_at', v_result.invalidated_at
|
||||
)
|
||||
end
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
revoke all on function public.get_agentic_rectification_case(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.get_agentic_rectification_case(uuid, uuid)
|
||||
to service_role;
|
||||
|
||||
create or replace function public.get_agentic_rectification_case_dossier(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.agentic_rectification_cases%rowtype;
|
||||
v_turns jsonb;
|
||||
v_evidence jsonb;
|
||||
v_summary public.agentic_rectification_case_conversation_summaries%rowtype;
|
||||
v_result public.agentic_rectification_results%rowtype;
|
||||
v_evidence_count bigint;
|
||||
v_turn_count bigint;
|
||||
begin
|
||||
if p_user_id is null or p_case_id is null then
|
||||
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
|
||||
end if;
|
||||
select * into v_case
|
||||
from public.agentic_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id;
|
||||
if not found then
|
||||
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', recent.id,
|
||||
'role', message.role,
|
||||
'text', message.text,
|
||||
'status', recent.status,
|
||||
'created_at', recent.created_at,
|
||||
'completed_at', recent.completed_at
|
||||
) order by recent.created_at, recent.id, message.ordinal), '[]'::jsonb)
|
||||
into v_turns
|
||||
from (
|
||||
select t.*
|
||||
from public.agentic_rectification_turns t
|
||||
where t.case_id = v_case.id
|
||||
order by t.created_at desc, t.id desc
|
||||
limit 50
|
||||
) recent
|
||||
cross join lateral (
|
||||
values
|
||||
(1, 'user'::text, recent.user_message),
|
||||
(2, 'assistant'::text, recent.assistant_message)
|
||||
) as message(ordinal, role, text)
|
||||
where message.text is not null;
|
||||
|
||||
select coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', e.id,
|
||||
'source_turn_id', e.source_turn_id,
|
||||
'subject', e.subject,
|
||||
'event_kind', e.event_kind,
|
||||
'domain', e.domain,
|
||||
'occurred_from', e.occurred_from,
|
||||
'occurred_to', e.occurred_to,
|
||||
'date_precision', e.date_precision,
|
||||
'summary', e.summary,
|
||||
'status', e.status,
|
||||
'supersedes_evidence_id', e.supersedes_evidence_id,
|
||||
'created_at', e.created_at
|
||||
) order by e.created_at, e.id), '[]'::jsonb)
|
||||
into v_evidence
|
||||
from public.agentic_rectification_evidence e
|
||||
where e.case_id = v_case.id;
|
||||
|
||||
select count(*) into v_evidence_count
|
||||
from public.agentic_rectification_evidence where case_id = v_case.id;
|
||||
select count(*) into v_turn_count
|
||||
from public.agentic_rectification_turns where case_id = v_case.id;
|
||||
|
||||
select * into v_summary
|
||||
from public.agentic_rectification_case_conversation_summaries
|
||||
where case_id = v_case.id;
|
||||
if not found then
|
||||
perform public.refresh_agentic_rectification_case_conversation_summary(v_case.id);
|
||||
select * into v_summary
|
||||
from public.agentic_rectification_case_conversation_summaries
|
||||
where case_id = v_case.id;
|
||||
end if;
|
||||
|
||||
select * into v_result
|
||||
from public.agentic_rectification_results
|
||||
where case_id = v_case.id and invalidated_at is null
|
||||
order by created_at desc, id desc
|
||||
limit 1;
|
||||
|
||||
return jsonb_build_object(
|
||||
'case', jsonb_build_object(
|
||||
'case_id', v_case.id,
|
||||
'session_id', v_case.session_id,
|
||||
'status', v_case.status,
|
||||
'skill_name', v_case.skill_name,
|
||||
'skill_version', v_case.skill_version,
|
||||
'candidate_range', v_case.candidate_range,
|
||||
'accepted_time', v_case.accepted_time,
|
||||
'confirmed_time', v_case.confirmed_time,
|
||||
'completed_at', v_case.completed_at,
|
||||
'closed_reason', v_case.closed_reason,
|
||||
'last_activity_at', v_case.last_activity_at,
|
||||
'evidence_count', v_evidence_count,
|
||||
'turn_count', v_turn_count
|
||||
),
|
||||
'turns', v_turns,
|
||||
'evidence', v_evidence,
|
||||
'conversation_summary', jsonb_build_object(
|
||||
'confirmed_evidence_summary', v_summary.confirmed_evidence_summary,
|
||||
'pending_revisions', v_summary.pending_revisions,
|
||||
'active_focus', v_summary.active_focus,
|
||||
'declined_skipped_topics', v_summary.declined_skipped_topics,
|
||||
'candidate_divergence_summary', v_summary.candidate_divergence_summary,
|
||||
'missing_evidence_categories', v_summary.missing_evidence_categories,
|
||||
'last_result_policy', v_summary.last_result_policy,
|
||||
'summary_version', v_summary.summary_version,
|
||||
'updated_at', v_summary.updated_at
|
||||
),
|
||||
'latest_result', case when v_result.id is null then null else jsonb_build_object(
|
||||
'result_id', v_result.id,
|
||||
'candidates', v_result.candidates,
|
||||
'overall_confidence', v_result.overall_confidence,
|
||||
'display_allowed', v_result.display_allowed,
|
||||
'selection_allowed', v_result.selection_allowed,
|
||||
'confirmation_allowed', v_result.confirmation_allowed,
|
||||
'representative_time', v_result.representative_time,
|
||||
'selected_candidate_id', v_result.selected_candidate_id,
|
||||
'selected_time', v_result.selected_time,
|
||||
'selection_kind', v_result.selection_kind,
|
||||
'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint,
|
||||
'candidate_range_fingerprint', v_result.candidate_range_fingerprint,
|
||||
'skill_version', v_result.skill_version,
|
||||
'algorithm_version', v_result.algorithm_version,
|
||||
'event_contract_version', v_result.event_contract_version,
|
||||
'decision_policy_version', v_result.decision_policy_version,
|
||||
'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt),
|
||||
'execution_ledger', v_result.execution_ledger,
|
||||
'created_at', v_result.created_at,
|
||||
'invalidated_at', v_result.invalidated_at
|
||||
) end
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
revoke all on function public.get_agentic_rectification_case_dossier(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.get_agentic_rectification_case_dossier(uuid, uuid)
|
||||
to service_role;
|
||||
|
||||
commit;
|
||||
@@ -5,8 +5,18 @@ import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-p
|
||||
import {
|
||||
answersFromEvidence,
|
||||
applyAnswerToState,
|
||||
applySupersedeAnswer,
|
||||
buildInferenceState,
|
||||
replayInferenceState,
|
||||
} from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import {
|
||||
composeInferenceReceipt,
|
||||
} from "../src/lib/rectification-agentic/core/compose-receipt.ts";
|
||||
import {
|
||||
decisionStateFingerprint,
|
||||
posteriorMap,
|
||||
} from "../src/lib/rectification-agentic/core/decision-fingerprint.ts";
|
||||
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
|
||||
import { HOLDOUT_MESSAGE_PREFIX } from "../src/lib/rectification-agentic/v9/choice-card.ts";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
@@ -346,6 +356,8 @@ test("C without new evidence updates the posterior immediately and D only marks
|
||||
});
|
||||
assert.equal(denied.applied, true);
|
||||
assert.equal(denied.answerClass, "no");
|
||||
assert.equal(denied.state.revision, state.revision + 1);
|
||||
assert.notDeepEqual(posteriorMap(denied.state.candidates), posteriorMap(state.candidates));
|
||||
assert.equal(denied.state.candidates.find((item) => item.id === "05:00")?.status, "eliminated");
|
||||
assert.ok(denied.state.entropy < state.entropy);
|
||||
assert.equal(denied.state.answered_probes.some((item) => item.semantic_key === conflict.semantic_key), true);
|
||||
@@ -411,17 +423,367 @@ test("holdout and collection declines do not write a probe answer", () => {
|
||||
assert.equal(collection.reason, "no_choice");
|
||||
});
|
||||
|
||||
test("choice answers without new evidence patch inference_state in place instead of the candidate cache", () => {
|
||||
test("choice answers append an inference transition instead of patching the candidate cache", () => {
|
||||
const migration = readFileSync(
|
||||
fileURLToPath(new URL("../supabase/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
|
||||
fileURLToPath(new URL("../supabase/migrations/20260824010000_rectification_inference_transition_ledger.sql", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(migration, /create or replace function public\.patch_agentic_rectification_inference_state\(/);
|
||||
assert.match(migration, /jsonb_set\(v_result\.decision_receipt, '\{inference_state\}', p_inference_state, true\)/);
|
||||
assert.doesNotMatch(migration, /persist_agentic_rectification_candidate_v2/);
|
||||
assert.match(migration, /create table if not exists public\.agentic_rectification_inference_transitions \(/);
|
||||
assert.match(migration, /create or replace function public\.append_agentic_rectification_inference_transition\(/);
|
||||
assert.match(migration, /agentic_rectification_revision_conflict/);
|
||||
assert.match(migration, /agentic_rectification_stale_probe/);
|
||||
assert.match(migration, /idempotency_key/);
|
||||
assert.match(migration, /raise exception 'agentic_rectification_inference_patch_retired'/);
|
||||
const appendSql = migration.slice(
|
||||
migration.indexOf("create or replace function public.append_agentic_rectification_inference_transition("),
|
||||
migration.indexOf("create or replace function public.get_agentic_rectification_latest_inference_transition("),
|
||||
);
|
||||
assert.doesNotMatch(appendSql, /update public\.agentic_rectification_results/);
|
||||
assert.match(
|
||||
migration,
|
||||
/compose_agentic_rectification_decision_receipt\(p_case_id, v_cached\.id, v_cached\.decision_receipt\)/,
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/compose_agentic_rectification_decision_receipt\(p_case_id, v_result_id, v_saved_decision_receipt\)/,
|
||||
);
|
||||
assert.match(
|
||||
migration,
|
||||
/compose_agentic_rectification_decision_receipt\(v_case\.id, v_result\.id, v_result\.decision_receipt\)/,
|
||||
);
|
||||
assert.doesNotMatch(migration, /and decision_state_fingerprint = /);
|
||||
assert.equal(
|
||||
existsSync(new URL("../db/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
|
||||
existsSync(new URL("../db/migrations/20260824010000_rectification_inference_transition_ledger.sql", import.meta.url)),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
const retired = readFileSync(
|
||||
fileURLToPath(new URL("../supabase/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(retired, /patch_agentic_rectification_inference_state/);
|
||||
});
|
||||
|
||||
function fingerprintOf(
|
||||
caseId: string,
|
||||
evidenceFp: string,
|
||||
state: { candidate_set_id: string; revision: number; answered_probes: readonly { probe_id: string }[] },
|
||||
): string {
|
||||
return decisionStateFingerprint({
|
||||
caseId,
|
||||
evidenceLedgerFingerprint: evidenceFp,
|
||||
candidateSetId: state.candidate_set_id,
|
||||
inferenceRevision: state.revision,
|
||||
answeredProbeIds: state.answered_probes.map((item) => item.probe_id),
|
||||
scoringPolicyVersion: INFERENCE_ALGORITHM_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
type LedgerRow = {
|
||||
revision: number;
|
||||
probeId: string;
|
||||
answerClass: string;
|
||||
idempotencyKey: string;
|
||||
inferenceState: ReturnType<typeof buildInferenceState>;
|
||||
fingerprint: string;
|
||||
};
|
||||
|
||||
function createLedger(seed: ReturnType<typeof buildInferenceState>) {
|
||||
const rows: LedgerRow[] = [];
|
||||
const evidenceFp = "e".repeat(64);
|
||||
const caseId = "case-1";
|
||||
let engineReceipt: Record<string, unknown> = { inference_state: seed };
|
||||
return {
|
||||
evidenceFp,
|
||||
append(input: {
|
||||
expectedRevision: number;
|
||||
probeId: string;
|
||||
openProbeId: string;
|
||||
answerClass: "yes" | "weak_yes" | "no" | "unsure";
|
||||
idempotencyKey: string;
|
||||
apply: () => ReturnType<typeof buildInferenceState>;
|
||||
}) {
|
||||
const existing = rows.find((row) => row.idempotencyKey === input.idempotencyKey);
|
||||
if (existing) {
|
||||
return { idempotent: true, row: existing, receipt: composeInferenceReceipt(engineReceipt, {
|
||||
resultId: "result-1",
|
||||
revision: existing.revision,
|
||||
probeId: existing.probeId,
|
||||
reason: "choice",
|
||||
decisionStateFingerprint: existing.fingerprint,
|
||||
inferenceState: existing.inferenceState,
|
||||
posteriorBefore: {},
|
||||
posteriorAfter: posteriorMap(existing.inferenceState.candidates),
|
||||
scoreDeltas: {},
|
||||
}, "result-1") };
|
||||
}
|
||||
const current = rows.at(-1)?.revision ?? seed.revision;
|
||||
if (input.expectedRevision !== current) {
|
||||
const error = new Error("agentic_rectification_revision_conflict");
|
||||
throw error;
|
||||
}
|
||||
if (input.probeId !== input.openProbeId) {
|
||||
throw new Error("agentic_rectification_stale_probe");
|
||||
}
|
||||
const next = input.apply();
|
||||
const fingerprint = fingerprintOf(caseId, evidenceFp, next);
|
||||
const row: LedgerRow = {
|
||||
revision: next.revision,
|
||||
probeId: input.probeId,
|
||||
answerClass: input.answerClass,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
inferenceState: next,
|
||||
fingerprint,
|
||||
};
|
||||
rows.push(row);
|
||||
return {
|
||||
idempotent: false,
|
||||
row,
|
||||
receipt: composeInferenceReceipt(engineReceipt, {
|
||||
resultId: "result-1",
|
||||
revision: row.revision,
|
||||
probeId: row.probeId,
|
||||
reason: "choice",
|
||||
decisionStateFingerprint: fingerprint,
|
||||
inferenceState: next,
|
||||
posteriorBefore: {},
|
||||
posteriorAfter: posteriorMap(next.candidates),
|
||||
scoreDeltas: {},
|
||||
}, "result-1"),
|
||||
};
|
||||
},
|
||||
reread() {
|
||||
const latest = rows.at(-1);
|
||||
if (!latest) return composeInferenceReceipt(engineReceipt, null, "result-1");
|
||||
return composeInferenceReceipt(engineReceipt, {
|
||||
resultId: "result-1",
|
||||
revision: latest.revision,
|
||||
probeId: latest.probeId,
|
||||
reason: "choice",
|
||||
decisionStateFingerprint: latest.fingerprint,
|
||||
inferenceState: latest.inferenceState,
|
||||
posteriorBefore: {},
|
||||
posteriorAfter: posteriorMap(latest.inferenceState.candidates),
|
||||
scoreDeltas: {},
|
||||
}, "result-1");
|
||||
},
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
test("evidence fingerprint can stay put while decision-state fingerprint and posterior change", () => {
|
||||
const conflict = probe({
|
||||
id: "p-cd",
|
||||
domain: "career",
|
||||
year: 2019,
|
||||
gain: 0.4,
|
||||
yesSupports: ["05:00"],
|
||||
yesConflicts: ["05:10"],
|
||||
});
|
||||
const state = buildInferenceState({
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 10 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 10 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "education", year: 2016, precision: "month" },
|
||||
{ id: "e2", domain: "career", year: 2018, precision: "year" },
|
||||
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
|
||||
{ id: "e4", domain: "family", year: 2023, precision: "year" },
|
||||
],
|
||||
probes: [conflict],
|
||||
});
|
||||
const after = applyChoiceWithoutEvidence(state, {
|
||||
choiceKey: "C",
|
||||
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
|
||||
});
|
||||
assert.equal(after.applied, true);
|
||||
assert.equal(after.state.revision, state.revision + 1);
|
||||
const evidenceFp = "e".repeat(64);
|
||||
const beforeFp = fingerprintOf("case-1", evidenceFp, state);
|
||||
const afterFp = fingerprintOf("case-1", evidenceFp, after.state);
|
||||
assert.equal(evidenceFp, "e".repeat(64));
|
||||
assert.notEqual(afterFp, beforeFp);
|
||||
const staleReceipt = { inference_state: state, display_allowed: true };
|
||||
const composed = composeInferenceReceipt(staleReceipt, {
|
||||
resultId: "result-1",
|
||||
revision: after.state.revision,
|
||||
probeId: conflict.id,
|
||||
reason: "choice",
|
||||
decisionStateFingerprint: afterFp,
|
||||
inferenceState: after.state,
|
||||
posteriorBefore: posteriorMap(state.candidates),
|
||||
posteriorAfter: posteriorMap(after.state.candidates),
|
||||
scoreDeltas: {},
|
||||
}, "result-1");
|
||||
assert.notDeepEqual(
|
||||
(composed.inference_state as { candidates: unknown }).candidates,
|
||||
(staleReceipt.inference_state as { candidates: unknown }).candidates,
|
||||
);
|
||||
assert.equal(composed.decision_state_fingerprint, afterFp);
|
||||
});
|
||||
|
||||
test("duplicate D is idempotent, C then D supersedes, stale probes and stale revisions are rejected, replay matches", () => {
|
||||
const conflict = probe({
|
||||
id: "p-cd",
|
||||
domain: "career",
|
||||
year: 2019,
|
||||
gain: 0.4,
|
||||
yesSupports: ["05:00"],
|
||||
yesConflicts: ["05:10"],
|
||||
});
|
||||
const other = probe({
|
||||
id: "p-old",
|
||||
domain: "relationship",
|
||||
year: 2021,
|
||||
gain: 0.2,
|
||||
yesSupports: ["05:00"],
|
||||
yesConflicts: ["05:10"],
|
||||
split: "05:00|2021",
|
||||
});
|
||||
const state = buildInferenceState({
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 10 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 10 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "education", year: 2016, precision: "month" },
|
||||
{ id: "e2", domain: "career", year: 2018, precision: "year" },
|
||||
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
|
||||
{ id: "e4", domain: "family", year: 2023, precision: "year" },
|
||||
],
|
||||
probes: [conflict, other],
|
||||
});
|
||||
const ledger = createLedger(state);
|
||||
const firstD = applyChoiceWithoutEvidence(state, {
|
||||
choiceKey: "D",
|
||||
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
|
||||
});
|
||||
assert.equal(firstD.applied, true);
|
||||
const stale = applyChoiceWithoutEvidence(state, {
|
||||
choiceKey: "D",
|
||||
schema: { probe_id: other.id, semantic_key: other.semantic_key },
|
||||
});
|
||||
assert.equal(stale.reason, "stale_probe");
|
||||
assert.deepEqual(posteriorMap(stale.state.candidates), posteriorMap(state.candidates));
|
||||
assert.throws(
|
||||
() => ledger.append({
|
||||
expectedRevision: state.revision,
|
||||
probeId: other.id,
|
||||
openProbeId: conflict.id,
|
||||
answerClass: "unsure",
|
||||
idempotencyKey: `choice:${other.id}:unsure`,
|
||||
apply: () => firstD.state,
|
||||
}),
|
||||
/stale_probe/,
|
||||
);
|
||||
const persisted = ledger.append({
|
||||
expectedRevision: state.revision,
|
||||
probeId: conflict.id,
|
||||
openProbeId: conflict.id,
|
||||
answerClass: "unsure",
|
||||
idempotencyKey: `choice:${conflict.id}:unsure`,
|
||||
apply: () => firstD.state,
|
||||
});
|
||||
assert.equal(persisted.idempotent, false);
|
||||
assert.equal(persisted.row.revision, state.revision + 1);
|
||||
const again = ledger.append({
|
||||
expectedRevision: state.revision,
|
||||
probeId: conflict.id,
|
||||
openProbeId: conflict.id,
|
||||
answerClass: "unsure",
|
||||
idempotencyKey: `choice:${conflict.id}:unsure`,
|
||||
apply: () => firstD.state,
|
||||
});
|
||||
assert.equal(again.idempotent, true);
|
||||
assert.equal(again.row.revision, persisted.row.revision);
|
||||
assert.equal(ledger.rows.length, 1);
|
||||
|
||||
const afterC = applyChoiceWithoutEvidence(state, {
|
||||
choiceKey: "C",
|
||||
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
|
||||
});
|
||||
const superseded = applyChoiceWithoutEvidence(afterC.state, {
|
||||
choiceKey: "D",
|
||||
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
|
||||
});
|
||||
assert.equal(superseded.reason, "superseded");
|
||||
assert.equal(superseded.state.revision, afterC.state.revision + 1);
|
||||
assert.equal(superseded.state.answered_probes.filter((item) => item.probe_id === conflict.id).length, 1);
|
||||
assert.equal(superseded.state.answered_probes[0]?.answer_class, "unsure");
|
||||
const keptC = applySupersedeAnswer(afterC.state, conflict.id, "unsure");
|
||||
assert.equal(keptC.revision, superseded.state.revision);
|
||||
|
||||
const corrections = createLedger(state);
|
||||
const writtenC = corrections.append({
|
||||
expectedRevision: state.revision,
|
||||
probeId: conflict.id,
|
||||
openProbeId: conflict.id,
|
||||
answerClass: "no",
|
||||
idempotencyKey: `choice:${conflict.id}:no`,
|
||||
apply: () => afterC.state,
|
||||
});
|
||||
const writtenD = corrections.append({
|
||||
expectedRevision: afterC.state.revision,
|
||||
probeId: conflict.id,
|
||||
openProbeId: conflict.id,
|
||||
answerClass: "unsure",
|
||||
idempotencyKey: `supersede:${conflict.id}:unsure`,
|
||||
apply: () => superseded.state,
|
||||
});
|
||||
assert.equal(writtenC.idempotent, false);
|
||||
assert.equal(writtenD.idempotent, false);
|
||||
assert.equal(writtenD.row.revision, writtenC.row.revision + 1);
|
||||
assert.equal(corrections.rows.length, 2);
|
||||
assert.equal(corrections.rows[0]?.answerClass, "no");
|
||||
assert.equal(corrections.rows[1]?.answerClass, "unsure");
|
||||
|
||||
assert.throws(
|
||||
() => ledger.append({
|
||||
expectedRevision: state.revision,
|
||||
probeId: conflict.id,
|
||||
openProbeId: conflict.id,
|
||||
answerClass: "no",
|
||||
idempotencyKey: `choice:${conflict.id}:no`,
|
||||
apply: () => afterC.state,
|
||||
}),
|
||||
/revision_conflict/,
|
||||
);
|
||||
|
||||
const reread = ledger.reread();
|
||||
assert.deepEqual(
|
||||
posteriorMap((reread.inference_state as typeof firstD.state).candidates),
|
||||
posteriorMap(firstD.state.candidates),
|
||||
);
|
||||
const replayed = replayInferenceState(state, firstD.state.answered_probes);
|
||||
assert.deepEqual(posteriorMap(replayed.candidates), posteriorMap(firstD.state.candidates));
|
||||
assert.equal(replayed.revision, firstD.state.revision);
|
||||
|
||||
const rescored = buildInferenceState({
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 12 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 8 },
|
||||
{ id: "05:04", time: "05:04", relative_support: 9 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "education", year: 2016, precision: "month" },
|
||||
{ id: "e2", domain: "career", year: 2018, precision: "year" },
|
||||
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
|
||||
{ id: "e4", domain: "family", year: 2023, precision: "year" },
|
||||
],
|
||||
probes: [conflict, other],
|
||||
previous: firstD.state,
|
||||
});
|
||||
assert.notEqual(rescored.candidate_set_id, firstD.state.candidate_set_id);
|
||||
assert.equal(rescored.revision, firstD.state.revision);
|
||||
assert.equal(
|
||||
rescored.answered_probes.some((item) => item.probe_id === conflict.id && item.answer_class === "unsure"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -368,7 +368,7 @@ test("batch preserves three independent items, parses all outcomes, and keeps it
|
||||
);
|
||||
});
|
||||
|
||||
test("resolve-focus C without new evidence patches inference_state on the latest result", async () => {
|
||||
test("resolve-focus C without new evidence appends an inference transition", async () => {
|
||||
const inference = buildInferenceState({
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
@@ -401,7 +401,7 @@ test("resolve-focus C without new evidence patches inference_state on the latest
|
||||
});
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
snapshot.decision_receipt = { ...snapshot.decision_receipt, inference_state: inference };
|
||||
let patchedState: unknown = null;
|
||||
let appendedState: unknown = null;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
@@ -416,18 +416,24 @@ test("resolve-focus C without new evidence patches inference_state on the latest
|
||||
option_c: "没有明显发生",
|
||||
option_d: "不记得 / 不确定",
|
||||
},
|
||||
probe_id: "p-cd",
|
||||
semantic_key: "career.2019",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
patch_agentic_rectification_inference_state: (_fn, args) => {
|
||||
patchedState = args.p_inference_state;
|
||||
append_agentic_rectification_inference_transition: (_fn, args) => {
|
||||
appendedState = args.p_inference_state;
|
||||
return {
|
||||
transition_id: "99999999-9999-4999-8999-999999999999",
|
||||
result_id: RESULT_ID,
|
||||
revision: (args.p_expected_revision as number) + 1,
|
||||
idempotent: false,
|
||||
decision_state_fingerprint: args.p_decision_state_fingerprint,
|
||||
decision_receipt: {
|
||||
...snapshot.decision_receipt,
|
||||
inference_state: args.p_inference_state,
|
||||
decision_state_fingerprint: args.p_decision_state_fingerprint,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -448,11 +454,19 @@ test("resolve-focus C without new evidence patches inference_state on the latest
|
||||
assert.equal(resolved.status, "declined");
|
||||
assert.equal(resolved.evidence_id, null);
|
||||
assert.ok(resolved.inference_state);
|
||||
assert.ok(patchedState);
|
||||
const answers = (patchedState as { answered_probes?: Array<{ answer_class?: string; semantic_key?: string }> }).answered_probes ?? [];
|
||||
assert.ok(appendedState);
|
||||
const answers = (appendedState as { answered_probes?: Array<{ answer_class?: string; semantic_key?: string }> }).answered_probes ?? [];
|
||||
assert.equal(answers.some((item) => item.semantic_key === "career.2019" && item.answer_class === "no"), true);
|
||||
const appendCall = accounting.calls.find((call) => call.fn === "append_agentic_rectification_inference_transition");
|
||||
assert.ok(appendCall);
|
||||
assert.equal(appendCall.args.p_probe_id, "p-cd");
|
||||
assert.equal(appendCall.args.p_reason, "choice");
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "persist_agentic_rectification_candidate_v2"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "patch_agentic_rectification_inference_state"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -456,10 +456,26 @@ test("a failed opening does not let the next turn skip the server Skill load gat
|
||||
});
|
||||
|
||||
test("same evidence + range fingerprints reuse the cached candidate snapshot", async () => {
|
||||
const inputReceipt = {
|
||||
receipt_version: "candidate-decision-receipt-v2",
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
representative_candidate_id: null,
|
||||
overall_confidence: "medium",
|
||||
inference_state: { revision: 1, candidates: [{ id: "05:02", posterior_score: 10 }] },
|
||||
};
|
||||
const composedReceipt = {
|
||||
...inputReceipt,
|
||||
inference_state: { revision: 2, candidates: [{ id: "05:02", posterior_score: 18 }] },
|
||||
decision_state_fingerprint: "a".repeat(64),
|
||||
};
|
||||
const accounting = fakeAccounting({
|
||||
persist_agentic_rectification_candidate_v2: () => ({
|
||||
...candidateSnapshotFixture(),
|
||||
cached: true,
|
||||
decision_receipt: composedReceipt,
|
||||
}),
|
||||
});
|
||||
const cached = await persistV9Candidate(accounting.client, USER_ID, CASE_ID, {
|
||||
@@ -472,15 +488,7 @@ test("same evidence + range fingerprints reuse the cached candidate snapshot", a
|
||||
eventContractVersion: "rectification-event-contract-v2",
|
||||
policyVersion: "rectification-candidate-policy-v2",
|
||||
candidates: [{ candidateId: CANDIDATE_ID, rank: 1, time: "05:02", relativeSupport: 58, tiedMinuteCount: 2 }],
|
||||
decisionReceipt: {
|
||||
receipt_version: "candidate-decision-receipt-v2",
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
representative_candidate_id: null,
|
||||
overall_confidence: "medium",
|
||||
},
|
||||
decisionReceipt: inputReceipt,
|
||||
executionLedger: [{ method: "d1-rashi", status: "executed" }],
|
||||
});
|
||||
assert.equal(cached.cached, true);
|
||||
@@ -491,12 +499,20 @@ test("same evidence + range fingerprints reuse the cached candidate snapshot", a
|
||||
assert.equal(call.args.p_skill_version, "9.0.0");
|
||||
assert.equal(call.args.p_event_contract_version, "rectification-event-contract-v2");
|
||||
assert.equal(call.args.p_decision_policy_version, "rectification-candidate-policy-v2");
|
||||
assert.deepEqual(call.args.p_decision_receipt, cached.decisionReceipt);
|
||||
assert.deepEqual(call.args.p_decision_receipt, inputReceipt);
|
||||
assert.notDeepEqual(cached.decisionReceipt, inputReceipt);
|
||||
assert.deepEqual(cached.decisionReceipt, composedReceipt);
|
||||
assert.equal(
|
||||
(cached.decisionReceipt.inference_state as { revision?: number }).revision,
|
||||
2,
|
||||
);
|
||||
assert.equal(cached.decisionReceipt.decision_state_fingerprint, "a".repeat(64));
|
||||
assert.deepEqual(call.args.p_execution_ledger, cached.executionLedger);
|
||||
assert.equal("p_selection_allowed" in call.args, false);
|
||||
assert.equal("p_confirmation_allowed" in call.args, false);
|
||||
assert.equal("p_representative_time" in call.args, false);
|
||||
assert.equal("p_margin_percent" in call.args, false);
|
||||
assert.equal("p_decision_state_fingerprint" in call.args, false);
|
||||
});
|
||||
|
||||
test("accept-candidate requires a server-persisted result; no tool means no minute", async () => {
|
||||
|
||||
@@ -664,4 +664,16 @@ test("rpc errors map to safe public views without leaking database text", async
|
||||
const publicLegacy = mapRectificationRpcError(new Error("agentic_rectification_skill_identity_unverifiable"));
|
||||
assert.equal(publicLegacy.status, 409);
|
||||
assert.equal(publicLegacy.code, "skill_identity_unverifiable");
|
||||
|
||||
const staleProbe = mapRectificationRpcError(new Error("agentic_rectification_stale_probe"));
|
||||
assert.equal(staleProbe.status, 409);
|
||||
assert.equal(staleProbe.code, "stale_probe");
|
||||
|
||||
const revisionConflict = mapRectificationRpcError(new Error("agentic_rectification_revision_conflict"));
|
||||
assert.equal(revisionConflict.status, 409);
|
||||
assert.equal(revisionConflict.code, "revision_conflict");
|
||||
|
||||
const patchRetired = mapRectificationRpcError(new Error("agentic_rectification_inference_patch_retired"));
|
||||
assert.equal(patchRetired.status, 409);
|
||||
assert.equal(patchRetired.code, "inference_patch_retired");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user