Files
Jyotisha/frontend/src/mastra/rectification-v9-tools.ts
T
Jesse_Chen 7c03c1a4b5
Staging Backend Quality Gate / validate (pull_request) Successful in 17m59s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
fix(rectification): confirm clear events in same turn
2026-08-12 14:18:48 +08:00

732 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
} 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,
},
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: ReturnType<typeof scorableEvidence>;
confirmedEvidence: readonly unknown[];
pendingEvidence: readonly unknown[];
scorable: readonly unknown[];
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: readonly { id: string; role: string; text: string | null }[];
};
function parseDossierForTools(dossier: import("@/lib/rectification-agentic/v9/tool-service").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: scorable,
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.map((turn) => ({ id: turn.id, role: turn.role, text: turn.text })),
};
}
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 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、证据摘要和权威出生上下文。birth_context 中的日期、地点、经纬度、IANA 时区、偏移、填报/当前时间及候选范围均来自服务端,必须原样使用,不得猜测或替换。任何一轮开始必须先调用本工具。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 必须能规范化匹配当前用户轮次原文;日期只保留用户说过的精度(只说年份则 year);subject 只能 self/family/othereventKind 使用受控枚举;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: "草稿证据已记录。当前轮主动、明确、单一且无歧义的事件应继续调用 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 };