825 lines
36 KiB
TypeScript
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 };
|