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
@@ -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,
);
});