diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts b/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts index 921b4910..dd20a5d0 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts @@ -33,6 +33,7 @@ import { parseQdrantVerdict, type StoredModerationVerdict, setCachedTextModeration, + upsertBareKeyToQdrant, } from "./textCacheStore.js"; const log = createChildLogger("moderationOrchestrator"); @@ -318,6 +319,7 @@ export async function runModerationAnalysis( }; cacheHits.push(hit); hitByKey.set(cacheKey, hit); + servedCacheKeys.add(cacheKey); // bump hit_count for metrics logCacheEvent("hit", cacheKey, "text"); } } else { @@ -356,6 +358,7 @@ export async function runModerationAnalysis( }; cacheHits.push(hit); hitByKey.set(cacheKey, hit); + servedCacheKeys.add(cacheKey); // bump hit_count for metrics logCacheEvent("hit", cacheKey, "text"); } } @@ -383,6 +386,7 @@ export async function runModerationAnalysis( cacheHits: cacheHits.length, uncached: uncachedTargets.length, total: targets.length, + servedKeys: servedCacheKeys.size, }, "User moderation cache applied", ); @@ -463,9 +467,14 @@ export async function runModerationAnalysis( // WITH conversation context (accurate), but its verdict is also stored // under the context-free bare key so repeats in OTHER channels hit the // exact cache instead of paying a new LLM call. Same guard as the read - // path — only non-actionable clean verdicts may cross channels. No - // embedding on the bare row: the semantic tier is already global, and - // writing one would create a duplicate Qdrant point for this content. + // path — only non-actionable clean verdicts may cross channels. + // + // 2026-08-25 cache-hit fix: the bare key is ALSO upserted to Qdrant + // (via upsertBareKeyToQdrant) with the SAME embedding already computed + // at lookup time. Previously the bare key was only PG-written with + // embedding=null — bare clean verdicts were DB-only and invisible to + // searchQdrantBatch, capping the semantic hit-rate below the exact-cache + // hit-rate for cross-channel repeats. const bareKey = makeTextModerationCacheKey(rawContent); if ( bareKey !== cacheKey && @@ -486,6 +495,10 @@ export async function runModerationAnalysis( ) { globalBareKeysWritten.set(bareKey, true); setCachedTextModeration(bareKey, stored, null).catch(() => {}); + const bareEmbedding = embeddingsByKey.get(cacheKey); + if (bareEmbedding && bareEmbedding.length > 0) { + upsertBareKeyToQdrant(bareKey, stored, bareEmbedding).catch(() => {}); + } } } diff --git a/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts b/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts index e505684a..f9b6632e 100644 --- a/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts +++ b/services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts @@ -664,6 +664,55 @@ export async function findSimilarTextModeration( } } +/** + * Upsert a bare (context-free) clean verdict to the Qdrant vector store, + * making global-reuse clean verdicts discoverable by semantic search. + * + * Why: the main `setCachedTextModeration` writes bare-key rows to Postgres + * with embedding=null (deliberate — no duplicate PG embedding column), but + * a bare clean verdict that never reaches Qdrant is invisible to + * searchQdrantBatch. So two messages with identical clean content in + * DIFFERENT channels never match semantically — the semantic hit-rate is + * capped below the exact-cache hit-rate. This helper shares the embedding + * already computed at lookup time so the bare point is semantically + * findable. + * + * Guard: only non-actionable clean verdicts qualify (same guard as the + * read path and as the orchestrator's bare-key write-back). No-op when + * Qdrant is disabled or no embedding is available. + */ +export async function upsertBareKeyToQdrant( + bareKey: string, + result: { + status: string; + flags: string[]; + score: number; + analysis: string; + categories: string[]; + severity: string; + confidence: number; + recommendedAction: string; + }, + embedding: number[] | null | undefined, +): Promise { + if (!isQdrantConfigured() || !embedding || embedding.length === 0) return; + if (!isGloballyReusableCleanVerdict(result, undefined)) return; + const now = Date.now(); + const USER_MOD_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + await upsertQdrantPoint(bareKey, embedding, { + text: bareKey, + flags: JSON.stringify(result), + analyzed_at: now, + expires_at: now + USER_MOD_CACHE_TTL_MS, + content_hash: bareKey.split(":").pop() ?? "", + }).catch((err: unknown) => { + logger.error( + { error: err instanceof Error ? err.message : String(err), bareKey }, + "Failed to upsert bare-key clean verdict to Qdrant", + ); + }); +} + /** * Store a moderation result for a (user, content) pair. * The `flags` field stores the full result object as JSON. diff --git a/services/discord-gateway/src/shared/config/index.ts b/services/discord-gateway/src/shared/config/index.ts index 9e5ab7f6..df09ee26 100644 --- a/services/discord-gateway/src/shared/config/index.ts +++ b/services/discord-gateway/src/shared/config/index.ts @@ -182,7 +182,7 @@ export const configSchema = z .number() .int() .positive() - .default(30), + .default(50), // Qdrant vector store for the semantic moderation cache. When // QDRANT_URL is set, embeddings are stored/searched there (Postgres // embedding column remains as a legacy fallback). @@ -276,7 +276,7 @@ export const configSchema = z .number() .int() .positive() - .default(72), + .default(120), AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000), AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(14000), AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce diff --git a/services/discord-gateway/tests/cacheGuards.test.ts b/services/discord-gateway/tests/cacheGuards.test.ts index 7c87b043..90fedbe6 100644 --- a/services/discord-gateway/tests/cacheGuards.test.ts +++ b/services/discord-gateway/tests/cacheGuards.test.ts @@ -145,10 +145,10 @@ describe("isGloballyReusableCleanVerdict", () => { }); it("rejects entries older than the freshness window", () => { - // Default AI_CACHE_GLOBAL_REUSE_MAX_AGE_H = 72h. - const tooOld = Date.now() - 73 * 60 * 60 * 1000; + // Default AI_CACHE_GLOBAL_REUSE_MAX_AGE_H = 120h. + const tooOld = Date.now() - 121 * 60 * 60 * 1000; expect(isGloballyReusableCleanVerdict(makeVerdict(), tooOld)).toBe(false); - const freshEnough = Date.now() - 71 * 60 * 60 * 1000; + const freshEnough = Date.now() - 119 * 60 * 60 * 1000; expect(isGloballyReusableCleanVerdict(makeVerdict(), freshEnough)).toBe( true, );