fix(rectification): inherit target year for partial dates

This commit is contained in:
Jesse_Chen
2026-08-01 01:04:33 +08:00
parent 32aa0ecbac
commit b5232e7903
8 changed files with 179 additions and 25 deletions
+16
View File
@@ -1928,3 +1928,19 @@
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-110 | 月份简答未继承目标事件年份导致重复追问并暂停
- 状态:resolvedlocal
- 首次发现:2026-07-31
- 最近更新:2026-07-31
- 影响面:V5 生时校正的目标事件日期补充、Director fallback 与下一问生成
- 用户现象:已有“2016 年离家去外地上大学”事件时,用户回答“9 月”后没有生成同一事件的新 revision;系统仍把目标视为 unresolved,重复月份问题,随后因 `question_repeated` 进入暂停。
- 触发条件:当前问题绑定一个仅有年份或季度精度的事件,用户只回答月份、月日、半年或月份区间,且 Agent 不可用或返回重复的临时问题。
- 根因:确定性 reconciliation 只接受答案中重新出现完整年份的日期;Evidence 阶段又提前校验临时公开问题,导致有效证据提议可能被重复问题校验一并拒绝。
- 修复:复用目标事件已有年份补全局部日期回答,成功后追加同一 Event ID 的 revision 并关闭目标;Evidence 阶段只验证证据和 target disposition,公开问题只在 final 阶段验证;fallback reason 同时保留原始异常和二次拒绝原因。
- 验证:回归覆盖“2016 年事件 + 9 月 + Agent 强制不可用”的完整 Orchestrator 流程,确认生成 `2016-09` revision、无 pending、下一问不再绑定原事件且状态保持 `awaiting_answer`Director 测试确认记录 `fallback_rejected:question_repeated`
- 防复发:单元测试分别锁定确定性日期继承、Evidence 阶段边界、fallback 原因和完整两轮回放。
- 相关记录:BUG-104、BUG-107、BUG-109
- 复发自:无
- 修复版本:local / pending release
@@ -494,7 +494,7 @@ export const agentRunSchema = z.object({
decision: rectificationDecisionSchema.nullable(),
validatedDecision: validatedDecisionSchema,
toolCalls: z.array(toolCallTraceSchema).max(10),
fallbackReason: nonblank(120).nullable(),
fallbackReason: nonblank(240).nullable(),
inputTokenCount: z.number().int().nonnegative().nullable(),
outputTokenCount: z.number().int().nonnegative().nullable(),
latencyMs: z.number().int().nonnegative().max(300_000),
@@ -211,6 +211,7 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d
if (plan.targetDisposition === "resolved" && !revisedCurrentTarget) issues.push("resolved_target_not_revised");
if (plan.targetDisposition === "answered_other_event" && !createdOtherEvent) issues.push("other_event_not_proposed");
}
if (input.phase === "evidence") return { plan: issues.length ? null : plan, issues };
const groundedEvent = latestGroundedEvent(input.dossier, input.latestAnswer);
const capabilityFacts = new Map(input.dossier.capabilities.publicTechniqueCapabilities.map((item) => [`domain:${item.domain}`, item]));
const observationFacts = new Map<"window_sensitivity" | "candidate_scan" | "diagnostic", Set<string>>([
@@ -425,6 +426,9 @@ export async function runRectificationDirector(input: Readonly<{ caseValue: Rect
publicReply: { acknowledgement: "本轮信息已经保留。", evidenceExplanation: null, candidateCommentary: null, limitation: "当前无法安全生成新的不重复问题,先暂停在这里。" },
publicExplanationGrounding: [],
};
return { plan: safePlan, dossier, 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 };
const primaryReason = error instanceof Error ? error.message : "director_failed";
const fallbackValidationReason = validatedFallback.plan ? null : `fallback_rejected:${validatedFallback.issues.join(",")}`;
const fallbackReason = [primaryReason, fallbackValidationReason].filter((value): value is string => Boolean(value)).join(";").slice(0, 240);
return { plan: safePlan, dossier, mode: "deterministic_fallback" as const, fallbackReason, toolCalls, inputTokenCount: usageObserved ? inputTokens : null, outputTokenCount: usageObserved ? outputTokens : null, latencyMs: Date.now() - started };
}
}
@@ -4,7 +4,7 @@ import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.t
import type { CandidateSnapshot, RectificationAnalysisTrace, RectificationV4Question } from "../rectification-v4/contracts.ts";
import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts";
import { reconcileV4Evidence, stageAgentEvidenceProposals, type ReconciledV4Evidence, type TargetDisposition } from "../rectification-v4/extraction.ts";
import { buildRectificationCaseDossier, runRectificationDirector } from "./director-agent.ts";
import { buildRectificationCaseDossier, runRectificationDirector, type RectificationDirectorGenerator } from "./director-agent.ts";
import { candidateUpdateFor } from "./renderer-agent.ts";
import { extractEventWithModel } from "./event-extractor-agent.ts";
import { evidenceSetHash } from "../rectification-v4/fingerprints.ts";
@@ -138,6 +138,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
engine: RectificationV4CandidateEngine;
now: Date;
onPhase?: (phase: "extracting_evidence" | "scoring_candidates" | "checking_robustness" | "planning_question" | "reasoning" | "rendering") => Promise<void>;
generateDirectorPlan?: RectificationDirectorGenerator;
}>): Promise<Readonly<{
newEventRevisions: ClaimedRectificationV4Job["events"];
pendingEvidence: import("../rectification-v4/contracts.ts").PendingEvidence[];
@@ -190,7 +191,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
currentTargetEventId: claimed.turn.questionTargetEventId,
});
evidenceDirector = await runRectificationDirector({
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics,
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "evidence", diagnostics: provisionalDiagnostics, generatePlan: input.generateDirectorPlan,
});
const serverReconciliation = reconcileV4Evidence({ caseId: claimed.case.id, answer: claimed.turn.answer, sourceTurnId: claimed.turn.id, asOfDate, existing: claimed.events, targetEventId: claimed.turn.questionTargetEventId, now });
reconciliation = claimed.case.deploymentMode === "v5_agent" && evidenceDirector.mode === "agent" && evidenceDirector.plan.evidenceProposals.length
@@ -395,7 +396,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
});
await enterPhase("reasoning");
const directed = await runRectificationDirector({
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics,
caseValue: claimed.case, dossier, latestAnswer: claimed.turn.answer, phase: "final", diagnostics: safeDiagnostics, generatePlan: input.generateDirectorPlan,
});
const plan = directed.plan;
const action = plan.action;
@@ -447,7 +448,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
: "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 fallbackReason = [evidenceDirector?.fallbackReason, directed.fallbackReason].filter(Boolean).join(";").slice(0, 240) || 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,
+37 -19
View File
@@ -242,24 +242,42 @@ export function reconcileV4Evidence(input: {
revisions.push(clarified);
targetResolved = true;
} else {
const targetAnswer = extracted.find((event) => event.dateValue && event.datePrecision !== "unknown" && describesTarget(event, target));
if (targetAnswer?.dateValue && targetAnswer.datePrecision !== "unknown") {
const dateRange = dateRangeFromDeclared(targetAnswer.dateValue, targetAnswer.datePrecision);
if (dateRange.start <= input.asOfDate) {
revisions.push(appendEventRevision(input.existing, {
eventId: target.eventId,
domain: target.domain,
eventKind: target.eventKind,
subject: target.subject,
relatedPerson: target.relatedPerson,
summary: target.summary,
rawText: input.answer,
dateRange,
...eventDateProvenance(target),
scoreability: target.scoreability,
}, { id: targetAnswer.id, now: input.now }));
consumed.add(targetAnswer.id);
targetResolved = true;
const inheritedDateRange = inferredTargetDateRange(input.answer, target, input.asOfDate);
if (inheritedDateRange && inheritedDateRange.start <= input.asOfDate) {
revisions.push(appendEventRevision(input.existing, {
eventId: target.eventId,
domain: target.domain,
eventKind: target.eventKind,
subject: target.subject,
relatedPerson: target.relatedPerson,
summary: target.summary,
rawText: input.answer,
dateRange: inheritedDateRange,
...eventDateProvenance(target),
scoreability: target.scoreability,
}, { now: input.now }));
extracted.forEach((event) => consumed.add(event.id));
targetResolved = true;
} else {
const targetAnswer = extracted.find((event) => event.dateValue && event.datePrecision !== "unknown" && describesTarget(event, target));
if (targetAnswer?.dateValue && targetAnswer.datePrecision !== "unknown") {
const dateRange = dateRangeFromDeclared(targetAnswer.dateValue, targetAnswer.datePrecision);
if (dateRange.start <= input.asOfDate) {
revisions.push(appendEventRevision(input.existing, {
eventId: target.eventId,
domain: target.domain,
eventKind: target.eventKind,
subject: target.subject,
relatedPerson: target.relatedPerson,
summary: target.summary,
rawText: input.answer,
dateRange,
...eventDateProvenance(target),
scoreability: target.scoreability,
}, { id: targetAnswer.id, now: input.now }));
consumed.add(targetAnswer.id);
targetResolved = true;
}
}
}
}
@@ -285,7 +303,7 @@ export function reconcileV4Evidence(input: {
const suppressPending = targetDisposition === "unknown"
|| targetDisposition === "declined"
|| targetDisposition === "direction_change";
if (extracted.length === 0 && !suppressPending) unresolvedReason = "event_unparsed";
if (extracted.length === 0 && !suppressPending && !targetResolved) unresolvedReason = "event_unparsed";
const pending = unresolvedReason && !suppressPending ? [
pendingEvidence({
caseId: input.caseId,
@@ -121,6 +121,33 @@ test("不知道或换方向不产生 pending,也不再生成同 target 机会"
assert.ok(opportunities.some((item) => item.kind === "ask_new_event"));
});
test("month-only answer refines the targeted year event", () => {
const target = event({
summary: "离家去外地上大学",
rawText: "2016 年离家去外地上大学",
dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" },
});
const result = reconcileV4Evidence({
caseId,
answer: "9 月",
sourceTurnId: randomUUID(),
asOfDate: "2026-07-31",
existing: [target],
targetEventId: target.eventId,
now: new Date(now),
});
assert.equal(result.targetDisposition, "resolved");
assert.equal(result.unansweredTargetEventId, null);
assert.deepEqual(result.pending, []);
assert.equal(result.revisions.length, 1);
assert.equal(result.revisions[0]?.eventId, target.eventId);
assert.equal(result.revisions[0]?.revision, 2);
assert.equal(result.revisions[0]?.dateRange.start, "2016-09-01");
assert.equal(result.revisions[0]?.dateRange.end, "2016-09-30");
assert.equal(result.revisions[0]?.dateRange.precision, "month");
});
test("无 target 的换方向表达也不会被记为 event_unparsed", () => {
const result = reconcileV4Evidence({ caseId, answer: "后来有一次搬家,但我记不清时间了,换一个吧。", sourceTurnId: randomUUID(), asOfDate: "2026-07-29", existing: [] });
assert.equal(result.targetDisposition, "direction_change");
@@ -562,6 +562,48 @@ test("a newly collected year-only event stays current until its month is refined
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /还能想到一件/);
});
test("a month-only answer closes the targeted event even when the Agent is unavailable", async () => {
const university: LifeEventRevision = {
...event("education", "education_milestone", "离家去外地上大学", "2016-01"),
dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" },
};
const base = makeClaimed([university]);
const earlierTurn: RectificationV4Turn = {
...base.turn,
id: randomUUID(),
caseVersion: 1,
questionDomain: "education",
questionTargetEventId: null,
question: "请先说一段自己记得比较清楚的人生经历。",
answer: "2016 年离家去外地上大学",
};
const turn: RectificationV4Turn = {
...base.turn,
id: randomUUID(),
caseVersion: 2,
questionDomain: "education",
questionTargetEventId: university.eventId,
question: "你还记得“离家去外地上大学”大概发生在哪个月,或一年中的哪个时间段吗?",
answer: "9 月",
};
const result = await processRectificationAgentTurn({
claimed: { ...base, turn, turns: [earlierTurn, turn] },
engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } },
generateDirectorPlan: async () => { throw new Error("forced_agent_unavailable"); },
now: new Date(now),
});
const revision = result.newEventRevisions[0];
assert.equal(revision?.eventId, university.eventId);
assert.equal(revision?.revision, 2);
assert.deepEqual(revision?.dateRange, { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "9 月" });
assert.deepEqual(result.pendingEvidence, []);
assert.equal(result.status, "awaiting_answer");
assert.notEqual(result.nextQuestion?.targetEventId, university.eventId);
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /哪个月|一年中的哪个时间段/);
assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /当前无法安全生成新的不重复问题|先暂停在这里/);
});
test("a completed internship answer is acknowledged, explained, and followed by one contrast-driven domain question", async () => {
const university = event("education", "education_milestone", "离家去外地上大学", "2016-09");
const base = makeClaimed([university]);
@@ -242,6 +242,50 @@ test("a natural question and multiple grounded event proposals pass without doma
assert.deepEqual(new Set(staged.revisions.map((item) => item.domain)), new Set(["relocation", "career"]));
});
test("evidence proposal survives a repeated provisional question", () => {
const target = event({
summary: "离家去外地上大学",
rawText: "2016 年离家去外地上大学",
dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" },
});
const repeatedQuestion = "你还记得“离家去外地上大学”大概发生在哪个月,或一年中的哪个时间段吗?";
const targetDossier = buildRectificationCaseDossier({
caseValue,
turns: [turn(0, { question: repeatedQuestion, answer: target.rawText, questionTargetEventId: target.eventId })],
events: [target],
pendingEvidence: [],
snapshot: null,
diagnostics: null,
targetDisposition: "unresolved",
currentTargetEventId: target.eventId,
});
const value = plan({
targetDisposition: "resolved",
evidenceProposals: [{
operation: "revise_date",
targetEventId: target.eventId,
sourceSpan: "9 月",
dateText: "9 月",
proposedSummary: target.summary,
proposedDomain: target.domain,
proposedEventKind: target.eventKind,
proposedSubject: target.subject,
proposedRelatedPerson: target.relatedPerson,
confidence: "high",
}],
action: {
type: "ask_question",
focus: { mode: "clarify_existing_event", targetEventId: target.eventId, domain: target.domain, requestedFacts: ["month"], rationaleCodes: ["provisional"] },
question: repeatedQuestion,
optionalQuickReplies: [],
},
});
const validated = validateRectificationTurnPlan({ plan: value, dossier: targetDossier, latestAnswer: "9 月", phase: "evidence" });
assert.deepEqual(validated.issues, []);
assert.equal(validated.plan?.evidenceProposals[0]?.operation, "revise_date");
});
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" }] });
@@ -822,6 +866,8 @@ test("deterministic fallback pauses instead of repeating a persisted public ques
assert.equal(result.plan.action.type, "stop_low_confidence");
if (result.plan.action.type !== "stop_low_confidence") return;
assert.deepEqual(result.plan.action.reasonCodes, ["deterministic_fallback_rejected"]);
assert.match(result.fallbackReason ?? "", /forced_fallback/);
assert.match(result.fallbackReason ?? "", /fallback_rejected:question_repeated/);
});
test("deterministic fallback stays domain-neutral when the Agent is unavailable", async () => {