fix(rectification): persist narration without routing domains
This commit is contained in:
@@ -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