From e6aa9af283a281deaf1d65fa179b37f11901c191 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 2 Sep 2026 13:15:05 +0700 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20make=20moderation=20score=20opt?= =?UTF-8?q?ional=20=E2=80=94=20LLM=20omits=20it=20in=20media=20batches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ai-moderation/moderationSchemas.ts | 5 +- .../tests/moderationScoreOptional.test.ts | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 services/discord-gateway/tests/moderationScoreOptional.test.ts diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationSchemas.ts b/services/discord-gateway/src/modules/ai-moderation/moderationSchemas.ts index 5a7cbbc0..b455981c 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationSchemas.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationSchemas.ts @@ -23,7 +23,10 @@ export const ResultItemSchema = z.object({ message_id: z.union([z.string(), z.number()]).transform(String), status: z.enum(["clean", "warn", "flagged"]), 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(), categories: z.array(z.string()).optional(), severity: SeveritySchema.optional(), diff --git a/services/discord-gateway/tests/moderationScoreOptional.test.ts b/services/discord-gateway/tests/moderationScoreOptional.test.ts new file mode 100644 index 00000000..97f21bc8 --- /dev/null +++ b/services/discord-gateway/tests/moderationScoreOptional.test.ts @@ -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; + 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); + }); +});