feat(rectification): add adaptive director tool loop

This commit is contained in:
Jesse_Chen
2026-07-31 15:09:03 +08:00
parent a7a11a1a7a
commit 453eacf3e2
11 changed files with 264 additions and 52 deletions
+16
View File
@@ -1866,3 +1866,19 @@
- 防复发:完整回放测试必须同时覆盖事件账本、问题排序、退出方式和公开文本;评分版本变化必须同步 Case 复用、数据库默认值和历史快照兼容;Case 创建测试必须随后真实调用一次 Answer。
- 相关记录:BUG-101、BUG-102、BUG-104
- 修复版本:local follow-up / `rectification-v5-matrix-scoring-2`
## BUG-106 | Director 缺少统一工具 Observation 与可更新 Dossier
- 状态:resolved
- 首次发现:2026-07-31
- 最近更新:2026-07-31
- 影响面:V8 生时纠正 Director Runtime、Skill 决策策略、Agent Run 诊断轨迹和未完成 Case 版本元数据
- 用户现象:Director 虽然能够请求只读诊断,但 Case、候选扫描和证据缺口仍由固定流程一次性塞入 prompt;取得结果后最多再读两次诊断,Dossier 本身不随观察更新,前端处理中也只显示单行状态。
- 触发条件:最终决策需要依次读取案件、候选差异、证据缺口和某项稳定性诊断才能决定下一问;或模型重复请求本轮已经读取过的静态工具。
- 根因:Director 合同只有 `request_diagnostic`Dossier 没有 revision、Observation 和候选假设;Runtime 只累计旁路诊断数组,前端没有把既有 Job phase 投影成稳定的分析步骤。
- 修复:新增服务器拥有的 `case_read``candidate_scan``evidence_gap``diagnostic_read` 统一只读工具;最终规划首轮只提供 Runtime 与工具可用性,不再预载这些工具拥有的完整数据。每次调用按需暴露当前权威服务器投影、生成结构化 Observation、递增 in-run Dossier revision,并把更新后的 Dossier 交回同一 Director。循环最多 10 轮,静态工具按工具+诊断去重,越界后只允许一次强制收敛;前端复用现有 Job phase 显示“整理事件 / 比较候选 / 确定验证方向”三步进度。Skill/Prompt 升级为 `birth-time-rectification-v8` / `rectification-director-v4`,向前迁移只推进未完成的 Agent Case,不改历史完成结果、评分算法或 Profile 出生时间。
- 验证:Director 回归覆盖 `case_read → candidate_scan → evidence_gap → diagnostic_read → final`、每轮 Dossier revision/Observation 回灌、重复工具不重复执行、一次强制收敛和单问题最终动作;前端回归覆盖三个公开分析步骤;V8 迁移合同覆盖默认版本、未完成 Agent Case 范围和禁止写入 `active_birth_time`
- 安全边界:服务器继续拥有事件事实、工具结果、候选 Snapshot、公开范围门禁、持久化与最终输出验证;Observation 只存在于本次 Agent Run 的内存 Dossier,不成为第二业务真源,也不向前端公开原始结果、内部技术层、候选分钟或评分。
- 防复发:不得把每次诊断后的 prompt 改回“必须立即结束”;增加新工具时必须定义 observation 是否会在同一 Turn 变化、重复调用策略和总轮次上限。
- 相关记录:BUG-101、BUG-102、BUG-105
- 修复版本:`birth-time-rectification-v8` / `rectification-director-v4`
@@ -51,6 +51,18 @@ export function rectificationPhaseLabel(
return phaseLabels[phase];
}
export function rectificationProgressLabel(
phase: NonNullable<RectificationV4ApiResponse["job"]>["phase"],
): string {
const activeStep = ["collecting_evidence", "extracting_evidence"].includes(phase)
? 0
: ["scoring_candidates", "checking_robustness"].includes(phase)
? 1
: 2;
const steps = ["整理已确认事件", "比较候选时间差异", "确定下一步验证方向"];
return [`${rectificationPhaseLabel(phase)}\n正在分析:`, ...steps.map((step, index) => `${index < activeStep || phase === "complete" ? "✓" : index === activeStep ? "●" : "○"} ${step}`)].join("\n");
}
function durationLabel(durationMs: number | null): string | null {
if (durationMs === null || durationMs < 0) return null;
return durationMs < 1_000 ? `${durationMs} 毫秒` : `${(durationMs / 1_000).toFixed(1)}`;
@@ -246,7 +258,7 @@ export function rectificationV4ChatMessages(
if (processing) {
messages.push({
role: "assistant",
text: rectificationPhaseLabel(data.job?.phase ?? caseValue.phase),
text: rectificationProgressLabel(data.job?.phase ?? caseValue.phase),
renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`,
state: "thinking",
});
@@ -5,8 +5,8 @@ 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 CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v6" as const;
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-director-v2" as const;
export const CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v8" as const;
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-director-v4" as const;
export const rectificationDiagnosticSchema = z.enum([
"leave_one_event_out",
@@ -17,6 +17,25 @@ export const rectificationDiagnosticSchema = z.enum([
]);
export type RectificationDiagnostic = z.infer<typeof rectificationDiagnosticSchema>;
export const rectificationAgentToolSchema = z.enum([
"case_read",
"candidate_scan",
"evidence_gap",
"diagnostic_read",
]);
export type RectificationAgentTool = z.infer<typeof rectificationAgentToolSchema>;
export const toolObservationSchema = z.object({
round: z.number().int().positive().max(10),
tool: rectificationAgentToolSchema,
diagnostic: rectificationDiagnosticSchema.nullable(),
outcome: z.enum(["succeeded", "failed"]),
result: z.record(z.string(), z.unknown()),
dossierRevision: z.number().int().positive().max(10),
errorCode: nonblank(120).nullable(),
}).strict();
export type ToolObservation = z.infer<typeof toolObservationSchema>;
export const targetDispositionSchema = z.enum([
"resolved",
"unknown",
@@ -72,6 +91,11 @@ const directorActionSchema = z.discriminatedUnion("type", [
question: nonblank(240),
optionalQuickReplies: z.array(z.object({ label: nonblank(40), value: nonblank(120) }).strict()).max(4),
}).strict(),
z.object({
type: z.literal("request_tool"),
tool: rectificationAgentToolSchema,
diagnostic: rectificationDiagnosticSchema.nullable(),
}).strict(),
z.object({ type: z.literal("request_diagnostic"), diagnostic: rectificationDiagnosticSchema }).strict(),
z.object({ type: z.literal("offer_candidate_range"), snapshotId: uuid }).strict(),
z.object({ type: z.literal("stop_low_confidence"), reasonCodes: z.array(nonblank(80)).min(1).max(8) }).strict(),
@@ -158,11 +182,20 @@ export const rectificationCaseDossierSchema = z.object({
gateReasons: z.array(z.string()).max(20),
currentSnapshotId: uuid.nullable(),
}).strict(),
runtime: z.object({
revision: z.number().int().nonnegative().max(10),
observations: z.array(toolObservationSchema).max(10),
hypotheses: z.array(z.object({
candidateRank: z.number().int().positive(),
supportingEventIds: z.array(uuid).max(100),
conflictingEventIds: z.array(uuid).max(100),
}).strict()).max(4),
}).strict(),
capabilities: z.object({
supportedDomains: z.array(evidenceDomainSchema),
supportedEventKinds: z.array(eventKindSchema),
maxQuestionsPerTurn: z.literal(1),
maxDiagnosticsPerRun: z.number().int().min(0).max(2),
maxToolRounds: z.literal(10),
forbiddenPublicClaims: z.array(z.string()),
}).strict(),
}).strict();
@@ -456,7 +489,7 @@ export const agentRunSchema = z.object({
deploymentMode: rectificationDeploymentModeSchema,
decision: rectificationDecisionSchema.nullable(),
validatedDecision: validatedDecisionSchema,
toolCalls: z.array(toolCallTraceSchema).max(8),
toolCalls: z.array(toolCallTraceSchema).max(10),
fallbackReason: nonblank(120).nullable(),
inputTokenCount: z.number().int().nonnegative().nullable(),
outputTokenCount: z.number().int().nonnegative().nullable(),
@@ -6,7 +6,7 @@ import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, P
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
import { hasPolicyInvalidScoreableEvents } from "../rectification-v4/evidence-ledger.ts";
import { buildCandidateContrastPacket } from "./opportunity-builder.ts";
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace } from "./contracts.ts";
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationAgentTool, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace, type ToolObservation } from "./contracts.ts";
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
const domains: EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family", "other"];
@@ -28,7 +28,7 @@ function asksMultipleQuestions(value: string): boolean {
const declinedPattern = /(?:|便|||||)/u;
type Generated = Readonly<{ object: unknown; totalUsage?: { inputTokens?: number; outputTokens?: number } | Promise<{ inputTokens?: number; outputTokens?: number }> }>;
const regeneratedQuestionSchema = z.object({ question: z.string().trim().min(8).max(500) }).strict();
export type RectificationDirectorGenerator = (prompt: string, phase: "evidence" | "final" | "after_diagnostic" | "repair") => Promise<Generated>;
export type RectificationDirectorGenerator = (prompt: string, phase: "evidence" | "final" | "after_observation" | "converge" | "repair") => Promise<Generated>;
function summarizeEarlierTurns(turns: readonly RectificationV4Turn[]): string | null {
const older = turns.slice(0, -12);
@@ -57,7 +57,15 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti
eventLedger: input.events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" : "superseded" })),
interviewState: { currentTargetEventId: input.currentTargetEventId, declinedDomains: [...new Set(input.turns.flatMap((turn) => turn.questionDomain && declinedPattern.test(turn.answer) ? [turn.questionDomain] : []))], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], pendingEvidence: (input.pendingEvidence ?? []).filter((item) => !item.resolvedAt).map(({ rawText, reasonCode, targetEventId, createdAt }) => ({ rawText, reasonCode, targetEventId, createdAt })), askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition },
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), contrastIntelligence: buildCandidateContrastPacket({ events: input.events, snapshot: input.snapshot, diagnostics: input.diagnostics }), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxDiagnosticsPerRun: 2, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
runtime: {
revision: 0,
observations: [],
hypotheses: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => {
const candidate = input.snapshot?.candidates.find((item) => item.time === cluster.representativeTime);
return { candidateRank: cluster.rank, supportingEventIds: candidate?.supportingEventIds ?? [], conflictingEventIds: candidate?.conflictingEventIds ?? [] };
}),
},
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxToolRounds: 10, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
});
}
@@ -146,7 +154,7 @@ export async function regenerateDirectorQuestion(input: Readonly<{
export async function runRectificationDirector(input: Readonly<{ caseValue: RectificationV4Case; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final"; diagnostics: DiagnosticsSummary; timeoutMs?: number; generatePlan?: RectificationDirectorGenerator }>) {
const started = Date.now();
const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel();
const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Direct the interview from the complete dossier. Propose every explicit event in the latest answer, choose the current focus, and write the public reply plus at most one natural question. Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output." }) : null;
const agent = model ? new Agent({ id: `rectification-director-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Direct the interview from the server-owned dossier and tool observations. Propose every explicit event in the latest answer, choose the current focus, and write the public reply plus at most one natural question. In final planning, use the server-owned read-only tools to inspect the case, candidate scan, evidence gaps, or one diagnostic at a time. Adapt after every observation, never repeat an immutable tool call in the same run, and converge as soon as another tool adds no value. Never write scores, internal ids, profile values, candidate minutes, status, phase, or database mutations. Return strict structured output." }) : null;
const generate = input.generatePlan ?? (async (prompt: string) => {
if (!agent) throw new Error("director_model_unavailable");
return agent.generate(prompt, { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 25_000), structuredOutput: { schema: rectificationTurnPlanSchema, jsonPromptInjection: "inline" } });
@@ -154,7 +162,7 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
let inputTokens = 0, outputTokens = 0;
let usageObserved = false;
const toolCalls: ToolCallTrace[] = [];
const diagnosticResults: Array<{ diagnostic: RectificationDiagnostic; result: ReturnType<typeof diagnosticResult> }> = [];
let dossier = input.dossier;
const addUsage = async (generated: Generated) => {
if (!generated.totalUsage) return;
const usage = await generated.totalUsage;
@@ -162,31 +170,91 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
outputTokens += Math.max(0, Math.trunc(usage.outputTokens ?? 0));
usageObserved = true;
};
const requestedTool = (action: RectificationTurnPlan["action"]): Readonly<{ tool: RectificationAgentTool; diagnostic: RectificationDiagnostic | null }> | null => {
if (action.type === "request_tool") {
if ((action.tool === "diagnostic_read") !== Boolean(action.diagnostic)) throw new Error("director_tool_request_invalid");
return { tool: action.tool, diagnostic: action.diagnostic };
}
if (action.type === "request_diagnostic") return { tool: "diagnostic_read", diagnostic: action.diagnostic };
return null;
};
const toolKey = (request: Readonly<{ tool: RectificationAgentTool; diagnostic: RectificationDiagnostic | null }>) => `${request.tool}:${request.diagnostic ?? ""}`;
const promptDossier = () => input.phase === "evidence" ? dossier : {
runtime: { revision: dossier.runtime.revision, observations: dossier.runtime.observations },
capabilities: dossier.capabilities,
availableTools: {
readOnly: ["case_read", "candidate_scan", "evidence_gap"],
diagnostics: ["leave_one_event_out", "leave_one_domain_out", "date_sensitivity", "neighbor_stability", "candidate_split"],
},
};
const executeTool = (request: Readonly<{ tool: RectificationAgentTool; diagnostic: RectificationDiagnostic | null }>, round: number): ToolObservation => {
const result = request.tool === "case_read"
? { case: dossier.case, conversation: dossier.conversation, eventLedger: dossier.eventLedger, interviewState: dossier.interviewState }
: request.tool === "candidate_scan"
? { candidateState: dossier.candidateState, hypotheses: dossier.runtime.hypotheses }
: request.tool === "evidence_gap"
? { pendingEvidence: dossier.interviewState.pendingEvidence, contrastIntelligence: dossier.candidateState.contrastIntelligence, hypotheses: dossier.runtime.hypotheses }
: { diagnostic: request.diagnostic, result: diagnosticResult(request.diagnostic!, input.diagnostics) };
return { round, tool: request.tool, diagnostic: request.diagnostic, outcome: "succeeded", result, dossierRevision: dossier.runtime.revision + 1, errorCode: null };
};
try {
const first = await generate(JSON.stringify({ task: input.phase === "evidence" ? "Interpret the latest answer and propose every explicit event. The action is provisional." : "Choose the final action and public response. evidenceProposals must be empty because staging is complete.", latestAnswer: input.latestAnswer, dossier: input.dossier }), input.phase);
const first = await generate(JSON.stringify({ task: input.phase === "evidence" ? "Interpret the latest answer and propose every explicit event. The action is provisional." : "Choose the final action and public response. evidenceProposals must be empty. Use a read-only tool only when its observation can materially change the next action.", latestAnswer: input.latestAnswer, dossier: promptDossier() }), input.phase);
await addUsage(first);
let candidate = rectificationTurnPlanSchema.parse(first.object);
while (input.phase === "final" && candidate.action.type === "request_diagnostic" && toolCalls.length < input.dossier.capabilities.maxDiagnosticsPerRun) {
const observedTools = new Set<string>();
let convergenceReason: "tool_repeated" | "tool_round_limit" | null = null;
let request = requestedTool(candidate.action);
while (input.phase === "final" && request) {
const key = toolKey(request);
if (observedTools.has(key)) {
convergenceReason = "tool_repeated";
break;
}
if (toolCalls.length >= dossier.capabilities.maxToolRounds) {
convergenceReason = "tool_round_limit";
break;
}
const toolStarted = Date.now();
const result = diagnosticResult(candidate.action.diagnostic, input.diagnostics);
diagnosticResults.push({ diagnostic: candidate.action.diagnostic, result });
toolCalls.push({ tool: "run_rectification_diagnostics", diagnostic: candidate.action.diagnostic, outcome: "succeeded", durationMs: Date.now() - toolStarted, errorCode: null });
const second = await generate(JSON.stringify({ task: "Use the diagnostic results and return a final non-diagnostic action with no evidence proposals.", latestAnswer: input.latestAnswer, dossier: input.dossier, diagnosticResults }), "after_diagnostic");
await addUsage(second);
candidate = rectificationTurnPlanSchema.parse(second.object);
const observation = executeTool(request, toolCalls.length + 1);
observedTools.add(key);
dossier = rectificationCaseDossierSchema.parse({
...dossier,
runtime: { ...dossier.runtime, revision: observation.dossierRevision, observations: [...dossier.runtime.observations, observation] },
});
toolCalls.push({ tool: request.tool, diagnostic: request.diagnostic, outcome: observation.outcome, durationMs: Date.now() - toolStarted, errorCode: observation.errorCode });
const next = await generate(JSON.stringify({
task: "Read the updated dossier and latest observation. Request another unobserved read-only tool only if it can materially change the interview strategy; otherwise converge to one final non-tool action. evidenceProposals must stay empty.",
latestAnswer: input.latestAnswer,
dossier: promptDossier(),
latestObservation: observation,
loopState: { round: toolCalls.length, maxRounds: dossier.capabilities.maxToolRounds, observedTools: [...observedTools] },
}), "after_observation");
await addUsage(next);
candidate = rectificationTurnPlanSchema.parse(next.object);
request = requestedTool(candidate.action);
}
if (input.phase === "final" && candidate.action.type === "request_diagnostic") throw new Error("director_final_plan_not_final");
let validated = validateRectificationTurnPlan({ plan: candidate, dossier: input.dossier, latestAnswer: input.latestAnswer, phase: input.phase });
if (input.phase === "final" && requestedTool(candidate.action)) {
const converged = await generate(JSON.stringify({
task: "The tool loop has reached its convergence boundary. Return one safe final non-tool action now; do not request another tool and keep evidenceProposals empty.",
latestAnswer: input.latestAnswer,
dossier: promptDossier(),
loopState: { round: toolCalls.length, maxRounds: dossier.capabilities.maxToolRounds, observedTools: [...observedTools], convergenceReason: convergenceReason ?? "tool_round_limit" },
}), "converge");
await addUsage(converged);
candidate = rectificationTurnPlanSchema.parse(converged.object);
if (requestedTool(candidate.action)) throw new Error("director_final_plan_not_final");
}
let validated = validateRectificationTurnPlan({ plan: candidate, dossier, latestAnswer: input.latestAnswer, phase: input.phase });
if (!validated.plan) {
const repaired = await generate(JSON.stringify({ task: "Repair the rejected plan once. Preserve grounded facts, return one safe final plan, and address every validation issue.", latestAnswer: input.latestAnswer, dossier: input.dossier, rejectedPlan: candidate, validationIssues: validated.issues }), "repair");
const repaired = await generate(JSON.stringify({ task: "Repair the rejected plan once. Preserve grounded facts, return one safe final plan, and address every validation issue.", latestAnswer: input.latestAnswer, dossier: promptDossier(), rejectedPlan: candidate, validationIssues: validated.issues }), "repair");
await addUsage(repaired);
candidate = rectificationTurnPlanSchema.parse(repaired.object);
if (candidate.action.type === "request_diagnostic") throw new Error("director_repair_requested_diagnostic");
validated = validateRectificationTurnPlan({ plan: candidate, dossier: input.dossier, latestAnswer: input.latestAnswer, phase: input.phase });
if (requestedTool(candidate.action)) throw new Error("director_repair_requested_tool");
validated = validateRectificationTurnPlan({ plan: candidate, dossier, latestAnswer: input.latestAnswer, phase: input.phase });
}
if (!validated.plan) throw new Error(`director_plan_rejected:${validated.issues.join(",")}`);
return { plan: validated.plan, mode: "agent" as const, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
return { plan: validated.plan, dossier, mode: "agent" as const, fallbackReason: null, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
} catch (error) {
return { plan: fallback(input.dossier, input.latestAnswer), mode: "deterministic_fallback" as const, fallbackReason: error instanceof Error ? error.message.slice(0, 120) : "director_failed", toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
return { plan: fallback(dossier, input.latestAnswer), dossier, mode: "deterministic_fallback" as const, fallbackReason: error instanceof Error ? error.message.slice(0, 120) : "director_failed", toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
}
}
@@ -383,8 +383,8 @@ export async function processRectificationAgentTurn(input: Readonly<{
});
const plan = directed.plan;
const action = plan.action;
if (action.type === "request_diagnostic") {
throw new Error("rectification_director_diagnostic_loop_incomplete");
if (action.type === "request_diagnostic" || action.type === "request_tool") {
throw new Error("rectification_director_tool_loop_incomplete");
}
const decision = action.type === "ask_question"
? { action: "ask_question" as const, focus: action.focus, question: action.question }
@@ -400,7 +400,8 @@ export async function processRectificationAgentTurn(input: Readonly<{
await enterPhase("rendering");
finishPhase();
for (const call of directed.toolCalls) analysisToolCalls.push({
category: "agent_diagnostic", label: call.diagnostic ? diagnosticLabels[call.diagnostic] : "只读诊断",
category: "agent_diagnostic",
label: call.diagnostic ? diagnosticLabels[call.diagnostic] : ({ case_read: "读取案件档案", candidate_scan: "读取候选扫描", evidence_gap: "检查证据缺口" } as Record<string, string>)[call.tool] ?? "只读诊断",
outcome: call.outcome, durationMs: call.durationMs,
});
const publicMessage: StoredPublicMessage = {
@@ -0,0 +1,14 @@
-- New and unfinished Agent cases use the adaptive Director loop contract.
alter table public.birth_time_rectification_v4_cases
alter column skill_version set default 'birth-time-rectification-v8',
alter column prompt_version set default 'rectification-director-v4';
-- The scoring and evidence contracts are unchanged, so unfinished cases can continue safely.
update public.birth_time_rectification_v4_cases
set skill_version = 'birth-time-rectification-v8',
prompt_version = 'rectification-director-v4',
updated_at = greatest(updated_at, pg_catalog.now())
where status in ('awaiting_answer', 'processing', 'paused')
and deployment_mode in ('v5_shadow', 'v5_agent')
and (skill_version is distinct from 'birth-time-rectification-v8'
or prompt_version is distinct from 'rectification-director-v4');
@@ -4,7 +4,7 @@ import test from "node:test";
import {
canRegenerateRectificationMessage,
rectificationV4ChatMessages,
rectificationPhaseLabel,
rectificationPhaseLabel, rectificationProgressLabel,
toggleRectificationFeedback,
} from "../src/components/rectification-v4-panel.tsx";
import { applyRectificationV4JobUpdate } from "../src/hooks/use-rectification-v4.ts";
@@ -259,9 +259,13 @@ test("processing follows every server job phase returned by polling", () => {
const message = rectificationV4ChatMessages(data, true).at(-1);
assert.equal(message?.role, "assistant");
assert.equal(message?.state, "thinking");
assert.equal(message?.text, label);
assert.equal(message?.text, rectificationProgressLabel(phase));
assert.match(message?.text ?? "", new RegExp(label));
}
assert.equal(rectificationPhaseLabel("checking_robustness"), "正在检查候选范围的稳定性…");
assert.equal(rectificationProgressLabel("extracting_evidence"), "正在整理你刚才提到的经历…\n正在分析:\n● 整理已确认事件\n○ 比较候选时间差异\n○ 确定下一步验证方向");
assert.equal(rectificationProgressLabel("checking_robustness"), "正在检查候选范围的稳定性…\n正在分析:\n✓ 整理已确认事件\n● 比较候选时间差异\n○ 确定下一步验证方向");
assert.equal(rectificationProgressLabel("reasoning"), "正在选择下一步动作…\n正在分析:\n✓ 整理已确认事件\n✓ 比较候选时间差异\n● 确定下一步验证方向");
});
test("polling ignores an older job response so the visible phase cannot move backward", () => {
@@ -441,6 +441,15 @@ test("V6 迁移只更新未完成 Case 版本且不写 active_birth_time", () =>
assert.doesNotMatch(migration, /profiles\s*\.\s*active_birth_time|active_birth_time/i);
});
test("V8 Director migration advances only unfinished Agent cases", () => {
const migration = readFileSync(new URL("../supabase/migrations/20260731020000_rectification_director_v4_runtime.sql", import.meta.url), "utf8");
assert.match(migration, /alter column skill_version set default 'birth-time-rectification-v8'/);
assert.match(migration, /alter column prompt_version set default 'rectification-director-v4'/);
assert.match(migration, /where status in \('awaiting_answer', 'processing', 'paused'\)/);
assert.match(migration, /deployment_mode in \('v5_shadow', 'v5_agent'\)/);
assert.doesNotMatch(migration, /profiles\s*\.\s*active_birth_time|active_birth_time/i);
});
test("新事件机会提供具体回忆线索和退出方式,不把离家上大学换词重问成迁居", () => {
const university = event({ summary: "离家去外地上大学", rawText: "2016年9月离家去外地上大学" });
const opportunities = buildQuestionOpportunities({
+61 -7
View File
@@ -38,8 +38,8 @@ const caseValue: RectificationV4Case = {
latestSnapshot: null,
orchestrationModelId: null,
narrationModelId: null,
skillVersion: "birth-time-rectification-v6",
promptVersion: "rectification-director-v1",
skillVersion: "birth-time-rectification-v8",
promptVersion: "rectification-director-v4",
algorithmVersion: "rectification-v5-matrix-scoring-1",
deploymentMode: "v5_agent",
agentMode: "agent",
@@ -319,7 +319,7 @@ test("explicit subject correction is grounded and enters pending review", () =>
assert.equal(staged.revisions[0]?.scoreability, "pending_review");
});
test("declined targets cannot be reopened and diagnostics stay in a bounded tool loop", async () => {
test("declined targets cannot be reopened and the Director adapts through server-owned observations", async () => {
const target = event({ eventId: "00000000-0000-4000-8000-000000000706" });
const targetDossier = buildRectificationCaseDossier({ caseValue, turns: [], events: [target], snapshot: null, diagnostics: null, targetDisposition: "declined", currentTargetEventId: target.eventId });
const reopened = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: target.eventId, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } });
@@ -327,6 +327,55 @@ test("declined targets cannot be reopened and diagnostics stay in a bounded tool
const reopenedWithoutId = plan({ targetDisposition: "declined", action: { type: "ask_question", focus: { mode: "clarify_existing_event", targetEventId: null, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["retry"] }, question: "再说说那件事?", optionalQuickReplies: [] } });
assert.ok(validateRectificationTurnPlan({ plan: reopenedWithoutId, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened"));
const phases: string[] = [];
const prompts: string[] = [];
const requests = [
{ tool: "case_read", diagnostic: null },
{ tool: "candidate_scan", diagnostic: null },
{ tool: "evidence_gap", diagnostic: null },
{ tool: "diagnostic_read", diagnostic: "candidate_split" },
] as const;
const result = await runRectificationDirector({
caseValue,
dossier: dossier(),
latestAnswer: "",
phase: "final",
diagnostics,
generatePlan: async (prompt, phase) => {
phases.push(phase);
prompts.push(prompt);
const request = requests[phases.length - 1];
return { object: request ? plan({ action: { type: "request_tool", ...request } }) : plan() };
},
});
assert.equal(result.mode, "agent");
assert.deepEqual(phases, ["final", "after_observation", "after_observation", "after_observation", "after_observation"]);
assert.deepEqual(result.toolCalls.map(({ tool, diagnostic }) => [tool, diagnostic]), [
["case_read", null],
["candidate_scan", null],
["evidence_gap", null],
["diagnostic_read", "candidate_split"],
]);
assert.equal(dossier().capabilities.maxToolRounds, 10);
const firstPrompt = JSON.parse(prompts[0]!);
assert.equal(firstPrompt.dossier.eventLedger, undefined);
assert.equal(firstPrompt.dossier.candidateState, undefined);
assert.equal(firstPrompt.dossier.runtime.hypotheses, undefined);
assert.deepEqual(firstPrompt.dossier.availableTools.readOnly, ["case_read", "candidate_scan", "evidence_gap"]);
const caseObservationPrompt = JSON.parse(prompts[1]!);
assert.ok(Array.isArray(caseObservationPrompt.latestObservation.result.eventLedger));
assert.equal(caseObservationPrompt.dossier.runtime.observations[0].tool, "case_read");
const candidateObservationPrompt = JSON.parse(prompts[2]!);
assert.equal(candidateObservationPrompt.latestObservation.result.candidateState.hasSnapshot, false);
assert.ok(Array.isArray(candidateObservationPrompt.latestObservation.result.hypotheses));
const finalPrompt = JSON.parse(prompts[4]!);
assert.equal(finalPrompt.dossier.runtime.revision, 4);
assert.deepEqual(finalPrompt.dossier.runtime.observations.map((item: { tool: string }) => item.tool), requests.map(({ tool }) => tool));
assert.equal(result.dossier.runtime.revision, 4);
assert.equal(result.plan.action.type, "ask_question");
});
test("repeated immutable tools are not executed twice and force one convergence decision", async () => {
const phases: string[] = [];
const prompts: string[] = [];
const result = await runRectificationDirector({
@@ -338,13 +387,18 @@ test("declined targets cannot be reopened and diagnostics stay in a bounded tool
generatePlan: async (prompt, phase) => {
phases.push(phase);
prompts.push(prompt);
return { object: phases.length <= 2 ? plan({ action: { type: "request_diagnostic", diagnostic: phases.length === 1 ? "candidate_split" : "date_sensitivity" } }) : plan() };
return { object: phase === "converge" ? plan() : plan({ action: { type: "request_tool", tool: "candidate_scan", diagnostic: null } }) };
},
});
assert.equal(result.mode, "agent");
assert.deepEqual(phases, ["final", "after_diagnostic", "after_diagnostic"]);
assert.equal(result.toolCalls.length, 2);
assert.deepEqual(JSON.parse(prompts[2]!).diagnosticResults.map((item: { diagnostic: string }) => item.diagnostic), ["candidate_split", "date_sensitivity"]);
assert.deepEqual(phases, ["final", "after_observation", "converge"]);
assert.equal(result.toolCalls.length, 1);
assert.deepEqual(JSON.parse(prompts[2]!).loopState, {
round: 1,
maxRounds: 10,
observedTools: ["candidate_scan:"],
convergenceReason: "tool_repeated",
});
assert.equal(result.plan.action.type, "ask_question");
});
+6 -6
View File
@@ -11,11 +11,11 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re
## Product boundary
- Current skill version: `birth-time-rectification-v6`.
- Current prompt version: `rectification-director-v2`.
- The scoring algorithm remains `rectification-v5-matrix-scoring-1`; the V6 label describes the conversation contract, not a replacement scoring engine.
- Current skill version: `birth-time-rectification-v8`.
- Current prompt version: `rectification-director-v4`.
- The scoring algorithm remains `rectification-v5-matrix-scoring-2`; the V8 label describes the conversation contract, not a replacement scoring engine.
- The server owns event reconciliation, the real Python scan of every minute in the candidate window, the event contribution matrix, Candidate Snapshots, LOEO/LODO, date sensitivity, neighbor stability, candidate split, jobs, replay, persistence, and final decision validation.
- The Director reads the complete event ledger plus the latest 1012 raw turns, may propose multiple grounded events or revisions, chooses one interview focus, writes the public reply and at most one natural question, and may call up to two allowed read-only diagnostics.
- The evidence pass reads the complete event ledger plus the latest 1012 raw turns to propose grounded events or revisions. The final pass starts with only runtime state and tool availability, then reads Case, candidate scan, evidence gaps, or diagnostics on demand before choosing one focus and writing at most one natural question. The adaptive loop allows up to ten unique read-only tool rounds; each immutable observation may be read only once per Turn.
- Event proposals are not facts until the server validates their source span, declared date, target revision, subject, classification, and scoreability. The Director never creates scores, candidate minutes, Case state, database mutations, or profile updates.
- VedAstro is a read-only post-validation gate for `v5_agent` only. It runs only after the local stability and range-eligibility gates pass, compares the server-provided primary and runner-up, and never replaces V5 local scoring or lets SearchEvents choose the final candidate.
- Candidate windows are inclusive. When `start_time > end_time`, the Python scan continues across midnight into the next calendar day; equal endpoints mean one candidate minute, and a window may not exceed 1,440 minutes.
@@ -53,9 +53,9 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re
## Turn strategy
1. Read the complete Case Dossier: recent raw turns, full revision ledger, current target disposition, pending evidence, candidate contrasts, event sensitivity, and range gate.
1. During evidence interpretation, read the complete Case Dossier: recent raw turns, full revision ledger, current target disposition, pending evidence, candidate contrasts, event sensitivity, and range gate. During final planning, do not assume those server-owned views are already loaded; request only the focused tool observations needed for the decision.
2. Propose every explicit event in the latest answer. Use exact source spans and declared date text; propose `revise` only with a server-issued event ID already present in the Dossier.
3. After the server stages valid revisions and recomputes diagnostics, choose the single most useful focus. The Dossier includes unresolved Pending Evidence and prior declined domains; use up to two read-only diagnostics when one result is not enough. Do not rotate through domains or ask for finer dates unless the diagnostics show value.
3. After the server stages valid revisions and recomputes diagnostics, choose the single most useful focus. Read focused server observations through `case_read`, `candidate_scan`, `evidence_gap`, or `diagnostic_read`; each result advances the in-run Dossier revision and must inform the next decision. Stop the loop as soon as the evidence supports one useful question, a gated range, or an honest low-confidence result. Do not rotate through domains or ask for finer dates unless the observations show value.
4. Write one short natural question and the public reply in the same TurnPlan. Do not expose internal IDs, scores, contribution details, tools, or candidate minutes.
5. If server validation rejects the TurnPlan, repair it once. If it still fails, accept the generic safety fallback. Offer a range only when the current server Snapshot allows it; otherwise stop honestly at low confidence when no useful question remains.
@@ -2,10 +2,10 @@
## Version and ownership
- Skill: `birth-time-rectification-v6`.
- Prompt: `rectification-agent-v6-1`.
- Algorithm: `rectification-v5-matrix-scoring-1` remains unchanged.
- V6 changes the conversation and semantic-question contracts; it does not replace the V5 candidate engine.
- Skill: `birth-time-rectification-v8`.
- Prompt: `rectification-director-v4`.
- Algorithm: `rectification-v5-matrix-scoring-2` remains unchanged.
- V8 changes the conversation and semantic-question contracts; it does not replace the V5 candidate engine.
- The server owns candidate-minute scanning, the event contribution matrix, Candidate Snapshots, diagnostics, stability gates, Decision Validator, deterministic fallback, Jobs, claim/lease, completed-job replay, atomic completion, idempotency, and persistence.
- Preserve `v4_legacy`, `v5_shadow`, and `v5_agent` deployment behavior. Shadow artifacts must not change the legacy visible reply.
@@ -21,9 +21,10 @@ When evidence is sparse, conflicting, tied, date-sensitive, or unstable, stop or
The agent may only:
1. select one active server-generated semantic question opportunity;
2. call at most one permitted read-only diagnostic;
3. offer a server-generated candidate range that has passed the public gate; or
4. stop with low confidence.
1. propose grounded evidence from the latest answer;
2. choose and directly write one safe interview question;
3. adapt through up to ten unique permitted read-only tool rounds;
4. offer a server-generated candidate range that has passed the public gate; or
5. stop with low confidence.
The agent must not create or alter events, normalized dates, candidate minutes, scores, diagnostic results, or profile birth data.
The final planning prompt exposes only the current runtime revision, prior tool observations, capabilities, and tool availability; it does not preload Case, candidate hypotheses, gap, or diagnostic payloads. The available read-only tools are `case_read`, `candidate_scan`, `evidence_gap`, and `diagnostic_read`; they expose the current authoritative server projection on demand. Every successful call becomes an immutable Observation in the in-run Dossier, increments its revision, and is visible to the next Director round. The agent must not create or alter events, normalized dates, candidate minutes, scores, diagnostic results, or profile birth data.