Root cause (3rd layer after50371bd+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).
106 lines
3.9 KiB
TypeScript
106 lines
3.9 KiB
TypeScript
// ═══════════════════════════════════════════════════════════════════════════
|
|
// llmClient chunk extraction — reasoning_content fallback (pure, no network)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Regression: 9router "multimodal" combo routed to cloudflare gemma-4-26b
|
|
// which streams ALL output in delta.reasoning_content with content:"" — the
|
|
// old extractor returned empty text → llmVision reported "Vision API null
|
|
// response" → every image moderation batch fell back to text-only analysis
|
|
// (LLM kept writing "Meskipun analisis gambar gagal").
|
|
import { describe, expect, it } from "vitest";
|
|
import { extractChunkText } from "../src/modules/ai-moderation/llmClient.js";
|
|
|
|
describe("extractChunkText — streaming chunk text extraction", () => {
|
|
it("reads delta.content (standard OpenAI streaming)", () => {
|
|
expect(
|
|
extractChunkText({
|
|
choices: [{ delta: { content: "halo" }, finish_reason: null }],
|
|
}),
|
|
).toBe("halo");
|
|
});
|
|
|
|
it("falls back to delta.reasoning_content when content is empty — reasoning-only models (cloudflare gemma)", () => {
|
|
// Exact shape seen from 9router → cloudflare-ai/@cf/google/gemma-4-26b:
|
|
// {"choices":[{"delta":{"content":"","reasoning_content":"Task","role":"assistant"},"finish_reason":null,...}]}
|
|
expect(
|
|
extractChunkText({
|
|
choices: [
|
|
{
|
|
delta: { content: "", reasoning_content: "Task" },
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
}),
|
|
).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({
|
|
choices: [
|
|
{
|
|
delta: { content: "jawaban akhir", reasoning_content: "pikiran" },
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
}),
|
|
).toBe("jawaban akhir");
|
|
});
|
|
|
|
it("handles Anthropic-style message.content", () => {
|
|
expect(extractChunkText({ message: { content: "via message" } })).toBe(
|
|
"via message",
|
|
);
|
|
});
|
|
|
|
it("handles top-level content / response fields (local LLM proxies)", () => {
|
|
expect(extractChunkText({ content: "top-level" })).toBe("top-level");
|
|
expect(extractChunkText({ response: "via response" })).toBe("via response");
|
|
});
|
|
|
|
it("returns empty string for null/undefined/empty chunks", () => {
|
|
expect(extractChunkText(null)).toBe("");
|
|
expect(extractChunkText(undefined)).toBe("");
|
|
expect(extractChunkText({})).toBe("");
|
|
expect(
|
|
extractChunkText({
|
|
choices: [{ delta: { content: "", reasoning_content: null } }],
|
|
}),
|
|
).toBe("");
|
|
});
|
|
});
|