feat(moderation): refactor word analysis caching to text analysis caching with improved context handling

This commit is contained in:
MythEclipse
2026-05-31 20:33:27 +07:00
parent 7e58741b5c
commit d63e34d259
5 changed files with 300 additions and 340 deletions
+151
View File
@@ -0,0 +1,151 @@
import { executeAll, executeGet } from "../database/drizzle.js";
import { createChildLogger } from "../logger.js";
const logger = createChildLogger("text-cache-store");
export interface TextCacheEntry {
text: string;
flags: string[];
source: "local" | "nvidia" | "primary_ai" | "groq";
analyzed_at: number;
expires_at: number;
hit_count: number;
}
/**
* Lookup cached analysis result for a normalized text string.
* Returns null if not found or expired.
*/
export async function getCachedText(
text: string,
): Promise<TextCacheEntry | null> {
try {
const row = await executeGet(
`SELECT text, flags, source, analyzed_at, expires_at, hit_count
FROM text_analysis_cache
WHERE text = $1 AND expires_at > $2`,
[text, Date.now()],
);
if (!row) return null;
return {
text: row.text,
flags: JSON.parse(row.flags),
source: row.source,
analyzed_at: row.analyzed_at,
expires_at: row.expires_at,
hit_count: row.hit_count,
};
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get cached text",
);
return null;
}
}
/**
* Insert or update a text analysis cache entry.
*/
export async function upsertCachedText(
text: string,
flags: string[],
source: "local" | "nvidia" | "primary_ai" | "groq",
expiresAt: number,
): Promise<void> {
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`,
[text, JSON.stringify(flags), source, now, expiresAt],
);
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to upsert cached text",
);
}
}
/**
* Increment hit count for a cached text entry (called on cache hit).
*/
export async function incrementTextCacheHit(text: string): Promise<void> {
try {
await executeAll(
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`,
[text],
);
} catch (error) {
// Silent fail — this is just a counter, not critical
}
}
/**
* Delete expired cache entries. Run periodically to keep the table clean.
*/
export async function pruneExpiredTexts(): Promise<number> {
try {
const result = await executeAll(
`DELETE FROM text_analysis_cache WHERE expires_at < $1`,
[Date.now()],
);
return (result as any).rowCount ?? 0;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to prune expired texts",
);
return 0;
}
}
/**
* Get cache statistics for observability.
*/
export async function getTextCacheStats(): Promise<{
total: number;
expired: number;
bySource: Record<string, number>;
}> {
try {
const now = Date.now();
const [totalRow, expiredRow, sourceRows] = await Promise.all([
executeAll(`SELECT count(*) as cnt FROM text_analysis_cache`),
executeAll(
`SELECT count(*) as cnt FROM text_analysis_cache WHERE expires_at < $1`,
[now],
),
executeAll(
`SELECT source, count(*) as cnt FROM text_analysis_cache GROUP BY source`,
),
]);
const bySource: Record<string, number> = {};
for (const row of sourceRows) {
bySource[row.source] = row.cnt;
}
return {
total: totalRow[0]?.cnt ?? 0,
expired: expiredRow[0]?.cnt ?? 0,
bySource,
};
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get text cache stats",
);
return { total: 0, expired: 0, bySource: {} };
}
}