fix(rectification): preserve director context and revision identity

This commit is contained in:
Jesse_Chen
2026-07-30 23:51:09 +08:00
parent 0e3ce47678
commit 77b0079653
11 changed files with 148 additions and 29 deletions
@@ -6,7 +6,7 @@ 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-v1" as const;
export const CURRENT_RECTIFICATION_PROMPT_VERSION = "rectification-director-v2" as const;
export const rectificationDiagnosticSchema = z.enum([
"leave_one_event_out",
@@ -124,6 +124,12 @@ export const rectificationCaseDossierSchema = z.object({
currentTargetEventId: uuid.nullable(),
declinedDomains: z.array(evidenceDomainSchema),
unresolvedTargets: z.array(uuid),
pendingEvidence: z.array(z.object({
rawText: nonblank(4_000),
reasonCode: z.enum(["date_unresolved", "event_unparsed"]),
targetEventId: uuid.nullable(),
createdAt: z.string().datetime({ offset: true }),
}).strict()).max(100),
askedTopics: z.array(z.string()).max(50),
turnCount: z.number().int().nonnegative(),
targetDisposition: targetDispositionSchema,
@@ -11,10 +11,17 @@ const domains: EvidenceDomain[] = ["education", "relocation", "relationship", "c
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;
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>;
function summarizeEarlierTurns(turns: readonly RectificationV4Turn[]): string | null {
const older = turns.slice(0, -12);
if (!older.length) return null;
return older.map((turn, index) => `${index + 1}. 问:${turn.question.slice(0, 240)}\n答:${turn.answer.slice(0, 500)}`).join("\n").slice(-12_000);
}
function diagnosticResult(kind: RectificationDiagnostic, value: DiagnosticsSummary) {
switch (kind) {
case "leave_one_event_out": return { retentionRate: value.leaveOneEventOutRetentionRate, unstableEventIds: value.unstableEventIds };
@@ -31,11 +38,11 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti
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 },
conversation: { recentRawTurns: recent.map(({ question, answer }) => ({ question, answer })), earlierConversationSummary: summarizeEarlierTurns(input.turns) },
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 },
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: 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"] },
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxDiagnosticsPerRun: 2, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
});
}
@@ -130,6 +137,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> }> = [];
const addUsage = async (generated: Generated) => {
if (!generated.totalUsage) return;
const usage = await generated.totalUsage;
@@ -141,11 +149,12 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
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") {
while (input.phase === "final" && candidate.action.type === "request_diagnostic" && toolCalls.length < input.dossier.capabilities.maxDiagnosticsPerRun) {
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 result and return a final non-diagnostic action with no evidence proposals.", latestAnswer: input.latestAnswer, dossier: input.dossier, diagnosticResult: result }), "after_diagnostic");
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);
}
@@ -156,7 +156,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
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,
caseValue: claimed.case, turns: claimed.turns, events: claimed.events, pendingEvidence: claimed.pendingEvidence, snapshot: claimed.case.latestSnapshot,
previousSnapshot: claimed.case.latestSnapshot, diagnostics: null, targetDisposition: provisionalDisposition,
currentTargetEventId: claimed.turn.questionTargetEventId,
});
@@ -354,7 +354,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
if (claimed.case.deploymentMode !== "v4_legacy") {
await enterPhase("planning_question");
const dossier = buildRectificationCaseDossier({
caseValue: claimed.case, turns: claimed.turns, events, pendingEvidence: reconciliation.pending,
caseValue: claimed.case, turns: claimed.turns, events, pendingEvidence: [...claimed.pendingEvidence, ...reconciliation.pending],
snapshot, previousSnapshot: claimed.case.latestSnapshot, diagnostics,
targetDisposition: reconciliation.targetDisposition, currentTargetEventId: claimed.turn.questionTargetEventId,
});
@@ -44,6 +44,14 @@ function normalizeKind(domain: EvidenceDomain, value: string, summary: string):
return ({ education: "education_milestone", relocation: "relocation", career: "career_change", finance: "finance_change", health_pressure: "self_health_event", family: "family_event", other: "other" } as const)[domain];
}
function isSameEventRevision(target: LifeEventRevision, extracted: ExtractedLifeEventEvidence): boolean {
return target.domain === extracted.domain
&& target.eventKind === extracted.eventKind
&& target.subject === extracted.subject
&& target.relatedPerson === extracted.relatedPerson
&& (target.summary.includes(extracted.eventSummary) || target.rawText.includes(extracted.eventSummary));
}
function pendingEvidence(input: {
caseId: string;
turnId: string;
@@ -261,21 +269,21 @@ export function stageAgentEvidenceProposals(input: Readonly<{
}
const target = proposal.targetEventId ? active.find((event) => event.eventId === proposal.targetEventId) : null;
const parsedDate = parseDeclaredDateText(proposal.dateText.normalize("NFKC"), input.asOfDate);
if (!target || !parsedDate) {
if (!target || !parsedDate || !isSameEventRevision(target, extracted)) {
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,
domain: target.domain,
eventKind: target.eventKind,
subject: target.subject,
relatedPerson: target.relatedPerson,
summary: extracted.eventSummary,
rawText: input.rawText,
dateRange: dateRangeFromDeclared(parsedDate.value, parsedDate.precision),
...eventDateProvenance(target),
scoreability: extracted.scoreability as Scoreability,
scoreability: target.scoreability,
}, { now: input.now }));
}
return {
@@ -258,6 +258,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
turn: turns.get(job.turnId)!,
turns: caseTurns,
events: events.get(job.caseId) ?? [],
pendingEvidence: [...pendingEvidence.values()].filter((item) => item.caseId === job.caseId && !item.resolvedAt),
attemptedRefinementEventIds: [...new Set(
[...turns.values()]
.filter((turn) => turn.caseId === job.caseId && turn.questionTargetEventId)
@@ -18,6 +18,7 @@ export type ClaimedRectificationV4Job = Readonly<{
turn: RectificationV4Turn;
turns: readonly RectificationV4Turn[];
events: readonly LifeEventRevision[];
pendingEvidence: readonly PendingEvidence[];
attemptedRefinementEventIds: readonly string[];
}>;
@@ -3,12 +3,14 @@ import { storedPublicMessageSchema, validatedDecisionSchema, type ValidatedDecis
import {
candidateSnapshotSchema,
lifeEventRevisionSchema,
pendingEvidenceSchema,
rectificationAnalysisItemSchema,
rectificationV4CaseSchema,
rectificationV4JobSchema,
rectificationV4TurnSchema,
type CandidateSnapshot,
type LifeEventRevision,
type PendingEvidence,
type RectificationAnalysisItem,
type RectificationV4Case,
type RectificationV4Job,
@@ -169,6 +171,20 @@ function turnValue(row: Row): RectificationV4Turn {
});
}
function pendingEvidenceValue(row: Row): PendingEvidence {
return pendingEvidenceSchema.parse({
id: row.id,
caseId: row.case_id,
turnId: row.turn_id,
rawText: row.raw_text,
reasonCode: row.reason_code,
targetEventId: row.target_event_id,
resolvedEventId: row.resolved_event_id,
createdAt: timestamp(row.created_at),
resolvedAt: row.resolved_at ? timestamp(row.resolved_at) : null,
});
}
export function createRectificationV4SupabaseStore(supabase: SupabaseClient): RectificationV4Store {
async function rowById(table: string, id: string): Promise<Row | null> {
const { data, error } = await supabase.from(table).select("*").eq("id", id).maybeSingle();
@@ -210,6 +226,14 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
return ((data ?? []) as Row[]).map(turnValue);
}
async function loadPendingEvidenceByCase(userId: string, caseId: string): Promise<readonly PendingEvidence[]> {
const { data, error } = await supabase.from("birth_time_rectification_pending_evidence")
.select("*").eq("case_id", caseId).eq("user_id", userId).is("resolved_at", null)
.order("created_at", { ascending: true });
if (error) throw storeError(error);
return ((data ?? []) as Row[]).map(pendingEvidenceValue);
}
async function loadAnalysisMessagesByCase(userId: string, caseId: string): Promise<readonly RectificationAnalysisItem[]> {
if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found");
const { data, error } = await supabase.from("birth_time_rectification_public_messages")
@@ -375,11 +399,12 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
if (!jobRow) throw new RectificationV4StoreError("not_found");
const userId = String(jobRow.user_id);
const caseId = String(jobRow.case_id);
const [caseResult, turnRow, events, turns] = await Promise.all([
const [caseResult, turnRow, events, turns, pendingEvidence] = await Promise.all([
loadCaseById(userId, caseId),
rowById("birth_time_rectification_v4_turns", String(jobRow.turn_id)),
loadEventsByCase(userId, caseId),
loadTurnsByCase(userId, caseId),
loadPendingEvidenceByCase(userId, caseId),
]);
if (!caseResult || !turnRow) throw new RectificationV4StoreError("not_found");
return {
@@ -388,6 +413,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
turn: turnValue(turnRow),
turns,
events,
pendingEvidence,
attemptedRefinementEventIds: [...new Set(
turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : []),
)],