feat: surface AI analysis duration across gateway, backend, and FE
Adds per-message AI moderation analysis time (ai_analysis_duration_ms) so operators can see how long the LLM took to moderate each message. Gateway: - messagesTable: new ai_analysis_duration_ms (bigint) column. - AIAnalysisUpdate + buildAIAnalysisSet: carry analysisDurationMs through both single and bulk update paths. - ai-analysis-worker: measure wall-clock time around runModerationAnalysis and attach it to every result in the batch. Backend: - Mirror schema column; messageMapper maps ai_analysis_duration_ms; moderation-types + MappedMessage expose it. Frontend: - message.ts type gains ai_analysis_duration_ms. - AiBadge (messages view) shows 'status · 1.2s' when duration is present; analysis view badge mirrors the same formatting. DB: - scripts/add-ai-analysis-duration.sql (idempotent ADD COLUMN IF NOT EXISTS). No behavior change for moderation logic; null until new gateway build records values.
This commit is contained in:
@@ -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;
|
||||||
@@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable(
|
|||||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||||
}),
|
}),
|
||||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
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"),
|
ai_error: pgText("ai_error"),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export interface MessageRecord {
|
|||||||
ai_confidence?: number | null;
|
ai_confidence?: number | null;
|
||||||
ai_recommended_action?: AIRecommendedAction | null;
|
ai_recommended_action?: AIRecommendedAction | null;
|
||||||
ai_analyzed_at?: number | null;
|
ai_analyzed_at?: number | null;
|
||||||
|
ai_analysis_duration_ms?: number | null;
|
||||||
ai_error?: string | null;
|
ai_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface MappedMessage {
|
|||||||
ai_confidence: number | null;
|
ai_confidence: number | null;
|
||||||
ai_recommended_action: string | null;
|
ai_recommended_action: string | null;
|
||||||
ai_analyzed_at: number | null;
|
ai_analyzed_at: number | null;
|
||||||
|
ai_analysis_duration_ms: number | null;
|
||||||
ai_error: string | null;
|
ai_error: string | null;
|
||||||
is_reply: boolean | null;
|
is_reply: boolean | null;
|
||||||
is_forward: boolean | null;
|
is_forward: boolean | null;
|
||||||
@@ -58,6 +59,8 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
|
|||||||
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
||||||
ai_recommended_action: (row.ai_recommended_action as string | 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_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,
|
ai_error: (row.ai_error as string | null) ?? null,
|
||||||
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
||||||
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
||||||
|
|||||||
@@ -316,11 +316,13 @@ async function processBatch(job: {
|
|||||||
// The orchestrator handles text/media split + caching + parallel paths
|
// The orchestrator handles text/media split + caching + parallel paths
|
||||||
// internally, so a 20-message batch = 1 text LLM call (+1 media call
|
// internally, so a 20-message batch = 1 text LLM call (+1 media call
|
||||||
// when media is present), not N per-message calls.
|
// when media is present), not N per-message calls.
|
||||||
|
const analysisStart = Date.now();
|
||||||
const moderationResult = await runModerationAnalysis({
|
const moderationResult = await runModerationAnalysis({
|
||||||
targets: readyMessages,
|
targets: readyMessages,
|
||||||
contextBlock,
|
contextBlock,
|
||||||
attachments,
|
attachments,
|
||||||
});
|
});
|
||||||
|
const analysisDurationMs = Date.now() - analysisStart;
|
||||||
|
|
||||||
const results = moderationResult.results.map((r) =>
|
const results = moderationResult.results.map((r) =>
|
||||||
normalizeResult(
|
normalizeResult(
|
||||||
@@ -342,6 +344,7 @@ async function processBatch(job: {
|
|||||||
confidence: result.confidence,
|
confidence: result.confidence,
|
||||||
recommendedAction: result.recommendedAction,
|
recommendedAction: result.recommendedAction,
|
||||||
analyzedAt: Date.now(),
|
analyzedAt: Date.now(),
|
||||||
|
analysisDurationMs,
|
||||||
error: result.status === "error" ? result.analysis : null,
|
error: result.status === "error" ? result.analysis : null,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface AIAnalysisUpdate {
|
|||||||
confidence?: number | null;
|
confidence?: number | null;
|
||||||
recommendedAction?: MessageRecord["ai_recommended_action"] | null;
|
recommendedAction?: MessageRecord["ai_recommended_action"] | null;
|
||||||
analyzedAt?: number | null;
|
analyzedAt?: number | null;
|
||||||
|
/** Wall-clock time the AI analysis (LLM call) took, in milliseconds. */
|
||||||
|
analysisDurationMs?: number | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ function buildAIAnalysisSet(result: AIAnalysisUpdate, now?: number) {
|
|||||||
ai_confidence: result.confidence ?? result.score ?? null,
|
ai_confidence: result.confidence ?? result.score ?? null,
|
||||||
ai_recommended_action: result.recommendedAction ?? null,
|
ai_recommended_action: result.recommendedAction ?? null,
|
||||||
ai_analyzed_at: result.analyzedAt ?? now ?? Date.now(),
|
ai_analyzed_at: result.analyzedAt ?? now ?? Date.now(),
|
||||||
|
ai_analysis_duration_ms: result.analysisDurationMs ?? null,
|
||||||
ai_error: result.error ?? null,
|
ai_error: result.error ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable(
|
|||||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||||
}),
|
}),
|
||||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
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"),
|
ai_error: pgText("ai_error"),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ function aiTone(
|
|||||||
return "neutral";
|
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() {
|
export function AnalysisView() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||||
@@ -99,7 +105,10 @@ export function AnalysisView() {
|
|||||||
</span>
|
</span>
|
||||||
{m.ai_status && (
|
{m.ai_status && (
|
||||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||||
{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}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ export function MessagesView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AiBadge status={m.ai_status} />
|
<AiBadge
|
||||||
|
status={m.ai_status}
|
||||||
|
durationMs={m.ai_analysis_duration_ms}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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;
|
if (!status) return null;
|
||||||
const tone = aiTone(status);
|
const tone = aiTone(status);
|
||||||
const icon =
|
const icon =
|
||||||
@@ -222,14 +231,25 @@ function AiBadge({ status }: { status?: AiStatus | null }) {
|
|||||||
) : (
|
) : (
|
||||||
<AlertTriangle className="size-3" />
|
<AlertTriangle className="size-3" />
|
||||||
);
|
);
|
||||||
|
const label =
|
||||||
|
durationMs && durationMs > 0
|
||||||
|
? `${status} · ${formatDuration(durationMs)}`
|
||||||
|
: status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge tone={tone} dot={status === "processing" || status === "pending"}>
|
<Badge tone={tone} dot={status === "processing" || status === "pending"}>
|
||||||
{icon}
|
{icon}
|
||||||
{status}
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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({
|
function MessageDetail({
|
||||||
m,
|
m,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -250,7 +270,10 @@ function MessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<AiBadge status={m.ai_status} />
|
<AiBadge
|
||||||
|
status={m.ai_status}
|
||||||
|
durationMs={m.ai_analysis_duration_ms}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export interface MessageRecord {
|
|||||||
ai_recommended_action?: AiRecommendedAction | null;
|
ai_recommended_action?: AiRecommendedAction | null;
|
||||||
ai_error?: string | null;
|
ai_error?: string | null;
|
||||||
ai_analyzed_at?: number | null;
|
ai_analyzed_at?: number | null;
|
||||||
|
ai_analysis_duration_ms?: number | null;
|
||||||
/** Detail-only: number of past edits (message_edits snapshots) */
|
/** Detail-only: number of past edits (message_edits snapshots) */
|
||||||
edit_count?: number;
|
edit_count?: number;
|
||||||
/** Detail-only: previous content snapshots, newest first */
|
/** Detail-only: previous content snapshots, newest first */
|
||||||
|
|||||||
Reference in New Issue
Block a user