732 lines
32 KiB
TypeScript
732 lines
32 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,
|
||
} 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/other;eventKind 使用受控枚举;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 };
|