feat(gmw): moderation explainability + semantic message search
- Persist structured verdict (flags/severity/confidence/evidence) on moderation_actions so the public web can show WHY a message was moderated. - Add a persistent Qdrant archive collection (gmw_message_archive); embed every captured message at capture time (fire-and-forget, best-effort). - Public semantic search over the archive (backend oRPC + FE toggle on the messages view). Both features are read-only/public and fully automatic. Migration: 0015_add_moderation_explainability.sql
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { embedText } from "@/modules/ai-moderation/embeddingClient.js";
|
||||
import {
|
||||
ARCHIVE_COLLECTION,
|
||||
qdrantPointId,
|
||||
upsertQdrantPointV2,
|
||||
} from "@/modules/ai-moderation/qdrantClient.js";
|
||||
import { config } from "@/shared/config/config.js";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
|
||||
const log = createChildLogger("archive-embedder");
|
||||
|
||||
export interface ArchiveMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
username: string;
|
||||
channel_id: string;
|
||||
guild_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: embed a captured message and upsert it into the persistent
|
||||
* archive collection so the public web can semantic-search the corpus.
|
||||
*
|
||||
* Failures are swallowed — searching is a nice-to-have, never a precondition
|
||||
* for capture or moderation. The message text is kept in the payload so the
|
||||
* search endpoint can return results even for deleted messages.
|
||||
*/
|
||||
export function archiveMessageEmbedded(message: ArchiveMessage): void {
|
||||
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
|
||||
const text = message.content?.trim();
|
||||
if (!text || text.length < 3) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const vector = await embedText(text);
|
||||
if (!vector) return;
|
||||
const ok = await upsertQdrantPointV2(
|
||||
ARCHIVE_COLLECTION,
|
||||
qdrantPointId(`archive:${message.id}`),
|
||||
vector,
|
||||
{
|
||||
text: text.slice(0, 4000),
|
||||
flags: "",
|
||||
analyzed_at: Date.now(),
|
||||
// 5-year persistent window (archive is NOT a TTL cache).
|
||||
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5,
|
||||
content_hash: message.id,
|
||||
},
|
||||
);
|
||||
if (!ok) return;
|
||||
log.debug({ messageId: message.id }, "Archived message embedding");
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
{
|
||||
messageId: message.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"archive embed skipped",
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user