fix(rectification): preserve director context and revision identity
This commit is contained in:
@@ -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] : []),
|
||||
)],
|
||||
|
||||
Reference in New Issue
Block a user