fix(ai): pertahankan status warn di cache moderasi + bersihkan prompt stale
- normalizeStoredStatus(): exact-hash & semantic (Qdrant/PG) cache reader sebelumnya menipiskan 'warn' jadi 'flagged'/'clean' (type narrowing legacy clean|flagged) — merusak gating auto-delete & label dashboard. Kini status tersimpan dipertahankan penuh (clean/warn/flagged). - prompts: hapus referensi <user_history> yang tak pernah di-inject, SearXNG -> Wikipedia (sudah migrasi), referensi section yang tak ada, typo 'secifik', dan baris list rusak '|-'. - moderationBuilders: buang dead code buildUserProfilesBlock/ buildUserProfileRef/UserProfileEntry/buildUserHistoryXml (tanpa caller produksi sejak context minimization) + test-nya. - test baru: tests/storedStatusNormalization.test.ts (regresi warn).
This commit is contained in:
@@ -75,8 +75,6 @@ export function buildConversationContextBlock(input: {
|
||||
// huge paste (stack traces, log dumps, copypasta). Truncation is explicit so
|
||||
// the model never mistakes the cut for a real message boundary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Max characters of a message's content sent to the LLM `<content>` payload. */
|
||||
export const AI_CONTENT_MAX_CHARS = 4000;
|
||||
|
||||
/** Marker appended when a message is longer than AI_CONTENT_MAX_CHARS. */
|
||||
@@ -89,79 +87,12 @@ export function truncateForAi(content: string): string {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User profile deduplication — a batch can contain many messages from the
|
||||
// same user. Instead of repeating the (up to 3000-char) profile summary on
|
||||
// every message, emit a single <user_profiles> map per batch and reference
|
||||
// entries per message with <user_profile_ref user_id="..."/>.
|
||||
// User profile deduplication — REMOVED (2026-08-22).
|
||||
// Per-user profile/history context was stripped from the moderation prompt
|
||||
// (context minimization): buildUserProfilesBlock / buildUserProfileRef /
|
||||
// UserProfileEntry / buildUserHistoryXml had no remaining production callers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UserProfileEntry {
|
||||
/** Profile summary text (from user_profiles.profile_summary). */
|
||||
text: string;
|
||||
/** Epoch ms when the profile was last generated — staleness signal for
|
||||
* the LLM (a profile from months ago may not reflect current behavior). */
|
||||
asOf?: number | null;
|
||||
}
|
||||
|
||||
/** Build a deduplicated `<user_profiles>` map block, keyed by Discord user id. */
|
||||
export function buildUserProfilesBlock(
|
||||
profiles: ReadonlyMap<string, UserProfileEntry>,
|
||||
): string {
|
||||
const entries = Array.from(profiles.entries()).filter(
|
||||
([, entry]) => entry.text.trim().length > 0,
|
||||
);
|
||||
if (entries.length === 0) return "";
|
||||
const lines = entries.map(([userId, entry]) => {
|
||||
const asOfAttr =
|
||||
typeof entry.asOf === "number" && entry.asOf > 0
|
||||
? ` as_of="${new Date(entry.asOf).toISOString()}"`
|
||||
: "";
|
||||
return ` <user_profile user_id="${escapeXml(userId)}"${asOfAttr}>${sanitizeAiContent(entry.text)}</user_profile>`;
|
||||
});
|
||||
return `<user_profiles>\n${lines.join("\n")}\n</user_profiles>`;
|
||||
}
|
||||
|
||||
/** Per-message reference tag pointing at an entry in the `<user_profiles>` map. */
|
||||
export function buildUserProfileRef(userId: string): string {
|
||||
return `<user_profile_ref user_id="${escapeXml(userId)}"/>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-user history context (last flagged messages only — no trust model).
|
||||
// context to AI moderation.
|
||||
// ---------------------------------------------------------------------------
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Builds an optional `<user_history>` block (last flagged messages) from
|
||||
* getUserRecentInfractions rows. Only emitted when there is real history —
|
||||
* lets the LLM see the PATTERN (e.g. the same scam link posted repeatedly)
|
||||
* without treating old flags as proof for the current message.
|
||||
*/
|
||||
export function buildUserHistoryXml(
|
||||
history: Array<{
|
||||
content: string;
|
||||
severity: string | null;
|
||||
created_at: number;
|
||||
}>,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const filtered = history.filter((h) => h.content?.trim());
|
||||
if (filtered.length === 0) return "";
|
||||
const lines = filtered.map((h) => {
|
||||
const daysAgo = Math.max(0, Math.floor((now - h.created_at) / DAY_MS));
|
||||
const severityAttr = h.severity
|
||||
? ` severity="${escapeXml(h.severity)}"`
|
||||
: "";
|
||||
const snippet =
|
||||
h.content.length > 100
|
||||
? `${h.content.slice(0, 100).trimEnd()}…`
|
||||
: h.content;
|
||||
return ` <infraction${severityAttr} time_ago_days="${daysAgo}">${escapeXml(snippet)}</infraction>`;
|
||||
});
|
||||
return `<user_history>\n${lines.join("\n")}\n</user_history>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the message author was a bot (captured in metadata.author.bot).
|
||||
* Bot posts (logging bots, webhook-style automation) deserve different
|
||||
|
||||
@@ -45,18 +45,18 @@ Wajib sebutkan ISI/KONTEN spesifik apa yang dibicarakan pengirim — bukan templ
|
||||
- **Teks + gambar**: "Pengirim mengirim screenshot chat sambil membahas makanan favorit. Gambar dan teks sama-sama tentang percakapan sehari-hari. Tidak ada pelanggaran." ✓ / "Pesan berisi teks dan gambar tanpa pelanggaran." ✗
|
||||
|
||||
### Per kasus:
|
||||
|- **Melanggar:** "Pengirim <pelanggaran X>. <bukti teks/gambar>. <dampak/konteks>."
|
||||
|- **conflict_instigation:** "Pengirim <ajakan memicu konflik>. <konteks>. Diberi peringatan karena berpotensi memicu drama."
|
||||
|- **Username ofensif (pesan bersih):** "Pengirim memiliki username yang <alasan ofensif>. Isi pesan hanya <isi>. Diberi warning ringan." — (pesan memperkuat): "<username SARA> + isi pesan memperkuat tone kebencian. Pelanggaran berat."
|
||||
|- **Evasi (zalgo/leetspeak):** "Pengirim menggunakan teknik obfuscation untuk menyembunyikan <makna asli>. <dampak>. <kesimpulan>."
|
||||
|- **Spam (repetitions > 1):** "Pengirim mengirim teks yang sama sebanyak N kali dalam waktu singkat. <isi pesan>. Diberi peringatan karena spam berulang." — nilai tetap dari isi; pengulangan saja (mis. "ok" x5 dalam obrolan aktif) bukan pelanggaran.
|
||||
|- **sexual_deviation:** "Pengirim <konten penyimpangan>. <konteks>. Melanggar kebijikan server."
|
||||
|- **SARA/penistaan agama:** "Pengirim <jenis penistaan spesifik: parodi ayat, mengaku Tuhan, mockery ritual, istilah agama sebagai joke, provokasi antar-agama>. <bukti>. Melanggar kebijikan SARA." — JANGAN gunakan kata "bercanda" untuk SARA.
|
||||
- **Melanggar:** "Pengirim <pelanggaran X>. <bukti teks/gambar>. <dampak/konteks>."
|
||||
- **conflict_instigation:** "Pengirim <ajakan memicu konflik>. <konteks>. Diberi peringatan karena berpotensi memicu drama."
|
||||
- **Username ofensif (pesan bersih):** "Pengirim memiliki username yang <alasan ofensif>. Isi pesan hanya <isi>. Diberi warning ringan." — (pesan memperkuat): "<username SARA> + isi pesan memperkuat tone kebencian. Pelanggaran berat."
|
||||
- **Evasi (zalgo/leetspeak):** "Pengirim menggunakan teknik obfuscation untuk menyembunyikan <makna asli>. <dampak>. <kesimpulan>."
|
||||
- **Spam (repetitions > 1):** "Pengirim mengirim teks yang sama sebanyak N kali dalam waktu singkat. <isi pesan>. Diberi peringatan karena spam berulang." — nilai tetap dari isi; pengulangan saja (mis. "ok" x5 dalam obrolan aktif) bukan pelanggaran.
|
||||
- **sexual_deviation:** "Pengirim <konten penyimpangan>. <konteks>. Melanggar kebijikan server."
|
||||
- **SARA/penistaan agama:** "Pengirim <jenis penistaan spesifik: parodi ayat, mengaku Tuhan, mockery ritual, istilah agama sebagai joke, provokasi antar-agama>. <bukti>. Melanggar kebijikan SARA." — JANGAN gunakan kata "bercanda" untuk SARA.
|
||||
|
||||
**CRITICAL — dilarang menulis analysis generik:** JANGAN PERNAH menulis "Pesan hanya berisi...", "Tidak ada indikasi pelanggaran", atau template seperti "Pengirim mengirimkan sebuah file GIF tanpa pelanggaran." Selalu sebutkan ISI/KONTEN secifik, apa yang dibicarakan, apa yang terlihat.
|
||||
**CRITICAL — dilarang menulis analysis generik:** JANGAN PERNAH menulis "Pesan hanya berisi...", "Tidak ada indikasi pelanggaran", atau template seperti "Pengirim mengirimkan sebuah file GIF tanpa pelanggaran." Selalu sebutkan ISI/KONTEN spesifik, apa yang dibicarakan, apa yang terlihat.
|
||||
|
||||
|- **BALASAN (reply):** jelaskan konteks balasannya (apa dibicarakan, siapa dibalas tanpa nama, bagaimana tanggapan pengirim).
|
||||
|- Gunakan Media analysis untuk mendeskripsikan gambar. Analisis harus MEMBERI KONTEKS, bukan hanya status.`;
|
||||
- **BALASAN (reply):** jelaskan konteks balasannya (apa dibicarakan, siapa dibalas tanpa nama, bagaimana tanggapan pengirim).
|
||||
- Gunakan Media analysis untuk mendeskripsikan gambar. Analisis harus MEMBERI KONTEKS, bukan hanya status.`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanitize AI-generated content (channel culture / user profile) to prevent
|
||||
|
||||
@@ -48,7 +48,7 @@ Kata alat kelamin/anatomi seksual (kontol, memek, titten, tit, dick) atau istila
|
||||
- Ageisme ("dasar bocil", "tau aja lo tua") → hate_speech / harassment.
|
||||
- Diskriminasi fisik ("gendut", "iteman", "cungkring") → harassment jika terarah.
|
||||
- Serangan personal, penghinaan, merendahkan = tidak ditoleransi. Perbedaan pendapat wajar.
|
||||
- **PESAN DINILAI SECARA STANDALONE:** Setiap pesan baru dinilai BERDASARKAN ISINYA SENDIRI. <user_history> (jika ada) HANYA untuk mendeteksi POLA PENGULANGAN dengan JAMAK (spam link yang SAMA, provokasi berulang yang MENGANDALKAN KONTEN YANG SAMA). JANGAN gunakan history untuk "menginterpretasi ulang" pesan bersih yang TERPISAH DARI riwayat pelanggaran sebelumnya. Jika pesan tidak mengandung unsur yang BERPANDUAN PADA riwayat → tetap CLEAN.
|
||||
- **PESAN DINILAI SECARA STANDALONE:** Setiap pesan baru dinilai BERDASARKAN ISINYA SENDIRI. Tidak ada data profil/reputasi per-user di payload — nilai murni dari isi pesan + konteks percakapan. Pengulangan teks sama dalam satu batch ditandai atribut repetitions="N" pada <message> (sinyal spam), bukan dari riwayat lampau.
|
||||
|
||||
## LARANGAN BERAT (ZERO TOLERANCE)
|
||||
- **LGBT:** Segala promosi, diskusi, pengakuan orientasi, coming out, atau curhat personal tentang LGBT WAJIB di-flag "sexual_deviation". Tidak ada pengecualian. (Lihat juga pohon keputusan #3.)
|
||||
|
||||
@@ -125,14 +125,14 @@ function buildSystemPromptCore(
|
||||
`- <location_context .../>: metadata channel/thread (channel_name, thread_name, topic, nsfw, age_restricted). topic = tujuan resmi channel; gunakan menilai kesesuaian pesan.\n` +
|
||||
`- <conversation_context>: obrolan SEBELUM target. Baris pertama "[conversation_flow] status=... context_msgs=... dropped=..." = metadata sistem (ongoing/sparse/cold_start), BUKAN pesan dinilai. Baris "[context] id=... time=... user=...: isi" = konteks, BUKAN target.\n` +
|
||||
`- Tidak ada data profil/reputasi per-user di context — nilai tiap pesan murni dari isinya + <conversation_context> + <web_searches> + <location_context>.\n` +
|
||||
`- <web_searches>/<web_content>: bukti web (prioritas tertinggi). <term_glossary>: definisi kata/slang/jargon (SearXNG) — pakai pahami kata asing, JANGAN tebak arti.\n` +
|
||||
`- <web_searches>/<web_content>: bukti web (prioritas tertinggi). <term_glossary>: definisi kata/slang/jargon (Wikipedia) — pakai pahami kata asing, JANGAN tebak arti.\n` +
|
||||
`- <messages_to_analyze>: pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user, time (ISO), repetitions (N = teks sama muncul N× di batch → sinyal spam), bot (true = bot), edited (true = hasil edit setelah posting → evasi potensial).`,
|
||||
);
|
||||
|
||||
parts.push(
|
||||
`## Framing & Aturan Konteks\n` +
|
||||
`- Hasilkan SATU hasil per message_id — jangan gabung, lewati, atau karang id.\n` +
|
||||
`- Setiap target dinilai BERDASARKAN ISINYA SENDIRI. Konteks memengaruhi interpretasi, tapi TIDAK menggantikan isi pesan. Profil/riwayat = REFERENSI personalisasi, BUKAN bukti pelanggaran (lihat "PERSONALITY & MEMORI").\n` +
|
||||
`- Setiap target dinilai BERDASARKAN ISINYA SENDIRI. Konteks memengaruhi interpretasi, tapi TIDAK menggantikan isi pesan.\n` +
|
||||
`- Marker "[pesan dipotong: terlalu panjang]" = TARGET dipotong; "[konteks dipotong: ...]" = konteks dipotong. Nilai dari bagian terlihat; pemotongan BUKAN pelanggaran/evasi.\n` +
|
||||
`- time= = kapan dikirim (rekonsiliasi spam beruntun / bump pesan lama). bot=true = otomatisasi, bukan pelanggaran personal.`,
|
||||
);
|
||||
|
||||
@@ -260,11 +260,31 @@ export async function invalidateTextModerationCache(
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a cached moderation result for a text content.
|
||||
* Returns the stored result fields or null.
|
||||
* Normalize a stored verdict status to the full three-state union.
|
||||
*
|
||||
* Bug history (2026-08-22): both cache readers narrowed their types to
|
||||
* "clean" | "flagged", so a stored "warn" verdict fell into the legacy
|
||||
* `flags.length === 0 ? clean : flagged` branch and was served back as
|
||||
* FLAGGED (breaking auto-delete gating + dashboard labels). New entries
|
||||
* store the exact status; legacy rows without one derive from flags.
|
||||
*/
|
||||
export async function getCachedTextModeration(cacheKey: string): Promise<{
|
||||
status: "clean" | "flagged";
|
||||
export function normalizeStoredStatus(
|
||||
storedStatus: string | undefined,
|
||||
flags: string[],
|
||||
): "clean" | "warn" | "flagged" {
|
||||
if (
|
||||
storedStatus === "clean" ||
|
||||
storedStatus === "warn" ||
|
||||
storedStatus === "flagged"
|
||||
) {
|
||||
return storedStatus;
|
||||
}
|
||||
return flags.length === 0 ? "clean" : "flagged";
|
||||
}
|
||||
|
||||
/** Shape shared by every moderation-cache read path. */
|
||||
export interface StoredModerationVerdict {
|
||||
status: "clean" | "warn" | "flagged";
|
||||
flags: string[];
|
||||
score: number;
|
||||
analysis: string;
|
||||
@@ -272,7 +292,15 @@ export async function getCachedTextModeration(cacheKey: string): Promise<{
|
||||
severity: string;
|
||||
confidence: number;
|
||||
recommendedAction: string;
|
||||
} | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a cached moderation result for a text content.
|
||||
* Returns the stored result fields or null.
|
||||
*/
|
||||
export async function getCachedTextModeration(
|
||||
cacheKey: string,
|
||||
): Promise<StoredModerationVerdict | null> {
|
||||
try {
|
||||
const row = await executeGet(
|
||||
`SELECT flags, source, analyzed_at, expires_at, hit_count
|
||||
@@ -285,14 +313,12 @@ export async function getCachedTextModeration(cacheKey: string): Promise<{
|
||||
|
||||
const parsed = JSON.parse(row.flags) as Record<string, unknown>;
|
||||
const flags = (parsed.flags as string[]) ?? [];
|
||||
// Use stored status if available (new entries), otherwise derive from flags (legacy compatibility)
|
||||
const storedStatus = parsed.status as string | undefined;
|
||||
const status: "clean" | "flagged" =
|
||||
storedStatus === "clean" || storedStatus === "flagged"
|
||||
? storedStatus
|
||||
: flags.length === 0
|
||||
? "clean"
|
||||
: "flagged";
|
||||
// Use stored status if available (new entries), otherwise derive from
|
||||
// flags (legacy compatibility). "warn" must survive the round-trip.
|
||||
const status = normalizeStoredStatus(
|
||||
parsed.status as string | undefined,
|
||||
flags,
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
@@ -321,18 +347,12 @@ export async function getCachedTextModeration(cacheKey: string): Promise<{
|
||||
export function parseQdrantVerdict(
|
||||
payload: QdrantVerdictPayload,
|
||||
similarity: number,
|
||||
): {
|
||||
text: string;
|
||||
similarity: number;
|
||||
status: "clean" | "warn" | "flagged";
|
||||
flags: string[];
|
||||
score: number;
|
||||
analysis: string;
|
||||
categories: string[];
|
||||
severity: string;
|
||||
confidence: number;
|
||||
recommendedAction: string;
|
||||
} | null {
|
||||
):
|
||||
| (StoredModerationVerdict & {
|
||||
text: string;
|
||||
similarity: number;
|
||||
})
|
||||
| null {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(payload.flags) as Record<string, unknown>;
|
||||
@@ -341,17 +361,17 @@ export function parseQdrantVerdict(
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
|
||||
const storedStatus = (parsed.status as string) ?? "clean";
|
||||
const status: "clean" | "warn" | "flagged" =
|
||||
storedStatus === "warn" || storedStatus === "flagged"
|
||||
? storedStatus
|
||||
: "clean";
|
||||
const flags = (parsed.flags as string[]) ?? [];
|
||||
const status = normalizeStoredStatus(
|
||||
parsed.status as string | undefined,
|
||||
flags,
|
||||
);
|
||||
|
||||
return {
|
||||
text: payload.text,
|
||||
similarity,
|
||||
status,
|
||||
flags: (parsed.flags as string[]) ?? [],
|
||||
flags,
|
||||
score: (parsed.score as number) ?? 0,
|
||||
analysis: (parsed.analysis as string) ?? "",
|
||||
categories: (parsed.categories as string[]) ?? [],
|
||||
@@ -373,18 +393,9 @@ export async function findSimilarTextModeration(
|
||||
embedding: number[],
|
||||
minSimilarity: number,
|
||||
limit: number,
|
||||
): Promise<{
|
||||
text: string;
|
||||
similarity: number;
|
||||
status: "clean" | "warn" | "flagged";
|
||||
flags: string[];
|
||||
score: number;
|
||||
analysis: string;
|
||||
categories: string[];
|
||||
severity: string;
|
||||
confidence: number;
|
||||
recommendedAction: string;
|
||||
} | null> {
|
||||
): Promise<
|
||||
(StoredModerationVerdict & { text: string; similarity: number }) | null
|
||||
> {
|
||||
// Qdrant path (primary)
|
||||
if (isQdrantConfigured()) {
|
||||
const hits = await searchQdrant(embedding, limit, minSimilarity);
|
||||
@@ -443,11 +454,10 @@ export async function findSimilarTextModeration(
|
||||
const hit = candidates[match.index];
|
||||
const parsed = hit.parsed;
|
||||
const flags = (parsed.flags as string[]) ?? [];
|
||||
const storedStatus = (parsed.status as string) ?? "clean";
|
||||
const status: "clean" | "warn" | "flagged" =
|
||||
storedStatus === "warn" || storedStatus === "flagged"
|
||||
? storedStatus
|
||||
: "clean";
|
||||
const status = normalizeStoredStatus(
|
||||
parsed.status as string | undefined,
|
||||
flags,
|
||||
);
|
||||
return {
|
||||
text: hit.text,
|
||||
similarity: match.similarity,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Context enrichment builders — <user_history>, <user_profiles> as_of,
|
||||
// bot/edited detection (pure, no DB)
|
||||
// Context enrichment builders — bot/edited detection (pure, no DB)
|
||||
// (buildUserHistoryXml / buildUserProfilesBlock were removed with the
|
||||
// per-user context minimization; their tests went with them.)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildUserHistoryXml,
|
||||
buildUserProfilesBlock,
|
||||
resolveIsBot,
|
||||
resolveIsEdited,
|
||||
} from "../src/modules/ai-moderation/moderationBuilders.js";
|
||||
@@ -39,85 +38,6 @@ function msg(overrides: Partial<MessageRecord> = {}): MessageRecord {
|
||||
};
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("buildUserHistoryXml — last flagged messages for repeat offenders", () => {
|
||||
it("returns empty when there is no real history", () => {
|
||||
expect(buildUserHistoryXml([])).toBe("");
|
||||
expect(
|
||||
buildUserHistoryXml([{ content: " ", severity: "low", created_at: 1 }]),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("renders <infraction> rows with severity and recency", () => {
|
||||
const xml = buildUserHistoryXml(
|
||||
[
|
||||
{
|
||||
content: "beli barang murah disini https://scam.example",
|
||||
severity: "high",
|
||||
created_at: NOW - 3 * DAY_MS,
|
||||
},
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(xml).toContain("<user_history>");
|
||||
expect(xml).toContain('severity="high"');
|
||||
expect(xml).toContain('time_ago_days="3"');
|
||||
expect(xml).toContain("beli barang murah disini");
|
||||
});
|
||||
|
||||
it("caps long snippets and XML-escapes content", () => {
|
||||
const xml = buildUserHistoryXml(
|
||||
[
|
||||
{
|
||||
content: "x".repeat(300),
|
||||
severity: "low",
|
||||
created_at: NOW - DAY_MS,
|
||||
},
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(xml.length).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUserProfilesBlock — deduplicated map with staleness", () => {
|
||||
it("emits as_of when the profile has a last-generated timestamp", () => {
|
||||
const block = buildUserProfilesBlock(
|
||||
new Map([
|
||||
[
|
||||
"u1",
|
||||
{
|
||||
text: "Developer teknis, bahasa Indonesia",
|
||||
asOf: NOW - 3 * DAY_MS,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(block).toContain('<user_profile user_id="u1"');
|
||||
expect(block).toContain(
|
||||
`as_of="${new Date(NOW - 3 * DAY_MS).toISOString()}"`,
|
||||
);
|
||||
expect(block).toContain("Developer teknis");
|
||||
});
|
||||
|
||||
it("omits as_of when absent, and drops empty profiles", () => {
|
||||
const block = buildUserProfilesBlock(
|
||||
new Map([
|
||||
["u1", { text: "profil aktif", asOf: null }],
|
||||
["u2", { text: " " }],
|
||||
]),
|
||||
);
|
||||
expect(block).toContain('user_id="u1"');
|
||||
expect(block).not.toContain("as_of");
|
||||
expect(block).not.toContain("u2");
|
||||
});
|
||||
|
||||
it("returns empty for no profiles", () => {
|
||||
expect(buildUserProfilesBlock(new Map())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIsBot / resolveIsEdited — message flags", () => {
|
||||
it("reads author.bot from captured metadata", () => {
|
||||
const bot = msg({
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Stored-status normalization — "warn" verdicts must survive the cache
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Bug (2026-08-22): getCachedTextModeration() narrowed its return type to
|
||||
// "clean" | "flagged". A stored "warn" verdict with flags (e.g.
|
||||
// ["conflict_instigation"]) fell into the legacy `flags.length === 0 ?
|
||||
// clean : flagged` branch and was read back as FLAGGED. Downstream this
|
||||
// broke auto-delete eligibility gating and mislabelled warnings on the
|
||||
// dashboard. parseQdrantVerdict had the same narrowing (warn → clean).
|
||||
//
|
||||
// Fix: normalizeStoredStatus() accepts the full clean/warn/flagged union in
|
||||
// BOTH readers; unknown/legacy values still derive from flags.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeStoredStatus } from "../src/modules/ai-moderation/textCacheStore.js";
|
||||
|
||||
describe("normalizeStoredStatus — warn survives cache round-trip", () => {
|
||||
it("keeps a stored 'warn' status as 'warn'", () => {
|
||||
expect(normalizeStoredStatus("warn", ["conflict_instigation"])).toBe(
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps stored 'clean' and 'flagged' unchanged", () => {
|
||||
expect(normalizeStoredStatus("clean", [])).toBe("clean");
|
||||
expect(normalizeStoredStatus("flagged", ["sara"])).toBe("flagged");
|
||||
});
|
||||
|
||||
it("derives from flags for legacy entries without a stored status", () => {
|
||||
expect(normalizeStoredStatus(undefined, [])).toBe("clean");
|
||||
expect(normalizeStoredStatus(undefined, ["spam"])).toBe("flagged");
|
||||
});
|
||||
|
||||
it("treats an unknown stored status like a legacy entry", () => {
|
||||
expect(normalizeStoredStatus("processing", [])).toBe("clean");
|
||||
expect(normalizeStoredStatus("processing", ["spam"])).toBe("flagged");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user