Keep rectification follow-ups on the current event (#36)
Co-authored-by: Jesse_Chen <chen.yue@mevixaga.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user