fix(gateway): make moderation score optional — LLM omits it in media batches
result.score was required by zod; the LLM (gemini-3.5-flash-lite via 9router)
occasionally omits it for media batches, hard-failing the whole batch parse
('Zod validation failed: expected number, received undefined' at
results[0].score). Callers already null-coalesce (result.score ?? 0) and the
parser clampScore()s it, so requiring it only caused parse failures.
Adds regression tests: media-batch without score parses (score->0), and
score-present responses still parse with the value.
This commit is contained in:
1 parent
4c38d53972
commit
e6aa9af283
2 files changed
+61
-1
No files matched your search
@@ -23,7 +23,10 @@ export const ResultItemSchema = z.object({
|
|||||||
message_id: z.union([z.string(), z.number()]).transform(String),
|
message_id: z.union([z.string(), z.number()]).transform(String),
|
||||||
status: z.enum(["clean", "warn", "flagged"]),
|
status: z.enum(["clean", "warn", "flagged"]),
|
||||||
flags: z.array(z.string()).optional(),
|
flags: z.array(z.string()).optional(),
|
||||||
score: z.number(),
|
// score is optional — the LLM occasionally omits it (especially media
|
||||||
|
// batches) and callers already null-coalesce (`result.score ?? 0`).
|
||||||
|
// Requiring it hard-fails the whole batch parse for one missing field.
|
||||||
|
score: z.number().optional(),
|
||||||
analysis: z.string().nullable().optional(),
|
analysis: z.string().nullable().optional(),
|
||||||
categories: z.array(z.string()).optional(),
|
categories: z.array(z.string()).optional(),
|
||||||
severity: SeveritySchema.optional(),
|
severity: SeveritySchema.optional(),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseModerationResponse } from "../src/modules/ai-moderation/moderationResponseParser.js";
|
||||||
|
|
||||||
|
const NO_SCORE_BODY = JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
message_id: "1544590005719146557",
|
||||||
|
status: "clean",
|
||||||
|
flags: [],
|
||||||
|
severity: "none",
|
||||||
|
confidence: 1.0,
|
||||||
|
recommended_action: "none",
|
||||||
|
policy_version: "default-2026-05-30",
|
||||||
|
evidence: [],
|
||||||
|
analysis: "Normal conversation, nothing concerning.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseModerationResponse — score optional", () => {
|
||||||
|
it("accepts a media-batch response WITHOUT score (LLM omits it)", () => {
|
||||||
|
// Before the fix this threw "Zod validation failed ... expected number,
|
||||||
|
// received undefined" at results[0].score — the exact production error.
|
||||||
|
let rows: ReturnType<typeof parseModerationResponse>;
|
||||||
|
expect(() => {
|
||||||
|
rows = parseModerationResponse(NO_SCORE_BODY, ["1544590005719146557"]);
|
||||||
|
}).not.toThrow();
|
||||||
|
const row = rows![0];
|
||||||
|
expect(row.messageId).toBe("1544590005719146557");
|
||||||
|
expect(row.status).toBe("clean");
|
||||||
|
// clampScore(undefined, 0) coalesces to 0.
|
||||||
|
expect(row.score).toBe(0);
|
||||||
|
expect(row.flags).toEqual([]);
|
||||||
|
expect(row.severity).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still accepts a response WITH score", () => {
|
||||||
|
const rows = parseModerationResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
message_id: "1",
|
||||||
|
status: "flagged",
|
||||||
|
flags: ["violence"],
|
||||||
|
score: 0.93,
|
||||||
|
severity: "high",
|
||||||
|
recommended_action: "delete",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
["1"],
|
||||||
|
);
|
||||||
|
expect(rows[0].messageId).toBe("1");
|
||||||
|
expect(rows[0].status).toBe("flagged");
|
||||||
|
expect(rows[0].score).toBeCloseTo(0.93, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user