perf(automod): compress prompts ~40% + semantic cache via AI_LLM_EMBEDDING_MODEL
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m4s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m29s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m33s

Prompt overhaul (token-frugal, same quality):
- rules.ts 28KB -> 10.3KB: every normative rule kept (safe lists, SARA
  6 kategori, LGBT/Israel zero tolerance, anti-evasion, decision tree,
  evasi hierarchy, image rules) with duplicated phrasing removed
- examples.ts 24.7KB -> 20KB: all 31 teaching examples kept; analysis
  strings shortened, redundant categories/policy_version dropped from
  example outputs (both optional in the response schema)
- output.ts 13.8KB -> 6.8KB: compressed schema + personality + format
  rules; CRITICAL bans on generic analysis and reply-context requirement
  retained
- system.ts: MEDIA_INSTRUCTIONS compressed, key rules kept

Semantic moderation cache (AI_LLM_EMBEDDING_MODEL):
- New embeddingClient.ts: OpenAI-compatible embeddings + cosine
  similarity; degrades gracefully when model/key unset
- textCacheStore: stores embedding JSON per verdict, findSimilarTextModeration
  reuses near-duplicate verdicts (min 0.97 cosine, processing locks skipped)
- moderationOrchestrator: after exact-hash miss, embed text-only targets
  and reuse stored verdict for near-duplicates -> skips expensive chat
  completion for spam variants; fresh verdicts written back with embedding
- Config: AI_LLM_EMBEDDING_MODEL / MIN_SIMILARITY (0.97) / MAX_CANDIDATES (30)
- Migration 0012: ADD COLUMN embedding to text_analysis_cache (idempotent)
- .env.example documents the new vars
This commit is contained in:
Developer
2026-07-31 19:37:53 +07:00
parent 60084b3cc3
commit 1249ae81d8
12 changed files with 474 additions and 406 deletions
@@ -0,0 +1,121 @@
/**
* embeddingClient.ts
*
* OpenAI-compatible embeddings helper used by the semantic moderation
* cache. When AI_LLM_EMBEDDING_MODEL is configured, near-duplicate
* messages can reuse a stored verdict (cosine similarity) instead of
* paying for a full chat-completion call — the main cost-saver.
*
* Every function degrades gracefully: if the embedding model is not
* configured or the API fails, callers fall back to the exact-hash cache
* and then the LLM, so moderation quality is never reduced.
*/
import OpenAI from "openai";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../../shared/config/config.js";
const log = createChildLogger("embedding-client");
// ---------------------------------------------------------------------------
// Client (lazy singleton — same base URL as the chat client)
// ---------------------------------------------------------------------------
let openaiClient: OpenAI | null = null;
function getClient(): OpenAI | null {
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
if (!openaiClient) {
openaiClient = new OpenAI({
apiKey: config.AI_LLM_API_KEY,
baseURL: config.AI_LLM_BASE_URL,
maxRetries: 0,
timeout: 60_000,
});
}
return openaiClient;
}
/** True when the semantic cache is usable (key + model configured). */
export function isEmbeddingEnabled(): boolean {
return Boolean(config.AI_LLM_API_KEY && config.AI_LLM_EMBEDDING_MODEL);
}
// ---------------------------------------------------------------------------
// Embedding calls
// ---------------------------------------------------------------------------
/**
* Embed a batch of texts with the configured model.
* Returns null on any failure so callers can skip semantic lookup.
*/
export async function embedTexts(texts: string[]): Promise<number[][] | null> {
if (!isEmbeddingEnabled()) return null;
if (texts.length === 0) return [];
const client = getClient();
if (!client) return null;
try {
const response = await client.embeddings.create({
model: config.AI_LLM_EMBEDDING_MODEL as string,
input: texts,
});
return response.data.map((item) => item.embedding);
} catch (error) {
log.warn(
{ error: error instanceof Error ? error.message : String(error) },
"Embedding request failed — semantic cache disabled for this call",
);
return null;
}
}
/** Embed a single text; returns null on failure. */
export async function embedText(text: string): Promise<number[] | null> {
const vectors = await embedTexts([text]);
return vectors?.[0] ?? null;
}
// ---------------------------------------------------------------------------
// Similarity
// ---------------------------------------------------------------------------
/** Cosine similarity between two equal-length vectors. */
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || a.length !== b.length) return 0;
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) return 0;
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Pick the best match above `minSimilarity`, or null.
* Returns { index, similarity } relative to the candidates array.
*/
export function findBestEmbeddingMatch(
vector: number[],
candidates: number[][],
minSimilarity: number,
): { index: number; similarity: number } | null {
let bestIndex = -1;
let bestSimilarity = minSimilarity;
for (let i = 0; i < candidates.length; i++) {
const sim = cosineSimilarity(vector, candidates[i]);
if (sim > bestSimilarity) {
bestSimilarity = sim;
bestIndex = i;
}
}
return bestIndex >= 0
? { index: bestIndex, similarity: bestSimilarity }
: null;
}