diff --git a/scripts/add-ai-analysis-duration.sql b/scripts/add-ai-analysis-duration.sql new file mode 100644 index 0000000..5f4f356 --- /dev/null +++ b/scripts/add-ai-analysis-duration.sql @@ -0,0 +1,10 @@ +-- Migration: add ai_analysis_duration_ms to messages +-- Tracks how long the AI moderation LLM call took, per message (ms). +-- Idempotent: safe to re-run. +-- +-- Run against the production GMW database, e.g.: +-- PGPASSWORD=*** psql -h 100.121.180.82 -p 6432 -U asephs -d dcbot \ +-- -f scripts/add-ai-analysis-duration.sql + +ALTER TABLE "messages" + ADD COLUMN IF NOT EXISTS "ai_analysis_duration_ms" BIGINT; diff --git a/services/backend/src/shared/database/schema.ts b/services/backend/src/shared/database/schema.ts index bca1b57..5855443 100644 --- a/services/backend/src/shared/database/schema.ts +++ b/services/backend/src/shared/database/schema.ts @@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable( enum: ["none", "monitor", "warn", "review", "delete", "escalate"], }), ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }), + ai_analysis_duration_ms: pgBigint("ai_analysis_duration_ms", { + mode: "number", + }), ai_error: pgText("ai_error"), }, (table) => ({ diff --git a/services/backend/src/shared/moderation-types.ts b/services/backend/src/shared/moderation-types.ts index ab33481..1ddee08 100644 --- a/services/backend/src/shared/moderation-types.ts +++ b/services/backend/src/shared/moderation-types.ts @@ -80,6 +80,7 @@ export interface MessageRecord { ai_confidence?: number | null; ai_recommended_action?: AIRecommendedAction | null; ai_analyzed_at?: number | null; + ai_analysis_duration_ms?: number | null; ai_error?: string | null; } diff --git a/services/backend/src/shared/utils/messageMapper.ts b/services/backend/src/shared/utils/messageMapper.ts index f238350..d2e0817 100644 --- a/services/backend/src/shared/utils/messageMapper.ts +++ b/services/backend/src/shared/utils/messageMapper.ts @@ -24,6 +24,7 @@ export interface MappedMessage { ai_confidence: number | null; ai_recommended_action: string | null; ai_analyzed_at: number | null; + ai_analysis_duration_ms: number | null; ai_error: string | null; is_reply: boolean | null; is_forward: boolean | null; @@ -58,6 +59,8 @@ export function mapMessageRow(row: Record): MappedMessage { ai_confidence: (row.ai_confidence as number | null) ?? null, ai_recommended_action: (row.ai_recommended_action as string | null) ?? null, ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null, + ai_analysis_duration_ms: + (row.ai_analysis_duration_ms as number | null) ?? null, ai_error: (row.ai_error as string | null) ?? null, is_reply: row.is_reply === null ? null : Boolean(row.is_reply), is_forward: row.is_forward === null ? null : Boolean(row.is_forward), diff --git a/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts b/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts index be31b60..ce80ae2 100644 --- a/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts +++ b/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts @@ -316,11 +316,13 @@ async function processBatch(job: { // The orchestrator handles text/media split + caching + parallel paths // internally, so a 20-message batch = 1 text LLM call (+1 media call // when media is present), not N per-message calls. + const analysisStart = Date.now(); const moderationResult = await runModerationAnalysis({ targets: readyMessages, contextBlock, attachments, }); + const analysisDurationMs = Date.now() - analysisStart; const results = moderationResult.results.map((r) => normalizeResult( @@ -342,6 +344,7 @@ async function processBatch(job: { confidence: result.confidence, recommendedAction: result.recommendedAction, analyzedAt: Date.now(), + analysisDurationMs, error: result.status === "error" ? result.analysis : null, }, })); diff --git a/services/discord-gateway/src/modules/message-capture/messagesAnalysis.ts b/services/discord-gateway/src/modules/message-capture/messagesAnalysis.ts index f761a67..c2161c4 100644 --- a/services/discord-gateway/src/modules/message-capture/messagesAnalysis.ts +++ b/services/discord-gateway/src/modules/message-capture/messagesAnalysis.ts @@ -26,6 +26,8 @@ export interface AIAnalysisUpdate { confidence?: number | null; recommendedAction?: MessageRecord["ai_recommended_action"] | null; analyzedAt?: number | null; + /** Wall-clock time the AI analysis (LLM call) took, in milliseconds. */ + analysisDurationMs?: number | null; error?: string | null; } @@ -42,6 +44,7 @@ function buildAIAnalysisSet(result: AIAnalysisUpdate, now?: number) { ai_confidence: result.confidence ?? result.score ?? null, ai_recommended_action: result.recommendedAction ?? null, ai_analyzed_at: result.analyzedAt ?? now ?? Date.now(), + ai_analysis_duration_ms: result.analysisDurationMs ?? null, ai_error: result.error ?? null, }; } diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index 7715a9d..414519f 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable( enum: ["none", "monitor", "warn", "review", "delete", "escalate"], }), ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }), + ai_analysis_duration_ms: pgBigint("ai_analysis_duration_ms", { + mode: "number", + }), ai_error: pgText("ai_error"), }, (table) => ({ diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx index 54ab97b..3addbe5 100644 --- a/services/frontend/src/app/(dashboard)/analysis/view.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx @@ -18,6 +18,12 @@ function aiTone( return "neutral"; } +/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */ +function formatAnalysisDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + export function AnalysisView() { const [query, setQuery] = useState(""); const search = useMessageSearch(query, query.trim().length >= 2); @@ -99,7 +105,10 @@ export function AnalysisView() { {m.ai_status && ( - {m.ai_status} + {m.ai_analysis_duration_ms && + m.ai_analysis_duration_ms > 0 + ? `${m.ai_status} · ${formatAnalysisDuration(m.ai_analysis_duration_ms)}` + : m.ai_status} )} diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 40bd3ae..0cd1af6 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -174,7 +174,10 @@ export function MessagesView({ )} - + ))} @@ -207,7 +210,13 @@ export function MessagesView({ ); } -function AiBadge({ status }: { status?: AiStatus | null }) { +function AiBadge({ + status, + durationMs, +}: { + status?: AiStatus | null; + durationMs?: number | null; +}) { if (!status) return null; const tone = aiTone(status); const icon = @@ -222,14 +231,25 @@ function AiBadge({ status }: { status?: AiStatus | null }) { ) : ( ); + const label = + durationMs && durationMs > 0 + ? `${status} · ${formatDuration(durationMs)}` + : status; + return ( {icon} - {status} + {label} ); } +/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */ +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + function MessageDetail({ m, attachments, @@ -250,7 +270,10 @@ function MessageDetail({
- +
diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index 9005baa..aa88338 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -130,6 +130,7 @@ export interface MessageRecord { ai_recommended_action?: AiRecommendedAction | null; ai_error?: string | null; ai_analyzed_at?: number | null; + ai_analysis_duration_ms?: number | null; /** Detail-only: number of past edits (message_edits snapshots) */ edit_count?: number; /** Detail-only: previous content snapshots, newest first */