refactor: rebuild birth time rectification agent
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { z } from "zod";
|
||||
import { clockTimeSchema, evidenceDomainSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts";
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const nonblank = (max: number) => z.string().trim().min(1).max(max);
|
||||
|
||||
export const rectificationDiagnosticSchema = z.enum([
|
||||
"leave_one_event_out",
|
||||
"leave_one_domain_out",
|
||||
"date_sensitivity",
|
||||
"neighbor_stability",
|
||||
"candidate_split",
|
||||
]);
|
||||
export type RectificationDiagnostic = z.infer<typeof rectificationDiagnosticSchema>;
|
||||
|
||||
export const rectificationDecisionSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("ask_question"),
|
||||
opportunityId: uuid,
|
||||
narrativeFocus: z.array(z.enum(["latest_event", "candidate_change", "date_precision", "uncertainty"])).max(3),
|
||||
}).strict(),
|
||||
z.object({ action: z.literal("run_diagnostic"), diagnostic: rectificationDiagnosticSchema }).strict(),
|
||||
z.object({ action: z.literal("offer_candidate_range"), snapshotId: uuid }).strict(),
|
||||
z.object({ action: z.literal("stop_low_confidence"), reasonCodes: z.array(nonblank(80)).min(1).max(8) }).strict(),
|
||||
]);
|
||||
export type RectificationDecision = z.infer<typeof rectificationDecisionSchema>;
|
||||
|
||||
export const questionOpportunitySchema = z.object({
|
||||
opportunityId: uuid,
|
||||
kind: z.enum([
|
||||
"clarify_intake",
|
||||
"clarify_event_subject",
|
||||
"refine_event_date",
|
||||
"pair_related_event",
|
||||
"ask_new_event",
|
||||
"resolve_event_conflict",
|
||||
"disambiguate_candidate_split",
|
||||
]),
|
||||
domain: evidenceDomainSchema,
|
||||
targetEventId: uuid.nullable(),
|
||||
prompt: nonblank(1_000),
|
||||
reason: nonblank(240),
|
||||
expectedInformationGain: z.number().finite().min(0).max(1),
|
||||
dateSensitivity: z.number().finite().min(0).max(1),
|
||||
candidateSplitRelevance: z.number().finite().min(0).max(1),
|
||||
domainCoverageGain: z.number().finite().min(0).max(1),
|
||||
recallEase: z.number().finite().min(0).max(1),
|
||||
novelty: z.number().finite().min(0).max(1),
|
||||
repetitionPenalty: z.number().finite().min(0).max(1),
|
||||
privacyCost: z.number().finite().min(0).max(1),
|
||||
utility: z.number().finite(),
|
||||
active: z.boolean(),
|
||||
}).strict();
|
||||
export type QuestionOpportunity = z.infer<typeof questionOpportunitySchema>;
|
||||
|
||||
export const eventDateSensitivitySchema = z.object({
|
||||
eventId: uuid,
|
||||
declaredDateRange: z.object({ start: nonblank(10), end: nonblank(10), precision: nonblank(20) }).strict(),
|
||||
sampleDates: z.array(nonblank(10)).min(1).max(12),
|
||||
winnerRetentionRate: z.number().finite().min(0).max(1),
|
||||
scoreVariance: z.number().finite().nonnegative(),
|
||||
candidateClusterRetentionRate: z.number().finite().min(0).max(1),
|
||||
}).strict();
|
||||
|
||||
export const candidateSplitSchema = z.object({
|
||||
leftCluster: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(),
|
||||
rightCluster: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(),
|
||||
techniqueLayers: z.array(nonblank(80)).max(40),
|
||||
eventIds: z.array(uuid).max(100),
|
||||
}).strict();
|
||||
|
||||
export const diagnosticsSummarySchema = z.object({
|
||||
id: uuid,
|
||||
caseId: uuid,
|
||||
snapshotId: uuid,
|
||||
primaryClusterRetentionRate: z.number().finite().min(0).max(1),
|
||||
leaveOneEventOutRetentionRate: z.number().finite().min(0).max(1),
|
||||
leaveOneDomainOutRetentionRate: z.number().finite().min(0).max(1),
|
||||
dateSensitivityRetentionRate: z.number().finite().min(0).max(1),
|
||||
neighborSupportMinutes: z.number().int().min(0).max(1_440),
|
||||
primarySecondaryMarginPercent: z.number().finite().min(0).max(100),
|
||||
clusterMassRatio: z.number().finite().min(0).max(1),
|
||||
unstableEventIds: z.array(uuid).max(100),
|
||||
mostDiscriminatingLayers: z.array(nonblank(80)).max(40),
|
||||
eventDateSensitivity: z.array(eventDateSensitivitySchema).max(100),
|
||||
candidateSplits: z.array(candidateSplitSchema).max(20),
|
||||
calculationHash: hash,
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type DiagnosticsSummary = z.infer<typeof diagnosticsSummarySchema>;
|
||||
|
||||
export const candidateFeatureSnapshotSchema = z.object({
|
||||
id: uuid,
|
||||
caseId: uuid,
|
||||
calculationSpecHash: hash,
|
||||
algorithmVersion: nonblank(120),
|
||||
candidateCount: z.number().int().positive().max(1_440),
|
||||
featureHash: hash,
|
||||
features: z.array(z.object({
|
||||
time: clockTimeSchema,
|
||||
ascendantDegree: z.number().finite().min(0).max(360).nullable(),
|
||||
ascendantSignIndex: z.number().int().min(0).max(11).nullable(),
|
||||
vargaAscendants: z.record(z.string(), z.number().int().min(0).max(11)),
|
||||
arudhaSigns: z.object({ A7: z.number().int().min(0).max(11).nullable(), A10: z.number().int().min(0).max(11).nullable(), UL: z.number().int().min(0).max(11).nullable() }).strict(),
|
||||
availableLayers: z.array(nonblank(80)).max(80),
|
||||
blockedLayers: z.array(nonblank(80)).max(80),
|
||||
fingerprints: z.record(z.string(), z.string()),
|
||||
}).strict()).max(1_440),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type CandidateFeatureSnapshot = z.infer<typeof candidateFeatureSnapshotSchema>;
|
||||
|
||||
export const toolCallTraceSchema = z.object({
|
||||
tool: nonblank(120),
|
||||
diagnostic: rectificationDiagnosticSchema.nullable(),
|
||||
outcome: z.enum(["succeeded", "failed", "rejected"]),
|
||||
durationMs: z.number().int().min(0).max(300_000),
|
||||
errorCode: nonblank(120).nullable(),
|
||||
}).strict();
|
||||
export type ToolCallTrace = z.infer<typeof toolCallTraceSchema>;
|
||||
|
||||
export const validatedDecisionSchema = z.object({
|
||||
decision: rectificationDecisionSchema,
|
||||
mode: z.enum(["agent", "deterministic_fallback"]),
|
||||
validationIssues: z.array(nonblank(120)).max(20),
|
||||
selectedOpportunity: questionOpportunitySchema.nullable(),
|
||||
}).strict();
|
||||
export type ValidatedDecision = z.infer<typeof validatedDecisionSchema>;
|
||||
|
||||
export const publicMessageSchema = z.object({
|
||||
acknowledgement: nonblank(1_000),
|
||||
candidateUpdate: nonblank(1_000).nullable(),
|
||||
limitation: nonblank(1_000).nullable(),
|
||||
question: nonblank(1_000).nullable(),
|
||||
}).strict();
|
||||
export type PublicMessage = z.infer<typeof publicMessageSchema>;
|
||||
|
||||
export const agentRunSchema = z.object({
|
||||
id: uuid,
|
||||
caseId: uuid,
|
||||
jobId: uuid,
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
modelId: nonblank(120).nullable(),
|
||||
skillVersion: nonblank(120),
|
||||
promptVersion: nonblank(120),
|
||||
deploymentSha: nonblank(80).nullable(),
|
||||
deploymentMode: rectificationDeploymentModeSchema,
|
||||
decision: rectificationDecisionSchema.nullable(),
|
||||
validatedDecision: validatedDecisionSchema,
|
||||
toolCalls: z.array(toolCallTraceSchema).max(8),
|
||||
fallbackReason: nonblank(120).nullable(),
|
||||
inputTokenCount: z.number().int().nonnegative().nullable(),
|
||||
outputTokenCount: z.number().int().nonnegative().nullable(),
|
||||
latencyMs: z.number().int().nonnegative().max(300_000),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type AgentRun = z.infer<typeof agentRunSchema>;
|
||||
|
||||
export type RectificationDecisionValidation = Readonly<{
|
||||
valid: boolean;
|
||||
decision: RectificationDecision | null;
|
||||
issues: readonly string[];
|
||||
}>;
|
||||
|
||||
export function validateRectificationDecision(input: Readonly<{
|
||||
decision: unknown;
|
||||
caseId?: string;
|
||||
snapshotId?: string | null;
|
||||
opportunities: readonly QuestionOpportunity[];
|
||||
diagnostics: DiagnosticsSummary;
|
||||
candidateRangeOfferAllowed: boolean;
|
||||
usedDiagnostics?: readonly RectificationDiagnostic[];
|
||||
toolCallCount?: number;
|
||||
maxToolCalls?: number;
|
||||
}>): RectificationDecisionValidation {
|
||||
const parsed = rectificationDecisionSchema.safeParse(input.decision);
|
||||
if (!parsed.success) return { valid: false, decision: null, issues: ["decision_schema_invalid"] };
|
||||
const decision = parsed.data;
|
||||
const issues: string[] = [];
|
||||
if (input.caseId && input.diagnostics.caseId !== input.caseId) issues.push("diagnostics_case_mismatch");
|
||||
if ((input.toolCallCount ?? 0) > (input.maxToolCalls ?? 2)) issues.push("tool_call_budget_exceeded");
|
||||
if (decision.action === "ask_question") {
|
||||
const opportunity = input.opportunities.find((item) => item.opportunityId === decision.opportunityId && item.active);
|
||||
if (!opportunity) issues.push("opportunity_not_active");
|
||||
if (opportunity?.kind === "clarify_event_subject" && !opportunity.targetEventId) issues.push("subject_clarification_requires_target_event");
|
||||
}
|
||||
if (decision.action === "offer_candidate_range") {
|
||||
if (!input.candidateRangeOfferAllowed) issues.push("candidate_range_gate_failed");
|
||||
if (!input.snapshotId || decision.snapshotId !== input.snapshotId || input.diagnostics.snapshotId !== input.snapshotId) issues.push("snapshot_not_current");
|
||||
}
|
||||
if (decision.action === "run_diagnostic" && input.usedDiagnostics?.includes(decision.diagnostic)) issues.push("diagnostic_already_run");
|
||||
return { valid: issues.length === 0, decision: issues.length === 0 ? decision : null, issues };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CandidateSnapshot } from "../rectification-v4/contracts.ts";
|
||||
import type { DiagnosticsSummary, QuestionOpportunity, RectificationDecision } from "./contracts.ts";
|
||||
|
||||
export function deterministicDecision(input: Readonly<{
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary | null;
|
||||
opportunities: readonly QuestionOpportunity[];
|
||||
}>): RectificationDecision {
|
||||
if (input.snapshot?.canAcceptRange) return { action: "offer_candidate_range", snapshotId: input.snapshot.id };
|
||||
const top = input.opportunities.find((item) => item.active);
|
||||
if (top) return { action: "ask_question", opportunityId: top.opportunityId, narrativeFocus: ["latest_event", ...(top.kind === "refine_event_date" ? ["date_precision" as const] : [])] };
|
||||
return { action: "stop_low_confidence", reasonCodes: input.diagnostics ? ["no_high_value_question", "diagnostics_not_stable"] : ["insufficient_scoreable_evidence"] };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { rectificationDeploymentModeSchema, type RectificationDeploymentMode } from "../rectification-v4/contracts.ts";
|
||||
|
||||
type RectificationFeatureEnv = Readonly<{
|
||||
RECTIFICATION_AGENT_V5_ENABLED?: string;
|
||||
RECTIFICATION_AGENT_V5_SHADOW?: string;
|
||||
RECTIFICATION_AGENT_V5_CANARY_PERCENT?: string;
|
||||
}>;
|
||||
|
||||
function enabled(value: string | undefined): boolean {
|
||||
return /^(1|true|yes|on)$/i.test(value?.trim() ?? "");
|
||||
}
|
||||
|
||||
function percentage(value: string | undefined): number {
|
||||
if (!value?.trim()) return 100;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
return Math.max(0, Math.min(100, parsed));
|
||||
}
|
||||
|
||||
export function rectificationCanaryBucket(stableId: string): number {
|
||||
const prefix = createHash("sha256").update(stableId).digest().readUInt32BE(0);
|
||||
return prefix / 0x1_0000_0000 * 100;
|
||||
}
|
||||
|
||||
export function selectRectificationDeploymentMode(
|
||||
stableId: string,
|
||||
env: RectificationFeatureEnv = {
|
||||
RECTIFICATION_AGENT_V5_ENABLED: process.env.RECTIFICATION_AGENT_V5_ENABLED,
|
||||
RECTIFICATION_AGENT_V5_SHADOW: process.env.RECTIFICATION_AGENT_V5_SHADOW,
|
||||
RECTIFICATION_AGENT_V5_CANARY_PERCENT: process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT,
|
||||
},
|
||||
): RectificationDeploymentMode {
|
||||
if (!enabled(env.RECTIFICATION_AGENT_V5_ENABLED)) return "v4_legacy";
|
||||
if (rectificationCanaryBucket(stableId) >= percentage(env.RECTIFICATION_AGENT_V5_CANARY_PERCENT)) return "v4_legacy";
|
||||
return rectificationDeploymentModeSchema.parse(
|
||||
enabled(env.RECTIFICATION_AGENT_V5_SHADOW) ? "v5_shadow" : "v5_agent",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import type { DiagnosticsSummary, QuestionOpportunity } from "./contracts.ts";
|
||||
|
||||
const domains: readonly EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure"];
|
||||
|
||||
function stableUuid(value: string): string {
|
||||
const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split("");
|
||||
hex[12] = "4";
|
||||
hex[16] = ((Number.parseInt(hex[16]!, 16) & 3) | 8).toString(16);
|
||||
return `${hex.slice(0, 8).join("")}-${hex.slice(8, 12).join("")}-${hex.slice(12, 16).join("")}-${hex.slice(16, 20).join("")}-${hex.slice(20).join("")}`;
|
||||
}
|
||||
|
||||
const routingValue: Record<QuestionOpportunity["kind"], number> = {
|
||||
clarify_intake: .18,
|
||||
resolve_event_conflict: .16,
|
||||
clarify_event_subject: .14,
|
||||
refine_event_date: .08,
|
||||
pair_related_event: .05,
|
||||
disambiguate_candidate_split: .04,
|
||||
ask_new_event: 0,
|
||||
};
|
||||
|
||||
function utility(value: Omit<QuestionOpportunity, "opportunityId" | "utility" | "active">): number {
|
||||
return Number((
|
||||
.35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance
|
||||
+ .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty
|
||||
+ routingValue[value.kind] - value.repetitionPenalty - value.privacyCost
|
||||
).toFixed(6));
|
||||
}
|
||||
|
||||
function opportunity(caseId: string, input: Omit<QuestionOpportunity, "opportunityId" | "utility" | "active">): QuestionOpportunity {
|
||||
const result = { ...input, opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.prompt}`), utility: utility(input), active: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildQuestionOpportunities(input: Readonly<{
|
||||
caseId: string;
|
||||
events: readonly LifeEventRevision[];
|
||||
turns: readonly RectificationV4Turn[];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary | null;
|
||||
retryTargetEventIds?: readonly string[];
|
||||
}>): readonly QuestionOpportunity[] {
|
||||
const attempted = new Set(input.turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : []));
|
||||
const retryTargets = new Set(input.retryTargetEventIds ?? []);
|
||||
const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain));
|
||||
const opportunities: QuestionOpportunity[] = [];
|
||||
for (const eventId of retryTargets) {
|
||||
const event = input.events.find((value) => value.eventId === eventId);
|
||||
if (!event) continue;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId,
|
||||
prompt: `你刚才补充的新经历已经另行保存。关于“${event.summary}”的时间仍没有确定;如果记不清,可以直接说不知道。`,
|
||||
reason: "用户补充了另一件事,原事件的日期或主体仍待确认。",
|
||||
expectedInformationGain: .85, dateSensitivity: .75, candidateSplitRelevance: .6, domainCoverageGain: 0, recallEase: .8, novelty: .7, repetitionPenalty: .15, privacyCost: .05,
|
||||
}));
|
||||
}
|
||||
if (opportunities.length > 0) {
|
||||
return opportunities.sort((left, right) =>
|
||||
right.utility - left.utility
|
||||
|| left.opportunityId.localeCompare(right.opportunityId));
|
||||
}
|
||||
for (const event of input.events) {
|
||||
if (retryTargets.has(event.eventId)) continue;
|
||||
if ((event.scoreability === "pending_review" || event.subject === "other") && !attempted.has(event.eventId)) {
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId,
|
||||
prompt: `你刚才提到“${event.summary}”,这件事主要发生在你本人,还是家人或伴侣身上?`, reason: "事件主体决定是否允许进入个人分盘评分。",
|
||||
expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: .05,
|
||||
}));
|
||||
}
|
||||
if (event.scoreability === "scoreable" && event.dateRange.precision !== "day" && !attempted.has(event.eventId)) {
|
||||
const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId);
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId,
|
||||
prompt: `关于“${event.summary}”,你还记得更具体的月份或日期吗?不确定也可以只说大概范围。`, reason: "日期采样显示这件事的时间精度可能影响候选排序。",
|
||||
expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .72,
|
||||
dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .7,
|
||||
candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: .72, novelty: .8, repetitionPenalty: 0, privacyCost: .05,
|
||||
}));
|
||||
}
|
||||
}
|
||||
const split = input.diagnostics?.candidateSplits[0];
|
||||
if (split) {
|
||||
const target = input.events.find((event) => split.eventIds.includes(event.eventId));
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null,
|
||||
prompt: target ? `围绕“${target.summary}”,当时最明显的转折是事情开始、达到高峰,还是正式结束?` : "剩余候选在同一事件的阶段上有差异:你记得当时更接近开始、达到高峰,还是正式结束吗?",
|
||||
reason: `候选簇在 ${split.techniqueLayers.slice(0, 3).join("、") || "技术层"} 上出现可检验分歧。`,
|
||||
expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, recallEase: .65, novelty: .9, repetitionPenalty: target && attempted.has(target.eventId) ? .35 : 0, privacyCost: .1,
|
||||
}));
|
||||
}
|
||||
const missingDomain = domains.find((domain) => !scoreableDomains.has(domain));
|
||||
if (missingDomain) {
|
||||
const prompts: Record<EvidenceDomain, string> = {
|
||||
education: "你人生中有没有一次入学、毕业、考试或专业变化,时间大致在什么时候?",
|
||||
relocation: "你有没有一次印象深刻的搬家、离乡或长期迁居?大致在什么时候?",
|
||||
relationship: "你有没有一段关系正式开始、结束或进入婚姻的明确时间点?",
|
||||
career: "你有没有一次入职、离职、升职、转行或创业的明确时间点?",
|
||||
finance: "你有没有一次收入、投资、负债或资产状况明显改变的时间点?",
|
||||
health_pressure: "你本人有没有一次住院、手术、事故或明显健康转折?大致在什么时候?",
|
||||
family: "请补充一个家庭事件。", other: "请补充一个有明确时间的重要人生事件。",
|
||||
};
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "ask_new_event", domain: missingDomain, targetEventId: null, prompt: prompts[missingDomain], reason: "当前证据领域覆盖不足。",
|
||||
expectedInformationGain: .7, dateSensitivity: .45, candidateSplitRelevance: .5, domainCoverageGain: 1, recallEase: .7, novelty: 1, repetitionPenalty: 0, privacyCost: missingDomain === "health_pressure" ? .2 : .08,
|
||||
}));
|
||||
}
|
||||
return opportunities.sort((left, right) =>
|
||||
right.utility - left.utility
|
||||
|| left.opportunityId.localeCompare(right.opportunityId));
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { RectificationV4CandidateEngine } from "../rectification-v4/candidate-engine.ts";
|
||||
import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.ts";
|
||||
import type { CandidateSnapshot, RectificationV4Question } from "../rectification-v4/contracts.ts";
|
||||
import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts";
|
||||
import { reconcileV4Evidence } from "../rectification-v4/extraction.ts";
|
||||
import { evidenceSetHash } from "../rectification-v4/fingerprints.ts";
|
||||
import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts";
|
||||
import { projectLegacyV4Turn } from "../rectification-v4/legacy-projector.ts";
|
||||
import type { ClaimedRectificationV4Job } from "../rectification-v4/store.ts";
|
||||
import { deterministicDecision } from "./fallback-policy.ts";
|
||||
import { buildQuestionOpportunities } from "./opportunity-builder.ts";
|
||||
import { renderPublicTurn } from "./renderer-agent.ts";
|
||||
import { runBoundedReasoner } from "./reasoner-agent.ts";
|
||||
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
|
||||
import {
|
||||
candidateFeatureSnapshotSchema,
|
||||
diagnosticsSummarySchema,
|
||||
validateRectificationDecision,
|
||||
type AgentRun,
|
||||
type CandidateFeatureSnapshot,
|
||||
type DiagnosticsSummary,
|
||||
type PublicMessage,
|
||||
type ValidatedDecision,
|
||||
} from "./contracts.ts";
|
||||
|
||||
function hash(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
export async function processRectificationAgentTurn(input: Readonly<{
|
||||
claimed: ClaimedRectificationV4Job;
|
||||
engine: RectificationV4CandidateEngine;
|
||||
now: Date;
|
||||
onPhase?: (phase: "extracting_evidence" | "scoring_candidates" | "checking_robustness" | "planning_question" | "reasoning" | "rendering") => Promise<void>;
|
||||
}>): Promise<Readonly<{
|
||||
newEventRevisions: ClaimedRectificationV4Job["events"];
|
||||
pendingEvidence: import("../rectification-v4/contracts.ts").PendingEvidence[];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary | null;
|
||||
featureSnapshot: CandidateFeatureSnapshot | null;
|
||||
validatedDecision: ValidatedDecision;
|
||||
publicMessage: PublicMessage;
|
||||
nextQuestion: RectificationV4Question | null;
|
||||
agentRun: AgentRun;
|
||||
status: "awaiting_answer" | "range_ready" | "paused";
|
||||
phase: "collecting_evidence" | "complete";
|
||||
}>> {
|
||||
const { claimed, now } = input;
|
||||
await input.onPhase?.("extracting_evidence");
|
||||
const reconciliation = claimed.turn.answer ? reconcileV4Evidence({
|
||||
caseId: claimed.case.id,
|
||||
answer: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate: now.toISOString().slice(0, 10),
|
||||
existing: claimed.events,
|
||||
targetEventId: claimed.turn.questionTargetEventId,
|
||||
now,
|
||||
}) : { revisions: [], pending: [], unansweredTargetEventId: null };
|
||||
const extracted = reconciliation.revisions;
|
||||
const events = latestEventRevisions([...claimed.events, ...extracted]);
|
||||
const scoreable = scoreableEvents(events);
|
||||
const domains = new Set(scoreable.map((event) => event.domain));
|
||||
let snapshot: CandidateSnapshot | null = null;
|
||||
let diagnostics: DiagnosticsSummary | null = null;
|
||||
let featureSnapshot: CandidateFeatureSnapshot | null = null;
|
||||
|
||||
if (scoreable.length >= 3 && domains.size >= 2) {
|
||||
await input.onPhase?.("scoring_candidates");
|
||||
const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable });
|
||||
await input.onPhase?.("checking_robustness");
|
||||
const clusters = buildCandidateClusters(scored.candidates);
|
||||
const robustness = {
|
||||
neighborSupportMinutes: scored.robustness.neighborSupportMinutes,
|
||||
leaveOneOutRetentionRate: scored.robustness.leaveOneOutRetentionRate,
|
||||
dateSensitivityRetentionRate: scored.robustness.dateSensitivityRetentionRate,
|
||||
calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash,
|
||||
};
|
||||
const gate = evaluateDecisionGate({
|
||||
clusters,
|
||||
robustness,
|
||||
scoreableEventCount: scoreable.length,
|
||||
scoreableDomainCount: domains.size,
|
||||
});
|
||||
snapshot = {
|
||||
id: scored.resultId,
|
||||
caseId: claimed.case.id,
|
||||
caseVersion: claimed.case.version,
|
||||
evidenceSetHash: evidenceSetHash(events),
|
||||
calculationSpecHash: claimed.case.calculationSpecHash,
|
||||
algorithmVersion: scored.featureSnapshot.algorithm_version,
|
||||
candidates: [...scored.candidates],
|
||||
clusters: [...clusters],
|
||||
robustness,
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: gate.canAcceptRange,
|
||||
gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)],
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
diagnostics = diagnosticsSummarySchema.parse({
|
||||
id: randomUUID(),
|
||||
caseId: claimed.case.id,
|
||||
snapshotId: snapshot.id,
|
||||
primaryClusterRetentionRate: scored.diagnostics.primary_cluster_retention_rate,
|
||||
leaveOneEventOutRetentionRate: scored.diagnostics.leave_one_event_out_retention_rate,
|
||||
leaveOneDomainOutRetentionRate: scored.diagnostics.leave_one_domain_out_retention_rate,
|
||||
dateSensitivityRetentionRate: scored.diagnostics.date_sensitivity_retention_rate,
|
||||
neighborSupportMinutes: scored.diagnostics.neighbor_support_minutes,
|
||||
primarySecondaryMarginPercent: scored.diagnostics.primary_secondary_margin_percent,
|
||||
clusterMassRatio: scored.diagnostics.cluster_mass_ratio,
|
||||
unstableEventIds: scored.diagnostics.unstable_event_ids,
|
||||
mostDiscriminatingLayers: scored.diagnostics.most_discriminating_layers,
|
||||
eventDateSensitivity: scored.diagnostics.event_date_sensitivity.map((item) => ({
|
||||
eventId: item.event_id,
|
||||
declaredDateRange: item.declared_date_range,
|
||||
sampleDates: item.sample_dates,
|
||||
winnerRetentionRate: item.winner_retention_rate,
|
||||
scoreVariance: item.score_variance,
|
||||
candidateClusterRetentionRate: item.candidate_cluster_retention_rate,
|
||||
})),
|
||||
candidateSplits: scored.diagnostics.candidate_splits.map((item) => ({
|
||||
leftCluster: item.left_cluster,
|
||||
rightCluster: item.right_cluster,
|
||||
techniqueLayers: item.technique_layers,
|
||||
eventIds: item.event_ids,
|
||||
})),
|
||||
calculationHash: hash(scored.diagnostics),
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
featureSnapshot = candidateFeatureSnapshotSchema.parse({
|
||||
id: randomUUID(),
|
||||
caseId: claimed.case.id,
|
||||
calculationSpecHash: scored.featureSnapshot.calculation_spec_hash,
|
||||
algorithmVersion: scored.featureSnapshot.algorithm_version,
|
||||
candidateCount: scored.featureSnapshot.candidate_count,
|
||||
featureHash: scored.featureSnapshot.feature_hash,
|
||||
features: scored.featureSnapshot.features.map((item) => ({
|
||||
time: item.time,
|
||||
ascendantDegree: item.ascendant_degree,
|
||||
ascendantSignIndex: item.ascendant_sign_index,
|
||||
vargaAscendants: item.varga_ascendants,
|
||||
arudhaSigns: item.arudha_signs,
|
||||
availableLayers: item.available_layers,
|
||||
blockedLayers: item.blocked_layers,
|
||||
fingerprints: item.fingerprints,
|
||||
})),
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const safeDiagnostics = diagnostics ?? diagnosticsSummarySchema.parse({
|
||||
id: randomUUID(),
|
||||
caseId: claimed.case.id,
|
||||
snapshotId: randomUUID(),
|
||||
primaryClusterRetentionRate: 0,
|
||||
leaveOneEventOutRetentionRate: 0,
|
||||
leaveOneDomainOutRetentionRate: 0,
|
||||
dateSensitivityRetentionRate: 0,
|
||||
neighborSupportMinutes: 0,
|
||||
primarySecondaryMarginPercent: 0,
|
||||
clusterMassRatio: 0,
|
||||
unstableEventIds: [],
|
||||
mostDiscriminatingLayers: [],
|
||||
eventDateSensitivity: [],
|
||||
candidateSplits: [],
|
||||
calculationHash: hash(events),
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
|
||||
await input.onPhase?.("planning_question");
|
||||
const opportunities = buildQuestionOpportunities({
|
||||
caseId: claimed.case.id,
|
||||
events,
|
||||
turns: claimed.turns,
|
||||
snapshot,
|
||||
diagnostics,
|
||||
retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [],
|
||||
});
|
||||
await input.onPhase?.("reasoning");
|
||||
const reasoned = await runBoundedReasoner({
|
||||
caseValue: claimed.case,
|
||||
snapshot,
|
||||
diagnostics: safeDiagnostics,
|
||||
opportunities,
|
||||
enabled: claimed.case.deploymentMode !== "v4_legacy",
|
||||
});
|
||||
const rawDecision = reasoned.decision;
|
||||
let validation = validateRectificationDecision({
|
||||
decision: rawDecision,
|
||||
caseId: claimed.case.id,
|
||||
snapshotId: snapshot?.id ?? null,
|
||||
opportunities,
|
||||
diagnostics: safeDiagnostics,
|
||||
candidateRangeOfferAllowed: snapshot?.canAcceptRange ?? false,
|
||||
toolCallCount: reasoned.toolCalls.length,
|
||||
maxToolCalls: 1,
|
||||
});
|
||||
let fallbackReason = reasoned.fallbackReason;
|
||||
if (!validation.decision) {
|
||||
recordRectificationAgentTelemetry({
|
||||
caseId: claimed.case.id, phase: "fallback", outcome: "rejected",
|
||||
modelId: claimed.case.orchestrationModelId, toolName: null,
|
||||
decisionAction: rawDecision.action, durationMs: reasoned.latencyMs,
|
||||
errorCode: "policy_validator_rejected", deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null,
|
||||
});
|
||||
validation = validateRectificationDecision({
|
||||
decision: deterministicDecision({ snapshot, diagnostics, opportunities }),
|
||||
caseId: claimed.case.id,
|
||||
snapshotId: snapshot?.id ?? null,
|
||||
opportunities,
|
||||
diagnostics: safeDiagnostics,
|
||||
candidateRangeOfferAllowed: snapshot?.canAcceptRange ?? false,
|
||||
});
|
||||
fallbackReason = `validator_rejected:${validation.issues.join(",") || "unknown"}`;
|
||||
}
|
||||
const finalDecision = validation.decision;
|
||||
if (!finalDecision) throw new Error("rectification_v5_fallback_validation_failed");
|
||||
const selectedOpportunity = finalDecision.action === "ask_question"
|
||||
? opportunities.find((item) => item.opportunityId === finalDecision.opportunityId) ?? null
|
||||
: null;
|
||||
const validatedDecision: ValidatedDecision = {
|
||||
decision: finalDecision,
|
||||
mode: fallbackReason ? "deterministic_fallback" : reasoned.mode,
|
||||
validationIssues: [...validation.issues],
|
||||
selectedOpportunity,
|
||||
};
|
||||
|
||||
await input.onPhase?.("rendering");
|
||||
const legacyProjection = projectLegacyV4Turn({
|
||||
events,
|
||||
newEvents: extracted,
|
||||
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
|
||||
latestAnswer: claimed.turn.answer,
|
||||
snapshot,
|
||||
});
|
||||
const agentVisible = claimed.case.deploymentMode === "v5_agent";
|
||||
const publicMessage = agentVisible
|
||||
? await renderPublicTurn({
|
||||
caseValue: claimed.case,
|
||||
latestAnswer: claimed.turn.answer,
|
||||
acceptedEvents: extracted,
|
||||
pendingEvidence: reconciliation.pending,
|
||||
snapshot,
|
||||
validated: validatedDecision,
|
||||
})
|
||||
: legacyProjection.publicMessage;
|
||||
const nextQuestion = agentVisible && selectedOpportunity ? {
|
||||
id: randomUUID(),
|
||||
domain: selectedOpportunity.domain,
|
||||
targetEventId: selectedOpportunity.targetEventId,
|
||||
prompt: selectedOpportunity.prompt,
|
||||
recallCost: selectedOpportunity.privacyCost >= .2
|
||||
? "high" as const
|
||||
: selectedOpportunity.recallEase < .6
|
||||
? "medium" as const
|
||||
: "low" as const,
|
||||
reason: selectedOpportunity.reason,
|
||||
} : agentVisible ? null : legacyProjection.nextQuestion;
|
||||
const status = agentVisible
|
||||
? finalDecision.action === "offer_candidate_range"
|
||||
? "range_ready" as const
|
||||
: finalDecision.action === "stop_low_confidence"
|
||||
? "paused" as const
|
||||
: "awaiting_answer" as const
|
||||
: legacyProjection.status;
|
||||
const phase = agentVisible
|
||||
? status === "awaiting_answer" ? "collecting_evidence" as const : "complete" as const
|
||||
: legacyProjection.phase;
|
||||
|
||||
const agentRun: AgentRun = {
|
||||
id: randomUUID(),
|
||||
caseId: claimed.case.id,
|
||||
jobId: claimed.job.id,
|
||||
caseVersion: claimed.case.version,
|
||||
modelId: claimed.case.orchestrationModelId,
|
||||
skillVersion: claimed.case.skillVersion,
|
||||
promptVersion: claimed.case.promptVersion,
|
||||
deploymentMode: claimed.case.deploymentMode,
|
||||
deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null,
|
||||
decision: rawDecision,
|
||||
validatedDecision,
|
||||
toolCalls: [...reasoned.toolCalls],
|
||||
fallbackReason,
|
||||
inputTokenCount: reasoned.inputTokenCount,
|
||||
outputTokenCount: reasoned.outputTokenCount,
|
||||
latencyMs: reasoned.latencyMs,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
return {
|
||||
newEventRevisions: extracted,
|
||||
pendingEvidence: [...reconciliation.pending],
|
||||
snapshot,
|
||||
diagnostics,
|
||||
featureSnapshot,
|
||||
validatedDecision,
|
||||
publicMessage,
|
||||
nextQuestion,
|
||||
agentRun,
|
||||
status,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import path from "node:path";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import { z } from "zod";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, RectificationV4Case } from "../rectification-v4/contracts.ts";
|
||||
import { deterministicDecision } from "./fallback-policy.ts";
|
||||
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
|
||||
import {
|
||||
rectificationDecisionSchema,
|
||||
rectificationDiagnosticSchema,
|
||||
type DiagnosticsSummary,
|
||||
type QuestionOpportunity,
|
||||
type RectificationDecision,
|
||||
type RectificationDiagnostic,
|
||||
type ToolCallTrace,
|
||||
} from "./contracts.ts";
|
||||
|
||||
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
|
||||
type Usage = Readonly<{ inputTokens?: number; outputTokens?: number }>;
|
||||
type GeneratedDecision = Readonly<{ object: unknown; totalUsage?: Usage | Promise<Usage> }>;
|
||||
export type RectificationReasonerGenerator = (
|
||||
prompt: string,
|
||||
phase: "initial" | "after_diagnostic",
|
||||
) => Promise<GeneratedDecision>;
|
||||
|
||||
function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: DiagnosticsSummary) {
|
||||
switch (diagnostic) {
|
||||
case "leave_one_event_out": return { retentionRate: summary.leaveOneEventOutRetentionRate, unstableEventIds: summary.unstableEventIds };
|
||||
case "leave_one_domain_out": return { retentionRate: summary.leaveOneDomainOutRetentionRate };
|
||||
case "date_sensitivity": return { retentionRate: summary.dateSensitivityRetentionRate, events: summary.eventDateSensitivity };
|
||||
case "neighbor_stability": return { supportMinutes: summary.neighborSupportMinutes, clusterMassRatio: summary.clusterMassRatio };
|
||||
case "candidate_split": return { marginPercent: summary.primarySecondaryMarginPercent, splits: summary.candidateSplits };
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBoundedReasoner(input: Readonly<{
|
||||
caseValue: RectificationV4Case;
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary;
|
||||
opportunities: readonly QuestionOpportunity[];
|
||||
maxToolCalls?: number;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
generateDecision?: RectificationReasonerGenerator;
|
||||
}>): Promise<Readonly<{
|
||||
decision: RectificationDecision;
|
||||
mode: "agent" | "deterministic_fallback";
|
||||
fallbackReason: string | null;
|
||||
toolCalls: readonly ToolCallTrace[];
|
||||
inputTokenCount: number | null;
|
||||
outputTokenCount: number | null;
|
||||
latencyMs: number;
|
||||
}>> {
|
||||
const started = Date.now();
|
||||
const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null;
|
||||
const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel();
|
||||
const modelId = model?.id ?? input.caseValue.orchestrationModelId;
|
||||
const toolCalls: ToolCallTrace[] = [];
|
||||
let inputTokenCount = 0;
|
||||
let outputTokenCount = 0;
|
||||
let usageObserved = false;
|
||||
const fallback = (reason: string) => {
|
||||
recordRectificationAgentTelemetry({
|
||||
caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId,
|
||||
toolName: null, decisionAction: null, durationMs: Date.now() - started,
|
||||
errorCode: reason, deploymentSha,
|
||||
});
|
||||
return {
|
||||
decision: deterministicDecision(input), mode: "deterministic_fallback" as const,
|
||||
fallbackReason: reason, toolCalls: [...toolCalls],
|
||||
inputTokenCount: usageObserved ? inputTokenCount : null,
|
||||
outputTokenCount: usageObserved ? outputTokenCount : null,
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
};
|
||||
if (input.enabled === false) return fallback("deployment_mode_legacy");
|
||||
if (!model && !input.generateDecision) return fallback("reasoner_model_unavailable");
|
||||
|
||||
const maxToolCalls = input.maxToolCalls ?? 1;
|
||||
const used = new Set<RectificationDiagnostic>();
|
||||
const readDiagnostic = async (diagnostic: RectificationDiagnostic) => {
|
||||
const toolStarted = Date.now();
|
||||
if (used.size >= maxToolCalls || used.has(diagnostic)) {
|
||||
const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "rejected" as const, durationMs: Date.now() - toolStarted, errorCode: "diagnostic_budget_exhausted" };
|
||||
toolCalls.push(trace);
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "rejected", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: trace.errorCode, deploymentSha });
|
||||
throw new Error("diagnostic_budget_exhausted");
|
||||
}
|
||||
used.add(diagnostic);
|
||||
try {
|
||||
const result = diagnosticPayload(diagnostic, input.diagnostics);
|
||||
const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "succeeded" as const, durationMs: Date.now() - toolStarted, errorCode: null };
|
||||
toolCalls.push(trace);
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "succeeded", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: null, deploymentSha });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "failed" as const, durationMs: Date.now() - toolStarted, errorCode: "diagnostic_read_failed" };
|
||||
toolCalls.push(trace);
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "failed", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: trace.errorCode, deploymentSha });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const diagnosticsTool = createTool({
|
||||
id: "run_rectification_diagnostics",
|
||||
description: "Read one server-owned diagnostic for the current rectification snapshot. Inputs cannot contain case data, dates, candidates, or scores.",
|
||||
inputSchema: z.object({ diagnostic: rectificationDiagnosticSchema }).strict(),
|
||||
outputSchema: z.object({ diagnostic: rectificationDiagnosticSchema, result: z.unknown() }).strict(),
|
||||
execute: async ({ diagnostic }) => ({ diagnostic, result: await readDiagnostic(diagnostic) }),
|
||||
});
|
||||
const agent = model ? new Agent({
|
||||
id: `rectification-v5-reasoner-${model.id}`,
|
||||
name: "Bounded Birth Time Rectification Reasoner",
|
||||
model: model.model,
|
||||
skills: [skillPath],
|
||||
tools: { run_rectification_diagnostics: diagnosticsTool },
|
||||
instructions: "Choose one server-owned action. Never create an event id, candidate, score, date, question, calculation input, or birth minute. Ask only by opportunityId. Candidate ranges may only use currentSnapshotId. You may request or call one diagnostic, then must return a final non-diagnostic action. Return strict structured output.",
|
||||
}) : null;
|
||||
const generate: RectificationReasonerGenerator = input.generateDecision ?? (async (prompt) => {
|
||||
if (!agent) throw new Error("reasoner_model_unavailable");
|
||||
return agent.generate(prompt, {
|
||||
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 20_000),
|
||||
maxSteps: maxToolCalls + 2,
|
||||
structuredOutput: { schema: rectificationDecisionSchema, jsonPromptInjection: "inline" },
|
||||
});
|
||||
});
|
||||
const addUsage = async (result: GeneratedDecision) => {
|
||||
if (!result.totalUsage) return;
|
||||
const usage = await result.totalUsage;
|
||||
inputTokenCount += Math.max(0, Math.trunc(usage.inputTokens ?? 0));
|
||||
outputTokenCount += Math.max(0, Math.trunc(usage.outputTokens ?? 0));
|
||||
usageObserved = true;
|
||||
};
|
||||
const baseState = {
|
||||
task: "Choose the next bounded rectification action.",
|
||||
currentSnapshotId: input.snapshot?.id ?? null,
|
||||
canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false,
|
||||
compactDiagnostics: {
|
||||
primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate,
|
||||
mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers,
|
||||
},
|
||||
opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, utility, reason }) => ({ opportunityId, kind, targetEventId, utility, reason })),
|
||||
};
|
||||
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "started", modelId, toolName: null, decisionAction: null, durationMs: null, errorCode: null, deploymentSha });
|
||||
try {
|
||||
const first = await generate(JSON.stringify(baseState), "initial");
|
||||
await addUsage(first);
|
||||
let decision = rectificationDecisionSchema.parse(first.object);
|
||||
if (decision.action === "run_diagnostic") {
|
||||
const result = await readDiagnostic(decision.diagnostic);
|
||||
const second = await generate(JSON.stringify({
|
||||
...baseState,
|
||||
requiredFinalAction: true,
|
||||
diagnosticResult: { diagnostic: decision.diagnostic, result },
|
||||
}), "after_diagnostic");
|
||||
await addUsage(second);
|
||||
decision = rectificationDecisionSchema.parse(second.object);
|
||||
if (decision.action === "run_diagnostic") return fallback("reasoner_returned_nonfinal_diagnostic");
|
||||
}
|
||||
const latencyMs = Date.now() - started;
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "succeeded", modelId, toolName: null, decisionAction: decision.action, durationMs: latencyMs, errorCode: null, deploymentSha });
|
||||
return {
|
||||
decision, mode: "agent", fallbackReason: null, toolCalls,
|
||||
inputTokenCount: usageObserved ? inputTokenCount : null,
|
||||
outputTokenCount: usageObserved ? outputTokenCount : null,
|
||||
latencyMs,
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof DOMException && error.name === "TimeoutError" ? "reasoner_timeout"
|
||||
: error instanceof Error && error.message === "diagnostic_budget_exhausted" ? "diagnostic_budget_exhausted"
|
||||
: error instanceof Error && error.message === "reasoner_model_unavailable" ? "reasoner_model_unavailable"
|
||||
: "reasoner_failed";
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "failed", modelId, toolName: null, decisionAction: null, durationMs: Date.now() - started, errorCode: reason, deploymentSha });
|
||||
return fallback(reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import path from "node:path";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts";
|
||||
import { publicMessageSchema, type PublicMessage, type ValidatedDecision } from "./contracts.ts";
|
||||
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
|
||||
|
||||
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
|
||||
const agents = new Map<string, Agent>();
|
||||
function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
|
||||
const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel();
|
||||
if (!selected) return null;
|
||||
const cached = agents.get(selected.id);
|
||||
if (cached) return { id: selected.id, agent: cached };
|
||||
const agent = new Agent({
|
||||
id: `rectification-v5-renderer-${selected.id}`, name: "Birth Time Rectification Response Renderer", model: selected.model, skills: [skillPath],
|
||||
instructions: "Write concise natural Simplified Chinese. Acknowledge the latest experience, state uncertainty honestly, and never expose ids, scores, internal domains, representative minutes, model/tool details, or claim an exact birth minute. Return strict JSON only.",
|
||||
});
|
||||
agents.set(selected.id, agent);
|
||||
return { id: selected.id, agent };
|
||||
}
|
||||
|
||||
function deterministic(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision }): PublicMessage {
|
||||
const latest = input.acceptedEvents.at(-1);
|
||||
const acknowledgement = latest
|
||||
? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。`
|
||||
: input.pendingEvidence.length
|
||||
? "我保留了你刚才的原始描述;其中的日期或事件关系还不能安全进入评分。"
|
||||
: input.latestAnswer
|
||||
? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。"
|
||||
: "我会继续根据已确认的人生事件比较候选范围。";
|
||||
const primary = input.snapshot?.clusters[0];
|
||||
const candidateUpdate = primary ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` : null;
|
||||
const limitation = input.validated.decision.action === "stop_low_confidence" ? "现有证据不足以安全缩小范围,我不会把不稳定结果包装成确定时间。" : null;
|
||||
return { acknowledgement, candidateUpdate, limitation, question: input.validated.selectedOpportunity?.prompt ?? null };
|
||||
}
|
||||
|
||||
export function enforceServerQuestion(value: unknown, question: string | null): PublicMessage {
|
||||
return { ...publicMessageSchema.parse(value), question };
|
||||
}
|
||||
|
||||
export async function renderPublicTurn(input: Readonly<{
|
||||
caseValue: RectificationV4Case; latestAnswer: string; acceptedEvents: readonly LifeEventRevision[];
|
||||
pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision; timeoutMs?: number;
|
||||
}>): Promise<PublicMessage> {
|
||||
const started = Date.now();
|
||||
const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null;
|
||||
const fallback = deterministic(input);
|
||||
const selected = agentFor(input.caseValue.narrationModelId);
|
||||
if (!selected) {
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: input.caseValue.narrationModelId, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_model_unavailable", deploymentSha });
|
||||
return fallback;
|
||||
}
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "started", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: null, errorCode: null, deploymentSha });
|
||||
try {
|
||||
const result = await selected.agent.generate(JSON.stringify({
|
||||
task: "Render the public turn. The server-owned question must not be changed.", latestAnswer: input.latestAnswer,
|
||||
acceptedEvents: input.acceptedEvents.slice(-3).map((event) => ({ summary: event.summary, date: event.dateRange.label, subject: event.subject })),
|
||||
pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ rawText: event.rawText, reasonCode: event.reasonCode })),
|
||||
candidateRange: input.snapshot?.clusters[0] ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } : null,
|
||||
action: input.validated.decision.action, exactQuestion: input.validated.selectedOpportunity?.prompt ?? null,
|
||||
}), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } });
|
||||
const message = enforceServerQuestion(result.object, input.validated.selectedOpportunity?.prompt ?? null);
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: null, deploymentSha });
|
||||
return message;
|
||||
} catch {
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "failed", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_failed", deploymentSha });
|
||||
recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_failed", deploymentSha });
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const telemetryEventSchema = z.object({
|
||||
caseId: z.string().uuid().nullable(),
|
||||
phase: z.enum(["reasoner", "renderer", "tool", "fallback"]),
|
||||
outcome: z.enum(["started", "succeeded", "failed", "rejected"]),
|
||||
modelId: z.string().trim().min(1).max(120).nullable(),
|
||||
toolName: z.string().trim().min(1).max(120).nullable(),
|
||||
decisionAction: z.string().trim().min(1).max(80).nullable(),
|
||||
durationMs: z.number().int().min(0).max(300_000).nullable(),
|
||||
errorCode: z.string().trim().min(1).max(120).nullable(),
|
||||
deploymentSha: z.string().trim().min(1).max(80).nullable(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationAgentTelemetryEvent = z.infer<typeof telemetryEventSchema>;
|
||||
|
||||
export function recordRectificationAgentTelemetry(
|
||||
event: RectificationAgentTelemetryEvent,
|
||||
): void {
|
||||
const parsed = telemetryEventSchema.safeParse(event);
|
||||
if (!parsed.success) return;
|
||||
const line = JSON.stringify({
|
||||
...parsed.data,
|
||||
component: "rectification-agent",
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
if (parsed.data.outcome === "failed" || parsed.data.outcome === "rejected") {
|
||||
console.warn(`[rectification-agent] ${line}`);
|
||||
} else {
|
||||
console.info(`[rectification-agent] ${line}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user