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
+32
View File
@@ -1802,3 +1802,35 @@
- 防复发:正常 Agent 路径不得重新依赖 Opportunity 枚举或领域正则决定访谈内容;Director 合同测试必须覆盖多事件提议、修订目标验证、拒绝/不知道后的换焦点、range gate、内部信息泄露与一次 repair;Python 测试必须断言 `event_kind` 从输入穿透到规则 trace。
- 相关记录:BUG-095、BUG-097、BUG-099
- 修复版本:staging
## BUG-102 | Director Dossier 丢失早期语境、历史 Pending Evidence 与拒答领域
- 状态:resolved
- 首次发现:2026-07-30
- 最近更新:2026-07-30
- 影响面:V5 Agent Director 的长期会话理解、拒答保护、未解析证据续接与候选诊断循环
- 用户现象:Director 已经替代正常路径的 Opportunity/Renderer,但超过十二轮的会话只收到“更早还有 N 轮”,`declinedDomains` 固定为空,历史未解决 Pending Evidence 未进入本轮 Dossier;一次只读诊断不足时无法继续观察后再决定下一问。
- 触发条件:Case 超过十二轮、用户曾拒绝某个问题领域、前序 Turn 留下未解决证据,或 Director 连续需要两类候选诊断。
- 根因:Dossier Builder 使用计数占位代替早期问答摘要,并未从 Turn 账本派生拒答领域;Job claim 合同也没有携带未解决 Pending Evidence。Director 的诊断处理使用单次 `if`,与 Dossier 声明的一次预算绑定。
- 修复:Dossier 现在保留最近十二轮原文,并把更早问答压缩为有内容的受限摘要;从历史 Turn 派生拒答领域;Memory/Supabase Job claim 加载未解决 Pending Evidence 并与本轮新增项一并交给 Director。只读诊断改为最多两次的有界循环,公开回复、数据库状态、候选范围门禁与原子提交仍由服务器控制。Prompt 版本升级为 `rectification-director-v2`
- 验证:Director 回归测试覆盖早期语境、拒答领域、Pending Evidence 和两次诊断循环;TypeScript 类型检查、相关 ESLint 与目标测试通过。
- 安全边界:Pending Evidence 仅作为私有 Dossier 输入,公开文本仍经过内部信息、精确分钟、单问题和候选范围门禁校验;工具循环保持只读且最多两次。
- 防复发:Dossier 测试必须断言早期语境不是计数占位、拒答领域和未解决证据可见;诊断测试必须断言循环有上限且最终返回非诊断动作。
- 相关记录:BUG-099、BUG-101
- 修复版本:local follow-up
## BUG-103 | Director revise 可跨事件覆盖既有 Event ID
- 状态:resolved
- 首次发现:2026-07-30
- 最近更新:2026-07-30
- 影响面:V5 Agent 事件修订暂存、事件账本身份连续性与后续候选评分
- 用户现象:模型可把“大学入学”的既有 Event ID 修订成“搬家”或其他无关事件,并把未受原文约束的 `proposedSummary` 写入账本。
- 触发条件:`revise` proposal 引用真实 Target ID 和回答中的日期/Span,但声明了不同 Domain、Kind、Subject、Related Person,或 Span 与原事件语义锚点不连续。
- 根因:服务器只验证 Target ID 存在、Span/日期来自最新回答,没有验证 Revision 的事件身份连续性;持久化 Summary 直接采用模型提议文本。
- 修复:`stageAgentEvidenceProposals()` 仅接受 Domain、Kind、Subject、Related Person 与 Target 一致,且最新原文事件摘要仍命中 Target 摘要或原始文本的修订;不连续的提议转为 Pending Evidence,不覆盖原 Event ID。合法修订的 Summary 改用服务器从已验证 Source Span 提取的事件摘要,身份字段与 Scoreability 继续沿用 Target。
- 验证:新增回归测试证明合法日期修订保留 Event ID 并使用原文摘要,跨领域且含虚构 Summary 的 revise 不产生 Revision、只产生 Pending Evidence。
- 安全边界:Agent 仍可创建新事件;跨事件内容必须走 `create`,不能借 `revise` 篡改既有账本身份。
- 防复发:Revision 测试必须同时覆盖合法日期更正和跨事件覆盖拒绝,不能只断言 Target ID 存在。
- 相关记录:BUG-101、BUG-102
- 修复版本:local follow-up
@@ -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] : []),
)],
@@ -118,6 +118,7 @@ function makeClaimed(events: readonly LifeEventRevision[]): ClaimedRectification
turn,
turns: [turn],
events,
pendingEvidence: [],
attemptedRefinementEventIds: [],
job: {
id: randomUUID(),
+45 -10
View File
@@ -4,7 +4,7 @@ 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 type { CalculationSpec, LifeEventRevision, PendingEvidence, 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";
@@ -68,7 +68,7 @@ function event(overrides: Partial<LifeEventRevision> = {}): LifeEventRevision {
};
}
function turn(index: number): RectificationV4Turn {
function turn(index: number, overrides: Partial<RectificationV4Turn> = {}): RectificationV4Turn {
return {
id: randomUUID(),
caseId,
@@ -81,6 +81,7 @@ function turn(index: number): RectificationV4Turn {
modelId: null,
actionId: randomUUID(),
createdAt: now,
...overrides,
};
}
@@ -110,11 +111,12 @@ function plan(overrides: Partial<RectificationTurnPlan> = {}): RectificationTurn
};
}
function dossier(events: readonly LifeEventRevision[] = [], turns: readonly RectificationV4Turn[] = []) {
function dossier(events: readonly LifeEventRevision[] = [], turns: readonly RectificationV4Turn[] = [], pendingEvidence: readonly PendingEvidence[] = []) {
return buildRectificationCaseDossier({
caseValue,
turns,
events,
pendingEvidence,
snapshot: null,
diagnostics: null,
targetDisposition: "not_applicable",
@@ -141,7 +143,7 @@ const diagnostics = diagnosticsSummarySchema.parse({
createdAt: now,
});
test("dossier keeps twelve raw turns and the complete revision ledger", () => {
test("dossier keeps recent raw turns, useful earlier context, refusals, pending evidence, and the complete revision ledger", () => {
const sharedEventId = randomUUID();
const events = Array.from({ length: 15 }, (_, index) => event({
eventId: index < 2 ? sharedEventId : randomUUID(),
@@ -150,9 +152,21 @@ test("dossier keeps twelve raw turns and the complete revision ledger", () => {
summary: `事件${index}`,
rawText: `事件${index}`,
}));
const value = dossier(events, Array.from({ length: 14 }, (_, index) => turn(index)));
const targetEventId = events.at(-1)!.eventId;
const turns = Array.from({ length: 14 }, (_, index) => turn(index));
turns[1] = turn(1, { questionDomain: "relationship", questionTargetEventId: targetEventId, answer: "这件事不方便说,换个方向。" });
const pending: PendingEvidence = {
id: randomUUID(), caseId, turnId: turns[0]!.id, rawText: "后来搬过一次家", reasonCode: "date_unresolved",
targetEventId, resolvedEventId: null, createdAt: now, resolvedAt: null,
};
const value = dossier(events, turns, [pending]);
assert.equal(value.conversation.recentRawTurns.length, 12);
assert.equal(value.conversation.recentRawTurns[0]?.question, "问题2");
assert.match(value.conversation.earlierConversationSummary ?? "", /问题0/);
assert.match(value.conversation.earlierConversationSummary ?? "", /不方便说/);
assert.deepEqual(value.interviewState.declinedDomains, ["relationship"]);
assert.equal(value.interviewState.pendingEvidence[0]?.reasonCode, "date_unresolved");
assert.equal(value.interviewState.pendingEvidence[0]?.targetEventId, targetEventId);
assert.equal(value.eventLedger.length, 15);
assert.equal(value.eventLedger[0]?.status, "superseded");
assert.equal(value.eventLedger[1]?.status, "active");
@@ -204,29 +218,50 @@ test("revisions keep the server-owned event id and append revision history", ()
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");
assert.equal(staged.revisions[0]?.summary, "大学入学");
});
test("declined targets cannot be reopened and a diagnostic is closed in one tool loop", async () => {
test("revisions cannot replace an existing event with unrelated model content", () => {
const target = event({ eventId: "00000000-0000-4000-8000-000000000708" });
const rawText = "2018年9月搬到北京。";
const staged = stageAgentEvidenceProposals({
caseId,
rawText,
sourceTurnId: randomUUID(),
asOfDate: "2026-07-30",
existing: [target],
proposals: [{ operation: "revise", targetEventId: target.eventId, sourceSpan: "2018年9月搬到北京", dateText: "2018年9月", proposedSummary: "2018年9月创办公司", proposedDomain: "relocation", proposedEventKind: "relocation", proposedSubject: "self", proposedRelatedPerson: null, confidence: "high" }],
now: new Date(now),
});
assert.equal(staged.revisions.length, 0);
assert.equal(staged.pending.length, 1);
assert.equal(staged.pending[0]?.targetEventId, target.eventId);
});
test("declined targets cannot be reopened and diagnostics stay in a bounded 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 prompts: string[] = [];
const result = await runRectificationDirector({
caseValue,
dossier: dossier(),
latestAnswer: "",
phase: "final",
diagnostics,
generatePlan: async (_prompt, phase) => {
generatePlan: async (prompt, phase) => {
phases.push(phase);
return { object: phase === "final" ? plan({ action: { type: "request_diagnostic", diagnostic: "candidate_split" } }) : plan() };
prompts.push(prompt);
return { object: phases.length <= 2 ? plan({ action: { type: "request_diagnostic", diagnostic: phases.length === 1 ? "candidate_split" : "date_sensitivity" } }) : plan() };
},
});
assert.equal(result.mode, "agent");
assert.deepEqual(phases, ["final", "after_diagnostic"]);
assert.equal(result.toolCalls.length, 1);
assert.deepEqual(phases, ["final", "after_diagnostic", "after_diagnostic"]);
assert.equal(result.toolCalls.length, 2);
assert.deepEqual(JSON.parse(prompts[2]!).diagnosticResults.map((item: { diagnostic: string }) => item.diagnostic), ["candidate_split", "date_sensitivity"]);
assert.equal(result.plan.action.type, "ask_question");
});
+3 -3
View File
@@ -12,10 +12,10 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re
## Product boundary
- Current skill version: `birth-time-rectification-v6`.
- Current prompt version: `rectification-director-v1`.
- Current prompt version: `rectification-director-v2`.
- The scoring algorithm remains `rectification-v5-matrix-scoring-1`; the V6 label describes the conversation contract, not a replacement scoring engine.
- The server owns event reconciliation, the real Python scan of every minute in the candidate window, the event contribution matrix, Candidate Snapshots, LOEO/LODO, date sensitivity, neighbor stability, candidate split, jobs, replay, persistence, and final decision validation.
- The Director reads the complete event ledger plus the latest 1012 raw turns, may propose multiple grounded events or revisions, chooses one interview focus, writes the public reply and at most one natural question, and may call at most one allowed read-only diagnostic.
- The Director reads the complete event ledger plus the latest 1012 raw turns, may propose multiple grounded events or revisions, chooses one interview focus, writes the public reply and at most one natural question, and may call up to two allowed read-only diagnostics.
- Event proposals are not facts until the server validates their source span, declared date, target revision, subject, classification, and scoreability. The Director never creates scores, candidate minutes, Case state, database mutations, or profile updates.
- VedAstro is a read-only post-validation gate for `v5_agent` only. It runs only after the local stability and range-eligibility gates pass, compares the server-provided primary and runner-up, and never replaces V5 local scoring or lets SearchEvents choose the final candidate.
- Candidate windows are inclusive. When `start_time > end_time`, the Python scan continues across midnight into the next calendar day; equal endpoints mean one candidate minute, and a window may not exceed 1,440 minutes.
@@ -55,7 +55,7 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re
1. Read the complete Case Dossier: recent raw turns, full revision ledger, current target disposition, pending evidence, candidate contrasts, event sensitivity, and range gate.
2. Propose every explicit event in the latest answer. Use exact source spans and declared date text; propose `revise` only with a server-issued event ID already present in the Dossier.
3. After the server stages valid revisions and recomputes diagnostics, choose the single most useful focus. Do not rotate through domains or ask for finer dates unless the diagnostics show value.
3. After the server stages valid revisions and recomputes diagnostics, choose the single most useful focus. The Dossier includes unresolved Pending Evidence and prior declined domains; use up to two read-only diagnostics when one result is not enough. Do not rotate through domains or ask for finer dates unless the diagnostics show value.
4. Write one short natural question and the public reply in the same TurnPlan. Do not expose internal IDs, scores, contribution details, tools, or candidate minutes.
5. If server validation rejects the TurnPlan, repair it once. If it still fails, accept the generic safety fallback. Offer a range only when the current server Snapshot allows it; otherwise stop honestly at low confidence when no useful question remains.