mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(coach): graceful fallback when sentinel-only output strips empty
When the model emits only a `---KEYVALUES---` block with no leading prose (or a malformed block missing the closing marker), the post-strip prose is empty. The previous fallback chained back to `rawReply`, surfacing raw sentinel markers to the user. Replace with a structured `coach.provider.empty` error frame so the UI renders the localised provider-error copy instead of leaking the markers. Add a regression integration test pinning that the streamed body never contains `---KEYVALUES---` for a sentinel-only reply, and that no assistant message is persisted in this branch. Closes Code-H1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
363e73cbf2
commit
75ccc01984
2 files changed
+70
-1
No files matched your search
@@ -272,7 +272,26 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
|||||||
// provenance envelope so the UI can render the collapsible
|
// provenance envelope so the UI can render the collapsible
|
||||||
// "Worauf bezieht sich das?" disclosure.
|
// "Worauf bezieht sich das?" disclosure.
|
||||||
const sentinel = parseKeyValuesSentinel(rawReply);
|
const sentinel = parseKeyValuesSentinel(rawReply);
|
||||||
const replyText = sentinel.prose.trim() || rawReply;
|
const proseAfterStrip = sentinel.prose.trim();
|
||||||
|
// v1.4.22 W5 reconcile (Code-H1) — when the model emits a
|
||||||
|
// sentinel-only / malformed reply, `sentinel.prose` is empty after
|
||||||
|
// stripping. The previous fallback `sentinel.prose.trim() || rawReply`
|
||||||
|
// surfaced raw `---KEYVALUES---` markers to the user. The empty-prose
|
||||||
|
// condition signals an unusable provider response: short-circuit to
|
||||||
|
// the structured `coach.provider.empty` error frame instead of
|
||||||
|
// streaming the raw sentinel body.
|
||||||
|
if (!proseAfterStrip) {
|
||||||
|
annotate({
|
||||||
|
action: { name: "coach.keyvalues.parse_failed" },
|
||||||
|
meta: {
|
||||||
|
kept: sentinel.keyValues.length,
|
||||||
|
reason: "empty_prose_after_strip",
|
||||||
|
promptVersion: PROMPT_VERSION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return streamProviderError({ code: "coach.provider.empty" });
|
||||||
|
}
|
||||||
|
const replyText = proseAfterStrip;
|
||||||
const enrichedProvenance =
|
const enrichedProvenance =
|
||||||
sentinel.keyValues.length > 0
|
sentinel.keyValues.length > 0
|
||||||
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
|
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
|
||||||
|
|||||||
@@ -398,4 +398,54 @@ describe("POST /api/insights/chat — integration", () => {
|
|||||||
};
|
};
|
||||||
expect(persisted.keyValues).toBeUndefined();
|
expect(persisted.keyValues).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never leaks raw ---KEYVALUES--- markers when the provider returns a sentinel-only reply", async () => {
|
||||||
|
// v1.4.22 W5 reconcile (Code-H1) — regression test. The model
|
||||||
|
// emits ONLY the sentinel block with no leading prose AND a
|
||||||
|
// missing close marker, so `parseKeyValuesSentinel` returns
|
||||||
|
// `{ prose: "", malformed: true }`. The route must surface the
|
||||||
|
// structured `coach.provider.empty` error frame instead of
|
||||||
|
// streaming the raw sentinel body to the client.
|
||||||
|
await seedUserWithSession();
|
||||||
|
runProviderMock.mockResolvedValue({
|
||||||
|
result: {
|
||||||
|
content: [
|
||||||
|
"---KEYVALUES---",
|
||||||
|
"avg7 systolic: 138 [mmHg] (last7days)",
|
||||||
|
// Note: no closing ---END--- and no prose before the marker.
|
||||||
|
].join("\n"),
|
||||||
|
tokensUsed: 64,
|
||||||
|
model: "mock",
|
||||||
|
providerType: "openai",
|
||||||
|
},
|
||||||
|
workingProvider: { providerType: "openai", instance: {} },
|
||||||
|
fallbackHops: [],
|
||||||
|
});
|
||||||
|
const { POST } = await import("@/app/api/insights/chat/route");
|
||||||
|
|
||||||
|
const res = await (POST as (req: Request) => Promise<Response>)(
|
||||||
|
new Request("http://localhost/api/insights/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: "How is my BP this week?" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = await readStream(res);
|
||||||
|
|
||||||
|
// Streamed body must NOT contain the raw sentinel markers and must
|
||||||
|
// surface the structured provider-empty error.
|
||||||
|
expect(text).not.toMatch(/---KEYVALUES---/);
|
||||||
|
expect(text).not.toMatch(/---END---/);
|
||||||
|
expect(text).toMatch(/"type":"error"/);
|
||||||
|
expect(text).toMatch(/coach\.provider\.empty/);
|
||||||
|
|
||||||
|
// No assistant message persisted — the empty-prose branch
|
||||||
|
// short-circuits before `appendMessage`.
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const messages = await prisma.coachMessage.findMany({
|
||||||
|
where: { role: "assistant" },
|
||||||
|
});
|
||||||
|
expect(messages.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user