Keep rectification follow-ups on the current event (#36)

Co-authored-by: Jesse_Chen <chen.yue@mevixaga.com>
This commit is contained in:
jesse-ux
2026-07-28 00:45:09 +08:00
committed by GitHub
parent 5e9751ef6f
commit 43581ac0f7
7 changed files with 109 additions and 16 deletions
+15
View File
@@ -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
- 修复版本:待提交
@@ -59,16 +59,20 @@ export async function authorRectificationV4Question(input: Readonly<{
events: readonly LifeEventRevision[];
attemptedRefinementEventIds: readonly string[];
}>): Promise<RectificationV4Question> {
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,
});
@@ -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);
+16 -7
View File
@@ -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,
@@ -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([], {
@@ -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(
@@ -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 });