From 47e7e8e5499097a915776ba795328ab26072186c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sun, 31 May 2026 20:57:01 +0700 Subject: [PATCH] feat(moderation): implement media analysis caching with deterministic keys and database integration --- src/moderation/llmModerationClient.ts | 28 +++++++++ src/moderation/textCacheStore.ts | 83 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 0fb469c..d9d0ddc 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -16,6 +16,12 @@ import { buildStickerTextOnlyWarning, buildStickerVisionPrompt, } from "./stickerPrompt.js"; +import { + getCachedMediaAnalysis, + makeImageCacheKey, + makeStickerCacheKey, + upsertCachedMediaAnalysis, +} from "./textCacheStore.js"; import type { AnalysisResult, AttachmentRecord, @@ -767,6 +773,19 @@ export async function runModerationAnalysis( messageId: string, image: MessageImagePart, ): Promise => { + // ── Build deterministic cache key ── + const cacheKey = image.stickerName + ? makeStickerCacheKey(image.stickerName) + : makeImageCacheKey(image.image_url.url); + + // ── Tier 1: DB cache lookup ── + const cached = await getCachedMediaAnalysis(cacheKey); + if (cached) { + log.debug({ cacheKey }, "Media analysis cache HIT"); + return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${cached}`; + } + + // ── Tier 2: Vision API call ── try { const completion = await openai.chat.completions.create({ model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL, @@ -794,6 +813,15 @@ export async function runModerationAnalysis( const content = completion.choices[0]?.message?.content?.trim(); if (!content) return null; + + // ── Cache the result (24h TTL, strips messageId wrapper) ── + await upsertCachedMediaAnalysis( + cacheKey, + content, + "vision_llm", + Date.now() + 24 * 60 * 60 * 1000, + ); + return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${content}`; } catch (error) { log.warn( diff --git a/src/moderation/textCacheStore.ts b/src/moderation/textCacheStore.ts index fe99802..e1c0046 100644 --- a/src/moderation/textCacheStore.ts +++ b/src/moderation/textCacheStore.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { executeAll, executeGet } from "../database/drizzle.js"; import { createChildLogger } from "../logger.js"; @@ -149,3 +150,85 @@ export async function getTextCacheStats(): Promise<{ return { total: 0, expired: 0, bySource: {} }; } } + +// --------------------------------------------------------------------------- +// Media / Vision analysis cache helpers (reuses text_analysis_cache table) +// --------------------------------------------------------------------------- + +/** + * Generate a deterministic cache key for a sticker. + * Same sticker name → same key across sessions and servers. + */ +export function makeStickerCacheKey(stickerName: string): string { + return `sticker:${stickerName}`; +} + +/** + * Generate a deterministic cache key for an image data URL. + * Hashes the first 128 chars of the data URL (enough to identify the image + * without storing the full base64 string as the key). + */ +export function makeImageCacheKey(dataUrl: string): string { + const prefix = dataUrl.slice(0, 128); + const hash = createHash("sha256").update(prefix).digest("hex").slice(0, 16); + return `image:${hash}`; +} + +/** + * Lookup a cached media analysis result. + * Returns the full cached text (the analysis summary string) or null. + */ +export async function getCachedMediaAnalysis( + cacheKey: string, +): Promise { + try { + const row = await executeGet( + `SELECT flags, hit_count + FROM text_analysis_cache + WHERE text = $1 AND expires_at > $2`, + [cacheKey, Date.now()], + ); + + if (!row) return null; + + // flags stores the analysis result for media entries + const result = JSON.parse(row.flags) as string; + return result || null; + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get cached media analysis", + ); + return null; + } +} + +/** + * Store a media analysis result in the cache. + */ +export async function upsertCachedMediaAnalysis( + cacheKey: string, + analysisResult: string, + source: "vision_llm", + expiresAt: number, +): Promise { + const now = Date.now(); + + try { + await executeAll( + `INSERT INTO text_analysis_cache (text, flags, source, analyzed_at, expires_at, hit_count) + VALUES ($1, $2, $3, $4, $5, 0) + ON CONFLICT (text) DO UPDATE SET + flags = EXCLUDED.flags, + source = EXCLUDED.source, + analyzed_at = EXCLUDED.analyzed_at, + expires_at = EXCLUDED.expires_at`, + [cacheKey, JSON.stringify(analysisResult), source, now, expiresAt], + ); + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to upsert cached media analysis", + ); + } +}