fix(ai-moderation): never cache vision outputs that claim 'no image seen'

Root cause (3rd layer after 50371bd + 4f4c435): a vision model run
(2026-08-10) returned 'Maaf, saya tidak melihat gambar apapun yang terlampir...'
and that text was cached as a VALID vision_llm result (image + phash keys,
24h/7d TTL). Every subsequent analysis of the same image (same hash/phash)
hit the poisoned cache, so image analysis looked broken forever even though
9router responded fine — the moderation LLM wrote 'lampiran yang gagal
terbaca' from a cache hit.

Also: mimo via 9router streams reasoning in delta.reasoning +
delta.reasoning_details[].text (content:"") — extractChunkText only read
delta.reasoning_content, so those runs aggregated empty → 'Vision API null
response' (observed 08:54/09:07/09:38).

Fixes:
- llmClient.extractChunkText: fall back to delta.reasoning and
  reasoning_details[].text (mimo), on top of reasoning_content (gemma).
- visionAnalyzer: isNoImageSeenText() detects 'no image' style outputs;
  such results are NEVER cached, and poisoned entries are purged when hit
  (LRU/DB/phash) so re-analysis actually re-runs vision.
- Tests: reasoning/reasoning_details extraction + isNoImageSeenText
  (Indonesian + English, no false positives on real descriptions).
This commit is contained in:
asepharyana
2026-08-11 09:55:43 +07:00
parent 4f4c43555f
commit c18431bdbf
4 changed files with 178 additions and 13 deletions
@@ -33,6 +33,41 @@ describe("extractChunkText — streaming chunk text extraction", () => {
).toBe("Task");
});
it('falls back to delta.reasoning — mimo via 9router streams reasoning there with content:""', () => {
// Exact shape seen from 9router → mimo-v2.5-free (2026-08-11):
// {"choices":[{"delta":{"content":"","reasoning":"The user wants a","role":"assistant"},"finish_reason":null,...}]}
expect(
extractChunkText({
choices: [
{
delta: { content: "", reasoning: "The user wants a" },
finish_reason: null,
},
],
}),
).toBe("The user wants a");
});
it("joins delta.reasoning_details[].text when present", () => {
expect(
extractChunkText({
choices: [
{
delta: {
content: "",
reasoning: "",
reasoning_details: [
{ type: "reasoning.text", text: " detailed", index: 0 },
{ type: "reasoning.text", text: " description", index: 1 },
],
},
finish_reason: null,
},
],
}),
).toBe(" detailed description");
});
it("prefers content over reasoning when both present (deepseek-style final answer)", () => {
expect(
extractChunkText({
@@ -0,0 +1,52 @@
// ═══════════════════════════════════════════════════════════════════════════
// isNoImageSeenText — vision outputs that claim "no image" must not be cached
// ═══════════════════════════════════════════════════════════════════════════
// Regression (2026-08-11): the vision model sometimes answered "Maaf, saya
// tidak melihat gambar apapun yang terlampir..." and that text was cached as
// a VALID vision_llm result. Every later analysis of the same image (same
// hash / phash) then hit the poisoned cache and the moderation LLM wrote
// "lampiran yang gagal terbaca" — image analysis seemed permanently broken
// even though 9router was responding fine.
import { describe, expect, it } from "vitest";
import { isNoImageSeenText } from "../src/modules/ai-moderation/visionAnalyzer.js";
describe("isNoImageSeenText — poisoned vision output detection", () => {
it("detects the exact poisoned strings seen in production", () => {
expect(
isNoImageSeenText(
"Maaf, saya tidak melihat gambar apapun yang terlampir dalam pesan Anda. Mohon kirimkan ulang gambarnya agar saya bisa mendeskripsikannya secara objektif dan spesifik.",
),
).toBe(true);
expect(
isNoImageSeenText(
"Tidak ada gambar yang terlampir. Tidak bisa deskripsi tanpa input visual.",
),
).toBe(true);
});
it("detects English variants", () => {
expect(isNoImageSeenText("I cannot see any image in this message")).toBe(
true,
);
expect(isNoImageSeenText("No image provided")).toBe(true);
expect(isNoImageSeenText("there is no image attached")).toBe(true);
expect(isNoImageSeenText("I don't see an image")).toBe(true);
});
it("does NOT flag legitimate image descriptions", () => {
expect(
isNoImageSeenText(
"Gambar ini menampilkan dua panel komik, seorang gadis berambut biru tersipu saat dipuji.",
),
).toBe(false);
expect(
isNoImageSeenText("Ini adalah screenshot dari sebuah website rekrutmen."),
).toBe(false);
expect(isNoImageSeenText("Emoji menampilkan ekspresi wajah tertawa.")).toBe(
false,
);
expect(isNoImageSeenText(null)).toBe(false);
expect(isNoImageSeenText(undefined)).toBe(false);
expect(isNoImageSeenText("")).toBe(false);
});
});