diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a1947f1a..657c8eac 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1563,3 +1563,18 @@ - 防复发:可见生时校正必须复用普通聊天 Surface;测试应锁定自然语言消息、turn 恢复、模型 ID 传递和无固定领域控件,不得锁定领域顺序或问题模板。模型只负责选择和表达下一条高信息量问题,证据修订、评分、稳定性门、范围接受、handoff 与扣费继续由确定性后端负责。 - 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084 - 修复版本:待提交(staging 验收中) + +## BUG-086 | 模型下一问可绕过当前事件而跳成领域问卷 + +- 状态:investigating +- 首次发现:2026-07-27 +- 最近更新:2026-07-27 +- 影响面:生时校正 V4 的模型提问规划、事件日期补全和 staging 对话体验 +- 用户现象:用户回答“2016 年离家去外地上大学”后,下一问直接变成“请说一次影响较大的搬家或长期迁居”,看起来仍按“升学 → 搬家”模板轮询,而没有承接刚才的具体经历。 +- 触发条件:最新可评分事件只有年份精度,但模型返回新的领域和空 `targetEventId`;Worker 直接接受格式合法的模型结果。 +- 根因:模型提示虽然要求优先延续当前事件,但 Worker 只校验了输出结构,没有把确定性 planner 识别出的必要日期补全当作服务端路由约束;因此模型可越过仍缺月份的当前事件。旧测试只证明模型拿到了完整上下文,没有覆盖模型违反路由建议的情况。 +- 修复:planner 将月份视为足够的首选精度;年份、季度或范围精度仍产生必要的当前事件补全。问题作者收到 `requiredContinuation`,必须围绕该事件自然追问月份或日期;Worker 在信任边界拒绝模型切换事件或领域,并回退到同一事件的开放式日期追问。当前事件达到月份精度后,模型才可根据上下文自由选择下一条高信息量问题,不设领域顺序。 +- 验证:新增用户原句回归,模拟模型错误返回搬家问题,断言 Worker 仍追问“离家去外地上大学”的月份且不出现搬家模板;同时锁定月份精度后模型可自由选题。聚焦 domain/service/replay 共 18 个测试通过;staging 部署与真实登录态 smoke 完成后更新状态。 +- 防复发:模型可以表达和选择下一题,但不能绕过服务端判定的当前事件必要补全;测试必须包含“模型输出合法但路由错误”的对抗用例,不能只测 happy path。 +- 相关记录:BUG-075、BUG-085 +- 修复版本:待提交 diff --git a/frontend/src/lib/rectification-v4/question-author.ts b/frontend/src/lib/rectification-v4/question-author.ts index d190d0ee..e0e63ee6 100644 --- a/frontend/src/lib/rectification-v4/question-author.ts +++ b/frontend/src/lib/rectification-v4/question-author.ts @@ -59,16 +59,20 @@ export async function authorRectificationV4Question(input: Readonly<{ events: readonly LifeEventRevision[]; attemptedRefinementEventIds: readonly string[]; }>): Promise { - const fallback = () => planNextQuestion({ + const plannedQuestion = planNextQuestion({ events: input.events, attemptedRefinementEventIds: input.attemptedRefinementEventIds, latestAnswer: input.turns.at(-1)?.answer, }); + const fallback = () => plannedQuestion; const agent = agentFor(input.modelId); if (!agent) return fallback(); const events = latestByEvent(input.events); const allowedTargets = new Map(events.map((event) => [event.eventId, event])); + const requiredContinuation = plannedQuestion.targetEventId + ? allowedTargets.get(plannedQuestion.targetEventId) ?? null + : null; const recentTurns = input.turns.slice(-6).flatMap((turn) => [ { role: "assistant", text: turn.question }, ...(turn.answer ? [{ role: "user", text: turn.answer }] : []), @@ -78,7 +82,8 @@ export async function authorRectificationV4Question(input: Readonly<{ constraints: [ "First acknowledge or connect to the latest user experience; do not say merely that an answer is complete or recorded.", "Ask zero or one question, never a checklist, form, domain menu, or fixed sequence.", - "Prefer continuing the current event when a date/detail clarification can change scoring; otherwise choose the highest-information missing life dimension.", + "When requiredContinuation is present, continue that exact event and ask naturally for a more precise month or date; do not switch to another event or domain.", + "When requiredContinuation is absent, choose the highest-information next question from context rather than following a domain order.", "The visible prompt must not mention internal domains, ids, scores, weights, gates, processing phases, or that a model selected a route.", "targetEventId must be null or one of allowedTargetEventIds.", ], @@ -96,6 +101,14 @@ export async function authorRectificationV4Question(input: Readonly<{ scoreability: event.scoreability, })), attemptedRefinementEventIds: input.attemptedRefinementEventIds, + requiredContinuation: requiredContinuation + ? { + eventId: requiredContinuation.eventId, + summary: requiredContinuation.summary, + currentDate: requiredContinuation.dateRange.label, + precision: requiredContinuation.dateRange.precision, + } + : null, allowedTargetEventIds: [...allowedTargets.keys()], allowedDomains: evidenceDomainSchema.options, }); diff --git a/frontend/src/lib/rectification-v4/question-planner.ts b/frontend/src/lib/rectification-v4/question-planner.ts index 6722a7f7..bda5d882 100644 --- a/frontend/src/lib/rectification-v4/question-planner.ts +++ b/frontend/src/lib/rectification-v4/question-planner.ts @@ -21,7 +21,7 @@ export function planNextQuestion(input: { }): RectificationV4Question { const attempted = new Set(input.attemptedRefinementEventIds ?? []); const target = scoreableEvents(input.events ?? []) - .filter((event) => event.dateRange.precision !== "day" && !attempted.has(event.eventId)) + .filter((event) => !["day", "month"].includes(event.dateRange.precision) && !attempted.has(event.eventId)) .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || left.eventId.localeCompare(right.eventId))[0]; if (target) return refinementQuestion(target, input.id); diff --git a/frontend/src/lib/rectification-v4/worker.ts b/frontend/src/lib/rectification-v4/worker.ts index 4f822cc3..f064f9a4 100644 --- a/frontend/src/lib/rectification-v4/worker.ts +++ b/frontend/src/lib/rectification-v4/worker.ts @@ -83,8 +83,15 @@ export function createRectificationV4Worker(input: { }; } await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "planning_question", now: now().toISOString() }); - const nextQuestion = snapshot?.canAcceptRange ? null : input.questionAuthor - ? await input.questionAuthor({ + let nextQuestion: RectificationV4Question | null = null; + if (!snapshot?.canAcceptRange) { + const plannedQuestion = planNextQuestion({ + events, + attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, + latestAnswer: claimed.turn.answer, + }); + const authoredQuestion = input.questionAuthor + ? await input.questionAuthor({ modelId: claimed.turn.modelId, candidateRange: claimed.case.calculationSpec.candidateRange, snapshot, @@ -92,11 +99,13 @@ export function createRectificationV4Worker(input: { events, attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, }) - : planNextQuestion({ - events, - attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, - latestAnswer: claimed.turn.answer, - }); + : plannedQuestion; + nextQuestion = plannedQuestion.targetEventId !== null + && (authoredQuestion.targetEventId !== plannedQuestion.targetEventId + || authoredQuestion.domain !== plannedQuestion.domain) + ? plannedQuestion + : authoredQuestion; + } await input.store.completeJob({ workerId, jobId: claimed.job.id, diff --git a/frontend/tests/rectification-v4-domain.test.ts b/frontend/tests/rectification-v4-domain.test.ts index 48de20d7..85f98ad7 100644 --- a/frontend/tests/rectification-v4-domain.test.ts +++ b/frontend/tests/rectification-v4-domain.test.ts @@ -113,6 +113,23 @@ test("fallback planner refines an imprecise event, then returns to open narratio assert.doesNotMatch(fallback.prompt, /搬家|恋爱|事业|财务|健康/); }); +test("month-precise evidence is sufficient for the model to choose the next topic", () => { + const event = appendEventRevision([], { + eventId: randomUUID(), domain: "education", eventKind: "education_milestone", summary: "去外地上大学", + rawText: "2016年9月去外地上大学", dateRange: dateRangeFromDeclared("2016-09", "month"), + }, { id: randomUUID(), now }); + + const question = planNextQuestion({ + events: [event], + attemptedRefinementEventIds: [], + latestAnswer: "2016年9月去外地上大学", + id: randomUUID(), + }); + + assert.equal(question.targetEventId, null); + assert.equal(question.domain, "other"); +}); + test("targeted date answer appends a revision without duplicating the scoreable event", () => { const eventId = randomUUID(); const original = appendEventRevision([], { diff --git a/frontend/tests/rectification-v4-replay.test.ts b/frontend/tests/rectification-v4-replay.test.ts index 0074b551..96239dcc 100644 --- a/frontend/tests/rectification-v4-replay.test.ts +++ b/frontend/tests/rectification-v4-replay.test.ts @@ -91,11 +91,11 @@ test("fixture replay returns ranges only and never mutates the profile birth min userId, created.case.id, created.case.version, - "2015年高中毕业后复读一年,2016年再次高中毕业", + "2015年7月高中毕业后复读一年,2016年6月再次高中毕业", ); assert.deepEqual(loaded.events.map((event) => [event.dateRange.start, event.dateRange.end]), [ - ["2015-01-01", "2015-12-31"], - ["2016-01-01", "2016-12-31"], + ["2015-07-01", "2015-07-31"], + ["2016-06-01", "2016-06-30"], ]); loaded = await answerAndRun(service, worker, userId, created.case.id, loaded.case.version, "2018年8月搬家到北京"); loaded = await answerAndRun( diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index 017e5028..a736eb28 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -116,13 +116,52 @@ test("worker extracts dated events, keeps one question and never confirms an exa const done = await service.loadCase(userId, created.case.id); assert.equal(done?.case.status, "awaiting_answer"); assert.equal(done?.events.length, 2); - assert.equal(done?.case.currentQuestion?.domain, "education"); - assert.equal(done?.events.some((event) => event.eventId === done.case.currentQuestion?.targetEventId), true); - assert.match(done?.case.currentQuestion?.prompt ?? "", /月份或日期/); + assert.equal(done?.case.currentQuestion?.domain, "other"); + assert.equal(done?.case.currentQuestion?.targetEventId, null); + assert.match(done?.case.currentQuestion?.prompt ?? "", /继续讲另一件/); assert.equal(done?.case.latestSnapshot, null); assert.equal(queued?.job?.status, "pending"); }); +test("worker rejects a model-authored domain jump while the latest event still needs a month", async () => { + const store = createRectificationV4MemoryStore(); + const service = createRectificationV4CaseService(store, { now: fixedNow }); + const userId = randomUUID(); + const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + await service.answer({ + userId, + caseId: created.case.id, + actionId: randomUUID(), + expectedCaseVersion: created.case.version, + answer: "2016年离家去外地上大学", + modelId: "gpt-5.5", + }); + const worker = createRectificationV4Worker({ + store, + now: fixedNow, + engine: { async score() { throw new Error("engine must not run before enough events"); } }, + questionAuthor: async () => ({ + id: randomUUID(), + domain: "relocation", + targetEventId: null, + prompt: "请说一次影响较大的搬家或长期迁居,并给出尽可能准确的年月。", + recallCost: "low", + reason: "模型错误地跳到了另一个领域。", + }), + }); + + assert.equal(await worker.runOnce(), true); + const done = await service.loadCase(userId, created.case.id); + const event = done?.events.find((item) => item.summary === "离家去外地上大学"); + + assert.ok(event); + assert.equal(done?.case.currentQuestion?.targetEventId, event.eventId); + assert.equal(done?.case.currentQuestion?.domain, "education"); + assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/); + assert.match(done?.case.currentQuestion?.prompt ?? "", /月份或日期/); + assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /搬家或长期迁居/); +}); + test("completed job rejects stale case or calculation hashes", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow });