From 2b4e2a7ab7f04fe346e60abd9f74c35e5deef42c Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 14 May 2026 19:10:13 +0700 Subject: [PATCH] feat: add conversation context builder --- src/moderation/conversationContext.ts | 76 ++++++++++++++++++++ src/moderation/messageStore.ts | 43 +++++++++++ tests/moderation/conversationContext.test.ts | 62 ++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/moderation/conversationContext.ts create mode 100644 tests/moderation/conversationContext.test.ts diff --git a/src/moderation/conversationContext.ts b/src/moderation/conversationContext.ts new file mode 100644 index 0000000..cc8bfce --- /dev/null +++ b/src/moderation/conversationContext.ts @@ -0,0 +1,76 @@ +import type { MessageRecord } from "./types"; + +export interface ConversationContextInput { + contextBefore: MessageRecord[]; + targets: MessageRecord[]; + maxTokens: number; +} + +/** + * Formats a timestamp to ISO 8601 string + */ +function formatTimestamp(ms: number): string { + return new Date(ms).toISOString(); +} + +/** + * Estimates token count for a string (rough approximation: ~4 chars per token) + */ +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Builds conversation prompt messages with context and targets + * - Marks target messages with [target], prior context with [context] + * - Uses edited_content when present, otherwise content + * - Maintains chronological order + * - Respects maxTokens budget, prioritizing targets and most recent context + */ +export function buildConversationPromptMessages( + input: ConversationContextInput, +): string[] { + const { contextBefore, targets, maxTokens } = input; + + // Format all messages + const formatMessage = (msg: MessageRecord, label: string): string => { + const content = msg.edited_content ?? msg.content; + const timestamp = formatTimestamp(msg.created_at); + return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}`; + }; + + const targetLines = targets.map((msg) => formatMessage(msg, "target")); + const contextLines = contextBefore.map((msg) => + formatMessage(msg, "context"), + ); + + // Calculate tokens for targets (always include) + let usedTokens = targetLines.reduce( + (sum, line) => sum + estimateTokens(line), + 0, + ); + + // Add context lines in reverse chronological order (most recent first) + // until we hit the token budget + const selectedContextLines: string[] = []; + for (let i = contextLines.length - 1; i >= 0; i--) { + const line = contextLines[i]; + const lineTokens = estimateTokens(line); + if (usedTokens + lineTokens <= maxTokens) { + selectedContextLines.unshift(line); // prepend to maintain chronological order + usedTokens += lineTokens; + } + } + + // Combine: context (chronological) + targets (chronological) + const allMessages = [...selectedContextLines, ...targetLines]; + + // Sort by timestamp to ensure chronological order + allMessages.sort((a, b) => { + const timeA = a.match(/time=([^\s]+)/)?.[1] ?? ""; + const timeB = b.match(/time=([^\s]+)/)?.[1] ?? ""; + return timeA.localeCompare(timeB); + }); + + return allMessages; +} diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index 298327f..832d86c 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -458,3 +458,46 @@ export async function listReviewMessages( status: ["warn", "flagged", "error"], }); } + +export async function getConversationContextBefore(input: { + channelId: string; + threadId: string | null; + beforeCreatedAt: number; + limit: number; +}): Promise { + try { + const db = getDatabase() as any; + const { channelId, threadId, beforeCreatedAt, limit } = input; + + // Query same thread if threadId exists, otherwise channelId + const locationCondition = threadId + ? eq(messagesTable.thread_id, threadId) + : eq(messagesTable.channel_id, channelId); + + const rows = await db + .select() + .from(messagesTable) + .where( + and( + locationCondition, + sql`${messagesTable.created_at} < ${beforeCreatedAt}`, + isNull(messagesTable.deleted_at), + ), + ) + .orderBy(desc(messagesTable.created_at)) + .limit(limit); + + // Return in chronological order (oldest first) + return (rows as MessageRecord[]).reverse(); + } catch (error) { + logger.error( + { + channelId: input.channelId, + threadId: input.threadId, + error: error instanceof Error ? error.message : String(error), + }, + "Failed to get conversation context before", + ); + throw error; + } +} diff --git a/tests/moderation/conversationContext.test.ts b/tests/moderation/conversationContext.test.ts new file mode 100644 index 0000000..8cbb76f --- /dev/null +++ b/tests/moderation/conversationContext.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { buildConversationPromptMessages } from "../../src/moderation/conversationContext"; +import type { MessageRecord } from "../../src/moderation/types"; + +function message( + id: string, + content: string, + created_at: number, +): MessageRecord { + return { + id, + guild_id: "g1", + channel_id: "c1", + thread_id: null, + user_id: `u-${id}`, + username: `user-${id}`, + avatar_url: null, + content, + edited_content: null, + created_at, + edited_at: null, + deleted_at: null, + type: "text", + metadata: null, + ai_status: "pending", + }; +} + +describe("buildConversationPromptMessages", () => { + it("marks target messages and keeps chronological order", () => { + const lines = buildConversationPromptMessages({ + contextBefore: [message("a", "hello", 1)], + targets: [message("b", "bad?", 2)], + maxTokens: 1000, + }); + + expect(lines).toContain( + "[context] id=a time=1970-01-01T00:00:00.001Z user=user-a: hello", + ); + expect(lines).toContain( + "[target] id=b time=1970-01-01T00:00:00.002Z user=user-b: bad?", + ); + + const indexA = lines.findIndex((line) => line.includes("id=a")); + const indexB = lines.findIndex((line) => line.includes("id=b")); + expect(indexA).toBeLessThan(indexB); + }); + + it("uses edited content when present", () => { + const target = message("b", "original", 2); + target.edited_content = "edited"; + + const lines = buildConversationPromptMessages({ + contextBefore: [], + targets: [target], + maxTokens: 1000, + }); + + expect(lines.some((line) => line.includes("edited"))).toBe(true); + expect(lines.some((line) => line.includes("original"))).toBe(false); + }); +});