feat: add conversation context builder
This commit is contained in:
76
src/moderation/conversationContext.ts
Normal file
76
src/moderation/conversationContext.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -458,3 +458,46 @@ export async function listReviewMessages(
|
|||||||
status: ["warn", "flagged", "error"],
|
status: ["warn", "flagged", "error"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getConversationContextBefore(input: {
|
||||||
|
channelId: string;
|
||||||
|
threadId: string | null;
|
||||||
|
beforeCreatedAt: number;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<MessageRecord[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
62
tests/moderation/conversationContext.test.ts
Normal file
62
tests/moderation/conversationContext.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user