refactor(rectification): let director agent own interview flow

This commit is contained in:
Jesse_Chen
2026-07-30 17:56:10 +08:00
parent 4db774f02b
commit 0e3ce47678
14 changed files with 948 additions and 124 deletions
@@ -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",
};
}