diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 202b11e9..11d0dc2e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1882,3 +1882,18 @@ - 防复发:不得把每次诊断后的 prompt 改回“必须立即结束”;增加新工具时必须定义 observation 是否会在同一 Turn 变化、重复调用策略和总轮次上限。 - 相关记录:BUG-101、BUG-102、BUG-105 - 修复版本:`birth-time-rectification-v8` / `rectification-director-v4` + +## BUG-107 | 开放问题收集年份事件后先切换新事件、下一轮再回访旧事件 + +- 状态:resolved(local) +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V8 Director 最终规划、确定性 fallback、年份/季度精度事件的访谈连续性 +- 用户现象:开放问题收到一件只有年份的本人事件后,系统先用固定句式要求另一件经历;收到第二件月份明确的经历后,又突然回头追问第一件事件的月份,表现为事件焦点来回跳转。 +- 触发条件:上一问没有 `questionTargetEventId`,本轮新建的可评分事件只有 `year` 或 `quarter` 精度,同时 Director 调用失败、超时或计划被拒绝而进入 fallback;即使 Agent 正常返回,旧校验也未禁止它在未闭合宽日期事件时切换目标。 +- 根因:Orchestrator 只把上一问的 `questionTargetEventId` 传入最终 Dossier,没有把本轮新建且日期仍宽的事件提升为当前目标;Director 因而看见 `targetDisposition=not_applicable`。确定性 fallback 随即输出通用“再说一件经历”模板,后续 Director 才从完整账本重新发现旧事件缺月份。最终计划校验也只保护拒答目标,没有保护 `unresolved` / `answered_other_event` 目标不被放弃。 +- 修复:最终规划优先保留服务器 reconciliation 的未回答目标;否则把本轮新建的 `year` / `quarter` 可评分事件设为 `unresolved` 当前目标。计划校验拒绝在该目标未闭合时切换到无关新事件;fallback 按目标真实日期精度追问月份或时间段,并绑定真实 Event ID,而不是继续输出通用新事件模板。 +- 验证:Director 回归断言未闭合宽日期目标不能被新事件问题放弃,强制 fallback 会锚定该事件并请求 `month`;Orchestrator 回放断言开放问题收到年份事件后,下一问立即补月份且 `targetEventId` 指向新事件。相关 Director/分析轨迹测试、TypeScript 和 ESLint 均通过;完整前端测试 1157 项通过。 +- 防复发:事件连续性由服务器的 `currentTargetEventId + targetDisposition` 约束,不能只靠 Agent prompt;新事件的必要日期精度应在切换话题前闭合,用户明确“不知道/跳过/换方向”时才解除目标。 +- 相关记录:BUG-092、BUG-097、BUG-106 +- 修复版本:local / pending release diff --git a/frontend/src/lib/rectification-agent/director-agent.ts b/frontend/src/lib/rectification-agent/director-agent.ts index a56e5aca..e06054b8 100644 --- a/frontend/src/lib/rectification-agent/director-agent.ts +++ b/frontend/src/lib/rectification-agent/director-agent.ts @@ -71,12 +71,21 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti function fallback(dossier: RectificationCaseDossier, latestAnswer: string): RectificationTurnPlan { if (dossier.candidateState.publicRangeAllowed && dossier.candidateState.currentSnapshotId) return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "offer_candidate_range", snapshotId: dossier.candidateState.currentSnapshotId }, publicReply: { acknowledgement: "现有事件已经完成本轮复核。", candidateCommentary: "候选范围已通过当前稳定性门槛,可以作为工作范围查看。", limitation: "这仍不是对某个精确出生分钟的确认。" } }; - const keepTarget = Boolean(dossier.interviewState.currentTargetEventId && ["unresolved", "answered_other_event"].includes(dossier.interviewState.targetDisposition)); + const targetEventId = dossier.interviewState.currentTargetEventId; + const keepTarget = Boolean(targetEventId && ["unresolved", "answered_other_event"].includes(dossier.interviewState.targetDisposition)); + const targetEvent = keepTarget ? dossier.eventLedger.find((event) => event.eventId === targetEventId && event.status === "active") : null; const latestGroundedEvent = [...dossier.eventLedger].reverse().find((event) => event.status === "active" && (event.rawText === latestAnswer || latestAnswer.includes(event.summary))); const safeSummary = latestGroundedEvent && !privatePattern.test(latestGroundedEvent.summary) && !containsExactMinute(latestGroundedEvent.summary) ? latestGroundedEvent.summary.slice(0, 120) : null; - return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "ask_question", focus: { mode: keepTarget ? "clarify_existing_event" : "collect_independent_event", targetEventId: keepTarget ? dossier.interviewState.currentTargetEventId : null, domain: null, requestedFacts: keepTarget ? ["day_or_period"] : ["independent_event", "year"], rationaleCodes: [keepTarget ? "unresolved_current_event" : "need_independent_dated_event"] }, question: keepTarget ? "关于刚才那件事,你还记得它大约发生在哪一年或哪个阶段吗?" : "你还能想到一件发生在你本人身上、时间大致确定的重要经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: safeSummary ? `你提到的“${safeSummary}”已经纳入本轮事件线索。` : latestAnswer.trim() ? "我已按你刚才的描述继续整理事件线索。" : "我们先从真实经历建立事件线索。", candidateCommentary: null, limitation: "在证据通过稳定性门槛前,我不会把某个具体分钟当成确定出生时间。" } }; + const safeTargetSummary = targetEvent && !privatePattern.test(targetEvent.summary) && !containsExactMinute(targetEvent.summary) + ? `“${targetEvent.summary.slice(0, 120)}”` + : "刚才那件事"; + const targetNeedsMonth = targetEvent?.dateRange.precision === "year" || targetEvent?.dateRange.precision === "quarter"; + const targetQuestion = targetNeedsMonth + ? `${safeTargetSummary}大概发生在哪个月,或一年中的哪个时间段?` + : `关于${safeTargetSummary},你还记得更具体的时间或阶段吗?`; + return { contractVersion: "rectification-turn-plan-v1", targetDisposition: dossier.interviewState.targetDisposition, evidenceProposals: [], action: { type: "ask_question", focus: { mode: keepTarget ? "clarify_existing_event" : "collect_independent_event", targetEventId: keepTarget ? targetEventId : null, domain: keepTarget ? targetEvent?.domain ?? null : null, requestedFacts: keepTarget ? [targetNeedsMonth ? "month" : "day_or_period"] : ["independent_event", "year"], rationaleCodes: [keepTarget ? "unresolved_current_event" : "need_independent_dated_event"] }, question: keepTarget ? targetQuestion : "你还能想到一件发生在你本人身上、时间大致确定的重要经历吗?", optionalQuickReplies: [] }, publicReply: { acknowledgement: safeSummary ? `你提到的“${safeSummary}”已经纳入本轮事件线索。` : latestAnswer.trim() ? "我已按你刚才的描述继续整理事件线索。" : "我们先从真实经历建立事件线索。", candidateCommentary: null, limitation: "在证据通过稳定性门槛前,我不会把某个具体分钟当成确定出生时间。" } }; } export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; dossier: RectificationCaseDossier; latestAnswer: string; phase: "evidence" | "final" }>): Readonly<{ plan: RectificationTurnPlan | null; issues: readonly string[] }> { @@ -110,6 +119,8 @@ export function validateRectificationTurnPlan(input: Readonly<{ plan: unknown; d if (plan.action.type === "ask_question") { if (asksMultipleQuestions(plan.action.question)) issues.push("multiple_questions"); if (plan.action.focus.targetEventId && !known.has(plan.action.focus.targetEventId)) issues.push("focus_target_invalid"); + if (currentTarget && ["unresolved", "answered_other_event"].includes(plan.targetDisposition) + && plan.action.focus.targetEventId !== currentTarget) issues.push("unresolved_target_abandoned"); if (["unknown", "declined", "direction_change"].includes(plan.targetDisposition) && (plan.action.focus.targetEventId === input.dossier.interviewState.currentTargetEventId || ["clarify_existing_event", "resolve_conflict"].includes(plan.action.focus.mode))) issues.push("declined_target_reopened"); diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index 97844e29..48ba4742 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -372,10 +372,17 @@ export async function processRectificationAgentTurn(input: Readonly<{ if (claimed.case.deploymentMode !== "v4_legacy") { await enterPhase("planning_question"); + const newlyCreatedBroadDateEvent = [...extracted].reverse().find((event) => event.revision === 1 + && event.scoreability === "scoreable" + && (event.dateRange.precision === "year" || event.dateRange.precision === "quarter")); + const planningTargetEventId = reconciliation.unansweredTargetEventId ?? newlyCreatedBroadDateEvent?.eventId ?? null; + const planningTargetDisposition = planningTargetEventId && reconciliation.targetDisposition === "not_applicable" + ? "unresolved" as const + : reconciliation.targetDisposition; const dossier = buildRectificationCaseDossier({ 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, + targetDisposition: planningTargetDisposition, currentTargetEventId: planningTargetEventId, }); await enterPhase("reasoning"); const directed = await runRectificationDirector({ diff --git a/frontend/tests/rectification-analysis-trace.test.ts b/frontend/tests/rectification-analysis-trace.test.ts index 14d7d629..571e1195 100644 --- a/frontend/tests/rectification-analysis-trace.test.ts +++ b/frontend/tests/rectification-analysis-trace.test.ts @@ -538,3 +538,25 @@ test("Supabase analysis projection ignores invalid traces", () => { created_at: "2026-07-29T01:00:00.000Z", }], [{ id: jobId, turn_id: turnId }]), []); }); + +test("a newly collected year-only event stays current until its month is refined", async () => { + const base = makeClaimed([]); + const turn: RectificationV4Turn = { + ...base.turn, + question: "请先说一段自己记得比较清楚的人生经历。", + answer: "2016 年离家去外地上大学", + }; + const result = await processRectificationAgentTurn({ + claimed: { ...base, turn, turns: [turn] }, + engine: { score: async () => { throw new Error("candidate_engine_should_not_run"); } }, + now: new Date(now), + }); + + assert.equal(result.newEventRevisions.length, 1); + const created = result.newEventRevisions[0]!; + assert.equal(created.dateRange.precision, "year"); + assert.equal(result.nextQuestion?.targetEventId, created.eventId); + assert.match(result.nextQuestion?.prompt ?? "", /离家去外地上大学/); + assert.match(result.nextQuestion?.prompt ?? "", /哪个月|时间段/); + assert.doesNotMatch(result.nextQuestion?.prompt ?? "", /还能想到一件/); +}); diff --git a/frontend/tests/rectification-director.test.ts b/frontend/tests/rectification-director.test.ts index 6ff5c0cd..8cc93a7e 100644 --- a/frontend/tests/rectification-director.test.ts +++ b/frontend/tests/rectification-director.test.ts @@ -477,3 +477,73 @@ test("candidate range requires the current approved snapshot id", () => { assert.equal(staleDossier.candidateState.publicRangeAllowed, false); assert.ok(validateRectificationTurnPlan({ plan: accepted, dossier: staleDossier, latestAnswer: "", phase: "final" }).issues.includes("candidate_range_gate_failed")); }); + +test("an unresolved broad-date target cannot be abandoned for a new event", () => { + const target = event({ + dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" }, + }); + const targetDossier = buildRectificationCaseDossier({ + caseValue, + turns: [], + events: [target], + snapshot: null, + diagnostics: null, + targetDisposition: "unresolved", + currentTargetEventId: target.eventId, + }); + const abandoned = plan({ + targetDisposition: "unresolved", + action: { + type: "ask_question", + focus: { + mode: "collect_independent_event", + targetEventId: null, + domain: null, + requestedFacts: ["independent_event"], + rationaleCodes: ["need_independent_event"], + }, + question: "你还能想到另一件时间大致确定的重要经历吗?", + optionalQuickReplies: [], + }, + }); + + assert.ok(validateRectificationTurnPlan({ + plan: abandoned, + dossier: targetDossier, + latestAnswer: target.rawText, + phase: "final", + }).issues.includes("unresolved_target_abandoned")); +}); + +test("deterministic fallback refines the known year before collecting another event", async () => { + const target = event({ + summary: "2016年离家去外地上大学", + rawText: "2016 年离家去外地上大学", + dateRange: { start: "2016-01-01", end: "2016-12-31", precision: "year", label: "2016年" }, + }); + const result = await runRectificationDirector({ + caseValue, + dossier: buildRectificationCaseDossier({ + caseValue, + turns: [], + events: [target], + snapshot: null, + diagnostics: null, + targetDisposition: "unresolved", + currentTargetEventId: target.eventId, + }), + latestAnswer: target.rawText, + phase: "final", + diagnostics, + generatePlan: async () => { throw new Error("forced_fallback"); }, + }); + + assert.equal(result.mode, "deterministic_fallback"); + assert.equal(result.plan.action.type, "ask_question"); + if (result.plan.action.type !== "ask_question") return; + assert.equal(result.plan.action.focus.targetEventId, target.eventId); + assert.deepEqual(result.plan.action.focus.requestedFacts, ["month"]); + assert.match(result.plan.action.question, /2016年离家去外地上大学/); + assert.match(result.plan.action.question, /哪个月|时间段/); + assert.doesNotMatch(result.plan.action.question, /另一件|还能想到一件/); +});