fix(rectification): persist narration without routing domains

This commit is contained in:
Jesse_Chen
2026-07-26 15:02:21 +08:00
parent f8576a01e0
commit 2f1d90963d
4 changed files with 102 additions and 31 deletions
+15
View File
@@ -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,
);
});