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
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user