refactor(rectification): let director agent own interview flow
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { clockTimeSchema, evidenceDomainSchema, rectificationAnalysisTraceSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts";
|
||||
import { clockTimeSchema, eventKindSchema, eventSubjectSchema, evidenceDomainSchema, relatedPersonSchema, rectificationAnalysisTraceSchema, 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 CURRENT_RECTIFICATION_SKILL_VERSION = "birth-time-rectification-v6" as const;
|
||||
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-agent-v6-1" as const;
|
||||
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-director-v1" as const;
|
||||
|
||||
export const rectificationDiagnosticSchema = z.enum([
|
||||
"leave_one_event_out",
|
||||
@@ -17,12 +17,144 @@ export const rectificationDiagnosticSchema = z.enum([
|
||||
]);
|
||||
export type RectificationDiagnostic = z.infer<typeof rectificationDiagnosticSchema>;
|
||||
|
||||
export const rectificationDecisionSchema = z.discriminatedUnion("action", [
|
||||
export const targetDispositionSchema = z.enum([
|
||||
"resolved",
|
||||
"unknown",
|
||||
"declined",
|
||||
"direction_change",
|
||||
"answered_other_event",
|
||||
"unresolved",
|
||||
"not_applicable",
|
||||
]);
|
||||
|
||||
export const evidenceProposalSchema = z.object({
|
||||
operation: z.enum(["create", "revise", "ignore"]),
|
||||
targetEventId: uuid.nullable(),
|
||||
sourceSpan: nonblank(4_000),
|
||||
dateText: nonblank(80).nullable(),
|
||||
proposedSummary: nonblank(1_000),
|
||||
proposedDomain: evidenceDomainSchema,
|
||||
proposedEventKind: eventKindSchema,
|
||||
proposedSubject: eventSubjectSchema,
|
||||
proposedRelatedPerson: relatedPersonSchema.nullable(),
|
||||
confidence: z.enum(["high", "medium", "low"]),
|
||||
}).strict();
|
||||
export type EvidenceProposal = z.infer<typeof evidenceProposalSchema>;
|
||||
|
||||
export const rectificationFocusSchema = z.object({
|
||||
mode: z.enum([
|
||||
"clarify_existing_event",
|
||||
"collect_independent_event",
|
||||
"pair_related_event",
|
||||
"resolve_conflict",
|
||||
"distinguish_candidate_clusters",
|
||||
]),
|
||||
targetEventId: uuid.nullable(),
|
||||
domain: evidenceDomainSchema.nullable(),
|
||||
requestedFacts: z.array(z.enum([
|
||||
"year",
|
||||
"month",
|
||||
"day_or_period",
|
||||
"subject",
|
||||
"event_type",
|
||||
"event_stage",
|
||||
"independent_event",
|
||||
"paired_event",
|
||||
])).max(3),
|
||||
rationaleCodes: z.array(nonblank(80)).max(8),
|
||||
}).strict();
|
||||
export type RectificationFocus = z.infer<typeof rectificationFocusSchema>;
|
||||
|
||||
const directorActionSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("ask_question"),
|
||||
focus: rectificationFocusSchema,
|
||||
question: nonblank(240),
|
||||
optionalQuickReplies: z.array(z.object({ label: nonblank(40), value: nonblank(120) }).strict()).max(4),
|
||||
}).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(),
|
||||
]);
|
||||
|
||||
export const rectificationTurnPlanSchema = z.object({
|
||||
contractVersion: z.literal("rectification-turn-plan-v1"),
|
||||
targetDisposition: targetDispositionSchema,
|
||||
evidenceProposals: z.array(evidenceProposalSchema).max(8),
|
||||
action: directorActionSchema,
|
||||
publicReply: z.object({
|
||||
acknowledgement: nonblank(1_000),
|
||||
candidateCommentary: nonblank(1_000).nullable(),
|
||||
limitation: nonblank(1_000).nullable(),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
export type RectificationTurnPlan = z.infer<typeof rectificationTurnPlanSchema>;
|
||||
|
||||
export const rectificationCaseDossierSchema = z.object({
|
||||
case: z.object({
|
||||
candidateWindow: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(),
|
||||
birthDate: nonblank(10),
|
||||
location: z.object({
|
||||
latitude: z.number().finite(),
|
||||
longitude: z.number().finite(),
|
||||
timezoneId: z.string().nullable(),
|
||||
timezoneOffsetHours: z.number().finite(),
|
||||
}).strict(),
|
||||
birthTimeSource: z.string().nullable(),
|
||||
algorithmVersion: nonblank(120),
|
||||
}).strict(),
|
||||
conversation: z.object({
|
||||
recentRawTurns: z.array(z.object({ question: z.string(), answer: z.string() }).strict()).max(12),
|
||||
earlierConversationSummary: z.string().nullable(),
|
||||
}).strict(),
|
||||
eventLedger: z.array(z.object({
|
||||
eventId: uuid,
|
||||
revision: z.number().int().positive(),
|
||||
summary: nonblank(1_000),
|
||||
rawText: nonblank(4_000),
|
||||
domain: evidenceDomainSchema,
|
||||
eventKind: eventKindSchema,
|
||||
subject: eventSubjectSchema,
|
||||
relatedPerson: relatedPersonSchema.nullable(),
|
||||
dateRange: z.object({ start: nonblank(10), end: nonblank(10), precision: nonblank(20), label: nonblank(80) }).strict(),
|
||||
scoreability: nonblank(40),
|
||||
status: z.enum(["active", "superseded", "pending"]),
|
||||
}).strict()),
|
||||
interviewState: z.object({
|
||||
currentTargetEventId: uuid.nullable(),
|
||||
declinedDomains: z.array(evidenceDomainSchema),
|
||||
unresolvedTargets: z.array(uuid),
|
||||
askedTopics: z.array(z.string()).max(50),
|
||||
turnCount: z.number().int().nonnegative(),
|
||||
targetDisposition: targetDispositionSchema,
|
||||
}).strict(),
|
||||
candidateState: z.object({
|
||||
hasSnapshot: z.boolean(),
|
||||
publicRangeAllowed: z.boolean(),
|
||||
rangeChanged: z.boolean(),
|
||||
topClusters: z.array(z.object({ rank: z.number().int(), widthMinutes: z.number().int(), stability: z.enum(["stable", "unstable"]) }).strict()).max(4),
|
||||
contrasts: z.array(z.object({ techniqueLayers: z.array(z.string()), relevantEventIds: z.array(uuid) }).strict()).max(8),
|
||||
eventDiagnostics: z.array(z.object({ eventId: uuid, winnerRetentionRate: z.number(), scoreVariance: z.number() }).strict()).max(100),
|
||||
gateReasons: z.array(z.string()).max(20),
|
||||
currentSnapshotId: uuid.nullable(),
|
||||
}).strict(),
|
||||
capabilities: z.object({
|
||||
supportedDomains: z.array(evidenceDomainSchema),
|
||||
supportedEventKinds: z.array(eventKindSchema),
|
||||
maxQuestionsPerTurn: z.literal(1),
|
||||
maxDiagnosticsPerRun: z.number().int().min(0).max(2),
|
||||
forbiddenPublicClaims: z.array(z.string()),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
export type RectificationCaseDossier = z.infer<typeof rectificationCaseDossierSchema>;
|
||||
|
||||
export const rectificationDecisionSchema = z.union([
|
||||
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("ask_question"), focus: rectificationFocusSchema, question: nonblank(240) }).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(),
|
||||
@@ -336,7 +468,7 @@ export function validateRectificationDecision(input: Readonly<{
|
||||
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") {
|
||||
if (decision.action === "ask_question" && "opportunityId" in decision) {
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import path from "node:path";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { z } from "zod";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace } 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"];
|
||||
const kinds: EventKind[] = ["education_milestone", "relocation", "relationship_start", "relationship_end", "relationship_change", "career_change", "finance_change", "self_health_event", "family_health_event", "family_bereavement", "family_event", "other"];
|
||||
const privatePattern = /(?:[0-9a-f]{8}-[0-9a-f-]{27,}|opportunity(?:id)?|snapshot(?:id)?|event(?:id)?|targetEventId|score|评分|得分|权重|rule[_ -]?id|贡献矩阵|tool[_ -]?call|cluster[_ -]?id)/iu;
|
||||
const exactMinutePattern = /(?:\b(?:[01]?\d|2[0-3]):[0-5]\d\b|(?:凌晨|清晨|上午|中午|下午|傍晚|晚上)?\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*[点时]\s*[零〇一二两三四五六七八九十百\d]{1,4}\s*分)/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>;
|
||||
|
||||
function diagnosticResult(kind: RectificationDiagnostic, value: DiagnosticsSummary) {
|
||||
switch (kind) {
|
||||
case "leave_one_event_out": return { retentionRate: value.leaveOneEventOutRetentionRate, unstableEventIds: value.unstableEventIds };
|
||||
case "leave_one_domain_out": return { retentionRate: value.leaveOneDomainOutRetentionRate };
|
||||
case "date_sensitivity": return { retentionRate: value.dateSensitivityRetentionRate, events: value.eventDateSensitivity };
|
||||
case "neighbor_stability": return { supportMinutes: value.neighborSupportMinutes, clusterMassRatio: value.clusterMassRatio };
|
||||
case "candidate_split": return { marginPercent: value.primarySecondaryMarginPercent, splits: value.candidateSplits };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRectificationCaseDossier(input: Readonly<{ caseValue: RectificationV4Case; turns: readonly RectificationV4Turn[]; events: readonly LifeEventRevision[]; pendingEvidence?: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; previousSnapshot?: CandidateSnapshot | null; diagnostics: DiagnosticsSummary | null; targetDisposition: TargetDisposition; currentTargetEventId: string | null }>): RectificationCaseDossier {
|
||||
const latest = new Map<string, number>();
|
||||
input.events.forEach((event) => latest.set(event.eventId, Math.max(latest.get(event.eventId) ?? 0, event.revision)));
|
||||
const recent = input.turns.slice(-12);
|
||||
return rectificationCaseDossierSchema.parse({
|
||||
case: { candidateWindow: input.caseValue.calculationSpec.candidateRange, birthDate: input.caseValue.calculationSpec.birthDate, location: { latitude: input.caseValue.calculationSpec.latitude, longitude: input.caseValue.calculationSpec.longitude, timezoneId: input.caseValue.calculationSpec.timezoneId ?? null, timezoneOffsetHours: input.caseValue.calculationSpec.timezoneOffsetHours }, birthTimeSource: input.caseValue.calculationSpec.birthTimeSource ?? null, algorithmVersion: input.caseValue.algorithmVersion },
|
||||
conversation: { recentRawTurns: recent.map(({ question, answer }) => ({ question, answer })), earlierConversationSummary: input.turns.length > 12 ? `更早还有 ${input.turns.length - 12} 轮;完整事实以事件账本为准。` : null },
|
||||
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: [], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition },
|
||||
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed: input.snapshot?.canAcceptRange ?? false, 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: input.snapshot?.canAcceptRange ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), 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: 1, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
|
||||
});
|
||||
}
|
||||
|
||||
function fallback(dossier: RectificationCaseDossier, latestAnswer: string): RectificationTurnPlan {
|
||||
if (dossier.candidateState.publicRangeAllowed && dossier.candidateState.currentSnapshotId) return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "offer_candidate_range", snapshotId: dossier.candidateState.currentSnapshotId }, publicReply: { acknowledgement: "现有事件已经完成本轮复核。", candidateCommentary: "候选范围已通过当前稳定性门槛,可以作为工作范围查看。", limitation: "这仍不是对某个精确出生分钟的确认。" } };
|
||||
const keepTarget = Boolean(dossier.interviewState.currentTargetEventId && ["unresolved", "answered_other_event"].includes(dossier.interviewState.targetDisposition));
|
||||
const latestGroundedEvent = [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary)));
|
||||
const safeSummary = latestGroundedEvent && !privatePattern.test(latestGroundedEvent.summary) && !exactMinutePattern.test(latestGroundedEvent.summary)
|
||||
? latestGroundedEvent.summary.slice(0, 120)
|
||||
: null;
|
||||
return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "ask_question", focus: { mode: keepTarget ? "clarify_existing_event" : "collect_independent_event", targetEventId: keepTarget ? dossier.interviewState.currentTargetEventId : null, domain: null, requestedFacts: keepTarget ? ["day_or_period"] : ["independent_event", "year"], rationaleCodes: [keepTarget ? "unresolved_current_event" : "need_independent_dated_event"] }, question: keepTarget ? "关于刚才那件事,你还记得它大约发生在哪一年或哪个阶段吗?" : "你还能想到一件发生在你本人身上、时间大致确定的重要经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: safeSummary ? `你提到的“${safeSummary}”已经纳入本轮事件线索。` : latestAnswer.trim() ? "我已按你刚才的描述继续整理事件线索。" : "我们先从真实经历建立事件线索。", candidateCommentary: null, limitation: "在证据通过稳定性门槛前,我不会把某个具体分钟当成确定出生时间。" } };
|
||||
}
|
||||
|
||||
export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final" }>): Readonly<{ plan: RectificationTurnPlan | null; issues: readonly string[] }> {
|
||||
const parsed = rectificationTurnPlanSchema.safeParse(input.plan);
|
||||
if (!parsed.success) return { plan: null, issues: ["turn_plan_schema_invalid"] };
|
||||
const plan = parsed.data;
|
||||
const issues: string[] = [];
|
||||
const known = new Set(input.dossier.eventLedger.map((event) => event.eventId));
|
||||
plan.evidenceProposals.forEach((proposal) => {
|
||||
if (!input.latestAnswer.includes(proposal.sourceSpan)) issues.push("evidence_source_not_in_latest_answer");
|
||||
if (proposal.dateText && !input.latestAnswer.includes(proposal.dateText)) issues.push("evidence_date_not_in_latest_answer");
|
||||
if (proposal.operation === "create" && proposal.targetEventId) issues.push("create_must_not_target_event");
|
||||
if (proposal.operation === "revise" && (!proposal.targetEventId || !known.has(proposal.targetEventId))) issues.push("revision_target_invalid");
|
||||
});
|
||||
const currentTarget = input.dossier.interviewState.currentTargetEventId;
|
||||
if (input.phase === "final") {
|
||||
if (plan.evidenceProposals.length) issues.push("final_plan_contains_evidence");
|
||||
if (plan.targetDisposition !== input.dossier.interviewState.targetDisposition) issues.push("final_target_disposition_changed");
|
||||
} else if (!currentTarget && plan.targetDisposition !== "not_applicable") {
|
||||
issues.push("target_disposition_requires_target");
|
||||
} else if (currentTarget) {
|
||||
const revisedCurrentTarget = plan.evidenceProposals.some((proposal) => proposal.operation === "revise" && proposal.targetEventId === currentTarget);
|
||||
const createdOtherEvent = plan.evidenceProposals.some((proposal) => proposal.operation === "create");
|
||||
if (plan.targetDisposition === "not_applicable") issues.push("target_disposition_missing");
|
||||
if (plan.targetDisposition === "resolved" && !revisedCurrentTarget) issues.push("resolved_target_not_revised");
|
||||
if (plan.targetDisposition === "answered_other_event" && !createdOtherEvent) issues.push("other_event_not_proposed");
|
||||
}
|
||||
const publicText = [plan.publicReply.acknowledgement, plan.publicReply.candidateCommentary, plan.publicReply.limitation, plan.action.type === "ask_question" ? plan.action.question : null].filter(Boolean).join(" ");
|
||||
if (privatePattern.test(publicText)) issues.push("private_detail_exposed");
|
||||
if (exactMinutePattern.test(publicText)) issues.push("exact_minute_claimed");
|
||||
if (plan.action.type === "ask_question") {
|
||||
if ((plan.action.question.match(/[??]/g) ?? []).length > 1) issues.push("multiple_questions");
|
||||
if (plan.action.focus.targetEventId && !known.has(plan.action.focus.targetEventId)) issues.push("focus_target_invalid");
|
||||
if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition) && plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId) issues.push("declined_target_reopened");
|
||||
}
|
||||
if (plan.action.type === "offer_candidate_range" && (!input.dossier.candidateState.publicRangeAllowed || plan.action.snapshotId !== input.dossier.candidateState.currentSnapshotId)) issues.push("candidate_range_gate_failed");
|
||||
return { plan: issues.length ? null : plan, issues };
|
||||
}
|
||||
|
||||
export async function regenerateDirectorQuestion(input: Readonly<{
|
||||
caseValue: RectificationV4Case;
|
||||
currentQuestion: string;
|
||||
latestAnswer: string;
|
||||
acceptedEvents: readonly LifeEventRevision[];
|
||||
focus: Extract<RectificationTurnPlan["action"], { type: "ask_question" }>["focus"];
|
||||
generateQuestion?: (prompt: string, phase: "regenerate" | "repair") => Promise<Generated>;
|
||||
}>): Promise<string> {
|
||||
const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel();
|
||||
const agent = model ? new Agent({ id: `rectification-director-regenerate-${model.id}`, name: "Birth Time Rectification Director", model: model.model, skills: [skillPath], instructions: "Rewrite one natural interview question while preserving the supplied structured focus. Do not expose internal ids, scores, tools, or a birth minute. Return only structured output." }) : null;
|
||||
const generate = input.generateQuestion ?? (async (prompt: string) => {
|
||||
if (!agent) throw new Error("director_model_unavailable");
|
||||
return agent.generate(prompt, { structuredOutput: { schema: regeneratedQuestionSchema, jsonPromptInjection: "inline" } });
|
||||
});
|
||||
const validate = (value: unknown) => {
|
||||
const parsed = regeneratedQuestionSchema.safeParse(value);
|
||||
if (!parsed.success) return { question: null, issues: ["question_schema_invalid"] };
|
||||
const issues: string[] = [];
|
||||
if ((parsed.data.question.match(/[??]/g) ?? []).length > 1) issues.push("multiple_questions");
|
||||
if (privatePattern.test(parsed.data.question)) issues.push("private_detail_exposed");
|
||||
if (exactMinutePattern.test(parsed.data.question)) issues.push("exact_minute_claimed");
|
||||
return { question: issues.length ? null : parsed.data.question, issues };
|
||||
};
|
||||
try {
|
||||
const context = { task: "Rewrite the current question without changing its structured focus.", currentQuestion: input.currentQuestion, latestAnswer: input.latestAnswer, focus: input.focus, acceptedEvents: input.acceptedEvents.map(({ summary, domain, eventKind, dateRange }) => ({ summary, domain, eventKind, dateRange })) };
|
||||
let result = validate((await generate(JSON.stringify(context), "regenerate")).object);
|
||||
if (!result.question) result = validate((await generate(JSON.stringify({ ...context, task: "Repair the rejected rewrite once.", validationIssues: result.issues }), "repair")).object);
|
||||
return result.question ?? input.currentQuestion;
|
||||
} catch {
|
||||
return input.currentQuestion;
|
||||
}
|
||||
}
|
||||
|
||||
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 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" } });
|
||||
});
|
||||
let inputTokens = 0, outputTokens = 0;
|
||||
let usageObserved = false;
|
||||
const toolCalls: ToolCallTrace[] = [];
|
||||
const addUsage = async (generated: Generated) => {
|
||||
if (!generated.totalUsage) return;
|
||||
const usage = await generated.totalUsage;
|
||||
inputTokens += Math.max(0, Math.trunc(usage.inputTokens ?? 0));
|
||||
outputTokens += Math.max(0, Math.trunc(usage.outputTokens ?? 0));
|
||||
usageObserved = true;
|
||||
};
|
||||
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);
|
||||
await addUsage(first);
|
||||
let candidate = rectificationTurnPlanSchema.parse(first.object);
|
||||
if (input.phase === "final" && candidate.action.type === "request_diagnostic") {
|
||||
const toolStarted = Date.now();
|
||||
const result = diagnosticResult(candidate.action.diagnostic, input.diagnostics);
|
||||
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 result and return a final non-diagnostic action with no evidence proposals.", latestAnswer: input.latestAnswer, dossier: input.dossier, diagnosticResult: result }), "after_diagnostic");
|
||||
await addUsage(second);
|
||||
candidate = rectificationTurnPlanSchema.parse(second.object);
|
||||
}
|
||||
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 (!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");
|
||||
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 (!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 };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import type { CandidateEngineResult, RectificationV4CandidateEngine } from "../r
|
||||
import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.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 { reconcileV4Evidence, stageAgentEvidenceProposals } from "../rectification-v4/extraction.ts";
|
||||
import { buildRectificationCaseDossier, runRectificationDirector } from "./director-agent.ts";
|
||||
import { extractEventWithModel } from "./event-extractor-agent.ts";
|
||||
import { evidenceSetHash } from "../rectification-v4/fingerprints.ts";
|
||||
import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts";
|
||||
@@ -11,7 +12,6 @@ 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 {
|
||||
@@ -144,36 +144,51 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
};
|
||||
await enterPhase("extracting_evidence");
|
||||
const asOfDate = now.toISOString().slice(0, 10);
|
||||
let reconciliation = claimed.turn.answer ? reconcileV4Evidence({
|
||||
caseId: claimed.case.id,
|
||||
answer: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate,
|
||||
existing: claimed.events,
|
||||
targetEventId: claimed.turn.questionTargetEventId,
|
||||
now,
|
||||
}) : { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition: "not_applicable" as const };
|
||||
const needsAssistance = claimed.case.deploymentMode !== "v4_legacy" && (
|
||||
const provisionalDisposition = claimed.turn.questionTargetEventId ? "unresolved" as const : "not_applicable" as const;
|
||||
const provisionalDiagnostics = diagnosticsSummarySchema.parse({
|
||||
id: randomUUID(), caseId: claimed.case.id, snapshotId: claimed.case.latestSnapshot?.id ?? randomUUID(),
|
||||
primaryClusterRetentionRate: 0, leaveOneEventOutRetentionRate: 0, leaveOneDomainOutRetentionRate: 0,
|
||||
dateSensitivityRetentionRate: 0, neighborSupportMinutes: 0, primarySecondaryMarginPercent: 0,
|
||||
clusterMassRatio: 0, unstableEventIds: [], mostDiscriminatingLayers: [], eventDateSensitivity: [],
|
||||
candidateSplits: [], calculationHash: hash(claimed.events), createdAt: now.toISOString(),
|
||||
});
|
||||
let evidenceDirector: Awaited<ReturnType<typeof runRectificationDirector>> | null = null;
|
||||
let reconciliation;
|
||||
if (claimed.case.deploymentMode !== "v4_legacy" && claimed.turn.answer) {
|
||||
const dossier = buildRectificationCaseDossier({
|
||||
caseValue: claimed.case, turns: claimed.turns, events: claimed.events, snapshot: claimed.case.latestSnapshot,
|
||||
previousSnapshot: claimed.case.latestSnapshot, diagnostics: null, targetDisposition: provisionalDisposition,
|
||||
currentTargetEventId: claimed.turn.questionTargetEventId,
|
||||
});
|
||||
evidenceDirector = await runRectificationDirector({
|
||||
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics,
|
||||
});
|
||||
reconciliation = claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent" && evidenceDirector.plan.evidenceProposals.length
|
||||
? stageAgentEvidenceProposals({ caseId: claimed.case.id, rawText: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, proposals: evidenceDirector.plan.evidenceProposals, now })
|
||||
: reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now });
|
||||
if (claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent") {
|
||||
const proposedDisposition = evidenceDirector.plan.targetDisposition;
|
||||
const currentTarget = claimed.turn.questionTargetEventId;
|
||||
const revisedCurrentTarget = Boolean(currentTarget && reconciliation.revisions.some((event) => event.eventId === currentTarget));
|
||||
const addedOtherEvent = reconciliation.revisions.some((event) => event.eventId !== currentTarget);
|
||||
const stagedDispositionIsValid = proposedDisposition !== "resolved" && proposedDisposition !== "answered_other_event"
|
||||
|| proposedDisposition === "resolved" && revisedCurrentTarget
|
||||
|| proposedDisposition === "answered_other_event" && addedOtherEvent;
|
||||
if (stagedDispositionIsValid) reconciliation = { ...reconciliation, targetDisposition: proposedDisposition };
|
||||
}
|
||||
} else {
|
||||
reconciliation = claimed.turn.answer ? reconcileV4Evidence({
|
||||
caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate,
|
||||
existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now,
|
||||
}) : { revisions: [], pending: [], unansweredTargetEventId: null, targetDisposition: "not_applicable" as const };
|
||||
}
|
||||
const needsAssistance = claimed.case.deploymentMode === "v4_legacy" && (
|
||||
reconciliation.pending.some((event) => event.reasonCode === "event_unparsed")
|
||||
|| reconciliation.revisions.some((event) => event.scoreability === "pending_review" || event.scoreability === "unsupported")
|
||||
);
|
||||
if (needsAssistance) {
|
||||
const assisted = await extractEventWithModel({
|
||||
rawText: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate,
|
||||
modelId: claimed.case.orchestrationModelId,
|
||||
});
|
||||
if (assisted) reconciliation = reconcileV4Evidence({
|
||||
caseId: claimed.case.id,
|
||||
answer: claimed.turn.answer,
|
||||
sourceTurnId: claimed.turn.id,
|
||||
asOfDate,
|
||||
existing: claimed.events,
|
||||
targetEventId: claimed.turn.questionTargetEventId,
|
||||
assistedEvidence: [assisted],
|
||||
now,
|
||||
});
|
||||
const assisted = await extractEventWithModel({ rawText: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, modelId: claimed.case.orchestrationModelId });
|
||||
if (assisted) reconciliation = reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, assistedEvidence: [assisted], now });
|
||||
}
|
||||
const extracted = reconciliation.revisions;
|
||||
const events = latestEventRevisions([...claimed.events, ...extracted]);
|
||||
@@ -336,6 +351,95 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
|
||||
if (claimed.case.deploymentMode !== "v4_legacy") {
|
||||
await enterPhase("planning_question");
|
||||
const dossier = buildRectificationCaseDossier({
|
||||
caseValue: claimed.case, turns: claimed.turns, events, pendingEvidence: reconciliation.pending,
|
||||
snapshot, previousSnapshot: claimed.case.latestSnapshot, diagnostics,
|
||||
targetDisposition: reconciliation.targetDisposition, currentTargetEventId: claimed.turn.questionTargetEventId,
|
||||
});
|
||||
await enterPhase("reasoning");
|
||||
const directed = await runRectificationDirector({
|
||||
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics,
|
||||
});
|
||||
const plan = directed.plan;
|
||||
const action = plan.action;
|
||||
if (action.type === "request_diagnostic") {
|
||||
throw new Error("rectification_director_diagnostic_loop_incomplete");
|
||||
}
|
||||
const decision = action.type === "ask_question"
|
||||
? { action: "ask_question" as const, focus: action.focus, question: action.question }
|
||||
: action.type === "offer_candidate_range"
|
||||
? { action: "offer_candidate_range" as const, snapshotId: action.snapshotId }
|
||||
: { action: "stop_low_confidence" as const, reasonCodes: action.reasonCodes };
|
||||
const validatedDecision: ValidatedDecision = {
|
||||
decision,
|
||||
mode: directed.mode,
|
||||
validationIssues: directed.fallbackReason ? [directed.fallbackReason] : [],
|
||||
selectedOpportunity: null,
|
||||
};
|
||||
await enterPhase("rendering");
|
||||
finishPhase();
|
||||
for (const call of directed.toolCalls) analysisToolCalls.push({
|
||||
category: "agent_diagnostic", label: call.diagnostic ? diagnosticLabels[call.diagnostic] : "只读诊断",
|
||||
outcome: call.outcome, durationMs: call.durationMs,
|
||||
});
|
||||
const publicMessage: StoredPublicMessage = {
|
||||
acknowledgement: plan.publicReply.acknowledgement,
|
||||
candidateUpdate: plan.publicReply.candidateCommentary,
|
||||
limitation: plan.publicReply.limitation,
|
||||
question: action.type === "ask_question" ? action.question : null,
|
||||
analysisTrace: {
|
||||
status: "completed", stages, toolCalls: analysisToolCalls, techniques: publicRectificationTechniques(engineResult),
|
||||
reasoningSummary: null, reasoningSource: "none",
|
||||
},
|
||||
};
|
||||
const targetEvent = action.type === "ask_question" && action.focus.targetEventId
|
||||
? events.find((event) => event.eventId === action.focus.targetEventId) ?? null
|
||||
: null;
|
||||
const nextQuestion: RectificationV4Question | null = action.type === "ask_question" ? {
|
||||
id: randomUUID(),
|
||||
domain: action.focus.domain ?? targetEvent?.domain ?? "other",
|
||||
targetEventId: action.focus.targetEventId,
|
||||
prompt: action.question,
|
||||
recallCost: "medium",
|
||||
reason: action.focus.rationaleCodes.join(",").slice(0, 240) || "agent_directed_focus",
|
||||
} : null;
|
||||
const status = action.type === "offer_candidate_range" ? "range_ready" as const
|
||||
: action.type === "stop_low_confidence" ? "paused" as const
|
||||
: "awaiting_answer" as const;
|
||||
const totalInput = [evidenceDirector?.inputTokenCount, directed.inputTokenCount].filter((value): value is number => value !== null && value !== undefined).reduce((sum, value) => sum + value, 0);
|
||||
const totalOutput = [evidenceDirector?.outputTokenCount, directed.outputTokenCount].filter((value): value is number => value !== null && value !== undefined).reduce((sum, value) => sum + value, 0);
|
||||
const fallbackReason = [evidenceDirector?.fallbackReason, directed.fallbackReason].filter(Boolean).join(";").slice(0, 120) || null;
|
||||
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, validatedDecision, toolCalls: [...directed.toolCalls], fallbackReason,
|
||||
inputTokenCount: totalInput || null, outputTokenCount: totalOutput || null,
|
||||
latencyMs: Math.min(300_000, (evidenceDirector?.latencyMs ?? 0) + directed.latencyMs), createdAt: now.toISOString(),
|
||||
};
|
||||
if (claimed.case.deploymentMode === "v5_shadow") {
|
||||
const legacy = projectLegacyV4Turn({
|
||||
events,
|
||||
newEvents: extracted,
|
||||
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
|
||||
latestAnswer: claimed.turn.answer,
|
||||
snapshot,
|
||||
});
|
||||
return {
|
||||
newEventRevisions: extracted, pendingEvidence: [...reconciliation.pending], snapshot, diagnostics, featureSnapshot,
|
||||
validatedDecision, publicMessage: { ...legacy.publicMessage, analysisTrace: publicMessage.analysisTrace },
|
||||
nextQuestion: legacy.nextQuestion, agentRun, status: legacy.status, phase: legacy.phase,
|
||||
};
|
||||
}
|
||||
return {
|
||||
newEventRevisions: extracted, pendingEvidence: [...reconciliation.pending], snapshot, diagnostics, featureSnapshot,
|
||||
validatedDecision, publicMessage, nextQuestion, agentRun, status,
|
||||
phase: status === "awaiting_answer" ? "collecting_evidence" as const : "complete" as const,
|
||||
};
|
||||
}
|
||||
|
||||
await enterPhase("planning_question");
|
||||
const opportunities = buildQuestionOpportunities({
|
||||
caseId: claimed.case.id,
|
||||
@@ -394,7 +498,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
}
|
||||
const finalDecision = validation.decision;
|
||||
if (!finalDecision) throw new Error("rectification_v5_fallback_validation_failed");
|
||||
const selectedOpportunity = finalDecision.action === "ask_question"
|
||||
const selectedOpportunity = finalDecision.action === "ask_question" && "opportunityId" in finalDecision
|
||||
? opportunities.find((item) => item.opportunityId === finalDecision.opportunityId) ?? null
|
||||
: null;
|
||||
const validatedDecision: ValidatedDecision = {
|
||||
@@ -412,37 +516,8 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
latestAnswer: claimed.turn.answer,
|
||||
snapshot,
|
||||
});
|
||||
const agentVisible = claimed.case.deploymentMode === "v5_agent";
|
||||
let rendererRealization: "model_validated" | "server_fallback" | null = null;
|
||||
let rendererFallbackReason: "model_unavailable" | "question_rejected" | "model_failed" | null = null;
|
||||
const renderedMessage = agentVisible
|
||||
? await renderPublicTurn({
|
||||
caseValue: claimed.case,
|
||||
latestAnswer: claimed.turn.answer,
|
||||
acceptedEvents: extracted,
|
||||
pendingEvidence: reconciliation.pending,
|
||||
snapshot,
|
||||
previousSnapshot: claimed.case.latestSnapshot,
|
||||
validated: validatedDecision,
|
||||
onRealization: (outcome) => {
|
||||
rendererRealization = outcome.mode;
|
||||
rendererFallbackReason = outcome.reason;
|
||||
},
|
||||
})
|
||||
: legacyProjection.publicMessage;
|
||||
const renderedMessage = legacyProjection.publicMessage;
|
||||
finishPhase();
|
||||
if (agentVisible && rendererRealization) {
|
||||
stages.push({
|
||||
phase: "rendering",
|
||||
label: rendererRealization === "model_validated"
|
||||
? "自然语言问题已通过安全校验"
|
||||
: rendererFallbackReason === "question_rejected"
|
||||
? "模型问题未通过安全校验,已使用服务器安全问题"
|
||||
: "模型回复不可用,已使用服务器安全问题",
|
||||
status: "completed",
|
||||
durationMs: null,
|
||||
});
|
||||
}
|
||||
for (const call of reasoned.toolCalls) {
|
||||
analysisToolCalls.push({
|
||||
category: "agent_diagnostic",
|
||||
@@ -453,7 +528,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
}
|
||||
const reasoningSummary = reasoned.mode === "agent" && !fallbackReason ? reasoned.reasoningSummary : null;
|
||||
const analysisTrace: RectificationAnalysisTrace = {
|
||||
status: claimed.case.deploymentMode === "v4_legacy" ? "legacy" : "completed",
|
||||
status: "legacy",
|
||||
stages,
|
||||
toolCalls: analysisToolCalls,
|
||||
techniques: publicRectificationTechniques(engineResult),
|
||||
@@ -461,28 +536,9 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
reasoningSource: reasoningSummary ? "provider_summary" : "none",
|
||||
};
|
||||
const publicMessage: StoredPublicMessage = { ...renderedMessage, analysisTrace };
|
||||
const nextQuestion = agentVisible && selectedOpportunity ? {
|
||||
id: randomUUID(),
|
||||
domain: selectedOpportunity.domain,
|
||||
targetEventId: selectedOpportunity.targetEventId,
|
||||
prompt: publicMessage.question ?? selectedOpportunity.fallbackPrompt,
|
||||
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 nextQuestion = legacyProjection.nextQuestion;
|
||||
const status = legacyProjection.status;
|
||||
const phase = legacyProjection.phase;
|
||||
|
||||
const agentRun: AgentRun = {
|
||||
id: randomUUID(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts";
|
||||
import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts";
|
||||
import { CURRENT_RECTIFICATION_PROMPT_VERSION, CURRENT_RECTIFICATION_SKILL_VERSION } from "../rectification-agent/contracts.ts";
|
||||
import { regenerateDirectorQuestion } from "../rectification-agent/director-agent.ts";
|
||||
import { regenerateQuestionRealization } from "../rectification-agent/renderer-agent.ts";
|
||||
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
|
||||
import { openingQuestion } from "./opening-question.ts";
|
||||
@@ -20,10 +21,12 @@ export function createRectificationV4CaseService(
|
||||
options: {
|
||||
readonly now?: () => Date;
|
||||
readonly regenerateQuestion?: typeof regenerateQuestionRealization;
|
||||
readonly regenerateDirectorQuestion?: typeof regenerateDirectorQuestion;
|
||||
} = {},
|
||||
) {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization;
|
||||
const redirectQuestion = options.regenerateDirectorQuestion ?? regenerateDirectorQuestion;
|
||||
|
||||
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
|
||||
const [events, turns, analysis, job] = await Promise.all([
|
||||
@@ -123,19 +126,30 @@ export function createRectificationV4CaseService(
|
||||
const current = await store.loadCase(input.userId, input.caseId);
|
||||
if (!current?.currentQuestion || current.deploymentMode !== "v5_agent") return null;
|
||||
const validated = await store.loadLatestValidatedDecision(input.userId, input.caseId);
|
||||
const opportunity = validated?.selectedOpportunity;
|
||||
if (!opportunity) return null;
|
||||
if (!validated || validated.decision.action !== "ask_question") return null;
|
||||
const [events, turns] = await Promise.all([
|
||||
store.loadEvents(input.userId, input.caseId),
|
||||
store.loadTurns(input.userId, input.caseId),
|
||||
]);
|
||||
const prompt = await realizeQuestion({
|
||||
caseValue: current,
|
||||
currentPrompt: current.currentQuestion.prompt,
|
||||
latestAnswer: turns.at(-1)?.answer ?? "",
|
||||
acceptedEvents: events,
|
||||
opportunity,
|
||||
});
|
||||
let prompt: string;
|
||||
if (validated.selectedOpportunity) {
|
||||
prompt = await realizeQuestion({
|
||||
caseValue: current,
|
||||
currentPrompt: current.currentQuestion.prompt,
|
||||
latestAnswer: turns.at(-1)?.answer ?? "",
|
||||
acceptedEvents: events,
|
||||
opportunity: validated.selectedOpportunity,
|
||||
});
|
||||
} else {
|
||||
if (!("focus" in validated.decision)) return null;
|
||||
prompt = await redirectQuestion({
|
||||
caseValue: current,
|
||||
currentQuestion: current.currentQuestion.prompt,
|
||||
latestAnswer: turns.at(-1)?.answer ?? "",
|
||||
acceptedEvents: events,
|
||||
focus: validated.decision.focus,
|
||||
});
|
||||
}
|
||||
return store.replaceCurrentQuestion({
|
||||
...input,
|
||||
question: { ...current.currentQuestion, id: randomUUID(), prompt },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { extractLifeEventEvidence, type ExtractedLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts";
|
||||
import { extractLifeEventEvidence, parseDeclaredDateText, validatedModelAssistedEvidence, type ExtractedLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts";
|
||||
import type { EvidenceProposal } from "../rectification-agent/contracts.ts";
|
||||
import type {
|
||||
EventKind,
|
||||
EvidenceDomain,
|
||||
@@ -215,3 +216,72 @@ export function reconcileV4Evidence(input: {
|
||||
export function extractV4EventRevisions(input: Omit<Parameters<typeof reconcileV4Evidence>[0], "caseId"> & { readonly caseId?: string }): readonly LifeEventRevision[] {
|
||||
return reconcileV4Evidence({ ...input, caseId: input.caseId ?? "00000000-0000-4000-8000-000000000000" }).revisions;
|
||||
}
|
||||
|
||||
|
||||
export function stageAgentEvidenceProposals(input: Readonly<{
|
||||
caseId: string;
|
||||
rawText: string;
|
||||
sourceTurnId: string;
|
||||
asOfDate: string;
|
||||
existing: readonly LifeEventRevision[];
|
||||
proposals: readonly EvidenceProposal[];
|
||||
now?: Date;
|
||||
}>): ReconciledV4Evidence {
|
||||
const revisions: LifeEventRevision[] = [];
|
||||
const pending: PendingEvidence[] = [];
|
||||
const active = latestEventRevisions(input.existing);
|
||||
for (const proposal of input.proposals) {
|
||||
if (proposal.operation === "ignore") continue;
|
||||
if (!input.rawText.includes(proposal.sourceSpan) || !proposal.dateText || !input.rawText.includes(proposal.dateText)) {
|
||||
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: proposal.dateText ? "event_unparsed" : "date_unresolved", targetEventId: proposal.targetEventId, now: input.now }));
|
||||
continue;
|
||||
}
|
||||
const extracted = validatedModelAssistedEvidence({
|
||||
rawText: input.rawText,
|
||||
sourceTurnId: input.sourceTurnId,
|
||||
asOfDate: input.asOfDate,
|
||||
extraction: {
|
||||
sourceSpan: proposal.sourceSpan,
|
||||
summary: proposal.proposedSummary,
|
||||
domain: proposal.proposedDomain,
|
||||
eventKind: proposal.proposedEventKind,
|
||||
subject: proposal.proposedSubject,
|
||||
relatedPerson: proposal.proposedRelatedPerson,
|
||||
dateText: proposal.dateText,
|
||||
},
|
||||
});
|
||||
if (!extracted?.dateValue || extracted.datePrecision === "unknown") {
|
||||
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now }));
|
||||
continue;
|
||||
}
|
||||
if (proposal.operation === "create") {
|
||||
const revision = newRevision(extracted, [...input.existing, ...revisions], input.now);
|
||||
if (revision && !revisions.some((value) => value.eventId === revision.eventId)) revisions.push(revision);
|
||||
continue;
|
||||
}
|
||||
const target = proposal.targetEventId ? active.find((event) => event.eventId === proposal.targetEventId) : null;
|
||||
const parsedDate = parseDeclaredDateText(proposal.dateText.normalize("NFKC"), input.asOfDate);
|
||||
if (!target || !parsedDate) {
|
||||
pending.push(pendingEvidence({ caseId: input.caseId, turnId: input.sourceTurnId, rawText: input.rawText, reasonCode: "event_unparsed", targetEventId: proposal.targetEventId, now: input.now }));
|
||||
continue;
|
||||
}
|
||||
revisions.push(appendEventRevision([...input.existing, ...revisions], {
|
||||
eventId: target.eventId,
|
||||
domain: extracted.domain as EvidenceDomain,
|
||||
eventKind: normalizeKind(extracted.domain as EvidenceDomain, extracted.eventKind, extracted.eventSummary),
|
||||
subject: extracted.subject as EventSubject,
|
||||
relatedPerson: extracted.relatedPerson as RelatedPerson | null,
|
||||
summary: proposal.proposedSummary,
|
||||
rawText: input.rawText,
|
||||
dateRange: dateRangeFromDeclared(parsedDate.value, parsedDate.precision),
|
||||
...eventDateProvenance(target),
|
||||
scoreability: extracted.scoreability as Scoreability,
|
||||
}, { now: input.now }));
|
||||
}
|
||||
return {
|
||||
revisions,
|
||||
pending,
|
||||
unansweredTargetEventId: null,
|
||||
targetDisposition: revisions.length ? "resolved" : "unresolved",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,7 +253,10 @@ test("below the scoring gate does not claim candidate scanning, diagnostics, or
|
||||
assert.equal(result.snapshot, null);
|
||||
assert.equal(trace.stages.some((stage) => stage.phase === "scoring_candidates"), false);
|
||||
assert.equal(trace.stages.some((stage) => stage.phase === "checking_robustness"), false);
|
||||
assert.equal(trace.stages.some((stage) => /安全校验|服务器安全问题/.test(stage.label)), true);
|
||||
assert.equal(
|
||||
trace.stages.some((stage) => stage.phase === "rendering" && stage.status === "completed"),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(trace.toolCalls, []);
|
||||
assert.deepEqual(trace.techniques, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import { diagnosticsSummarySchema, type RectificationTurnPlan } from "../src/lib/rectification-agent/contracts.ts";
|
||||
import { buildRectificationCaseDossier, regenerateDirectorQuestion, runRectificationDirector, validateRectificationTurnPlan } from "../src/lib/rectification-agent/director-agent.ts";
|
||||
import type { CalculationSpec, LifeEventRevision, RectificationV4Case, RectificationV4Turn } from "../src/lib/rectification-v4/contracts.ts";
|
||||
import { stageAgentEvidenceProposals } from "../src/lib/rectification-v4/extraction.ts";
|
||||
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
|
||||
|
||||
const now = "2026-07-30T00:00:00.000Z";
|
||||
const caseId = "00000000-0000-4000-8000-000000000701";
|
||||
const spec: CalculationSpec = {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: "1993-04-17",
|
||||
candidateRange: { start: "05:00", end: "06:00" },
|
||||
latitude: 36.683333,
|
||||
longitude: 114.35,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneOffsetHours: 8,
|
||||
birthTimeSource: "approximate",
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
};
|
||||
const caseValue: RectificationV4Case = {
|
||||
id: caseId,
|
||||
userId: "00000000-0000-4000-8000-000000000702",
|
||||
protocol: "rectification-evidence-v5",
|
||||
version: 3,
|
||||
status: "processing",
|
||||
phase: "reasoning",
|
||||
calculationSpec: spec,
|
||||
calculationSpecHash: calculationSpecHash(spec),
|
||||
evidenceSetHash: "e".repeat(64),
|
||||
currentQuestion: null,
|
||||
latestSnapshot: null,
|
||||
orchestrationModelId: null,
|
||||
narrationModelId: null,
|
||||
skillVersion: "birth-time-rectification-v6",
|
||||
promptVersion: "rectification-director-v1",
|
||||
algorithmVersion: "rectification-v5-matrix-scoring-1",
|
||||
deploymentMode: "v5_agent",
|
||||
agentMode: "agent",
|
||||
featureSnapshotId: null,
|
||||
latestDiagnosticsId: null,
|
||||
acceptedRange: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
function event(overrides: Partial<LifeEventRevision> = {}): LifeEventRevision {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
eventId: randomUUID(),
|
||||
revision: 1,
|
||||
domain: "education",
|
||||
eventKind: "education_milestone",
|
||||
subject: "self",
|
||||
relatedPerson: null,
|
||||
summary: "2016年9月大学入学",
|
||||
rawText: "2016年9月大学入学",
|
||||
dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" },
|
||||
scoreability: "scoreable",
|
||||
supersedesRevisionId: null,
|
||||
createdAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function turn(index: number): RectificationV4Turn {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
caseId,
|
||||
caseVersion: index + 1,
|
||||
questionId: null,
|
||||
questionDomain: null,
|
||||
questionTargetEventId: null,
|
||||
question: `问题${index}`,
|
||||
answer: `回答${index}`,
|
||||
modelId: null,
|
||||
actionId: randomUUID(),
|
||||
createdAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(overrides: Partial<RectificationTurnPlan> = {}): RectificationTurnPlan {
|
||||
return {
|
||||
contractVersion: "rectification-turn-plan-v1",
|
||||
targetDisposition: "not_applicable",
|
||||
evidenceProposals: [],
|
||||
action: {
|
||||
type: "ask_question",
|
||||
focus: {
|
||||
mode: "collect_independent_event",
|
||||
targetEventId: null,
|
||||
domain: null,
|
||||
requestedFacts: ["independent_event"],
|
||||
rationaleCodes: ["need_independent_event"],
|
||||
},
|
||||
question: "接下来想从哪段变化继续聊?",
|
||||
optionalQuickReplies: [],
|
||||
},
|
||||
publicReply: {
|
||||
acknowledgement: "我已按你的描述整理这轮线索。",
|
||||
candidateCommentary: null,
|
||||
limitation: "目前仍不足以确认具体出生分钟。",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function dossier(events: readonly LifeEventRevision[] = [], turns: readonly RectificationV4Turn[] = []) {
|
||||
return buildRectificationCaseDossier({
|
||||
caseValue,
|
||||
turns,
|
||||
events,
|
||||
snapshot: null,
|
||||
diagnostics: null,
|
||||
targetDisposition: "not_applicable",
|
||||
currentTargetEventId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const diagnostics = diagnosticsSummarySchema.parse({
|
||||
id: "00000000-0000-4000-8000-000000000703",
|
||||
caseId,
|
||||
snapshotId: "00000000-0000-4000-8000-000000000704",
|
||||
primaryClusterRetentionRate: 0.8,
|
||||
leaveOneEventOutRetentionRate: 0.8,
|
||||
leaveOneDomainOutRetentionRate: 0.8,
|
||||
dateSensitivityRetentionRate: 0.8,
|
||||
neighborSupportMinutes: 3,
|
||||
primarySecondaryMarginPercent: 8,
|
||||
clusterMassRatio: 0.6,
|
||||
unstableEventIds: [],
|
||||
mostDiscriminatingLayers: ["D9"],
|
||||
eventDateSensitivity: [],
|
||||
candidateSplits: [],
|
||||
calculationHash: "d".repeat(64),
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
test("dossier keeps twelve raw turns and the complete revision ledger", () => {
|
||||
const sharedEventId = randomUUID();
|
||||
const events = Array.from({ length: 15 }, (_, index) => event({
|
||||
eventId: index < 2 ? sharedEventId : randomUUID(),
|
||||
revision: index < 2 ? index + 1 : 1,
|
||||
supersedesRevisionId: index === 1 ? randomUUID() : null,
|
||||
summary: `事件${index}`,
|
||||
rawText: `事件${index}`,
|
||||
}));
|
||||
const value = dossier(events, Array.from({ length: 14 }, (_, index) => turn(index)));
|
||||
assert.equal(value.conversation.recentRawTurns.length, 12);
|
||||
assert.equal(value.conversation.recentRawTurns[0]?.question, "问题2");
|
||||
assert.equal(value.eventLedger.length, 15);
|
||||
assert.equal(value.eventLedger[0]?.status, "superseded");
|
||||
assert.equal(value.eventLedger[1]?.status, "active");
|
||||
assert.equal(value.case.location.timezoneId, "Asia/Shanghai");
|
||||
});
|
||||
|
||||
test("a natural question and multiple grounded event proposals pass without domain keywords or anchors", () => {
|
||||
const latestAnswer = "2018年9月搬到北京,2020年4月开始第一份工作。";
|
||||
const value = plan({
|
||||
evidenceProposals: [
|
||||
{ operation: "create", targetEventId: null, sourceSpan: "2018年9月搬到北京", dateText: "2018年9月", proposedSummary: "搬到北京", proposedDomain: "relocation", proposedEventKind: "relocation", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" },
|
||||
{ operation: "create", targetEventId: null, sourceSpan: "2020年4月开始第一份工作", dateText: "2020年4月", proposedSummary: "开始第一份工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(validateRectificationTurnPlan({ plan: value, dossier: dossier(), latestAnswer, phase: "evidence" }).issues, []);
|
||||
const staged = stageAgentEvidenceProposals({ caseId, rawText: latestAnswer, sourceTurnId: randomUUID(), asOfDate: "2026-07-30", existing: [], proposals: value.evidenceProposals, now: new Date(now) });
|
||||
assert.equal(staged.revisions.length, 2);
|
||||
assert.deepEqual(new Set(staged.revisions.map((item) => item.domain)), new Set(["relocation", "career"]));
|
||||
});
|
||||
|
||||
test("server rejects invented sources, private details, exact minutes, and ungated ranges", () => {
|
||||
const latestAnswer = "2018年9月搬到北京。";
|
||||
const invented = plan({ evidenceProposals: [{ operation: "create", targetEventId: null, sourceSpan: "2020年工作", dateText: "2020年", proposedSummary: "开始工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "low" }] });
|
||||
assert.ok(validateRectificationTurnPlan({ plan: invented, dossier: dossier(), latestAnswer, phase: "evidence" }).issues.includes("evidence_source_not_in_latest_answer"));
|
||||
|
||||
const unsafe = plan({ publicReply: { acknowledgement: "内部 eventId 是 00000000-0000-4000-8000-000000000799。", candidateCommentary: "出生时间是05:13。", limitation: null } });
|
||||
const unsafeIssues = validateRectificationTurnPlan({ plan: unsafe, dossier: dossier(), latestAnswer, phase: "final" }).issues;
|
||||
assert.ok(unsafeIssues.includes("private_detail_exposed"));
|
||||
assert.ok(unsafeIssues.includes("exact_minute_claimed"));
|
||||
|
||||
const range = plan({ action: { type: "offer_candidate_range", snapshotId: "00000000-0000-4000-8000-000000000704" } });
|
||||
assert.ok(validateRectificationTurnPlan({ plan: range, dossier: dossier(), latestAnswer, phase: "final" }).issues.includes("candidate_range_gate_failed"));
|
||||
});
|
||||
|
||||
test("revisions keep the server-owned event id and append revision history", () => {
|
||||
const target = event({ eventId: "00000000-0000-4000-8000-000000000705" });
|
||||
const rawText = "其实是2016年10月大学入学。";
|
||||
const staged = stageAgentEvidenceProposals({
|
||||
caseId,
|
||||
rawText,
|
||||
sourceTurnId: randomUUID(),
|
||||
asOfDate: "2026-07-30",
|
||||
existing: [target],
|
||||
proposals: [{ operation: "revise", targetEventId: target.eventId, sourceSpan: "2016年10月大学入学", dateText: "2016年10月", proposedSummary: "2016年10月大学入学", proposedDomain: "education", proposedEventKind: "education_milestone", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }],
|
||||
now: new Date(now),
|
||||
});
|
||||
assert.equal(staged.revisions.length, 1);
|
||||
assert.equal(staged.revisions[0]?.eventId, target.eventId);
|
||||
assert.equal(staged.revisions[0]?.revision, 2);
|
||||
assert.equal(staged.revisions[0]?.dateRange.start, "2016-10-01");
|
||||
assert.equal(staged.revisions[0]?.dateRange.end, "2016-10-31");
|
||||
});
|
||||
|
||||
test("declined targets cannot be reopened and a diagnostic is closed in one tool loop", 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: [] } });
|
||||
assert.ok(validateRectificationTurnPlan({ plan: reopened, dossier: targetDossier, latestAnswer: "不想说", phase: "final" }).issues.includes("declined_target_reopened"));
|
||||
|
||||
const phases: string[] = [];
|
||||
const result = await runRectificationDirector({
|
||||
caseValue,
|
||||
dossier: dossier(),
|
||||
latestAnswer: "",
|
||||
phase: "final",
|
||||
diagnostics,
|
||||
generatePlan: async (_prompt, phase) => {
|
||||
phases.push(phase);
|
||||
return { object: phase === "final" ? plan({ action: { type: "request_diagnostic", diagnostic: "candidate_split" } }) : plan() };
|
||||
},
|
||||
});
|
||||
assert.equal(result.mode, "agent");
|
||||
assert.deepEqual(phases, ["final", "after_diagnostic"]);
|
||||
assert.equal(result.toolCalls.length, 1);
|
||||
assert.equal(result.plan.action.type, "ask_question");
|
||||
});
|
||||
|
||||
test("the same Director gets one repair attempt before deterministic fallback", async () => {
|
||||
const phases: string[] = [];
|
||||
const result = await runRectificationDirector({
|
||||
caseValue,
|
||||
dossier: dossier(),
|
||||
latestAnswer: "2018年9月搬到北京。",
|
||||
phase: "evidence",
|
||||
diagnostics,
|
||||
generatePlan: async (_prompt, phase) => {
|
||||
phases.push(phase);
|
||||
return { object: phase === "repair" ? plan() : plan({ evidenceProposals: [{ operation: "create", targetEventId: null, sourceSpan: "不存在的内容", dateText: "2020年", proposedSummary: "开始工作", proposedDomain: "career", proposedEventKind: "career_change", proposedSubject: "self", proposedRelatedPerson: null, confidence: "low" }] }) };
|
||||
},
|
||||
});
|
||||
assert.equal(result.mode, "agent");
|
||||
assert.deepEqual(phases, ["evidence", "repair"]);
|
||||
assert.equal(result.fallbackReason, null);
|
||||
});
|
||||
|
||||
|
||||
test("manual question regeneration preserves focus and repairs unsafe text once", async () => {
|
||||
const phases: string[] = [];
|
||||
const question = await regenerateDirectorQuestion({
|
||||
caseValue,
|
||||
currentQuestion: "除了这段经历,你还想从哪件事继续?",
|
||||
latestAnswer: "2016年9月大学入学",
|
||||
acceptedEvents: [event()],
|
||||
focus: {
|
||||
mode: "collect_independent_event",
|
||||
targetEventId: null,
|
||||
domain: null,
|
||||
requestedFacts: ["independent_event"],
|
||||
rationaleCodes: ["need_independent_event"],
|
||||
},
|
||||
generateQuestion: async (_prompt, phase) => {
|
||||
phases.push(phase);
|
||||
return { object: { question: phase === "regenerate" ? "请确认出生时间05:13?" : "除了这段经历,你还想从哪件事继续?" } };
|
||||
},
|
||||
});
|
||||
assert.equal(question, "除了这段经历,你还想从哪件事继续?");
|
||||
assert.deepEqual(phases, ["regenerate", "repair"]);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test("answer is durably queued and a processing case reload restores its active
|
||||
assert.equal(JSON.stringify([...store.jobs.values()]), before);
|
||||
}));
|
||||
|
||||
test("V5 agent fallback persists Agent Run, Public Message and a server-owned opportunity", async () => withMode("v5_agent", async () => {
|
||||
test("V5 agent fallback persists the Director decision, Public Message and next question", async () => withMode("v5_agent", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
@@ -97,10 +97,10 @@ test("V5 agent fallback persists Agent Run, Public Message and a server-owned op
|
||||
assert.ok(event && run && message);
|
||||
assert.equal(run.deploymentMode, "v5_agent");
|
||||
assert.equal(run.validatedDecision.mode, "deterministic_fallback");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null);
|
||||
assert.equal(run.validatedDecision.decision.action, "ask_question");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity, null);
|
||||
assert.equal(done?.case.currentQuestion?.targetEventId, null);
|
||||
assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/);
|
||||
assert.ok((done?.case.currentQuestion?.prompt ?? "").length > 0);
|
||||
assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /具体哪一天|几号/);
|
||||
assert.equal(message.question, done?.case.currentQuestion?.prompt);
|
||||
assert.equal(done?.case.latestSnapshot, null);
|
||||
@@ -125,8 +125,8 @@ test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible proj
|
||||
const run = [...store.agentRuns.values()][0];
|
||||
assert.ok(queued?.job && event && run);
|
||||
assert.equal(run.deploymentMode, "v5_shadow");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null);
|
||||
assert.equal(run.validatedDecision.decision.action, "ask_question");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity, null);
|
||||
assert.equal(done.case.currentQuestion?.targetEventId, null);
|
||||
assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/);
|
||||
assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/);
|
||||
@@ -181,10 +181,10 @@ test("V5 Agent regenerate rewrites only the current semantic question and replay
|
||||
let realizationCalls = 0;
|
||||
const service = createRectificationV4CaseService(store, {
|
||||
now: fixedNow,
|
||||
regenerateQuestion: async ({ opportunity }) => {
|
||||
regenerateDirectorQuestion: async ({ currentQuestion }) => {
|
||||
realizationCalls += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return opportunity.fallbackPrompt;
|
||||
return `${currentQuestion}(换一种问法)`;
|
||||
},
|
||||
});
|
||||
const userId = randomUUID();
|
||||
|
||||
Reference in New Issue
Block a user