Merge pull request #72 from jesse-ux/staging
fix(rectification): preserve unresolved event focus
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? "", /还能想到一件/);
|
||||
});
|
||||
|
||||
@@ -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, /另一件|还能想到一件/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user