feat: add conversation context builder
This commit is contained in:
@@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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