mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +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
|
||||
// "Worauf bezieht sich das?" disclosure.
|
||||
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 =
|
||||
sentinel.keyValues.length > 0
|
||||
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
|
||||
|
||||
@@ -398,4 +398,54 @@ describe("POST /api/insights/chat — integration", () => {
|
||||
};
|
||||
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