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:
@@ -0,0 +1,127 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Batched exact-cache lookup (getCachedTextModerations)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Design (2026-08-24): phase-1 cache lookups collapse N sequential per-key
|
||||
// queries into ONE `text = ANY($1::text[])` query. Semantics must match the
|
||||
// single-key getter: unexpired rows only, malformed rows skipped, verdicts
|
||||
// normalized through the shared parser. The DB layer is mocked — no live
|
||||
// Postgres in unit tests.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const executeAll = vi.fn();
|
||||
const executeGet = vi.fn();
|
||||
|
||||
vi.mock("../src/shared/database/drizzle.js", () => ({
|
||||
executeAll: (...args: unknown[]) => executeAll(...args),
|
||||
executeGet: (...args: unknown[]) => executeGet(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
getCachedTextModerations,
|
||||
parseStoredVerdictRow,
|
||||
} from "../src/modules/ai-moderation/textCacheStore.js";
|
||||
|
||||
function rowFor(
|
||||
text: string,
|
||||
status: string,
|
||||
analyzedAt = Date.now() - 1000,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
text,
|
||||
flags: JSON.stringify({
|
||||
status,
|
||||
flags: [],
|
||||
score: 0,
|
||||
analysis: "ok",
|
||||
categories: [],
|
||||
severity: "none",
|
||||
confidence: 0.9,
|
||||
recommendedAction: "none",
|
||||
}),
|
||||
source: "user_moderation",
|
||||
analyzed_at: analyzedAt,
|
||||
expires_at: Date.now() + 3_600_000,
|
||||
hit_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
executeAll.mockReset();
|
||||
executeGet.mockReset();
|
||||
});
|
||||
|
||||
describe("parseStoredVerdictRow", () => {
|
||||
it("parses a valid stored verdict", () => {
|
||||
const v = parseStoredVerdictRow({
|
||||
flags: JSON.stringify({ status: "warn", flags: ["x"] }),
|
||||
});
|
||||
expect(v).not.toBeNull();
|
||||
expect(v?.status).toBe("warn");
|
||||
expect(v?.flags).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("returns null on malformed JSON", () => {
|
||||
expect(parseStoredVerdictRow({ flags: "{not-json" })).toBeNull();
|
||||
});
|
||||
|
||||
it("derives legacy status from flags when status is absent", () => {
|
||||
const v = parseStoredVerdictRow({
|
||||
flags: JSON.stringify({ flags: ["a"] }),
|
||||
});
|
||||
expect(v?.status).toBe("flagged");
|
||||
const v2 = parseStoredVerdictRow({ flags: JSON.stringify({}) });
|
||||
expect(v2?.status).toBe("clean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedTextModerations", () => {
|
||||
it("returns an empty map for empty input and issues no query", async () => {
|
||||
const result = await getCachedTextModerations([]);
|
||||
expect(result.size).toBe(0);
|
||||
expect(executeAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dedupes keys and returns parsed verdicts keyed by cache key", async () => {
|
||||
executeAll.mockResolvedValueOnce([
|
||||
rowFor("text_mod:c1:aaa", "clean"),
|
||||
rowFor("text_mod:c1:bbb", "warn"),
|
||||
]);
|
||||
const result = await getCachedTextModerations([
|
||||
"text_mod:c1:aaa",
|
||||
"text_mod:c1:aaa",
|
||||
"text_mod:c1:bbb",
|
||||
]);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("text_mod:c1:aaa")?.verdict.status).toBe("clean");
|
||||
expect(result.get("text_mod:c1:bbb")?.verdict.status).toBe("warn");
|
||||
// Exactly ONE batched round-trip.
|
||||
expect(executeAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips rows with malformed payloads instead of failing the batch", async () => {
|
||||
executeAll.mockResolvedValueOnce([
|
||||
{
|
||||
text: "k_good",
|
||||
flags: JSON.stringify({ status: "clean", flags: [] }),
|
||||
analyzed_at: 1,
|
||||
},
|
||||
{ text: "k_bad", flags: "{broken" },
|
||||
]);
|
||||
const result = await getCachedTextModerations(["k_good", "k_bad"]);
|
||||
expect(result.has("k_good")).toBe(true);
|
||||
expect(result.has("k_bad")).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a DB failure and returns an empty map (fail-open)", async () => {
|
||||
executeAll.mockRejectedValueOnce(new Error("connection refused"));
|
||||
const result = await getCachedTextModerations(["k1", "k2"]);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("chunks queries beyond 200 keys", async () => {
|
||||
executeAll.mockResolvedValue([]);
|
||||
const keys = Array.from({ length: 450 }, (_, i) => `k${i}`);
|
||||
await getCachedTextModerations(keys);
|
||||
expect(executeAll).toHaveBeenCalledTimes(3); // 200 + 200 + 50
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Semantic two-band acceptance + global exact-cache reuse guard
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Design (2026-08-24): cache hits may be served MORE aggressively for
|
||||
// verdicts that cannot trigger enforcement actions, and NEVER more
|
||||
// aggressively for actionable ones. Two layers enforce this:
|
||||
// - isSemanticBandAccepted: similarity thresholds differ by verdict class
|
||||
// (clean band 0.92 default vs strict actionable band 0.97 default).
|
||||
// - isGloballyReusableCleanVerdict: context-free (cross-channel) reuse of
|
||||
// the legacy bare key only for clean / flagless / action=none verdicts
|
||||
// with high confidence and bounded age.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isGloballyReusableCleanVerdict,
|
||||
type StoredModerationVerdict,
|
||||
} from "../src/modules/ai-moderation/textCacheStore.js";
|
||||
|
||||
function makeVerdict(
|
||||
overrides: Partial<StoredModerationVerdict> = {},
|
||||
): StoredModerationVerdict {
|
||||
return {
|
||||
status: "clean",
|
||||
flags: [],
|
||||
score: 0,
|
||||
analysis: "",
|
||||
categories: [],
|
||||
severity: "none",
|
||||
confidence: 0.95,
|
||||
recommendedAction: "none",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isSemanticBandAccepted", () => {
|
||||
it("accepts a non-actionable clean verdict at the loose clean band", () => {
|
||||
// Default AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN = 0.92.
|
||||
expect(isBandAccept(makeVerdict(), 0.93)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a clean verdict exactly at the clean band boundary", () => {
|
||||
expect(isBandAccept(makeVerdict({ confidence: 0.99 }), 0.92)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a clean verdict below the clean band", () => {
|
||||
expect(isBandAccept(makeVerdict(), 0.91)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an actionable flagged verdict between the bands", () => {
|
||||
// 0.93 >= clean band BUT < strict band → must NOT be served.
|
||||
expect(
|
||||
isBandAccept(
|
||||
makeVerdict({ status: "flagged", flags: ["hate_speech"] }),
|
||||
0.93,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a flagged verdict at the strict band", () => {
|
||||
expect(
|
||||
isBandAccept(
|
||||
makeVerdict({ status: "flagged", flags: ["hate_speech"] }),
|
||||
0.98,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a warn verdict below the strict band", () => {
|
||||
expect(
|
||||
isBandAccept(
|
||||
makeVerdict({ status: "warn", recommendedAction: "warn" }),
|
||||
0.96,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a clean verdict WITH flags as actionable (strict band)", () => {
|
||||
expect(isBandAccept(makeVerdict({ flags: ["borderline"] }), 0.93)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a clean verdict with a non-none action as actionable", () => {
|
||||
expect(
|
||||
isBandAccept(makeVerdict({ recommendedAction: "review" }), 0.93),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Import indirection so the describe block reads cleanly.
|
||||
import { isSemanticBandAccepted as isBandAccept } from "../src/modules/ai-moderation/textCacheStore.js";
|
||||
|
||||
describe("isGloballyReusableCleanVerdict", () => {
|
||||
it("accepts a fresh, confident, flagless clean verdict", () => {
|
||||
const v = makeVerdict({ confidence: 0.9 });
|
||||
expect(isGloballyReusableCleanVerdict(v, Date.now() - 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects flagged / warn verdicts outright", () => {
|
||||
expect(
|
||||
isGloballyReusableCleanVerdict(
|
||||
makeVerdict({ status: "flagged", flags: ["harassment"] }),
|
||||
Date.now(),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isGloballyReusableCleanVerdict(
|
||||
makeVerdict({ status: "warn", flags: ["mild"] }),
|
||||
Date.now(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects clean verdicts carrying flags", () => {
|
||||
expect(
|
||||
isGloballyReusableCleanVerdict(makeVerdict({ flags: ["x"] }), Date.now()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects verdicts whose recommended action is not none", () => {
|
||||
expect(
|
||||
isGloballyReusableCleanVerdict(
|
||||
makeVerdict({ recommendedAction: "delete" }),
|
||||
Date.now(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects low-confidence verdicts below the guard threshold", () => {
|
||||
// Default AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE = 0.85.
|
||||
expect(
|
||||
isGloballyReusableCleanVerdict(
|
||||
makeVerdict({ confidence: 0.6 }),
|
||||
Date.now(),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts confidence exactly at the guard threshold", () => {
|
||||
expect(
|
||||
isGloballyReusableCleanVerdict(
|
||||
makeVerdict({ confidence: 0.85 }),
|
||||
Date.now(),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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;
|
||||
expect(isGloballyReusableCleanVerdict(makeVerdict(), tooOld)).toBe(false);
|
||||
const freshEnough = Date.now() - 71 * 60 * 60 * 1000;
|
||||
expect(isGloballyReusableCleanVerdict(makeVerdict(), freshEnough)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the age check when analyzedAt is unknown", () => {
|
||||
expect(isGloballyReusableCleanVerdict(makeVerdict(), undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// normalizeDiscordImageUrl — unified vision cache keys for Discord CDN URLs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Design (2026-08-24): the same attachment reached through different signed
|
||||
// URLs (?ex=&is=&hm= tokens rotate per fetch) or render variants
|
||||
// (?format=&width=) must map to ONE vision-cache key, otherwise the vision
|
||||
// model re-downloads and re-analyzes the identical image once per variant.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
makeImageCacheKey,
|
||||
normalizeDiscordImageUrl,
|
||||
} from "../src/modules/ai-moderation/textCacheStore.js";
|
||||
|
||||
describe("normalizeDiscordImageUrl", () => {
|
||||
it("strips rotating signed tokens from cdn.discordapp.com URLs", () => {
|
||||
const a = normalizeDiscordImageUrl(
|
||||
"https://cdn.discordapp.com/attachments/1/2/img.png?ex=67a&is=67b&hm=tokA",
|
||||
);
|
||||
const b = normalizeDiscordImageUrl(
|
||||
"https://cdn.discordapp.com/attachments/1/2/img.png?ex=78c&is=78d&hm=tokB",
|
||||
);
|
||||
expect(a).toBe("https://cdn.discordapp.com/attachments/1/2/img.png");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("strips render variants from media.discordapp.net URLs", () => {
|
||||
const a = normalizeDiscordImageUrl(
|
||||
"https://media.discordapp.net/attachments/1/2/img.png?format=webp&width=400&height=300",
|
||||
);
|
||||
const b = normalizeDiscordImageUrl(
|
||||
"https://media.discordapp.net/attachments/1/2/img.png?format=png&width=1024&height=768",
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe("https://media.discordapp.net/attachments/1/2/img.png");
|
||||
});
|
||||
|
||||
it("strips query params from images-ext preview hosts", () => {
|
||||
const a = normalizeDiscordImageUrl(
|
||||
"https://images-ext-1.discordapp.net/external/X/https/example.com/cat.jpg?format=webp",
|
||||
);
|
||||
expect(a).toBe(
|
||||
"https://images-ext-1.discordapp.net/external/X/https/example.com/cat.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves URLs without query untouched", () => {
|
||||
const u = "https://cdn.discordapp.com/attachments/1/2/img.png";
|
||||
expect(normalizeDiscordImageUrl(u)).toBe(u);
|
||||
});
|
||||
|
||||
it("leaves non-Discord URLs untouched (query may be meaningful)", () => {
|
||||
const u = "https://example.com/image?token=abc&id=1";
|
||||
expect(normalizeDiscordImageUrl(u)).toBe(u);
|
||||
});
|
||||
|
||||
it("leaves data: URLs untouched", () => {
|
||||
const u = "data:image/png;base64,iVBORw0KGgoAAAANS?weird=query";
|
||||
// new URL() parses data: with protocol "data:" → not http(s) → untouched.
|
||||
expect(normalizeDiscordImageUrl(u)).toBe(u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeImageCacheKey — Discord URL unification", () => {
|
||||
it("produces the SAME key for the same attachment across token variants", () => {
|
||||
const keyA = makeImageCacheKey(
|
||||
"https://cdn.discordapp.com/attachments/9/8/pic.png?ex=111&is=222&hm=AAA",
|
||||
);
|
||||
const keyB = makeImageCacheKey(
|
||||
"https://cdn.discordapp.com/attachments/9/8/pic.png?ex=333&is=444&hm=BBB",
|
||||
);
|
||||
expect(keyA).toBe(keyB);
|
||||
});
|
||||
|
||||
it("still produces DIFFERENT keys for different attachments", () => {
|
||||
const keyA = makeImageCacheKey(
|
||||
"https://cdn.discordapp.com/attachments/9/8/a.png?ex=1",
|
||||
);
|
||||
const keyB = makeImageCacheKey(
|
||||
"https://cdn.discordapp.com/attachments/9/8/b.png?ex=1",
|
||||
);
|
||||
expect(keyA).not.toBe(keyB);
|
||||
});
|
||||
|
||||
it("preserves the historical full-hash behavior for non-Discord URLs", () => {
|
||||
// Regression guard: external URLs keep pre-change keys.
|
||||
const key = makeImageCacheKey("https://example.com/x.png");
|
||||
expect(key).toBe(makeImageCacheKey("https://example.com/x.png"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user