fix(rectification): persist narration without routing domains
This commit is contained in:
@@ -1459,3 +1459,18 @@
|
||||
- 防复发:叙事模型只能影响自然语言表达,不能成为事件持久化和确定性收敛的单点故障;降级输出必须来自已验证 packet。
|
||||
- 相关记录:BUG-067、BUG-075、BUG-077
|
||||
- 修复版本:本次修复提交
|
||||
|
||||
## BUG-079 | 无可用路由领域时 Agent 追问导致 paused 会话返回 503
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-26
|
||||
- 最近更新:2026-07-26
|
||||
- 影响面:生时校正开放叙事、暂停后恢复、历史事件保存、Narrative Agent 到公开 turn 的适配边界
|
||||
- 用户现象:完整 synthetic smoke 在暂停并恢复后提交下一条历史事件,技术评分和模型叙事都成功,但接口返回 `service_unavailable`,事件无法保存。
|
||||
- 触发条件:技术 packet 的 `suggestedDomains` 为空,而模型自然提出了一个可继续回答的问题。
|
||||
- 根因:Narrative Agent 会把模型领域替换成服务端允许的领域;允许集合为空时产生 `evidenceRequest.domains=[]`。叙事校验未拒绝空数组,但公开 turn 合同要求至少一个领域,导致 `turnFromNarrative()` 在持久化前把整轮转换为 503。
|
||||
- 修复:在 Narrative Agent 的共享适配边界统一处理 authored 与 legacy 输出;没有任何服务端可验证路由领域时保留自然语言正文,只把可选 `evidenceRequest` 归一化为 `null`。不放宽公开持久化 schema,也不信任模型自报领域。
|
||||
- 验证:回归覆盖无路由领域的 authored 输出,并覆盖 `pause → resume → answer` 完整编排路径;确认正文保留、`evidenceRequest=null`、事件正常保存且不返回 503。
|
||||
- 防复发:模型自然语言不得成为事件保存的硬依赖;私有路由元数据为空时应省略可选状态,而不是生成违反持久化合同的半合法对象。
|
||||
- 相关记录:BUG-075、BUG-078
|
||||
- 修复版本:本次修复提交
|
||||
|
||||
@@ -168,16 +168,20 @@ function groundedEvidenceDomains(
|
||||
return grounded.length > 0 ? grounded : suggested;
|
||||
}
|
||||
|
||||
function groundedEvidenceRequest(
|
||||
request: z.infer<typeof rectificationNarrativeAuthoredOutputSchema>["evidenceRequest"],
|
||||
packet: RectificationTechnicalPacket,
|
||||
) {
|
||||
if (request === null) return null;
|
||||
const domains = groundedEvidenceDomains(request.domains, packet);
|
||||
return domains.length > 0 ? { ...request, domains } : null;
|
||||
}
|
||||
|
||||
function completeAuthoredOutput(
|
||||
output: z.infer<typeof rectificationNarrativeAuthoredOutputSchema>,
|
||||
packet: RectificationTechnicalPacket,
|
||||
): RectificationNarrativeModelOutput {
|
||||
const evidenceRequest = output.evidenceRequest === null ? null : {
|
||||
datePrecision: output.evidenceRequest.datePrecision,
|
||||
prompt: output.evidenceRequest.prompt,
|
||||
followUp: output.evidenceRequest.followUp,
|
||||
domains: groundedEvidenceDomains(output.evidenceRequest.domains, packet),
|
||||
};
|
||||
const evidenceRequest = groundedEvidenceRequest(output.evidenceRequest, packet);
|
||||
return {
|
||||
...output,
|
||||
evidenceRequest,
|
||||
@@ -206,14 +210,10 @@ function parseModelOutput(
|
||||
if (legacy.success) {
|
||||
return {
|
||||
...legacy.data,
|
||||
evidenceRequest: legacy.data.evidenceRequest === null ? null : {
|
||||
...legacy.data.evidenceRequest,
|
||||
// The next conversational topic is authored by the model, but the
|
||||
// scoring-domain allowlist remains server-owned. Do not let a useful
|
||||
// answer fail merely because the model described that topic with a
|
||||
// different internal domain label.
|
||||
domains: groundedEvidenceDomains(legacy.data.evidenceRequest.domains, packet),
|
||||
},
|
||||
// The next conversational topic is authored by the model, but the
|
||||
// scoring-domain allowlist remains server-owned. If no grounded routing
|
||||
// domain exists, keep the prose and omit only the optional follow-up state.
|
||||
evidenceRequest: groundedEvidenceRequest(legacy.data.evidenceRequest, packet),
|
||||
};
|
||||
}
|
||||
return completeAuthoredOutput(rectificationNarrativeAuthoredOutputSchema.parse(parsed), packet);
|
||||
|
||||
@@ -935,6 +935,28 @@ test("replaces legacy model-selected evidence domains instead of rejecting the a
|
||||
assert.deepEqual(result.output.evidenceRequest?.domains, ["relationship", "career"]);
|
||||
});
|
||||
|
||||
test("keeps authored prose but drops follow-up state when no grounded routing domain exists", async () => {
|
||||
const packet = { ...syntheticTechnicalPacket(), suggestedDomains: [] };
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet,
|
||||
generator: generator([{
|
||||
narrative: "这段经历已经记下。你愿意的话,可以继续讲当时发生了什么。",
|
||||
evidenceRequest: {
|
||||
domains: ["career"],
|
||||
datePrecision: "month_preferred",
|
||||
prompt: "当时发生了什么?",
|
||||
followUp: { kind: "new_event", evidenceId: null },
|
||||
},
|
||||
}]),
|
||||
});
|
||||
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.equal(result.output.evidenceRequest, null);
|
||||
assert.match(result.narrative, /继续讲/);
|
||||
});
|
||||
|
||||
test("uses a fresh second provider request after the first attempt times out", async () => {
|
||||
let calls = 0;
|
||||
const signals: Array<AbortSignal | undefined> = [];
|
||||
|
||||
@@ -1641,22 +1641,22 @@ test("the next evidence request moves past a domain the user already answered",
|
||||
assert.doesNotMatch(turn.narrative, /下一步[^\n]*重要关系/);
|
||||
});
|
||||
|
||||
test("a rejected intermediate narrative returns a retryable error without saving a template turn", async () => {
|
||||
test("a rejected intermediate narrative falls back without discarding the event", async () => {
|
||||
const value = harness({ invalidNarrativeFromGeneration: 1 });
|
||||
await start(value, null);
|
||||
|
||||
await assert.rejects(value.service.answer(userId, {
|
||||
const turn = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2021年7月开始第一份长期工作",
|
||||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "service_unavailable");
|
||||
});
|
||||
|
||||
const stored = value.cases.get(startActionId)?.row;
|
||||
assert.equal(stored?.turnVersion, 0);
|
||||
assert.equal(turn.turnVersion, 1);
|
||||
assert.match(turn.narrative, /按自己的节奏/);
|
||||
assert.equal(stored?.privateCandidate.resultId, null);
|
||||
assert.equal(stored?.eventEvidence.length, 0);
|
||||
assert.equal(stored?.validationReceipts.length, 1);
|
||||
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 0);
|
||||
assert.equal(stored?.eventEvidence.length, 1);
|
||||
assert.equal(stored?.validationReceipts.length, 2);
|
||||
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 1);
|
||||
});
|
||||
|
||||
test("one through three supported events save and narrate before the fourth accumulated event ranks", async () => {
|
||||
@@ -1785,6 +1785,39 @@ test("an unanswered suggested domain keeps a plateaued candidate conversational"
|
||||
assert.deepEqual(latest?.actions, ["answer", "pause", "abandon"]);
|
||||
});
|
||||
|
||||
test("answer persists after pause when the packet has no grounded follow-up domain", async () => {
|
||||
const value = harness({
|
||||
packetForEvidenceCount() {
|
||||
return { ...packet(false), suggestedDomains: [] };
|
||||
},
|
||||
});
|
||||
const started = await start(value, null);
|
||||
const paused = await value.service.pause(userId, {
|
||||
type: "pause",
|
||||
caseId: startActionId,
|
||||
actionId: pauseActionId,
|
||||
turnVersion: started.turnVersion,
|
||||
});
|
||||
await value.service.resume(userId, {
|
||||
type: "resume",
|
||||
caseId: startActionId,
|
||||
actionId: resumeActionId,
|
||||
turnVersion: paused.turnVersion,
|
||||
});
|
||||
|
||||
const turn = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: paused.turnVersion,
|
||||
answer: "2020年4月进入研究院实习,10月主动辞职",
|
||||
});
|
||||
|
||||
assert.equal(turn.status, "active");
|
||||
assert.equal(turn.evidenceRequest, null);
|
||||
assert.match(turn.narrative, /记下了/);
|
||||
});
|
||||
|
||||
test("system-only blockers return a bounded result without waiting for another plateau", async () => {
|
||||
const value = harness({
|
||||
packetForEvidenceCount(count) {
|
||||
@@ -2034,7 +2067,7 @@ test("regenerate rewrites only the current narrative and preserves evidence, sco
|
||||
assert.match(value.narrativePrompts.at(-1) ?? "", /2012年12月正式退学/);
|
||||
});
|
||||
|
||||
test("a failed regenerate preserves the prior turn, evidence, candidate, and billing", async () => {
|
||||
test("a failed regenerate saves a fallback turn without changing evidence, candidate, or billing", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 2 });
|
||||
await start(value, null);
|
||||
const answered = await value.service.answer(userId, {
|
||||
@@ -2046,28 +2079,29 @@ test("a failed regenerate preserves the prior turn, evidence, candidate, and bil
|
||||
});
|
||||
const storedBefore = value.cases.get(startActionId)?.row;
|
||||
assert.ok(storedBefore);
|
||||
const snapshotBefore = structuredClone(storedBefore);
|
||||
const evidenceBefore = structuredClone(storedBefore.eventEvidence);
|
||||
const candidateBefore = structuredClone(storedBefore.privateCandidate);
|
||||
const countsBefore = value.counts();
|
||||
const saveTurnsBefore = value.mutations.filter((mutation) => mutation === "saveTurn").length;
|
||||
|
||||
await assert.rejects(value.service.regenerate(userId, {
|
||||
const regenerated = await value.service.regenerate(userId, {
|
||||
type: "regenerate",
|
||||
caseId: startActionId,
|
||||
actionId: laterActionId,
|
||||
turnVersion: answered.turnVersion,
|
||||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "service_unavailable");
|
||||
});
|
||||
|
||||
const storedAfter = value.cases.get(startActionId)?.row;
|
||||
assert.deepEqual(storedAfter, snapshotBefore);
|
||||
assert.equal(storedAfter?.turnVersion, answered.turnVersion);
|
||||
assert.equal(storedAfter?.latestTurn.narrative, answered.narrative);
|
||||
assert.equal(regenerated.turnVersion, answered.turnVersion + 1);
|
||||
assert.match(regenerated.narrative, /按自己的节奏/);
|
||||
assert.deepEqual(storedAfter?.eventEvidence, evidenceBefore);
|
||||
assert.deepEqual(storedAfter?.privateCandidate, candidateBefore);
|
||||
assert.equal(value.counts().reserveCount, countsBefore.reserveCount);
|
||||
assert.equal(value.counts().releaseCount, countsBefore.releaseCount);
|
||||
assert.equal(value.counts().packetBuilds, countsBefore.packetBuilds + 1);
|
||||
assert.equal(
|
||||
value.mutations.filter((mutation) => mutation === "saveTurn").length,
|
||||
saveTurnsBefore,
|
||||
saveTurnsBefore + 1,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user