perf(ai-moderation): naikkan cache hit dgn guard akurasi

- Fase-1 exact-cache lookup: N query serial -> SATU query ANY($1::text[])
- Global reuse utk bare key legacy, HANYA verdict non-actionable
  (clean/flagless/action=none, conf>=0.85, umur<=72h) — flagged/warn
  tetap context-scoped
- Semantic cache dua-band: clean band 0.92 default, actionable tetap
  0.97; di antara band -> LLM (fail-open ke akurasi)
- hit_count kini di-increment (bulk UPDATE per batch) -> hit-rate terukur
- Cache hasil wikipediaSearch di Redis (6h, hanya hasil non-kosong)
- Memoize fetchUrlSafely utk type=text (LRU 30m + in-flight dedupe)
- makeImageCacheKey strip query CDN Discord (?ex/is/hm, format/width)
  -> attachment sama = satu key vision, skip re-download+re-vision

Spec: .hermes/plans/2026-08-24-ai-analysis-cache-optimization.md
Tests: +33 (cacheGuards, discordImageKeyNormalize, cacheBatchLookup)
This commit is contained in:
asepharyana committed 2026-08-24 18:51:23 +07:00
1 parent 440ec41da8
commit 1accfd9390
11 files changed
+1297 -112

No files matched your search

@@ -1,5 +1,6 @@
import { resolve } from "node:dns/promises";
import { isIP } from "node:net";
import { LRUCache } from "lru-cache";
import { createChildLogger } from "@/shared/logger/index";
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
@@ -150,6 +151,47 @@ function truncateAndCleanHtml(html: string, maxLen = 1000): string {
export async function fetchUrlSafely(
url: string,
depth = 0,
): Promise<FetchedUrlContext> {
// Text results are memoized (in-process, short TTL): the same link recurs
// across batches and re-downloading + re-parsing the page each time was
// pure latency. Images are NEVER cached here — they are vision evidence
// and multi-MB buffers don't belong in an LRU. Errors are not cached so a
// transient network blip retries on the next batch.
if (depth === 0) {
const memo = textFetchMemo.get(url);
if (memo) return memo;
// In-flight dedupe: concurrent callers share one live request.
const existing = textInFlight.get(url);
if (existing) return existing;
const promise = fetchUrlSafelyUncached(url, depth)
.then((fetched) => {
if (fetched.type === "text") textFetchMemo.set(url, fetched);
return fetched;
})
.finally(() => {
textInFlight.delete(url);
});
textInFlight.set(url, promise);
return promise;
}
return fetchUrlSafelyUncached(url, depth);
}
/** In-process memo of successful TEXT fetches (30 min TTL, bounded size). */
const textFetchMemo = new LRUCache<string, FetchedUrlContext>({
max: 500,
ttl: 30 * 60 * 1000,
});
/** Concurrent same-URL text fetches collapse into one live request. */
const textInFlight = new LRUCache<string, Promise<FetchedUrlContext>>({
max: 100,
ttl: 60_000,
});
async function fetchUrlSafelyUncached(
url: string,
depth = 0,
): Promise<FetchedUrlContext> {
if (depth > 1) {
return { url, type: "error", error: "Max redirect/meta depth reached" };