fix(ai-moderation): never cache vision outputs that claim 'no image seen'
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).
This commit is contained in:
@@ -56,7 +56,16 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
||||
*/
|
||||
type LLMResponseChunk = {
|
||||
choices?: Array<{
|
||||
delta?: { content?: string | null; reasoning_content?: string | null };
|
||||
delta?: {
|
||||
content?: string | null;
|
||||
reasoning_content?: string | null;
|
||||
reasoning?: string | null;
|
||||
reasoning_details?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
index?: number;
|
||||
}> | null;
|
||||
};
|
||||
message?: { content?: string | null };
|
||||
finish_reason?: string | null;
|
||||
text?: string;
|
||||
@@ -69,18 +78,28 @@ type LLMResponseChunk = {
|
||||
|
||||
/**
|
||||
* Extract the textual payload from a single streaming chunk. Prefers
|
||||
* `delta.content`; falls back to `delta.reasoning_content` (DeepSeek-style /
|
||||
* Cloudflare gemma stream ALL output there with content:"") so reasoning-only
|
||||
* models still produce usable aggregated text. Exported for unit tests.
|
||||
* `delta.content`; falls back to reasoning fields so reasoning-only models
|
||||
* still produce usable aggregated text. Providers differ in the field name:
|
||||
* - DeepSeek-style / Cloudflare gemma → `delta.reasoning_content`
|
||||
* - mimo (via 9router) streams reasoning in `delta.reasoning` +
|
||||
* `delta.reasoning_details[].text` (content:"") — without these fallbacks
|
||||
* vision aggregation came back empty ("Vision API null response").
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function extractChunkText(
|
||||
chunk: LLMResponseChunk | null | undefined,
|
||||
): string {
|
||||
if (!chunk) return "";
|
||||
const choice = chunk.choices?.[0];
|
||||
const reasoningDetails = choice?.delta?.reasoning_details
|
||||
?.map((d) => d.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
return (
|
||||
choice?.delta?.content ||
|
||||
choice?.delta?.reasoning_content ||
|
||||
choice?.delta?.reasoning ||
|
||||
reasoningDetails ||
|
||||
choice?.message?.content ||
|
||||
choice?.text ||
|
||||
chunk?.message?.content ||
|
||||
|
||||
Reference in New Issue
Block a user