feat: persist rectification analysis traces
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { clockTimeSchema, evidenceDomainSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts";
|
||||
import { clockTimeSchema, evidenceDomainSchema, rectificationAnalysisTraceSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts";
|
||||
|
||||
const uuid = z.string().uuid();
|
||||
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
@@ -254,6 +254,11 @@ export const publicMessageSchema = z.object({
|
||||
}).strict();
|
||||
export type PublicMessage = z.infer<typeof publicMessageSchema>;
|
||||
|
||||
export const storedPublicMessageSchema = publicMessageSchema.extend({
|
||||
analysisTrace: rectificationAnalysisTraceSchema.optional(),
|
||||
}).strict();
|
||||
export type StoredPublicMessage = z.infer<typeof storedPublicMessageSchema>;
|
||||
|
||||
export const agentRunSchema = z.object({
|
||||
id: uuid,
|
||||
caseId: uuid,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { RectificationV4CandidateEngine } from "../rectification-v4/candidate-engine.ts";
|
||||
import type { CandidateEngineResult, 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 type { CandidateSnapshot, RectificationAnalysisTrace, RectificationV4Question } from "../rectification-v4/contracts.ts";
|
||||
import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts";
|
||||
import { reconcileV4Evidence } from "../rectification-v4/extraction.ts";
|
||||
import { extractEventWithModel } from "./event-extractor-agent.ts";
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type AgentRun,
|
||||
type CandidateFeatureSnapshot,
|
||||
type DiagnosticsSummary,
|
||||
type PublicMessage,
|
||||
type StoredPublicMessage,
|
||||
type ValidatedDecision,
|
||||
} from "./contracts.ts";
|
||||
|
||||
@@ -29,6 +29,48 @@ function hash(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
const analysisPhaseLabels = {
|
||||
extracting_evidence: "整理用户经历",
|
||||
scoring_candidates: "扫描候选分钟",
|
||||
checking_robustness: "检查候选稳定性",
|
||||
planning_question: "生成语义问题机会",
|
||||
reasoning: "选择下一步动作",
|
||||
rendering: "生成安全回复",
|
||||
} as const;
|
||||
|
||||
const diagnosticLabels = {
|
||||
leave_one_event_out: "留一事件稳定性",
|
||||
leave_one_domain_out: "留一领域稳定性",
|
||||
date_sensitivity: "日期敏感性",
|
||||
neighbor_stability: "相邻分钟稳定性",
|
||||
candidate_split: "候选分裂诊断",
|
||||
} as const;
|
||||
|
||||
type AnalysisPhase = keyof typeof analysisPhaseLabels;
|
||||
|
||||
export function publicRectificationTechniques(result: CandidateEngineResult | null): string[] {
|
||||
if (!result) return [];
|
||||
const techniques = new Set<string>();
|
||||
const add = (value: string) => {
|
||||
const normalized = value.toLocaleLowerCase();
|
||||
if (normalized.includes("vim")) techniques.add("Vimshottari Dasha");
|
||||
if (normalized.includes("narayana")) techniques.add("Narayana Dasha");
|
||||
if (normalized.includes("controlled_transit")) techniques.add("木星/土星受控行运");
|
||||
if (normalized.includes("ashtakavarga")) techniques.add("Ashtakavarga");
|
||||
if (normalized.includes("shadbala")) techniques.add("Shadbala 已验证分量");
|
||||
for (const layer of ["D2", "D4", "D9", "D10", "D11", "D24", "D30"] as const) {
|
||||
if (new RegExp(`(?:^|[^0-9])${layer}(?:$|[^0-9])`, "i").test(value)) techniques.add(layer);
|
||||
}
|
||||
};
|
||||
for (const candidate of Object.values(result.contributionMatrix)) {
|
||||
for (const contribution of Object.values(candidate)) {
|
||||
contribution.rule_ids.forEach(add);
|
||||
contribution.technique_layers.forEach(add);
|
||||
}
|
||||
}
|
||||
return [...techniques];
|
||||
}
|
||||
|
||||
export async function processRectificationAgentTurn(input: Readonly<{
|
||||
claimed: ClaimedRectificationV4Job;
|
||||
engine: RectificationV4CandidateEngine;
|
||||
@@ -41,14 +83,33 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
diagnostics: DiagnosticsSummary | null;
|
||||
featureSnapshot: CandidateFeatureSnapshot | null;
|
||||
validatedDecision: ValidatedDecision;
|
||||
publicMessage: PublicMessage;
|
||||
publicMessage: StoredPublicMessage;
|
||||
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 stages: RectificationAnalysisTrace["stages"] = [];
|
||||
let activePhase: AnalysisPhase | null = null;
|
||||
let activePhaseStarted = 0;
|
||||
const finishPhase = (status: "completed" | "failed" = "completed") => {
|
||||
if (!activePhase) return;
|
||||
stages.push({
|
||||
phase: activePhase,
|
||||
label: analysisPhaseLabels[activePhase],
|
||||
status,
|
||||
durationMs: Math.max(0, Date.now() - activePhaseStarted),
|
||||
});
|
||||
activePhase = null;
|
||||
};
|
||||
const enterPhase = async (phase: AnalysisPhase) => {
|
||||
finishPhase();
|
||||
activePhase = phase;
|
||||
activePhaseStarted = Date.now();
|
||||
await input.onPhase?.(phase);
|
||||
};
|
||||
await enterPhase("extracting_evidence");
|
||||
const asOfDate = now.toISOString().slice(0, 10);
|
||||
let reconciliation = claimed.turn.answer ? reconcileV4Evidence({
|
||||
caseId: claimed.case.id,
|
||||
@@ -88,11 +149,16 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
let snapshot: CandidateSnapshot | null = null;
|
||||
let diagnostics: DiagnosticsSummary | null = null;
|
||||
let featureSnapshot: CandidateFeatureSnapshot | null = null;
|
||||
let engineResult: CandidateEngineResult | null = null;
|
||||
const analysisToolCalls: RectificationAnalysisTrace["toolCalls"] = [];
|
||||
|
||||
if (scoreable.length >= 3 && domains.size >= 2) {
|
||||
await input.onPhase?.("scoring_candidates");
|
||||
await enterPhase("scoring_candidates");
|
||||
const engineStarted = Date.now();
|
||||
const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable });
|
||||
await input.onPhase?.("checking_robustness");
|
||||
engineResult = scored;
|
||||
analysisToolCalls.push({ category: "candidate_engine", label: "候选分钟扫描与稳定性诊断", outcome: "succeeded", durationMs: Date.now() - engineStarted });
|
||||
await enterPhase("checking_robustness");
|
||||
const clusters = buildCandidateClusters(scored.candidates);
|
||||
const robustness = {
|
||||
neighborSupportMinutes: scored.robustness.neighborSupportMinutes,
|
||||
@@ -191,7 +257,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
|
||||
await input.onPhase?.("planning_question");
|
||||
await enterPhase("planning_question");
|
||||
const opportunities = buildQuestionOpportunities({
|
||||
caseId: claimed.case.id,
|
||||
events,
|
||||
@@ -201,7 +267,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
targetDisposition: reconciliation.targetDisposition,
|
||||
retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [],
|
||||
});
|
||||
await input.onPhase?.("reasoning");
|
||||
await enterPhase("reasoning");
|
||||
const reasoned = await runBoundedReasoner({
|
||||
caseValue: claimed.case,
|
||||
snapshot,
|
||||
@@ -259,7 +325,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
selectedOpportunity,
|
||||
};
|
||||
|
||||
await input.onPhase?.("rendering");
|
||||
await enterPhase("rendering");
|
||||
const legacyProjection = projectLegacyV4Turn({
|
||||
events,
|
||||
newEvents: extracted,
|
||||
@@ -268,7 +334,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
snapshot,
|
||||
});
|
||||
const agentVisible = claimed.case.deploymentMode === "v5_agent";
|
||||
const publicMessage = agentVisible
|
||||
const renderedMessage = agentVisible
|
||||
? await renderPublicTurn({
|
||||
caseValue: claimed.case,
|
||||
latestAnswer: claimed.turn.answer,
|
||||
@@ -279,6 +345,25 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
validated: validatedDecision,
|
||||
})
|
||||
: legacyProjection.publicMessage;
|
||||
finishPhase();
|
||||
for (const call of reasoned.toolCalls) {
|
||||
analysisToolCalls.push({
|
||||
category: "agent_diagnostic",
|
||||
label: call.diagnostic ? diagnosticLabels[call.diagnostic] : "只读诊断",
|
||||
outcome: call.outcome,
|
||||
durationMs: call.durationMs,
|
||||
});
|
||||
}
|
||||
const reasoningSummary = reasoned.mode === "agent" && !fallbackReason ? reasoned.reasoningSummary : null;
|
||||
const analysisTrace: RectificationAnalysisTrace = {
|
||||
status: claimed.case.deploymentMode === "v4_legacy" ? "legacy" : "completed",
|
||||
stages,
|
||||
toolCalls: analysisToolCalls,
|
||||
techniques: publicRectificationTechniques(engineResult),
|
||||
reasoningSummary,
|
||||
reasoningSource: reasoningSummary ? "provider_summary" : "none",
|
||||
};
|
||||
const publicMessage: StoredPublicMessage = { ...renderedMessage, analysisTrace };
|
||||
const nextQuestion = agentVisible && selectedOpportunity ? {
|
||||
id: randomUUID(),
|
||||
domain: selectedOpportunity.domain,
|
||||
|
||||
@@ -20,12 +20,40 @@ import {
|
||||
|
||||
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> }>;
|
||||
type GeneratedDecision = Readonly<{
|
||||
object: unknown;
|
||||
totalUsage?: Usage | Promise<Usage>;
|
||||
reasoningSummary?: string | null;
|
||||
reasoningSource?: "provider_summary" | null;
|
||||
}>;
|
||||
export type RectificationReasonerGenerator = (
|
||||
prompt: string,
|
||||
phase: "initial" | "after_diagnostic",
|
||||
) => Promise<GeneratedDecision>;
|
||||
|
||||
const unsafeReasoningPattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|(?:[01]\d|2[0-3]):[0-5]\d|(?:凌晨|清晨|上午|中午|下午|傍晚|晚上)?[零〇一二两三四五六七八九十百\d]{1,4}[点时](?:[零〇一二两三四五六七八九十百\d]{1,4}分?)?|opportunity(?:id)?|snapshot(?:id)?|event(?:id)?|tool[ _-]?call|score|diagnostic|rule[ _-]?id|贡献矩阵|内部字段|权重|保留率|比例|百分之|分数|得分|阈值|边际|cluster|D\d{1,2})/iu;
|
||||
|
||||
function compactText(value: string): string {
|
||||
return value.replace(/\s+/gu, "").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export function sanitizeReasoningSummary(value: unknown, sensitiveTexts: readonly string[] = []): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const text = value.replace(/\s+/gu, " ").trim();
|
||||
if (!text || unsafeReasoningPattern.test(text)) return null;
|
||||
const compact = compactText(text);
|
||||
for (const sensitiveText of sensitiveTexts) {
|
||||
const source = compactText(sensitiveText);
|
||||
if (source.length < 2) continue;
|
||||
const overlapLength = Math.min(4, source.length);
|
||||
for (let index = 0; index <= source.length - overlapLength; index += 1) {
|
||||
if (compact.includes(source.slice(index, index + overlapLength))) return null;
|
||||
}
|
||||
}
|
||||
const sentences = text.match(/[^。!?!?]+[。!?!?]?/gu)?.slice(0, 2).join("").trim() ?? text;
|
||||
return sentences.slice(0, 240).trim() || null;
|
||||
}
|
||||
|
||||
function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: DiagnosticsSummary) {
|
||||
switch (diagnostic) {
|
||||
case "leave_one_event_out": return { retentionRate: summary.leaveOneEventOutRetentionRate, unstableEventIds: summary.unstableEventIds };
|
||||
@@ -95,6 +123,7 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
inputTokenCount: number | null;
|
||||
outputTokenCount: number | null;
|
||||
latencyMs: number;
|
||||
reasoningSummary: string | null;
|
||||
}>> {
|
||||
const started = Date.now();
|
||||
const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null;
|
||||
@@ -116,6 +145,7 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
inputTokenCount: usageObserved ? inputTokenCount : null,
|
||||
outputTokenCount: usageObserved ? outputTokenCount : null,
|
||||
latencyMs: Date.now() - started,
|
||||
reasoningSummary: null,
|
||||
};
|
||||
};
|
||||
if (input.enabled === false) return fallback("deployment_mode_legacy");
|
||||
@@ -160,13 +190,30 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
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 isOpenAiProvider = model?.mode === "openai";
|
||||
const generate: RectificationReasonerGenerator = input.generateDecision ?? (async (prompt) => {
|
||||
if (!agent) throw new Error("reasoner_model_unavailable");
|
||||
return agent.generate(prompt, {
|
||||
let reasoningSummary = "";
|
||||
const stream = await agent.stream(prompt, {
|
||||
abortSignal: AbortSignal.timeout(input.timeoutMs ?? 20_000),
|
||||
maxSteps: maxToolCalls + 2,
|
||||
providerOptions: isOpenAiProvider
|
||||
? { openai: { reasoningEffort: "high", reasoningSummary: "auto" } }
|
||||
: undefined,
|
||||
structuredOutput: { schema: rectificationDecisionSchema, jsonPromptInjection: "inline" },
|
||||
});
|
||||
for await (const chunk of stream.fullStream) {
|
||||
const isOpenAiSummary = isOpenAiProvider && chunk.type === "reasoning-delta";
|
||||
if (isOpenAiSummary && reasoningSummary.length < 2_000) {
|
||||
reasoningSummary += chunk.payload.text.slice(0, 2_000 - reasoningSummary.length);
|
||||
}
|
||||
}
|
||||
return {
|
||||
object: await stream.object,
|
||||
totalUsage: stream.totalUsage,
|
||||
reasoningSummary,
|
||||
reasoningSource: reasoningSummary ? "provider_summary" : null,
|
||||
};
|
||||
});
|
||||
const addUsage = async (result: GeneratedDecision) => {
|
||||
if (!result.totalUsage) return;
|
||||
@@ -176,11 +223,22 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
usageObserved = true;
|
||||
};
|
||||
const baseState = buildReasonerState(input);
|
||||
const sensitiveTexts = [
|
||||
baseState.latestAnswer,
|
||||
...baseState.recentTurns.flatMap((turn) => [turn.question, turn.answer]),
|
||||
...baseState.recentEvents.flatMap((event) => [event.summary, event.date]),
|
||||
...(baseState.currentTarget ? [baseState.currentTarget.summary, baseState.currentTarget.date] : []),
|
||||
...baseState.opportunities.flatMap((opportunity) => opportunity.anchors),
|
||||
];
|
||||
let reasoningSummary: string | null = null;
|
||||
|
||||
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);
|
||||
reasoningSummary = first.reasoningSource === "provider_summary"
|
||||
? sanitizeReasoningSummary(first.reasoningSummary, sensitiveTexts)
|
||||
: null;
|
||||
let decision = rectificationDecisionSchema.parse(first.object);
|
||||
if (decision.action === "run_diagnostic") {
|
||||
const result = await readDiagnostic(decision.diagnostic);
|
||||
@@ -190,6 +248,9 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
diagnosticResult: { diagnostic: decision.diagnostic, result },
|
||||
}), "after_diagnostic");
|
||||
await addUsage(second);
|
||||
reasoningSummary = second.reasoningSource === "provider_summary"
|
||||
? sanitizeReasoningSummary(second.reasoningSummary, sensitiveTexts) ?? reasoningSummary
|
||||
: reasoningSummary;
|
||||
decision = rectificationDecisionSchema.parse(second.object);
|
||||
if (decision.action === "run_diagnostic") return fallback("reasoner_returned_nonfinal_diagnostic");
|
||||
}
|
||||
@@ -200,6 +261,7 @@ export async function runBoundedReasoner(input: Readonly<{
|
||||
inputTokenCount: usageObserved ? inputTokenCount : null,
|
||||
outputTokenCount: usageObserved ? outputTokenCount : null,
|
||||
latencyMs,
|
||||
reasoningSummary,
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof DOMException && error.name === "TimeoutError" ? "reasoner_timeout"
|
||||
|
||||
Reference in New Issue
Block a user