feat(ai-moderation): enhance context handling with structured XML blocks and user profiles
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@ worktrees/
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
services/frontend/frontend/dist/
|
services/frontend/frontend/dist/
|
||||||
target/
|
target/
|
||||||
|
nix/
|
||||||
# Gitea CI runner logs
|
# Gitea CI runner logs
|
||||||
.gitea/workflows/*.log
|
.gitea/workflows/*.log
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
buildConversationContext,
|
buildConversationContext,
|
||||||
buildLocationContext,
|
buildLocationContext,
|
||||||
} from "./conversationContext.js";
|
} from "./conversationContext.js";
|
||||||
|
import { buildConversationContextBlock } from "./moderationBuilders.js";
|
||||||
import { runModerationAnalysis } from "./moderationOrchestrator.js";
|
import { runModerationAnalysis } from "./moderationOrchestrator.js";
|
||||||
|
|
||||||
const logger = createChildLogger("ai-analysis-worker");
|
const logger = createChildLogger("ai-analysis-worker");
|
||||||
@@ -280,13 +281,11 @@ async function processBatch(job: {
|
|||||||
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
|
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
|
||||||
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
|
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
|
||||||
});
|
});
|
||||||
const contextText = [
|
const contextBlock = buildConversationContextBlock({
|
||||||
buildLocationContext(messages),
|
location: buildLocationContext(messages),
|
||||||
contextLines.descriptor,
|
descriptor: contextLines.descriptor,
|
||||||
...contextLines.lines,
|
lines: contextLines.lines,
|
||||||
]
|
});
|
||||||
.filter((l) => l.trim().length > 0)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const targetIds = messages.map((m) => m.id);
|
const targetIds = messages.map((m) => m.id);
|
||||||
const contextIds = contextBefore.map((m) => m.id);
|
const contextIds = contextBefore.map((m) => m.id);
|
||||||
@@ -300,7 +299,7 @@ async function processBatch(job: {
|
|||||||
// when media is present), not N per-message calls.
|
// when media is present), not N per-message calls.
|
||||||
const moderationResult = await runModerationAnalysis({
|
const moderationResult = await runModerationAnalysis({
|
||||||
targets: messages,
|
targets: messages,
|
||||||
contextText,
|
contextBlock,
|
||||||
attachments,
|
attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -373,13 +372,11 @@ async function processIndividual(job: {
|
|||||||
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
|
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
|
||||||
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
|
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
|
||||||
});
|
});
|
||||||
const contextText = [
|
const contextBlock = buildConversationContextBlock({
|
||||||
buildLocationContext([message]),
|
location: buildLocationContext([message]),
|
||||||
contextLines.descriptor,
|
descriptor: contextLines.descriptor,
|
||||||
...contextLines.lines,
|
lines: contextLines.lines,
|
||||||
]
|
});
|
||||||
.filter((l) => l.trim().length > 0)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const contextIds = contextBefore.map((m) => m.id);
|
const contextIds = contextBefore.map((m) => m.id);
|
||||||
const attachments = await messageStore.getAttachmentsForMessages([
|
const attachments = await messageStore.getAttachmentsForMessages([
|
||||||
@@ -390,7 +387,7 @@ async function processIndividual(job: {
|
|||||||
try {
|
try {
|
||||||
const moderationResult = await runModerationAnalysis({
|
const moderationResult = await runModerationAnalysis({
|
||||||
targets: [message],
|
targets: [message],
|
||||||
contextText,
|
contextBlock,
|
||||||
attachments,
|
attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "../message-capture/messageMetadata.js";
|
} from "../message-capture/messageMetadata.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
||||||
import { resolveDisplayName } from "./moderationBuilders.js";
|
import { escapeXml, resolveDisplayName } from "./moderationBuilders.js";
|
||||||
|
|
||||||
const logger = createChildLogger("conversationContext");
|
const logger = createChildLogger("conversationContext");
|
||||||
|
|
||||||
@@ -124,8 +124,10 @@ export function formatMessageForPrompt(
|
|||||||
msg: MessageRecord,
|
msg: MessageRecord,
|
||||||
label: "context" | "target",
|
label: "context" | "target",
|
||||||
): string {
|
): string {
|
||||||
const content = sanitizeDiscordTokens(
|
const content = truncateContextLine(
|
||||||
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
|
sanitizeDiscordTokens(
|
||||||
|
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const timestamp = formatTimestamp(msg.created_at);
|
const timestamp = formatTimestamp(msg.created_at);
|
||||||
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
||||||
@@ -134,11 +136,26 @@ export function formatMessageForPrompt(
|
|||||||
return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`;
|
return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Max content chars per context line — a single huge paste (log dump,
|
||||||
|
* copypasta) must not eat the whole conversation budget. */
|
||||||
|
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
|
||||||
|
|
||||||
|
/** Marker appended when a context line's content was cut. Distinct from the
|
||||||
|
* target-content marker so the model knows which side was truncated. */
|
||||||
|
export const CONTEXT_TRUNC_MARKER = "…[konteks dipotong: terlalu panjang]";
|
||||||
|
|
||||||
|
/** Cap one context message's content to CONTEXT_LINE_CONTENT_MAX_CHARS. */
|
||||||
|
export function truncateContextLine(content: string): string {
|
||||||
|
if (content.length <= CONTEXT_LINE_CONTENT_MAX_CHARS) return content;
|
||||||
|
return `${content.slice(0, CONTEXT_LINE_CONTENT_MAX_CHARS).trimEnd()}${CONTEXT_TRUNC_MARKER}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a one-line `<location_context>` source line for the batch — channel
|
* Builds a structured `<location_context .../>` element for the batch —
|
||||||
* name, thread name and age-restriction flags from captured message metadata.
|
* channel/thread name and age-restriction flags from captured message
|
||||||
* The LLM uses it to judge messages in the right channel context (e.g. a
|
* metadata. The LLM uses it to judge messages in the right channel context
|
||||||
* thread about a specific topic, or an age-restricted channel).
|
* (e.g. a thread about a specific topic, or an age-restricted channel).
|
||||||
|
* Returns "" when no channel metadata was captured.
|
||||||
*/
|
*/
|
||||||
export function buildLocationContext(targets: MessageRecord[]): string {
|
export function buildLocationContext(targets: MessageRecord[]): string {
|
||||||
const target = targets[0];
|
const target = targets[0];
|
||||||
@@ -155,26 +172,20 @@ export function buildLocationContext(targets: MessageRecord[]): string {
|
|||||||
};
|
};
|
||||||
const ch = meta?.channel;
|
const ch = meta?.channel;
|
||||||
if (!ch) return "";
|
if (!ch) return "";
|
||||||
const parts: string[] = [];
|
const attrs: string[] = [`channel_id="${escapeXml(target.channel_id)}"`];
|
||||||
parts.push(
|
if (ch.channelName)
|
||||||
`id=${target.channel_id}${
|
attrs.push(`channel_name="${escapeXml(ch.channelName)}"`);
|
||||||
ch.channelName ? ` name=${JSON.stringify(ch.channelName)}` : ""
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
if (target.thread_id || ch.threadName) {
|
if (target.thread_id || ch.threadName) {
|
||||||
parts.push(
|
if (target.thread_id)
|
||||||
`thread=${target.thread_id}${
|
attrs.push(`thread_id="${escapeXml(target.thread_id)}"`);
|
||||||
ch.threadName ? ` thread_name=${JSON.stringify(ch.threadName)}` : ""
|
if (ch.threadName)
|
||||||
}`,
|
attrs.push(`thread_name="${escapeXml(ch.threadName)}"`);
|
||||||
);
|
|
||||||
}
|
|
||||||
if (typeof ch.nsfw === "boolean") {
|
|
||||||
parts.push(`nsfw=${ch.nsfw}`);
|
|
||||||
}
|
}
|
||||||
|
if (typeof ch.nsfw === "boolean") attrs.push(`nsfw="${ch.nsfw}"`);
|
||||||
if (typeof ch.ageRestricted === "boolean") {
|
if (typeof ch.ageRestricted === "boolean") {
|
||||||
parts.push(`age_restricted=${ch.ageRestricted}`);
|
attrs.push(`age_restricted="${ch.ageRestricted}"`);
|
||||||
}
|
}
|
||||||
return `[location] ${parts.join(" ")}`;
|
return `<location_context ${attrs.join(" ")}/>`;
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import { getChannelCulture } from "./channelCultureStore.js";
|
|||||||
import type { RetryState } from "./llmCaller.js";
|
import type { RetryState } from "./llmCaller.js";
|
||||||
import { callModerationLLM } from "./llmCaller.js";
|
import { callModerationLLM } from "./llmCaller.js";
|
||||||
import { prepareMediaMessage } from "./mediaAnalysisClient.js";
|
import { prepareMediaMessage } from "./mediaAnalysisClient.js";
|
||||||
|
import { buildUserProfilesBlock } from "./moderationBuilders.js";
|
||||||
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||||
import { buildCorrectedFewShotExamples } from "./textBatchProcessor.js";
|
import { buildCorrectedFewShotExamples } from "./textBatchProcessor.js";
|
||||||
|
import { getUserProfile } from "./userProfileStore.js";
|
||||||
|
|
||||||
const log = createChildLogger("mediaBatchProcessor");
|
const log = createChildLogger("mediaBatchProcessor");
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ const log = createChildLogger("mediaBatchProcessor");
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export async function runMediaBatch(
|
export async function runMediaBatch(
|
||||||
targets: MessageRecord[],
|
targets: MessageRecord[],
|
||||||
contextText: string,
|
contextBlock: string,
|
||||||
attachments: AttachmentRecord[] | undefined,
|
attachments: AttachmentRecord[] | undefined,
|
||||||
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
||||||
if (!targets.length) return { results: [], raw: null };
|
if (!targets.length) return { results: [], raw: null };
|
||||||
@@ -58,14 +60,32 @@ export async function runMediaBatch(
|
|||||||
const channelCulture = channelCultureObj?.culture_summary;
|
const channelCulture = channelCultureObj?.culture_summary;
|
||||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||||
const systemText = buildSystemPromptModular({
|
const systemText = buildSystemPromptModular({
|
||||||
contextText,
|
|
||||||
mode: "mixed",
|
mode: "mixed",
|
||||||
correctedExamples,
|
correctedExamples,
|
||||||
channelCulture,
|
channelCulture,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Gather user profiles ONCE for the whole batch and emit a deduplicated
|
||||||
|
// <user_profiles> map; per-message blocks (from prepareMediaMessage)
|
||||||
|
// reference it via <user_profile_ref>.
|
||||||
|
const profileByUser = new Map<string, string>();
|
||||||
|
for (const t of targets) {
|
||||||
|
if (profileByUser.has(t.user_id)) continue;
|
||||||
|
const profile = await getUserProfile(t.user_id);
|
||||||
|
profileByUser.set(t.user_id, profile?.profile_summary ?? "");
|
||||||
|
}
|
||||||
|
const userProfilesBlock = buildUserProfilesBlock(profileByUser);
|
||||||
|
|
||||||
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
|
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
|
||||||
const userContent = `<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
|
// Data/instruction separation: the system prompt is stable per mode — all
|
||||||
|
// per-batch context (profiles, conversation) lives in the USER payload,
|
||||||
|
// ordered oldest-first so targets come last.
|
||||||
|
const userBlocks = [
|
||||||
|
userProfilesBlock?.trimEnd() ?? "",
|
||||||
|
contextBlock?.trimEnd() ?? "",
|
||||||
|
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
||||||
|
].filter((b) => b.trim().length > 0);
|
||||||
|
const userContent = userBlocks.join("\n\n");
|
||||||
|
|
||||||
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
||||||
const batchTimeout = Math.min(
|
const batchTimeout = Math.min(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { renderDiscordMentions } from "../message-capture/messageMetadata.js";
|
|||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
||||||
|
import { sanitizeAiContent } from "./prompts/output.js";
|
||||||
|
|
||||||
/** Simple XML-escaping for content text. */
|
/** Simple XML-escaping for content text. */
|
||||||
export function escapeXml(s: string): string {
|
export function escapeXml(s: string): string {
|
||||||
@@ -19,6 +20,101 @@ export function escapeXml(s: string): string {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conversation context block — structured data for the USER message.
|
||||||
|
//
|
||||||
|
// All per-batch context lives in the USER message (not the SYSTEM prompt) so
|
||||||
|
// the system prompt is stable per mode (cacheable on routers/providers) and
|
||||||
|
// the role boundary is clean: instructions in SYSTEM, data in USER.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Outer char cap for the assembled `<conversation_context>` inner text. */
|
||||||
|
export const CONVERSATION_CONTEXT_MAX_CHARS = 40_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps per-batch context data into structured XML blocks for the USER
|
||||||
|
* message:
|
||||||
|
*
|
||||||
|
* <location_context channel_id="..." channel_name="..." nsfw="..."/>
|
||||||
|
* <conversation_context>
|
||||||
|
* [conversation_flow] status=ongoing context_msgs=12 dropped=0
|
||||||
|
* [context] id=... time=... user=...: isi pesan
|
||||||
|
* ...
|
||||||
|
* </conversation_context>
|
||||||
|
*
|
||||||
|
* Empty blocks are omitted entirely (never emit a hollow `<conversation_context>`
|
||||||
|
* with no content). The inner text is AI/user-derived and passed through
|
||||||
|
* `sanitizeAiContent` (CDATA + XML-escape) to block prompt injection.
|
||||||
|
*/
|
||||||
|
export function buildConversationContextBlock(input: {
|
||||||
|
/** Pre-built `<location_context .../>` string (or ""). */
|
||||||
|
location?: string;
|
||||||
|
/** `[conversation_flow]` descriptor line from buildConversationContext. */
|
||||||
|
descriptor?: string;
|
||||||
|
/** `[context]` lines, oldest → newest. */
|
||||||
|
lines: string[];
|
||||||
|
}): string {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
const location = input.location?.trim();
|
||||||
|
if (location) blocks.push(location);
|
||||||
|
|
||||||
|
const inner = [input.descriptor ?? "", ...input.lines]
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.join("\n");
|
||||||
|
if (inner) {
|
||||||
|
blocks.push(
|
||||||
|
`<conversation_context>\n${sanitizeAiContent(inner, CONVERSATION_CONTEXT_MAX_CHARS)}\n</conversation_context>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return blocks.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-message content bounds — protects the LLM token budget from a single
|
||||||
|
// huge paste (stack traces, log dumps, copypasta). Truncation is explicit so
|
||||||
|
// the model never mistakes the cut for a real message boundary.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Max characters of a message's content sent to the LLM `<content>` payload. */
|
||||||
|
export const AI_CONTENT_MAX_CHARS = 4000;
|
||||||
|
|
||||||
|
/** Marker appended when a message is longer than AI_CONTENT_MAX_CHARS. */
|
||||||
|
export const AI_CONTENT_TRUNC_MARKER = "\n…[pesan dipotong: terlalu panjang]";
|
||||||
|
|
||||||
|
/** Truncate a message's content for the LLM `<content>` payload. */
|
||||||
|
export function truncateForAi(content: string): string {
|
||||||
|
if (content.length <= AI_CONTENT_MAX_CHARS) return content;
|
||||||
|
return `${content.slice(0, AI_CONTENT_MAX_CHARS)}${AI_CONTENT_TRUNC_MARKER}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User profile deduplication — a batch can contain many messages from the
|
||||||
|
// same user. Instead of repeating the (up to 3000-char) profile summary on
|
||||||
|
// every message, emit a single <user_profiles> map per batch and reference
|
||||||
|
// entries per message with <user_profile_ref user_id="..."/>.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Build a deduplicated `<user_profiles>` map block, keyed by Discord user id. */
|
||||||
|
export function buildUserProfilesBlock(
|
||||||
|
profiles: ReadonlyMap<string, string>,
|
||||||
|
): string {
|
||||||
|
const entries = Array.from(profiles.entries()).filter(
|
||||||
|
([, text]) => text.trim().length > 0,
|
||||||
|
);
|
||||||
|
if (entries.length === 0) return "";
|
||||||
|
const lines = entries.map(
|
||||||
|
([userId, text]) =>
|
||||||
|
` <user_profile user_id="${escapeXml(userId)}">${sanitizeAiContent(text)}</user_profile>`,
|
||||||
|
);
|
||||||
|
return `<user_profiles>\n${lines.join("\n")}\n</user_profiles>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-message reference tag pointing at an entry in the `<user_profiles>` map. */
|
||||||
|
export function buildUserProfileRef(userId: string): string {
|
||||||
|
return `<user_profile_ref user_id="${escapeXml(userId)}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the real text content for AI analysis, stripping fallback text
|
* Returns the real text content for AI analysis, stripping fallback text
|
||||||
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
|
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ const log = createChildLogger("moderationOrchestrator");
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export interface ModerationInput {
|
export interface ModerationInput {
|
||||||
targets: MessageRecord[];
|
targets: MessageRecord[];
|
||||||
contextText: string;
|
/**
|
||||||
|
* Pre-built XML context block for the USER message (from
|
||||||
|
* `buildConversationContextBlock`): `<location_context .../>` +
|
||||||
|
* `<conversation_context>...</conversation_context>`. Kept out of the
|
||||||
|
* system prompt so it stays stable/cacheable per mode.
|
||||||
|
*/
|
||||||
|
contextBlock: string;
|
||||||
attachments?: AttachmentRecord[];
|
attachments?: AttachmentRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +68,7 @@ export interface ModerationOutput {
|
|||||||
export async function runModerationAnalysis(
|
export async function runModerationAnalysis(
|
||||||
input: ModerationInput,
|
input: ModerationInput,
|
||||||
): Promise<ModerationOutput> {
|
): Promise<ModerationOutput> {
|
||||||
const { targets, contextText, attachments } = input;
|
const { targets, contextBlock, attachments } = input;
|
||||||
|
|
||||||
initSearxngCache(config.REDIS_URL);
|
initSearxngCache(config.REDIS_URL);
|
||||||
if (!targets.length) throw new Error("No targets provided for analysis");
|
if (!targets.length) throw new Error("No targets provided for analysis");
|
||||||
@@ -320,10 +326,10 @@ export async function runModerationAnalysis(
|
|||||||
// Run both paths in parallel
|
// Run both paths in parallel
|
||||||
const [textBatchResult, mediaBatchResult] = await Promise.all([
|
const [textBatchResult, mediaBatchResult] = await Promise.all([
|
||||||
textOnlyTargets.length > 0
|
textOnlyTargets.length > 0
|
||||||
? runTextOnlyBatch(textOnlyTargets, contextText)
|
? runTextOnlyBatch(textOnlyTargets, contextBlock)
|
||||||
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
|
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
|
||||||
mediaTargets.length > 0
|
mediaTargets.length > 0
|
||||||
? runMediaBatch(mediaTargets, contextText, attachments)
|
? runMediaBatch(mediaTargets, contextBlock, attachments)
|
||||||
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
|
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ Struktur wajib:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Instruksi per field:
|
||||||
|
- "message_id": WAJIB sama persis dengan id di input. Setiap <message> di <messages_to_analyze> menghasilkan SATU hasil. Jangan gabungkan beberapa pesan, jangan lewati, jangan karang id.
|
||||||
|
- "evidence": kutipan PERSIS frasa yang melanggar (maks 1 baris). Pelanggaran di gambar/sticker → kutip deskripsi Media analysis. Pelanggaran lewat balasan/referensi → sebut konteks pesan yang dibalas. Boleh tambah label sumber, mis. [media analysis] / [web_search] / [reply]. Kosong jika clean.
|
||||||
|
|
||||||
## PERSONALITY & MEMORI — Profil Pengguna dan Kultur Channel
|
## PERSONALITY & MEMORI — Profil Pengguna dan Kultur Channel
|
||||||
Data konteks tersedia: <user_profile> (ringkasan kepribadian pengguna) dan <channel_culture> (topik/vibe channel).
|
Data konteks tersedia: <user_profiles> (peta ringkasan kepribadian, di pesan USER), <user_reputation> (skor trust), dan <channel_culture> (topik/vibe channel). Setiap <message> dapat memuat <user_profile_ref user_id="..."/> yang menunjuk ke entri di peta <user_profiles>.
|
||||||
Gunakan untuk personalisasi analysis, tapi:
|
Gunakan untuk personalisasi analysis, tapi:
|
||||||
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran.
|
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran.
|
||||||
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis.
|
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis.
|
||||||
@@ -66,7 +70,7 @@ CRITICAL:
|
|||||||
- Jika pesan adalah BALASAN (reply) ke pesan lain, jelaskan konteks balasannya: apa yang sedang dibicarakan, siapa yang dibalas (tanpa nama, cukup peran/isi pesan yang dibalas), dan bagaimana tanggapan pengirim terhadapnya.
|
- Jika pesan adalah BALASAN (reply) ke pesan lain, jelaskan konteks balasannya: apa yang sedang dibicarakan, siapa yang dibalas (tanpa nama, cukup peran/isi pesan yang dibalas), dan bagaimana tanggapan pengirim terhadapnya.
|
||||||
- Gunakan informasi dari Media analysis untuk mendeskripsikan gambar.
|
- Gunakan informasi dari Media analysis untuk mendeskripsikan gambar.
|
||||||
- Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status.
|
- Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status.
|
||||||
- GUNAKAN <user_profile> untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna.
|
- GUNAKAN <user_profile_ref>/<user_profiles> untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna.
|
||||||
- Jika perilaku pesan menyimpang dari profil yang diketahui, CATAT dalam analysis sebagai informasi kontekstual yang relevan.
|
- Jika perilaku pesan menyimpang dari profil yang diketahui, CATAT dalam analysis sebagai informasi kontekstual yang relevan.
|
||||||
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik dari yang dipaksakan.`;
|
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik dari yang dipaksakan.`;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ Gambar/sticker/embed/preview link sudah DIDESKRIPSIKAN vision model sebelum batc
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface BuildSystemPromptOptions {
|
export interface BuildSystemPromptOptions {
|
||||||
contextText: string;
|
|
||||||
/** Prompt mode — determines which sections are included. */
|
/** Prompt mode — determines which sections are included. */
|
||||||
mode: PromptMode;
|
mode: PromptMode;
|
||||||
/** @deprecated Use `mode` instead. */
|
/** @deprecated Use `mode` instead. */
|
||||||
@@ -59,7 +58,6 @@ export interface BuildSystemPromptOptions {
|
|||||||
|
|
||||||
export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||||
const {
|
const {
|
||||||
contextText,
|
|
||||||
mode,
|
mode,
|
||||||
includeMediaInstructions,
|
includeMediaInstructions,
|
||||||
correction,
|
correction,
|
||||||
@@ -105,15 +103,34 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
`## Konteks Pengguna\nSetiap pesan mungkin memiliki tag <user_reputation>. Tag ini hanya indikator **referensi**, bukan bukti pelanggaran. Nilai trust_score yang rendah bukan alasan untuk memflag pesan yang bersih. Nilai trust_score yang tinggi bukan alasan untuk mengabaikan pelanggaran nyata. **Setiap pesan harus dinilai berdasarkan isinya sendiri.**`,
|
`## Blok Data di Pesan USER\n` +
|
||||||
|
`Semua data dinamis per-batch dikirim di pesan USER — system prompt ini TIDAK memuat data batch:\n` +
|
||||||
|
`- <location_context .../> = metadata channel/thread (channel_id, channel_name, thread_name, nsfw, age_restricted).\n` +
|
||||||
|
`- <conversation_context> = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` +
|
||||||
|
`- <user_profiles> = peta ringkasan kepribadian per user_id; setiap <message> merujuk lewat <user_profile_ref user_id="..."/>.\n` +
|
||||||
|
`- <web_searches> / <web_content> = bukti web (lihat "Web Sebagai Bukti Utama").\n` +
|
||||||
|
`- <messages_to_analyze> = pesan-pesan TARGET yang WAJIB dinilai.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
`## Konteks Pengguna (Referensi, Bukan Bukti)\n` +
|
||||||
|
`Konteks per pengguna hanya indikator **referensi** untuk personalisasi analisis, BUKAN bukti pelanggaran:\n` +
|
||||||
|
`- <user_reputation trust_score="..."> = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata.\n` +
|
||||||
|
`- <user_profiles> (di pesan USER) = peta ringkasan kepribadian per user_id. <user_profile_ref user_id="..."/> dalam sebuah pesan menunjuk ke peta itu. Tanpa ref = tidak ada profil untuk pengguna tersebut.\n` +
|
||||||
|
`- Profil berguna untuk mengenali penyimpangan perilaku mencolok (mis. pengguna teknis tiba-tiba provokatif), tapi JANGAN memflag atau meloloskan hanya karena profil.\n` +
|
||||||
|
`**Setiap pesan dinilai berdasarkan isinya sendiri.**`,
|
||||||
|
);
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
`## Framing: Konteks vs Target\n` +
|
||||||
|
`- Baris dalam <conversation_context> berformat "[context] id=... time=<ISO> user=<nama>: isi", diurutkan paling lama → paling baru. Baris pertama biasanya "[conversation_flow] status=... context_msgs=... dropped=..." — metadata sistem tentang status percakapan (ongoing/sparse/cold_start), BUKAN pesan yang dinilai.\n` +
|
||||||
|
`- <messages_to_analyze> berisi pesan-pesan TARGET yang WAJIB dinilai. Hasilkan SATU hasil per message_id — jangan menggabungkan beberapa pesan, jangan melewati, jangan mengarang id.\n` +
|
||||||
|
`- Setiap target dinilai berdasarkan isinya sendiri; konteks percakapan memengaruhi interpretasi, bukan menggantikan isi pesan.\n` +
|
||||||
|
`- Marker "…[pesan dipotong: terlalu panjang]" = konten TARGET sengaja dipotong; marker "…[konteks dipotong: terlalu panjang]" = konten pesan KONTEKS dipotong. Nilai dari bagian yang terlihat; pemotongan BUKAN pelanggaran dan BUKAN teknik evasi.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
parts.push(OUTPUT_INSTRUCTIONS);
|
parts.push(OUTPUT_INSTRUCTIONS);
|
||||||
|
|
||||||
// XML-delimited context — prevents prompt injection
|
|
||||||
const delimitedContext = `<conversation_context>\n${sanitizeAiContent(contextText, 8000)}\n</conversation_context>`;
|
|
||||||
parts.push(delimitedContext);
|
|
||||||
|
|
||||||
let base = parts.join("\n\n");
|
let base = parts.join("\n\n");
|
||||||
|
|
||||||
if (correction) {
|
if (correction) {
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ import { callModerationLLM } from "./llmCaller.js";
|
|||||||
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
|
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
|
||||||
import {
|
import {
|
||||||
buildReferenceXml,
|
buildReferenceXml,
|
||||||
|
buildUserProfileRef,
|
||||||
|
buildUserProfilesBlock,
|
||||||
escapeXml,
|
escapeXml,
|
||||||
getAnalysisContent,
|
getAnalysisContent,
|
||||||
resolveDisplayName,
|
resolveDisplayName,
|
||||||
|
truncateForAi,
|
||||||
} from "./moderationBuilders.js";
|
} from "./moderationBuilders.js";
|
||||||
import {
|
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||||
buildSystemPrompt as buildSystemPromptModular,
|
|
||||||
sanitizeAiContent,
|
|
||||||
} from "./moderationPrompt.js";
|
|
||||||
import { logModerationAnalysis } from "./responseLogger.js";
|
import { logModerationAnalysis } from "./responseLogger.js";
|
||||||
import {
|
import {
|
||||||
extractSearchQueries,
|
extractSearchQueries,
|
||||||
@@ -74,7 +74,7 @@ export async function buildCorrectedFewShotExamples(): Promise<string> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export async function runTextOnlyBatch(
|
export async function runTextOnlyBatch(
|
||||||
targets: MessageRecord[],
|
targets: MessageRecord[],
|
||||||
contextText: string,
|
contextBlock: string,
|
||||||
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
||||||
if (!targets.length) return { results: [], raw: null };
|
if (!targets.length) return { results: [], raw: null };
|
||||||
|
|
||||||
@@ -188,7 +188,8 @@ export async function runTextOnlyBatch(
|
|||||||
const batch = subBatches[i];
|
const batch = subBatches[i];
|
||||||
const targetIds = batch.map((t) => t.id);
|
const targetIds = batch.map((t) => t.id);
|
||||||
|
|
||||||
// User reputation + profiles
|
// User reputation + profiles (raw summary text — deduplicated into a
|
||||||
|
// single <user_profiles> map per batch; messages only reference it).
|
||||||
const userContexts = new Map<string, string>();
|
const userContexts = new Map<string, string>();
|
||||||
const userProfiles = new Map<string, string>();
|
const userProfiles = new Map<string, string>();
|
||||||
for (const msg of batch) {
|
for (const msg of batch) {
|
||||||
@@ -201,14 +202,10 @@ export async function runTextOnlyBatch(
|
|||||||
}
|
}
|
||||||
if (!userProfiles.has(msg.user_id)) {
|
if (!userProfiles.has(msg.user_id)) {
|
||||||
const profile = await getUserProfile(msg.user_id);
|
const profile = await getUserProfile(msg.user_id);
|
||||||
userProfiles.set(
|
userProfiles.set(msg.user_id, profile?.profile_summary ?? "");
|
||||||
msg.user_id,
|
|
||||||
profile
|
|
||||||
? `<user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>`
|
|
||||||
: "",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const userProfilesBlock = buildUserProfilesBlock(userProfiles);
|
||||||
|
|
||||||
// ── URL images → multimodal vision evidence ─────────────────────────
|
// ── URL images → multimodal vision evidence ─────────────────────────
|
||||||
// The text batch fetches inline URLs; whenever one resolved to an image
|
// The text batch fetches inline URLs; whenever one resolved to an image
|
||||||
@@ -280,7 +277,6 @@ export async function runTextOnlyBatch(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||||
const systemText = buildSystemPromptModular({
|
const systemText = buildSystemPromptModular({
|
||||||
contextText,
|
|
||||||
mode: batchHasImageEvidence ? "mixed" : "text",
|
mode: batchHasImageEvidence ? "mixed" : "text",
|
||||||
correction,
|
correction,
|
||||||
correctedExamples,
|
correctedExamples,
|
||||||
@@ -290,7 +286,7 @@ export async function runTextOnlyBatch(
|
|||||||
const messagesBlock = (
|
const messagesBlock = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async (msg) => {
|
batch.map(async (msg) => {
|
||||||
const content = getAnalysisContent(msg);
|
const content = truncateForAi(getAnalysisContent(msg));
|
||||||
const msgUrls = extractUrlsFromText(content);
|
const msgUrls = extractUrlsFromText(content);
|
||||||
const urlContexts = msgUrls
|
const urlContexts = msgUrls
|
||||||
.map((url) => {
|
.map((url) => {
|
||||||
@@ -307,25 +303,36 @@ export async function runTextOnlyBatch(
|
|||||||
.map((line) => `\n${line}`)
|
.map((line) => `\n${line}`)
|
||||||
.join("");
|
.join("");
|
||||||
const userCtx = userContexts.get(msg.user_id) ?? "";
|
const userCtx = userContexts.get(msg.user_id) ?? "";
|
||||||
const userProfileCtx = userProfiles.get(msg.user_id) ?? "";
|
const userProfileRef = (userProfiles.get(msg.user_id) ?? "").trim()
|
||||||
|
? buildUserProfileRef(msg.user_id)
|
||||||
|
: "";
|
||||||
const refXml = await buildReferenceXml(msg);
|
const refXml = await buildReferenceXml(msg);
|
||||||
return `<message id="${msg.id}" user="${resolveDisplayName(msg)}">\n ${userCtx}${userProfileCtx ? `\n ${userProfileCtx}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`;
|
return `<message id="${msg.id}" user="${resolveDisplayName(msg)}">\n ${userCtx}${userProfileRef ? `\n ${userProfileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
).join("\n");
|
).join("\n");
|
||||||
|
|
||||||
const searxngBlock =
|
const searxngBlock =
|
||||||
searxngResults.size > 0
|
searxngResults.size > 0
|
||||||
? `\n\n<web_searches>\n${Array.from(searxngResults.entries())
|
? `<web_searches>\n${Array.from(searxngResults.entries())
|
||||||
.map(
|
.map(
|
||||||
([q, xml]) =>
|
([q, xml]) =>
|
||||||
` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`,
|
` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`,
|
||||||
)
|
)
|
||||||
.join("\n")}\n</web_searches>`
|
.join("\n")}\n</web_searches>`
|
||||||
: "";
|
: "";
|
||||||
|
// Data/instruction separation: the system prompt is stable per mode —
|
||||||
|
// all per-batch context (profiles, conversation, web evidence) lives in
|
||||||
|
// the USER payload, ordered oldest-first so targets come last.
|
||||||
|
const userBlocks = [
|
||||||
|
userProfilesBlock?.trimEnd() ?? "",
|
||||||
|
contextBlock?.trimEnd() ?? "",
|
||||||
|
searxngBlock,
|
||||||
|
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
||||||
|
].filter((b) => b.trim().length > 0);
|
||||||
return {
|
return {
|
||||||
system: systemText,
|
system: systemText,
|
||||||
user: `${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
user: userBlocks.join("\n\n"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,16 +37,17 @@ import {
|
|||||||
} from "./mediaDownloader.js";
|
} from "./mediaDownloader.js";
|
||||||
import {
|
import {
|
||||||
buildReferenceXml,
|
buildReferenceXml,
|
||||||
|
buildUserProfileRef,
|
||||||
escapeXml,
|
escapeXml,
|
||||||
getAnalysisContent,
|
getAnalysisContent,
|
||||||
resolveDisplayName,
|
resolveDisplayName,
|
||||||
|
truncateForAi,
|
||||||
} from "./moderationBuilders.js";
|
} from "./moderationBuilders.js";
|
||||||
import {
|
import {
|
||||||
buildCustomEmojiVisionPrompt,
|
buildCustomEmojiVisionPrompt,
|
||||||
buildGeneralImageVisionPrompt,
|
buildGeneralImageVisionPrompt,
|
||||||
buildStickerTextOnlyWarning,
|
buildStickerTextOnlyWarning,
|
||||||
buildStickerVisionPrompt,
|
buildStickerVisionPrompt,
|
||||||
sanitizeAiContent,
|
|
||||||
} from "./moderationPrompt.js";
|
} from "./moderationPrompt.js";
|
||||||
import {
|
import {
|
||||||
extractSearchQueries,
|
extractSearchQueries,
|
||||||
@@ -367,7 +368,14 @@ export async function prepareMediaMessage(
|
|||||||
const rep = await initializeUserReputation(target.user_id, target.guild_id);
|
const rep = await initializeUserReputation(target.user_id, target.guild_id);
|
||||||
const profile = await getUserProfile(target.user_id);
|
const profile = await getUserProfile(target.user_id);
|
||||||
const refXml = await buildReferenceXml(target);
|
const refXml = await buildReferenceXml(target);
|
||||||
|
// Profile is emitted ONCE per batch in a <user_profiles> map (see
|
||||||
|
// mediaBatchProcessor); here we only reference it to avoid repeating the
|
||||||
|
// full summary on every message of the same user.
|
||||||
|
const profileRef =
|
||||||
|
profile && profile.profile_summary?.trim()
|
||||||
|
? buildUserProfileRef(target.user_id)
|
||||||
|
: "";
|
||||||
|
|
||||||
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}">\n <user_reputation trust_score="${rep.trust_score}" />${profile ? `\n <user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
|
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}">\n <user_reputation trust_score="${rep.trust_score}" />${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
|
||||||
return { targetId, messageBlock };
|
return { targetId, messageBlock };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
buildConversationContext,
|
buildConversationContext,
|
||||||
buildLocationContext,
|
buildLocationContext,
|
||||||
formatMessageForPrompt,
|
formatMessageForPrompt,
|
||||||
|
truncateContextLine,
|
||||||
} from "../src/modules/ai-moderation/conversationContext.js";
|
} from "../src/modules/ai-moderation/conversationContext.js";
|
||||||
|
import { buildConversationContextBlock } from "../src/modules/ai-moderation/moderationBuilders.js";
|
||||||
import { extractOgMeta } from "../src/modules/ai-moderation/urlFetcher.js";
|
import { extractOgMeta } from "../src/modules/ai-moderation/urlFetcher.js";
|
||||||
import type { MessageRecord } from "../src/modules/message-capture/types.js";
|
import type { MessageRecord } from "../src/modules/message-capture/types.js";
|
||||||
|
|
||||||
@@ -162,10 +164,21 @@ describe("formatMessageForPrompt — server nickname (displayName)", () => {
|
|||||||
);
|
);
|
||||||
expect(line).toContain("user=user_n2");
|
expect(line).toContain("user=user_n2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("truncates an oversized context message so one paste cannot eat the whole budget", () => {
|
||||||
|
const huge = "A".repeat(5000);
|
||||||
|
const line = formatMessageForPrompt(msg("n3", NOW - MIN, huge), "context");
|
||||||
|
expect(line).toContain("…[konteks dipotong: terlalu panjang]");
|
||||||
|
expect(line.length).toBeLessThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps short context content intact", () => {
|
||||||
|
expect(truncateContextLine("pendek")).toBe("pendek");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildLocationContext — channel/thread/nsfw enrichment", () => {
|
describe("buildLocationContext — channel/thread/nsfw enrichment", () => {
|
||||||
it("renders channel name + thread name from captured metadata", () => {
|
it("renders a structured <location_context/> element from captured metadata", () => {
|
||||||
const t = target();
|
const t = target();
|
||||||
t.metadata = JSON.stringify({
|
t.metadata = JSON.stringify({
|
||||||
channel: {
|
channel: {
|
||||||
@@ -176,10 +189,12 @@ describe("buildLocationContext — channel/thread/nsfw enrichment", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const line = buildLocationContext([t]);
|
const line = buildLocationContext([t]);
|
||||||
expect(line).toContain("[location]");
|
expect(line).toContain("<location_context");
|
||||||
expect(line).toContain('name="general"');
|
expect(line).toContain('channel_id="c1"');
|
||||||
|
expect(line).toContain('channel_name="general"');
|
||||||
expect(line).toContain('thread_name="tanya coding"');
|
expect(line).toContain('thread_name="tanya coding"');
|
||||||
expect(line).toContain("nsfw=false");
|
expect(line).toContain('nsfw="false"');
|
||||||
|
expect(line).toContain('age_restricted="false"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty when no metadata", () => {
|
it("returns empty when no metadata", () => {
|
||||||
@@ -187,6 +202,50 @@ describe("buildLocationContext — channel/thread/nsfw enrichment", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildConversationContextBlock — structured USER-message context", () => {
|
||||||
|
it("wraps location + descriptor + lines into XML blocks", () => {
|
||||||
|
const block = buildConversationContextBlock({
|
||||||
|
location: buildLocationContext(
|
||||||
|
(() => {
|
||||||
|
const t = target();
|
||||||
|
t.metadata = JSON.stringify({
|
||||||
|
channel: { channelName: "general", nsfw: false },
|
||||||
|
});
|
||||||
|
return [t];
|
||||||
|
})(),
|
||||||
|
),
|
||||||
|
descriptor: "[conversation_flow] status=ongoing context_msgs=1 dropped=0",
|
||||||
|
lines: ["[context] id=a time=2027-01-01T00:00:00.000Z user=user_a: hai"],
|
||||||
|
});
|
||||||
|
expect(block).toContain("<location_context");
|
||||||
|
expect(block).toContain("<conversation_context>");
|
||||||
|
expect(block).toContain("[conversation_flow] status=ongoing");
|
||||||
|
expect(block).toContain("[context] id=a");
|
||||||
|
// location block comes before conversation block
|
||||||
|
expect(block.indexOf("<location_context")).toBeLessThan(
|
||||||
|
block.indexOf("<conversation_context>"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the conversation block when there are no lines", () => {
|
||||||
|
const block = buildConversationContextBlock({
|
||||||
|
location: "",
|
||||||
|
descriptor: "",
|
||||||
|
lines: [],
|
||||||
|
});
|
||||||
|
expect(block).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the location block when lines are empty but location exists", () => {
|
||||||
|
const block = buildConversationContextBlock({
|
||||||
|
location: '<location_context channel_id="c1"/>',
|
||||||
|
descriptor: "",
|
||||||
|
lines: [],
|
||||||
|
});
|
||||||
|
expect(block).toBe('<location_context channel_id="c1"/>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("extractOgMeta — page title/site for <web_content>", () => {
|
describe("extractOgMeta — page title/site for <web_content>", () => {
|
||||||
it("extracts og:title, og:description and og:site_name", () => {
|
it("extracts og:title, og:description and og:site_name", () => {
|
||||||
const html = `
|
const html = `
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// 1. AppError Hierarchy
|
// 1. AppError Hierarchy
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
AppError,
|
AppError,
|
||||||
ConfigError,
|
ConfigError,
|
||||||
@@ -9,7 +11,6 @@ import {
|
|||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
} from "../src/shared/errors/index.js";
|
} from "../src/shared/errors/index.js";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
describe("AppError subclasses", () => {
|
describe("AppError subclasses", () => {
|
||||||
it("AppError carries code, statusCode, and details", () => {
|
it("AppError carries code, statusCode, and details", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user