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
@@ -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");
});