Files
Jyotisha/frontend/src/mastra/rectification-v9-tools.ts
T
Jesse_Chen 2e17ccad64
Independent Staging Quality Gate / validate (push) Failing after 30m22s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(rectification): restore message actions and retry receipts
2026-08-14 00:10:22 +08:00

825 lines
36 KiB
TypeScript

/**
* V9 rectification tools (Case-ref only).
*
* The model may only pass minimal references: caseId, quote, proposedKind,
* evidenceId, resultId, candidateId (plus optional date fields
* the model grounded in the user's own words). userId, birth data, candidate
* ranges, event arrays, scores and permission booleans never appear in tool
* inputs. Every tool authenticates through the Case row, validates ownership
* / status / skill version, reads the PostgreSQL evidence ledger, persists a
* tool receipt and returns a compact safe projection.
*/
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { PublicRectificationMethod } from "@/lib/rectification-agentic/v9/public-receipt";
import {
canonicalToolInputFingerprint,
candidateRangeFingerprint,
evidenceLedgerFingerprint,
insertV9ToolReceipt,
loadV9CaseCompute,
loadV9CaseDossier,
proposeV9Evidence,
confirmV9Evidence,
reviseV9Evidence,
transitionV9CaseStatus,
persistV9Candidate,
acceptV9Candidate,
confirmV9BirthTime,
closeV9Case,
safeToolErrorCode,
scorableEvidence,
RectificationToolServiceError,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import { isEvidenceKind, isEvidenceDomain, isDatePrecision } from "@/lib/rectification-agentic/v9/evidence-model";
import {
isResumableStatus,
RECTIFICATION_SKILL_NAME,
RECTIFICATION_SKILL_VERSION,
type RectificationCaseStatus,
} from "@/lib/rectification-agentic/v9/case-status";
import {
runV9CandidateScore,
runV9Diagnostics,
toEngineEvents,
v9EngineVersion,
type V9EngineScoreResult,
} from "@/lib/rectification-agentic/v9/engine-client";
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export type RectificationV9Context = Readonly<{
userId: string;
caseId: string;
turnId: string;
accounting: SupabaseClient;
engineBase?: string;
}>;
const dateLike = /^\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?$/;
function safeBirthContext(compute: Awaited<ReturnType<typeof loadV9CaseCompute>>): Record<string, unknown> {
const snapshot = compute.baselineBirthSnapshot;
return {
birth_date: String(snapshot.birth_date ?? ""),
reported_birth_time: typeof snapshot.reported_birth_time === "string" ? snapshot.reported_birth_time : null,
active_birth_time: typeof snapshot.active_birth_time === "string" ? snapshot.active_birth_time : null,
candidate_range: compute.candidateRange,
birthplace: {
label: String(snapshot.birth_place_label ?? ""),
latitude: Number(snapshot.latitude),
longitude: Number(snapshot.longitude),
},
timezone: {
id: String(snapshot.timezone_id ?? ""),
offset: Number(snapshot.timezone_offset),
},
};
}
function safeCaseProjection(
dossier: ReturnType<typeof parseDossierForTools>,
compute: Awaited<ReturnType<typeof loadV9CaseCompute>>,
): Record<string, unknown> {
const caseRow = dossier.case;
const latest = dossier.latestResult;
return {
case_id: caseRow.caseId,
status: caseRow.status,
skill_name: caseRow.skillName,
skill_version: caseRow.skillVersion,
candidate_range: caseRow.candidateRange,
accepted_time: caseRow.acceptedTime,
confirmed_time: caseRow.confirmedTime,
completed_at: caseRow.completedAt,
closed_reason: caseRow.closedReason,
evidence_count: caseRow.evidenceCount,
turn_count: caseRow.turnCount,
evidence_summary: {
confirmed: dossier.confirmedEvidence.length,
pending: dossier.pendingEvidence.length,
by_domain: dossier.domainCounts,
by_kind: dossier.kindCounts,
scorable: dossier.scorable.length,
},
evidence_context: safeEvidenceContext(dossier),
conversation_context: safeConversationContext(dossier),
birth_context: safeBirthContext(compute),
latest_result: latest
? {
result_id: latest.resultId,
candidates: latest.candidates,
selection_allowed: latest.selectionAllowed,
confirmation_allowed: latest.confirmationAllowed,
representative_time: latest.representativeTime,
selected_time: latest.selectedTime,
selection_kind: latest.selectionKind,
algorithm_version: latest.algorithmVersion,
}
: null,
};
}
type DossierForTools = {
case: {
caseId: string;
sessionId: string;
status: RectificationCaseStatus | string;
skillName: string;
skillVersion: string;
candidateRange: { start_time: string; end_time: string } | null;
acceptedTime: string | null;
confirmedTime: string | null;
completedAt: string | null;
closedReason: string | null;
evidenceCount: number;
turnCount: number;
};
evidence: V9CaseDossier["evidence"];
confirmedEvidence: V9CaseDossier["evidence"];
pendingEvidence: V9CaseDossier["evidence"];
scorable: ReturnType<typeof scorableEvidence>;
domainCounts: Record<string, number>;
kindCounts: Record<string, number>;
latestResult: {
resultId: string;
candidates: readonly { rank: number; time: string; relative_support: number; tied_minute_count: number }[];
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
selectedTime: string | null;
selectionKind: string | null;
algorithmVersion: string | null;
} | null;
turns: V9CaseDossier["turns"];
};
const SAFE_EVIDENCE_CONTEXT_LIMIT = 20;
const SAFE_RECENT_TURNS_LIMIT = 8;
const SAFE_CONVERSATION_SCAN_LIMIT = 24;
const SAFE_TURN_TEXT_LIMIT = 1600;
const SAFE_DECLINED_TARGETS_LIMIT = 3;
const declinedReplyPattern = /(?:不知道|不清楚|记不清|想不起来|不想回答|不想说|换个方向|换个问题|先不说|目前没有|没有更多)/;
function safeConversationTurns(dossier: DossierForTools) {
return dossier.turns.slice(-SAFE_CONVERSATION_SCAN_LIMIT).flatMap((turn) => {
const text = turn.text?.trim();
const isVisibleTurn = turn.status === "completed"
|| (turn.role === "user" && turn.status === "pending");
if (!text || !isVisibleTurn) return [];
return [{ role: turn.role, text: text.slice(0, SAFE_TURN_TEXT_LIMIT) }];
});
}
function isDeclinedReply(text: string): boolean {
return declinedReplyPattern.test(text);
}
function safeEvidenceContext(dossier: DossierForTools) {
return dossier.evidence.slice(-SAFE_EVIDENCE_CONTEXT_LIMIT).map((item) => ({
evidence_id: item.id,
status: item.status,
summary: item.summary,
event_kind: item.eventKind,
domain: item.domain,
date_precision: item.datePrecision,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
}));
}
function safeConversationContext(dossier: DossierForTools) {
const conversationTurns = safeConversationTurns(dossier);
const recentTurns = conversationTurns.slice(-SAFE_RECENT_TURNS_LIMIT);
const declinedTargets: string[] = [];
for (let index = 1; index < conversationTurns.length; index += 1) {
const previous = conversationTurns[index - 1];
const current = conversationTurns[index];
if (
previous?.role === "assistant"
&& current?.role === "user"
&& isDeclinedReply(current.text)
&& !declinedTargets.includes(previous.text)
) {
declinedTargets.push(previous.text);
}
}
const latestTurn = conversationTurns.at(-1);
const pendingRevision = [...dossier.evidence]
.reverse()
.find((item) => item.status === "pending_confirmation" && item.supersedesEvidenceId);
const activeFollowup = latestTurn?.role === "user" && isDeclinedReply(latestTurn.text)
? null
: pendingRevision
? {
evidence_id: pendingRevision.id,
intent: "confirm_revision",
missing_fields: [],
}
: null;
return {
active_followup: activeFollowup,
recent_turns: recentTurns,
declined_targets: declinedTargets.slice(-SAFE_DECLINED_TARGETS_LIMIT),
};
}
function parseDossierForTools(dossier: V9CaseDossier): DossierForTools {
const confirmedEvidence = dossier.evidence.filter((item) => item.status === "confirmed");
const pendingEvidence = dossier.evidence.filter((item) => item.status === "pending_confirmation" || item.status === "draft");
const scorable = scorableEvidence(dossier.evidence);
const domainCounts: Record<string, number> = {};
const kindCounts: Record<string, number> = {};
for (const item of scorable) {
domainCounts[item.domain] = (domainCounts[item.domain] ?? 0) + 1;
kindCounts[item.eventKind] = (kindCounts[item.eventKind] ?? 0) + 1;
}
return {
case: {
caseId: dossier.case.caseId,
sessionId: dossier.case.sessionId,
status: dossier.case.status,
skillName: dossier.case.skillName,
skillVersion: dossier.case.skillVersion,
candidateRange: dossier.case.candidateRange,
acceptedTime: dossier.case.acceptedTime,
confirmedTime: dossier.case.confirmedTime,
completedAt: dossier.case.completedAt,
closedReason: dossier.case.closedReason,
evidenceCount: dossier.case.evidenceCount,
turnCount: dossier.case.turnCount,
},
evidence: dossier.evidence,
confirmedEvidence,
pendingEvidence,
scorable,
domainCounts,
kindCounts,
latestResult: dossier.latestResult
? {
resultId: dossier.latestResult.resultId,
candidates: dossier.latestResult.candidates,
selectionAllowed: dossier.latestResult.selectionAllowed,
confirmationAllowed: dossier.latestResult.confirmationAllowed,
representativeTime: dossier.latestResult.representativeTime,
selectedTime: dossier.latestResult.selectedTime,
selectionKind: dossier.latestResult.selectionKind,
algorithmVersion: dossier.latestResult.algorithmVersion,
}
: null,
turns: dossier.turns,
};
}
function assertCaseRef(input: { caseId?: unknown }): void {
if (typeof input.caseId !== "string" || !uuidPattern.test(input.caseId)) {
throw new RectificationToolServiceError("invalid_case_id");
}
}
function assertEvidenceRef(input: { evidenceId?: unknown }): string {
if (typeof input.evidenceId !== "string" || !uuidPattern.test(input.evidenceId)) {
throw new RectificationToolServiceError("invalid_evidence_id");
}
return input.evidenceId;
}
function assertCandidateRef(input: { resultId?: unknown; candidateId?: unknown }): { resultId: string; candidateId: string } {
if (
typeof input.resultId !== "string" || !uuidPattern.test(input.resultId)
|| typeof input.candidateId !== "string" || !timePattern.test(input.candidateId)
) {
throw new RectificationToolServiceError("invalid_candidate_ref");
}
return { resultId: input.resultId, candidateId: input.candidateId };
}
function normalizeDatePart(value: string): string | null {
const trimmed = value.trim();
if (!dateLike.test(trimmed)) return null;
const parts = trimmed.split("-");
if (parts.length === 1) return `${parts[0]}-01-01`;
if (parts.length === 2) return `${parts[0]}-${parts[1]!.padStart(2, "0")}-01`;
return `${parts[0]}-${parts[1]!.padStart(2, "0")}-${parts[2]!.padStart(2, "0")}`;
}
export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context) {
const { accounting, userId } = ctx;
const readCaseTool = createTool({
id: "rectification-read-case",
description:
"只读加载当前服务端 Case、受限事件摘要、近期对话上下文和权威出生上下文。重新生成回答时只允许调用本工具,不写证据、候选、Case、turn 或计费数据。input 只允许 caseId。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const compute = await loadV9CaseCompute(accounting, userId, input.caseId);
return safeCaseProjection(parseDossierForTools(dossier), compute);
},
});
return { "rectification-read-case": readCaseTool };
}
export function createRectificationV9Tools(ctx: RectificationV9Context) {
const { accounting, userId, caseId, turnId } = ctx;
const engineVersion = v9EngineVersion();
const receipt = async (
toolName: string,
publicPhase: string,
status: "started" | "completed" | "failed" | "skipped",
extra: { inputFingerprint?: string | null; resultFingerprint?: string | null; engineVersion?: string | null; safeErrorCode?: string | null; executedMethods?: readonly PublicRectificationMethod[] } = {},
) => {
try {
await insertV9ToolReceipt(accounting, userId, caseId, turnId, {
toolName,
publicPhase,
status,
inputFingerprint: extra.inputFingerprint ?? null,
resultFingerprint: extra.resultFingerprint ?? null,
engineVersion: extra.engineVersion ?? null,
safeErrorCode: extra.safeErrorCode ?? null,
executedMethods: extra.executedMethods ?? [],
});
} catch (error) {
// Receipt persistence must never break the tool result; it is auditable
// best-effort and surfaced in the run receipt.
console.warn(`[rectification-v9] receipt write failed tool=${toolName} reason=${safeToolErrorCode(error)}`);
}
};
const hashResult = (value: unknown): string => {
try {
return canonicalToolInputFingerprint("result", { value: JSON.stringify(value) });
} catch {
return "";
}
};
const readCaseTool = createTool({
id: "rectification-read-case",
description:
"读取服务端 Case、受限事件摘要、近期对话上下文和权威出生上下文。conversation_context 只用于理解当前追问、承接回答与已拒绝目标;evidence_context 只含必要引用和事实摘要;birth_context 来自服务端,必须原样使用。任何一轮开始必须先调用本工具。input 只允许 caseId。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-read-case", input);
await receipt("rectification-read-case", "case.loaded", "started", { inputFingerprint });
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const compute = await loadV9CaseCompute(accounting, userId, input.caseId);
const projection = safeCaseProjection(parseDossierForTools(dossier), compute);
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
return projection;
} catch (error) {
await receipt("rectification-read-case", "case.loaded", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
const proposeEvidenceTool = createTool({
id: "rectification-propose-evidence",
description:
"为当前用户轮次中的一件清晰事件提出证据草稿;同一轮有多件可清楚拆分的事件时,每件分别调用一次。quote 必须匹配当前用户原文,日期精度如实保留,summary 不得补充原文没有的事实。进度提问、原因提问、拒答或单独确认词不是新事件,不得调用。返回服务器生成的 evidenceId。",
inputSchema: z.object({
caseId: z.string().uuid(),
quote: z.string().trim().min(2).max(400),
proposedKind: z.string().trim().min(1).max(48),
subject: z.enum(["self", "family", "other"]).default("self"),
domain: z.string().trim().min(1).max(32),
datePrecision: z.enum(["year", "month", "day", "range", "unknown"]),
occurredFrom: z.string().trim().max(10).optional(),
occurredTo: z.string().trim().max(10).optional(),
summary: z.string().trim().min(1).max(1000),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
if (!isEvidenceKind(input.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind");
if (!isEvidenceDomain(input.domain)) throw new RectificationToolServiceError("invalid_domain");
if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision");
const inputFingerprint = canonicalToolInputFingerprint("rectification-propose-evidence", {
caseId: input.caseId,
sourceTurnId: turnId,
quote: input.quote,
proposedKind: input.proposedKind,
});
await receipt("rectification-propose-evidence", "evidence.proposed", "started", { inputFingerprint });
try {
const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null;
const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null;
const result = await proposeV9Evidence(accounting, userId, input.caseId, {
sourceTurnId: turnId,
quote: input.quote,
subject: input.subject,
eventKind: input.proposedKind,
domain: input.domain,
occurredFrom,
occurredTo,
datePrecision: input.datePrecision,
summary: input.summary,
});
await receipt("rectification-propose-evidence", "evidence.proposed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(result),
});
return {
evidence_id: result.evidenceId,
status: "draft",
idempotent: result.idempotent,
note: "草稿证据已记录。当前轮主动、明确且无歧义的一件或多件可拆分事件,应在各自 proposal 成功后逐条调用 rectification-confirm-evidence;模糊、冲突或修订事件才等待用户补充或确认。",
};
} catch (error) {
await receipt("rectification-propose-evidence", "evidence.proposed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
const confirmEvidenceTool = createTool({
id: "rectification-confirm-evidence",
description:
"通过服务器确认路径确认既有证据。当前轮主动、明确且无歧义的一件或多件可拆分事件,在各自 propose-evidence 成功后应逐条同轮调用;用户明确确认既有 pending draft 时也可调用。不得确认助手文本、模型推断、历史摘要、模糊或冲突事实。",
inputSchema: z.object({
caseId: z.string().uuid(),
evidenceId: z.string().uuid(),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const evidenceId = assertEvidenceRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-evidence", input);
await receipt("rectification-confirm-evidence", "evidence.confirmed", "started", { inputFingerprint });
try {
const result = await confirmV9Evidence(accounting, userId, input.caseId, evidenceId);
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
if (isResumableStatus(dossier.case.status as RectificationCaseStatus)) {
await transitionV9CaseStatus(accounting, userId, input.caseId, "collecting_evidence");
}
await receipt("rectification-confirm-evidence", "evidence.confirmed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(result),
});
return {
evidence_id: result.evidenceId,
status: result.status,
idempotent: result.idempotent,
};
} catch (error) {
await receipt("rectification-confirm-evidence", "evidence.confirmed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
const reviseEvidenceTool = createTool({
id: "rectification-revise-evidence",
description:
"修订既有证据的事实(通常是用户更正日期)。旧记录标记 superseded,生成新 revision,不覆盖历史。quote 必须来自用户本轮原话;日期精度如实保留。",
inputSchema: z.object({
caseId: z.string().uuid(),
evidenceId: z.string().uuid(),
quote: z.string().trim().min(2).max(400),
datePrecision: z.enum(["year", "month", "day", "range", "unknown"]),
occurredFrom: z.string().trim().max(10).optional(),
occurredTo: z.string().trim().max(10).optional(),
summary: z.string().trim().min(1).max(1000),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const evidenceId = assertEvidenceRef(input);
if (!isDatePrecision(input.datePrecision)) throw new RectificationToolServiceError("invalid_date_precision");
const inputFingerprint = canonicalToolInputFingerprint("rectification-revise-evidence", {
caseId: input.caseId,
evidenceId,
quote: input.quote,
datePrecision: input.datePrecision,
});
await receipt("rectification-revise-evidence", "evidence.proposed", "started", { inputFingerprint });
try {
const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null;
const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null;
const result = await reviseV9Evidence(accounting, userId, input.caseId, {
evidenceId,
quote: input.quote,
occurredFrom,
occurredTo,
datePrecision: input.datePrecision,
summary: input.summary,
});
await receipt("rectification-revise-evidence", "evidence.proposed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(result),
});
return {
evidence_id: result.evidenceId,
supersedes_evidence_id: result.supersedesEvidenceId,
status: "pending_confirmation",
idempotent: result.idempotent,
};
} catch (error) {
await receipt("rectification-revise-evidence", "evidence.proposed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
const compareCandidatesTool = createTool({
id: "rectification-compare-candidates",
description:
"用当前已确认证据运行候选比较。服务端按事件的实际领域选择分盘,并结合 Vimshottari、Narayana 与稳健性诊断。相同证据指纹会直接复用缓存,不重复计算。返回候选时间与相对支持度(不是概率),不返回原始分数与权重。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-compare-candidates", input);
await receipt("rectification-compare-candidates", "candidates.comparing", "started", { inputFingerprint, engineVersion });
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const parsed = parseDossierForTools(dossier);
if (parsed.scorable.length === 0) {
throw new RectificationToolServiceError("no_scorable_evidence");
}
if (!parsed.case.candidateRange) throw new RectificationToolServiceError("case_range_missing");
const compute = await loadV9CaseCompute(accounting, userId, input.caseId);
const evidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence);
const rangeFingerprint = candidateRangeFingerprint(
parsed.case.candidateRange,
compute.baselineProfileFingerprint,
);
const events = toEngineEvents(scorableEvidence(dossier.evidence));
// Engine errors (including no_scorable_evidence after the V9 evidence
// -> engine vocabulary mapping) must fail the tool honestly; cached
// snapshots are only reused by the persist RPC's fingerprint cache.
const score: V9EngineScoreResult = await runV9CandidateScore({
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events,
});
const persisted = await persistV9Candidate(accounting, userId, input.caseId, {
engineResultId: score.engineResultId,
algorithmVersion: score.algorithmVersion,
evidenceFingerprint,
rangeFingerprint,
skillVersion: RECTIFICATION_SKILL_VERSION,
candidateRange: parsed.case.candidateRange,
candidates: score.candidates,
overallConfidence: score.overallConfidence,
marginPercent: score.marginPercent,
selectionAllowed: score.selectionAllowed,
confirmationAllowed: score.confirmationAllowed,
representativeTime: score.representativeTime,
});
const projection = {
result_id: persisted.resultId,
cached: persisted.cached,
candidate_range: parsed.case.candidateRange,
candidates: persisted.candidates,
overall_confidence: persisted.overallConfidence,
selection_allowed: persisted.selectionAllowed,
confirmation_allowed: persisted.confirmationAllowed,
representative_time: persisted.representativeTime,
algorithm_version: persisted.algorithmVersion,
evidence_count: parsed.scorable.length,
domain_count: Object.keys(parsed.domainCounts).length,
};
await receipt("rectification-compare-candidates", "candidates.comparing", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
engineVersion: persisted.algorithmVersion ?? engineVersion,
executedMethods: score.executedMethods,
});
return { ...projection, executed_methods: score.executedMethods };
} catch (error) {
await receipt("rectification-compare-candidates", "candidates.comparing", "failed", {
inputFingerprint,
engineVersion,
safeErrorCode: safeToolErrorCode(error),
});
throw error;
}
},
});
const readDiagnosticsTool = createTool({
id: "rectification-read-diagnostics",
description:
"读取候选稳健性诊断:留一事件/留一领域保留率、日期敏感性、邻域稳定性、不稳定事件与最能区分候选的技法层。用于决定下一问补哪个方向。不返回原始分数。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-read-diagnostics", input);
await receipt("rectification-read-diagnostics", "diagnostics.completed", "started", { inputFingerprint, engineVersion });
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const parsed = parseDossierForTools(dossier);
if (parsed.scorable.length === 0) {
throw new RectificationToolServiceError("no_scorable_evidence");
}
if (!parsed.case.candidateRange) throw new RectificationToolServiceError("case_range_missing");
const compute = await loadV9CaseCompute(accounting, userId, input.caseId);
const diagnostics = await runV9Diagnostics({
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events: toEngineEvents(scorableEvidence(dossier.evidence)),
});
const projection = {
engine_result_id: diagnostics.engineResultId,
algorithm_version: diagnostics.algorithmVersion,
diagnostics: diagnostics.diagnostics,
missing_layers: diagnostics.missingLayers,
can_confirm_exact_minute: diagnostics.canConfirmExactMinute,
};
await receipt("rectification-read-diagnostics", "diagnostics.completed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
engineVersion: diagnostics.algorithmVersion,
executedMethods: diagnostics.executedMethods,
});
return { ...projection, executed_methods: diagnostics.executedMethods };
} catch (error) {
await receipt("rectification-read-diagnostics", "diagnostics.completed", "failed", {
inputFingerprint,
engineVersion,
safeErrorCode: safeToolErrorCode(error),
});
throw error;
}
},
});
const offerCandidatesTool = createTool({
id: "rectification-offer-candidates",
description:
"把已持久化的候选快照呈现给用户(当前候选/相对支持度,不是概率或确定性)。调用前必须已运行 compare-candidates。不会在同一回复中要求继续补证据。",
inputSchema: z.object({ caseId: z.string().uuid() }).strict(),
execute: async (input) => {
assertCaseRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-offer-candidates", input);
await receipt("rectification-offer-candidates", "candidates.updated", "started", { inputFingerprint });
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const parsed = parseDossierForTools(dossier);
if (!parsed.latestResult) {
throw new RectificationToolServiceError("no_candidate_result");
}
const latest = parsed.latestResult;
if (isResumableStatus(dossier.case.status as RectificationCaseStatus)) {
await transitionV9CaseStatus(accounting, userId, input.caseId, "candidate_ready");
}
const projection = {
result_id: latest.resultId,
candidates: latest.candidates,
selection_allowed: latest.selectionAllowed,
confirmation_allowed: latest.confirmationAllowed,
representative_time: latest.representativeTime,
selected_time: latest.selectedTime,
selection_kind: latest.selectionKind,
};
await receipt("rectification-offer-candidates", "candidates.updated", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
return projection;
} catch (error) {
await receipt("rectification-offer-candidates", "candidates.updated", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
const acceptCandidateTool = createTool({
id: "rectification-accept-candidate",
description:
"用户明确选择某个候选排盘时间后采用。服务器校验候选属于当前用户/Case/Result 快照且 profile 基线未变。采用状态是 accepted(“校正采用时间”),不是引擎确认。",
inputSchema: z.object({
caseId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(timePattern),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId });
const inputFingerprint = canonicalToolInputFingerprint("rectification-accept-candidate", input);
await receipt("rectification-accept-candidate", "candidate.accepted", "started", { inputFingerprint });
try {
const result = await acceptV9Candidate(accounting, userId, input.caseId, resultId, input.candidateId);
const projection = {
saved_time: result.savedTime,
status: result.status,
case_status: result.caseStatus,
result_id: result.resultId,
idempotent: result.idempotent,
};
await receipt("rectification-accept-candidate", "candidate.accepted", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
return projection;
} catch (error) {
await receipt("rectification-accept-candidate", "candidate.accepted", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
const confirmBirthTimeTool = createTool({
id: "rectification-confirm-birth-time",
description:
"仅在服务器确认门允许且用户本轮明确同意“就用 HH:MM / 确认这个时间”时调用。consentQuote 必须是用户原话片段,会在服务端做原文匹配。成功后写入 profile 并进入 confirmed 终态。",
inputSchema: z.object({
caseId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(timePattern),
consentQuote: z.string().trim().min(2).max(400),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const { resultId } = assertCandidateRef({ resultId: input.resultId, candidateId: input.candidateId });
const inputFingerprint = canonicalToolInputFingerprint("rectification-confirm-birth-time", {
caseId: input.caseId,
resultId,
candidateId: input.candidateId,
});
await receipt("rectification-confirm-birth-time", "birth_time.confirmed", "started", { inputFingerprint });
try {
const result = await confirmV9BirthTime(accounting, userId, input.caseId, {
resultId,
time: input.candidateId,
consentQuote: input.consentQuote,
sourceTurnId: turnId,
});
const projection = {
saved_time: result.savedTime,
status: result.status,
case_status: result.caseStatus,
result_id: result.resultId,
idempotent: result.idempotent,
};
await receipt("rectification-confirm-birth-time", "birth_time.confirmed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
return projection;
} catch (error) {
await receipt("rectification-confirm-birth-time", "birth_time.confirmed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
const closeCaseTool = createTool({
id: "rectification-close-case",
description:
"用户主动结束本次校正(“先到这里/完成了”),Case 转 closed。这是用户选择的完成,不是引擎确认;不会写入 confirmed。",
inputSchema: z.object({
caseId: z.string().uuid(),
reason: z.enum(["completed_by_user", "abandoned_by_user", "other"]).default("completed_by_user"),
}).strict(),
execute: async (input) => {
assertCaseRef(input);
const inputFingerprint = canonicalToolInputFingerprint("rectification-close-case", input);
await receipt("rectification-close-case", "run.completed", "started", { inputFingerprint });
try {
const result = await closeV9Case(accounting, userId, input.caseId, input.reason);
const projection = {
case_id: result.caseId,
status: result.status,
idempotent: result.idempotent,
};
await receipt("rectification-close-case", "run.completed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
return projection;
} catch (error) {
await receipt("rectification-close-case", "run.completed", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
throw error;
}
},
});
return {
"rectification-read-case": readCaseTool,
"rectification-propose-evidence": proposeEvidenceTool,
"rectification-confirm-evidence": confirmEvidenceTool,
"rectification-revise-evidence": reviseEvidenceTool,
"rectification-compare-candidates": compareCandidatesTool,
"rectification-read-diagnostics": readDiagnosticsTool,
"rectification-offer-candidates": offerCandidatesTool,
"rectification-accept-candidate": acceptCandidateTool,
"rectification-confirm-birth-time": confirmBirthTimeTool,
"rectification-close-case": closeCaseTool,
};
}
export type RectificationV9Tools = ReturnType<typeof createRectificationV9Tools>;
export const RECTIFICATION_V9_SKILL_NAME = RECTIFICATION_SKILL_NAME;
export const RECTIFICATION_V9_SKILL_VERSION = RECTIFICATION_SKILL_VERSION;
export { timePattern as rectificationV9TimePattern };