diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1c142fe7..42f0e31c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1928,3 +1928,19 @@ - 相关记录:无 - 复发自:无 - 修复版本:待发布 + +## BUG-110 | 月份简答未继承目标事件年份导致重复追问并暂停 + +- 状态:resolved(local) +- 首次发现: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 diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index 4dcc4dd3..44108ae8 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -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), diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts index 5a9a5263..b31ad2a5 100644 --- a/frontend/src/lib/rectification-agent/director-agent.ts +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -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>([ @@ -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 }; } } diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index 1874b5f1..271e48bb 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -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; + generateDirectorPlan?: RectificationDirectorGenerator; }>): Promise 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, diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index 5b60bd14..8a045eaf 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -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, diff --git a/frontend/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts index 5030fd34..2b438d0d 100644 --- a/frontend/tests/rectification-agent-v6.test.ts +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -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"); diff --git a/frontend/tests/rectification-analysis-trace.test.ts b/frontend/tests/rectification-analysis-trace.test.ts index 4e1220d5..0cc906ce 100644 --- a/frontend/tests/rectification-analysis-trace.test.ts +++ b/frontend/tests/rectification-analysis-trace.test.ts @@ -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]); diff --git a/frontend/tests/rectification-director.test.ts b/frontend/tests/rectification-director.test.ts index 4969998f..0165a49a 100644 --- a/frontend/tests/rectification-director.test.ts +++ b/frontend/tests/rectification-director.test.ts @@ -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 () => {