diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index 11cf5309..41628417 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -105,6 +105,7 @@ export class MessagesService { vector, input.limit, config.AI_LLM_EMBEDDING_ARCHIVE_MIN_SIMILARITY, + input.guildId, ); const results = hits.map((h) => mapSearchHit(h)); return { results, nextCursor: null }; @@ -120,16 +121,31 @@ export class MessagesService { } } -/** Shape returned to the frontend (text + metadata from the archive payload). */ +/** Shape returned to the frontend (text + rich metadata from the archive payload). */ function mapSearchHit(hit: { score: number; - payload: { text: string; content_hash?: string; analyzed_at: number }; + payload: { + text: string; + content_hash?: string; + analyzed_at: number; + username?: string; + channel_id?: string; + guild_id?: string; + thread_id?: string | null; + created_at?: number; + }; }) { return { message_id: hit.payload.content_hash ?? null, content: hit.payload.text, score: hit.score, - created_at: hit.payload.analyzed_at, + // Prefer the real message timestamp; fall back to embed time for old + // points that predate rich metadata. + created_at: hit.payload.created_at ?? hit.payload.analyzed_at, + username: hit.payload.username ?? null, + channel_id: hit.payload.channel_id ?? null, + guild_id: hit.payload.guild_id ?? null, + thread_id: hit.payload.thread_id ?? null, }; } diff --git a/services/backend/src/modules/messages/qdrant.ts b/services/backend/src/modules/messages/qdrant.ts index 07b0b3ea..d995cd78 100644 --- a/services/backend/src/modules/messages/qdrant.ts +++ b/services/backend/src/modules/messages/qdrant.ts @@ -10,6 +10,11 @@ export interface ArchiveHit { content_hash?: string; analyzed_at: number; expires_at: number; + username?: string; + channel_id?: string; + guild_id?: string; + thread_id?: string | null; + created_at?: number; }; } @@ -61,6 +66,7 @@ export async function searchArchive( vector: number[], limit: number, scoreThreshold: number, + guildId?: string, ): Promise { if (!config.QDRANT_URL) return []; try { @@ -72,6 +78,17 @@ export async function searchArchive( limit, score_threshold: scoreThreshold, with_payload: true, + // Optional scope: only return vectors from a specific guild's archive. + // Old points (embedded before rich metadata) have no guild_id payload — + // the `must` match simply excludes them, which is the correct behavior + // for a guild-scoped search. + ...(guildId + ? { + filter: { + must: [{ key: "guild_id", match: { value: guildId } }], + }, + } + : {}), }, )) as { result?: Array<{ diff --git a/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts b/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts index 2343b877..8f718b0e 100644 --- a/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts @@ -37,6 +37,14 @@ export interface QdrantVerdictPayload { /** Bare content hash (16 hex chars) — enables content-based invalidation * regardless of the (context-scoped) point id. */ content_hash?: string; + // Archive metadata (archiveEmbedder writes these; moderation cache doesn't). + // Optional so the same payload shape serves both the moderation cache + // collection and the public message archive collection. + username?: string; + channel_id?: string; + guild_id?: string; + thread_id?: string | null; + created_at?: number; } function baseUrl(): string { diff --git a/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts b/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts index 460cd0bc..fbbbce91 100644 --- a/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts +++ b/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts @@ -18,6 +18,7 @@ export interface ArchiveMessage { username: string; channel_id: string; guild_id: string; + thread_id: string | null; created_at: number; /** True when the message came from an age-restricted (NSFW) channel. NSFW * content is deliberately NOT embedded into the public archive so it can't @@ -58,6 +59,13 @@ export function archiveMessageEmbedded(message: ArchiveMessage): void { { text: normalized.slice(0, 4000), flags: "", + // Rich metadata so public semantic search results can be shown in + // context (who said it, where, when) instead of a bare text blob. + username: message.username ?? "", + channel_id: message.channel_id ?? "", + guild_id: message.guild_id ?? "", + thread_id: message.thread_id ?? null, + created_at: message.created_at ?? Date.now(), analyzed_at: Date.now(), // 5-year persistent window (archive is NOT a TTL cache). expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5, diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 5bac5c55..0fc7ee2e 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -129,6 +129,7 @@ export function MessagesView({ const semantic = useSemanticSearch( query, query.trim().length >= 2 && semanticMode, + guildId, ); const activity = useMessageActivity(30); const edits = useRecentEdits(50, undefined, initialEdits); @@ -360,6 +361,16 @@ export function MessagesView({ {(r.score * 100).toFixed(0)}% RELEVANCE + {r.username && ( + + {r.username} + + )} + {r.channel_id && ( + + #{r.channel_id} + + )} ( enabled && query.trim().length >= 2 - ? ["semantic-search", query.trim()] + ? ["semantic-search", query.trim(), guildId ?? ""] : null, async () => { - const res = await messagesApi.semanticSearch(query.trim(), 10); + const res = await messagesApi.semanticSearch( + query.trim(), + 10, + guildId ?? undefined, + ); return res.results; }, { keepPreviousData: true }, diff --git a/services/frontend/src/lib/api/messages.ts b/services/frontend/src/lib/api/messages.ts index acdb97ee..54636e77 100644 --- a/services/frontend/src/lib/api/messages.ts +++ b/services/frontend/src/lib/api/messages.ts @@ -70,8 +70,12 @@ export const messagesApi = { }>, // Public semantic search over the persistent message archive (Qdrant). - semanticSearch: (query: string, limit?: number) => - orpc.messages.semanticSearch({ query, limit }) as unknown as Promise<{ + semanticSearch: (query: string, limit?: number, guildId?: string) => + orpc.messages.semanticSearch({ + query, + limit, + ...(guildId ? { guildId } : {}), + }) as unknown as Promise<{ results: SemanticSearchResult[]; nextCursor: null; }>, diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index d375fe93..17b7924e 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -173,6 +173,10 @@ export interface SemanticSearchResult { content: string; score: number; created_at: number; + username: string | null; + channel_id: string | null; + guild_id: string | null; + thread_id: string | null; } export interface MessageActivityBucket {