diff --git a/packages/shared/src/database/schema.ts b/packages/shared/src/database/schema.ts index e1b032e..831de74 100644 --- a/packages/shared/src/database/schema.ts +++ b/packages/shared/src/database/schema.ts @@ -91,6 +91,37 @@ export const pgMessagesTable = pgTable( }), ); +/** + * Corrected Moderations Table (PostgreSQL) + * Stores manual corrections of AI moderation false positives + * for few-shot injection into LLM moderation prompts. + */ +export const pgCorrectedModerationsTable = pgTable( + "corrected_moderations", + { + id: pgText("id").primaryKey(), + message_id: pgText("message_id").notNull(), + original_flags: pgText("original_flags").notNull(), + corrected_flags: pgText("corrected_flags").notNull(), + correction_notes: pgText("correction_notes"), + content_snippet: pgText("content_snippet").notNull(), + created_at: pgBigint("created_at", { mode: "number" }).notNull(), + }, + (table) => ({ + createdAtIdx: pgIndex("idx_corrected_moderations_created_at").on( + table.created_at, + ), + messageIdx: pgIndex("idx_corrected_moderations_message_id").on( + table.message_id, + ), + }), +); + +export type CorrectedModeration = + typeof pgCorrectedModerationsTable.$inferSelect; +export type CorrectedModerationInsert = + typeof pgCorrectedModerationsTable.$inferInsert; + /** * Attachments Table (PostgreSQL) * Stores attachment metadata with upload status tracking diff --git a/services/backend/src/http/app.ts b/services/backend/src/http/app.ts index 8ab7a71..353a1a9 100644 --- a/services/backend/src/http/app.ts +++ b/services/backend/src/http/app.ts @@ -9,6 +9,7 @@ import helmet from "helmet"; import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js"; import { createAuthRouter } from "../modules/auth/auth.routes.js"; import { createConfigRouter } from "../modules/config/config.routes.js"; +import { createCorrectionsRouter } from "../modules/corrections/corrections.routes.js"; import { createHealthRouter } from "../modules/health/health.routes.js"; import { createMascotChatRouter } from "../modules/mascot-chat/mascot-chat.routes.js"; import { createMediaRouter } from "../modules/media/media.routes.js"; @@ -63,6 +64,7 @@ export function createHttpApp(): Express { // API routes app.use("/api", createAuthRouter()); app.use("/api", createConfigRouter()); + app.use("/api", createCorrectionsRouter()); app.use("/api", createMessagesRouter()); app.use("/api", createAnalysisRouter()); app.use("/api", createMascotChatRouter()); diff --git a/services/backend/src/modules/corrections/corrections.repository.ts b/services/backend/src/modules/corrections/corrections.repository.ts new file mode 100644 index 0000000..c4e2c72 --- /dev/null +++ b/services/backend/src/modules/corrections/corrections.repository.ts @@ -0,0 +1,116 @@ +import { createChildLogger } from "@bete/shared/logger"; +import { + pgCorrectedModerationsTable, + type CorrectedModeration, + type CorrectedModerationInsert, +} from "@bete/shared"; +import { and, desc, lt, eq, sql } from "drizzle-orm"; +import { getDatabase } from "../../shared/database/index.js"; +import type { CorrectionCreate, CorrectionQuery } from "./corrections.schema.js"; + +const logger = createChildLogger("corrections.repository"); + +export interface CorrectionStatsResult { + total_corrections: number; + recent_count_7d: number; + by_flag: Array<{ flag: string; count: number }>; +} + +export class CorrectionsRepository { + async getStats(): Promise { + const db = getDatabase(); + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + + // Total count + const [totalRow] = await db + .select({ count: sql`count(*)::int` }) + .from(pgCorrectedModerationsTable); + + // Recent 7 days count + const [recentRow] = await db + .select({ count: sql`count(*)::int` }) + .from(pgCorrectedModerationsTable) + .where(lt(pgCorrectedModerationsTable.created_at, sevenDaysAgo)); + + // Count by original_flag using JSON array unnest + const byFlagRows = await db.execute(sql` + SELECT flag, count(*)::int as count + FROM corrected_moderations, + json_array_elements_text(original_flags::json) AS flag + GROUP BY flag + ORDER BY count DESC + LIMIT 20 + `); + + const byFlag = (byFlagRows.rows ?? []).map( + (r: Record) => ({ + flag: String(r.flag), + count: Number(r.count), + }), + ); + + return { + total_corrections: totalRow?.count ?? 0, + recent_count_7d: recentRow?.count ?? 0, + by_flag: byFlag, + }; + } + + async list( + query: CorrectionQuery, + ): Promise<{ data: CorrectedModeration[]; nextCursor: string | null }> { + const db = getDatabase(); + const limit = query.limit ?? 20; + const conditions = []; + + if (query.cursor) { + conditions.push( + lt(pgCorrectedModerationsTable.created_at, Number(query.cursor)), + ); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const rows = await db + .select() + .from(pgCorrectedModerationsTable) + .where(where) + .orderBy(desc(pgCorrectedModerationsTable.created_at)) + .limit(limit + 1); + + const data = rows.slice(0, limit); + const nextCursor = + rows.length > limit ? String(rows[limit].created_at) : null; + + return { data, nextCursor }; + } + + async create(data: CorrectionCreate): Promise { + const db = getDatabase(); + const id = `corr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + + const insert: CorrectedModerationInsert = { + id, + message_id: data.message_id, + original_flags: JSON.stringify(data.original_flags), + corrected_flags: JSON.stringify(data.corrected_flags), + correction_notes: data.correction_notes ?? null, + content_snippet: data.content_snippet, + created_at: Date.now(), + }; + + const [row] = await db + .insert(pgCorrectedModerationsTable) + .values(insert) + .returning(); + + logger.info( + { id, messageId: data.message_id }, + "Correction recorded", + ); + + return row; + } +} + +export const correctionsRepository = new CorrectionsRepository(); diff --git a/services/backend/src/modules/corrections/corrections.routes.ts b/services/backend/src/modules/corrections/corrections.routes.ts new file mode 100644 index 0000000..b31541d --- /dev/null +++ b/services/backend/src/modules/corrections/corrections.routes.ts @@ -0,0 +1,99 @@ +import { createChildLogger } from "@bete/shared/logger"; +import type { Request, Response, Router } from "express"; +import express from "express"; +import { asyncHandler } from "../../shared/middlewares/index.js"; +import { correctionsService } from "./corrections.service.js"; + +const logger = createChildLogger("corrections.routes"); + +/** + * Prevents concurrent duplicate correction submissions + * for the same message_id within a short window. + */ +const createInFlight = new Set(); + +export function createCorrectionsRouter(): Router { + const router = express.Router(); + + // GET /api/corrections/stats — aggregated correction statistics + router.get( + "/corrections/stats", + asyncHandler(async (_req: Request, res: Response) => { + const stats = await correctionsService.getStats(); + res.json(stats); + }), + ); + + // GET /api/corrections — paginated correction history + router.get( + "/corrections", + asyncHandler(async (req: Request, res: Response) => { + const limit = Number(req.query.limit) || 20; + const cursor = (req.query.cursor as string) || undefined; + + const result = await correctionsService.list({ limit, cursor }); + res.json(result); + }), + ); + + // POST /api/corrections — submit a new correction + router.post( + "/corrections", + asyncHandler(async (req: Request, res: Response) => { + const { message_id, original_flags, corrected_flags, correction_notes, content_snippet } = (req.body ?? {}) as { + message_id?: string; + original_flags?: string[]; + corrected_flags?: string[]; + correction_notes?: string; + content_snippet?: string; + }; + + // Validation + if (!message_id) { + res.status(400).json({ error: "VALIDATION_ERROR", message: "message_id is required" }); + return; + } + if (!Array.isArray(original_flags) || original_flags.length === 0) { + res.status(400).json({ error: "VALIDATION_ERROR", message: "original_flags must be a non-empty array" }); + return; + } + if (!Array.isArray(corrected_flags)) { + res.status(400).json({ error: "VALIDATION_ERROR", message: "corrected_flags must be an array" }); + return; + } + if (!content_snippet) { + res.status(400).json({ error: "VALIDATION_ERROR", message: "content_snippet is required" }); + return; + } + + // Idempotency guard: prevent duplicate submissions for same message_id + if (createInFlight.has(message_id)) { + res.status(409).json({ error: "CORRECTION_IN_PROGRESS", messageId: message_id }); + return; + } + + createInFlight.add(message_id); + let entry; + try { + entry = await correctionsService.create({ + message_id, + original_flags, + corrected_flags, + correction_notes, + content_snippet, + }); + } finally { + // Clean up after a delay to still prevent rapid duplicates + setTimeout(() => createInFlight.delete(message_id), 5_000); + } + + logger.info( + { messageId: message_id, id: entry.id }, + "Correction submitted", + ); + res.status(201).json(entry); + }), + ); + + return router; +} diff --git a/services/backend/src/modules/corrections/corrections.schema.ts b/services/backend/src/modules/corrections/corrections.schema.ts new file mode 100644 index 0000000..ec463a0 --- /dev/null +++ b/services/backend/src/modules/corrections/corrections.schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const correctionQuerySchema = z.object({ + limit: z.coerce.number().int().positive().max(100).default(20), + cursor: z.string().optional(), +}); + +export const correctionCreateSchema = z.object({ + message_id: z.string().min(1, "message_id is required"), + original_flags: z + .array(z.string()) + .min(1, "original_flags must be non-empty"), + corrected_flags: z + .array(z.string()) + .min(0) + .refine( + (val) => val.length >= 0, + "corrected_flags must be an array of strings", + ), + correction_notes: z.string().optional(), + content_snippet: z.string().min(1, "content_snippet is required"), +}); + +export type CorrectionQuery = z.infer; +export type CorrectionCreate = z.infer; diff --git a/services/backend/src/modules/corrections/corrections.service.ts b/services/backend/src/modules/corrections/corrections.service.ts new file mode 100644 index 0000000..5f92177 --- /dev/null +++ b/services/backend/src/modules/corrections/corrections.service.ts @@ -0,0 +1,33 @@ +import { createChildLogger } from "@bete/shared/logger"; +import type { CorrectedModeration } from "@bete/shared"; +import type { CorrectionCreate, CorrectionQuery } from "./corrections.schema.js"; +import { + correctionsRepository, + type CorrectionStatsResult, +} from "./corrections.repository.js"; + +const logger = createChildLogger("corrections.service"); + +export class CorrectionsService { + async getStats(): Promise { + logger.debug("Fetching correction stats"); + return correctionsRepository.getStats(); + } + + async list( + query: CorrectionQuery, + ): Promise<{ data: CorrectedModeration[]; nextCursor: string | null }> { + logger.debug({ limit: query.limit }, "Listing corrections"); + return correctionsRepository.list(query); + } + + async create(data: CorrectionCreate): Promise { + logger.debug( + { messageId: data.message_id }, + "Creating correction", + ); + return correctionsRepository.create(data); + } +} + +export const correctionsService = new CorrectionsService(); diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index df8d949..c39e94b 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { LivePanel } from "./features/live"; import { useMediaControl } from "./features/live/hooks/useMediaControl"; import { useVoiceControl } from "./features/live/hooks/useVoiceControl"; import { MessagesPanel } from "./features/messages"; +import { TunerPanel } from "./features/tuner"; import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener"; import { mergeMessages, @@ -34,7 +35,7 @@ export default function App() { const [monitorGuildId, setMonitorGuildId] = useState(""); const audio = useAudioPlayback(); - const activeTab = uiState.activeTab || "live"; + const activeTab = uiState.activeTab || "messages"; const selectedVoiceGuild = uiState.selectedVoiceGuild || uiState.selectedGuild || ""; @@ -182,6 +183,12 @@ export default function App() { onVolumeChange={media.setVolume} /> ) + ) : activeTab === "tuner" ? ( + !isAuthenticated ? ( + setIsAuthenticated(true)} /> + ) : ( + + ) ) : ( + + {formatDate(entry.created_at)} + + +
+ {originalFlags.map((f) => ( + + {f.replace(/_/g, " ")} + + ))} +
+ + + {isCleared ? ( + + Cleared + + ) : ( +
+ {correctedFlags.map((f) => ( + + {f.replace(/_/g, " ")} + + ))} +
+ )} + + + {entry.content_snippet} + + + {entry.correction_notes || "—"} + + + ); +} + +export function CorrectionHistoryContent() { + const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = useCorrectionHistory(); + + if (loading) { + return ( +
+ + + + + +
+ ); + } + + if (error) { + return ( + + + +

{error}

+ +
+
+ ); + } + + if (entries.length === 0) { + return ( + + + +

+ No corrections submitted yet. Use the Submit tab to record your first correction. +

+
+
+ ); + } + + return ( + + + + Correction History + + + + + + + + + + + + + + + + {entries.map((entry, i) => ( + + ))} + +
DateOriginalCorrectedContentNotes
+
+ + {hasMore && ( +
+ +
+ )} +
+
+ ); +} diff --git a/services/frontend/src/features/tuner/components/CorrectionStats.tsx b/services/frontend/src/features/tuner/components/CorrectionStats.tsx new file mode 100644 index 0000000..dda344a --- /dev/null +++ b/services/frontend/src/features/tuner/components/CorrectionStats.tsx @@ -0,0 +1,157 @@ +import { motion } from "framer-motion"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { useCorrectionStats } from "../hooks/useCorrections"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from "../../../shared/ui"; +import { EmptyStateMascot } from "../../../shared/ui"; + +function FlagsBar({ flag, count, max }: { flag: string; count: number; max: number }) { + const pct = max > 0 ? (count / max) * 100 : 0; + return ( +
+ + {flag.replace(/_/g, " ")} + +
+
+ +
+
+ + {count} + +
+ ); +} + +export function CorrectionStatsContent() { + const { stats, loading, error, refetch } = useCorrectionStats(); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( + + + +

{error}

+ +
+
+ ); + } + + if (!stats || stats.total_corrections === 0) { + return ( + + + +

+ No corrections yet. When admins correct false positives, statistics will appear here. +

+
+
+ ); + } + + return ( + + {/* Summary cards */} +
+ + + + + Total Corrections + + + +

+ {stats.total_corrections} +

+
+
+
+ + + + + + Last 7 Days + + + +

+ {stats.recent_count_7d} +

+
+
+
+
+ + {/* Flags bar chart */} + {stats.by_flag.length > 0 && ( + + + + Most Corrected Flags + + + + {stats.by_flag.map((item) => ( + + ))} + + + )} +
+ ); +} diff --git a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx new file mode 100644 index 0000000..8a8b446 --- /dev/null +++ b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import { motion } from "framer-motion"; +import { AlertCircle, CheckCircle, Send, X } from "lucide-react"; +import { useSubmitCorrection } from "../hooks/useCorrections"; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "../../../shared/ui"; +import { useToast } from "../../../shared/ui"; + +export function SubmitCorrectionContent() { + const { submit, submitting, error, success, reset } = useSubmitCorrection(); + const { addToast } = useToast(); + + const [messageId, setMessageId] = useState(""); + const [contentSnippet, setContentSnippet] = useState(""); + const [correctionNotes, setCorrectionNotes] = useState(""); + + // Pre-selected flags that were wrong + const [originalFlags, setOriginalFlags] = useState([]); + const [flagInput, setFlagInput] = useState(""); + + const [formError, setFormError] = useState(null); + + const addFlag = () => { + const trimmed = flagInput.trim().toLowerCase(); + if (!trimmed) return; + if (originalFlags.includes(trimmed)) return; + setOriginalFlags((prev) => [...prev, trimmed]); + setFlagInput(""); + }; + + const removeFlag = (flag: string) => { + setOriginalFlags((prev) => prev.filter((f) => f !== flag)); + }; + + const handleSubmit = async () => { + setFormError(null); + reset(); + + // Client-side validation + if (!messageId.trim()) { + setFormError("Message ID is required"); + return; + } + if (originalFlags.length === 0) { + setFormError("Add at least one original flag that was incorrect"); + return; + } + if (!contentSnippet.trim()) { + setFormError("Content snippet is required"); + return; + } + + try { + await submit({ + message_id: messageId.trim(), + original_flags: originalFlags, + corrected_flags: [], // Always clearing the false positive flags + correction_notes: correctionNotes.trim() || undefined, + content_snippet: contentSnippet.trim(), + }); + + addToast("Correction submitted — the AI prompt will learn from this.", "success"); + + // Reset form + setMessageId(""); + setContentSnippet(""); + setCorrectionNotes(""); + setOriginalFlags([]); + } catch { + addToast(error || "Failed to submit correction", "error"); + } + }; + + return ( + + + + + Submit Correction + + + Record a false positive — a message that was incorrectly flagged by AI moderation. + This helps the system learn and improve accuracy. + + + + {/* Message ID */} +
+ + setMessageId(e.target.value)} + /> +
+ + {/* Content Snippet */} +
+ + setContentSnippet(e.target.value)} + /> +
+ + {/* Original Flags (the incorrect ones) */} +
+ +

+ Add the AI flags that were wrong for this message. +

+
+ setFlagInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addFlag(); } }} + className="flex-1" + /> + +
+ {originalFlags.length > 0 && ( +
+ {originalFlags.map((f) => ( + + {f.replace(/_/g, " ")} + + + ))} +
+ )} +
+ + {/* Correction Notes */} +
+ + setCorrectionNotes(e.target.value)} + /> +
+ + {/* Error message */} + {(formError || error) && ( +
+ + {formError || error} +
+ )} + + {/* Success message */} + {success && ( +
+ + Correction recorded successfully. +
+ )} + + {/* Submit button */} + +
+
+
+ ); +} diff --git a/services/frontend/src/features/tuner/hooks/useCorrections.ts b/services/frontend/src/features/tuner/hooks/useCorrections.ts new file mode 100644 index 0000000..f3d6fe6 --- /dev/null +++ b/services/frontend/src/features/tuner/hooks/useCorrections.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type CorrectionEntry, + type CorrectionStats, + getCorrectionStats, + listCorrections, + submitCorrection, +} from "../../../shared/api/client"; + +// ─── Stats ────────────────────────────────────────────────────────────────── + +export function useCorrectionStats() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await getCorrectionStats(); + setStats(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load stats"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetch().catch(() => undefined); }, [fetch]); + + return { stats, loading, error, refetch: fetch }; +} + +// ─── History ──────────────────────────────────────────────────────────────── + +export function useCorrectionHistory() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const cursorRef = useRef(null); + const hasMoreRef = useRef(true); + + const fetchInitial = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await listCorrections({ limit: 20 }); + setEntries(result.data); + cursorRef.current = result.nextCursor; + hasMoreRef.current = result.nextCursor !== null; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load corrections"); + } finally { + setLoading(false); + } + }, []); + + const loadMore = useCallback(async () => { + if (!cursorRef.current || loadingMore) return; + setLoadingMore(true); + try { + const result = await listCorrections({ limit: 20, cursor: cursorRef.current }); + setEntries((prev) => [...prev, ...result.data]); + cursorRef.current = result.nextCursor; + hasMoreRef.current = result.nextCursor !== null; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load more"); + } finally { + setLoadingMore(false); + } + }, [loadingMore]); + + useEffect(() => { fetchInitial().catch(() => undefined); }, [fetchInitial]); + + return { entries, loading, loadingMore, error, hasMore: hasMoreRef.current, loadMore, refetch: fetchInitial }; +} + +// ─── Submit ───────────────────────────────────────────────────────────────── + +export function useSubmitCorrection() { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const submit = useCallback(async (data: { + message_id: string; + original_flags: string[]; + corrected_flags: string[]; + correction_notes?: string; + content_snippet: string; + }) => { + setSubmitting(true); + setError(null); + setSuccess(null); + try { + const result = await submitCorrection(data); + setSuccess(result); + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to submit correction"; + setError(msg); + throw err; + } finally { + setSubmitting(false); + } + }, []); + + const reset = useCallback(() => { + setError(null); + setSuccess(null); + }, []); + + return { submit, submitting, error, success, reset }; +} diff --git a/services/frontend/src/features/tuner/index.tsx b/services/frontend/src/features/tuner/index.tsx new file mode 100644 index 0000000..b9fdfdc --- /dev/null +++ b/services/frontend/src/features/tuner/index.tsx @@ -0,0 +1,33 @@ +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../../shared/ui"; +import { CorrectionStatsContent } from "./components/CorrectionStats"; +import { CorrectionHistoryContent } from "./components/CorrectionHistory"; +import { SubmitCorrectionContent } from "./components/SubmitCorrection"; + +export function TunerPanel() { + return ( + + + Stats + History + Submit + + + + + + + + + + + + + + + ); +} diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index ba3e70c..6bcd737 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -118,7 +118,7 @@ export interface UIState { selectedTextChannel?: string; selectedAnalyticsGuild?: string; selectedAnalyticsChannel?: string; - activeTab?: "live" | "messages"; + activeTab?: "live" | "messages" | "tuner"; isListening?: boolean; isStreaming?: boolean; } @@ -131,7 +131,7 @@ export interface ChatResponse { response?: string; } -export type DashboardTab = "live" | "messages"; +export type DashboardTab = "live" | "messages" | "tuner"; // ─── Messages ──────────────────────────────────────────────────────────────── @@ -272,6 +272,52 @@ export function login(password: string): Promise<{ ok: boolean }> { }); } +// ─── Corrections (Adaptive Prompt Tuner) ────────────────────────────────────── + +export interface CorrectionStats { + total_corrections: number; + recent_count_7d: number; + by_flag: Array<{ flag: string; count: number }>; +} + +export interface CorrectionEntry { + id: string; + message_id: string; + original_flags: string; + corrected_flags: string; + correction_notes: string | null; + content_snippet: string; + created_at: number; +} + +export function getCorrectionStats(): Promise { + return request("/api/corrections/stats"); +} + +export function listCorrections( + params: { limit?: number; cursor?: string } = {}, +): Promise<{ data: CorrectionEntry[]; nextCursor: string | null }> { + const sp = new URLSearchParams(); + if (params.limit) sp.set("limit", String(params.limit)); + if (params.cursor) sp.set("cursor", params.cursor); + return request<{ data: CorrectionEntry[]; nextCursor: string | null }>( + `/api/corrections?${sp}`, + ); +} + +export function submitCorrection(data: { + message_id: string; + original_flags: string[]; + corrected_flags: string[]; + correction_notes?: string; + content_snippet: string; +}): Promise { + return request("/api/corrections", { + method: "POST", + body: JSON.stringify(data), + }); +} + // ─── UI State ──────────────────────────────────────────────────────────────── export function getUIState(): Promise { diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx index 23f6ede..8aa4b92 100644 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ b/services/frontend/src/shared/ui/MobileTabBar.tsx @@ -1,10 +1,11 @@ -import { MessageSquare, Radio } from "lucide-react"; +import { MessageSquare, Radio, SlidersHorizontal } from "lucide-react"; import type { DashboardTab } from "../../entities/ui/types"; import { cn } from "../lib/utils"; const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [ { id: "live", label: "Live", Icon: Radio }, { id: "messages", label: "Messages", Icon: MessageSquare }, + { id: "tuner", label: "Tuner", Icon: SlidersHorizontal }, ]; interface MobileTabBarProps { diff --git a/services/frontend/src/widgets/Header.tsx b/services/frontend/src/widgets/Header.tsx index 20f3e8f..d36ec00 100644 --- a/services/frontend/src/widgets/Header.tsx +++ b/services/frontend/src/widgets/Header.tsx @@ -10,11 +10,13 @@ import type { WsStatus } from "../shared/ws/socket"; const titles: Record = { live: "Voice, Media & Recordings", messages: "Messages & Moderation", + tuner: "Prompt Tuner", }; const subtitles: Record = { live: "Join voice channels, play media, stream audio, and browse recordings.", messages: "Capture, analyse, and moderate Discord messages.", + tuner: "Monitor correction patterns and improve AI moderation accuracy.", }; interface HeaderProps { diff --git a/services/frontend/src/widgets/Sidebar.tsx b/services/frontend/src/widgets/Sidebar.tsx index 55001d6..a7a5e37 100644 --- a/services/frontend/src/widgets/Sidebar.tsx +++ b/services/frontend/src/widgets/Sidebar.tsx @@ -1,5 +1,5 @@ import { motion } from "framer-motion"; -import { MessageSquare, Radio } from "lucide-react"; +import { MessageSquare, Radio, SlidersHorizontal } from "lucide-react"; import type { DashboardTab } from "../entities/ui/types"; import type { MessageRecord } from "../shared/api/client"; import { useMascotChat } from "../shared/hooks/useMascotChat"; @@ -11,6 +11,7 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> = [ { id: "live", label: "Live", icon: Radio }, { id: "messages", label: "Messages", icon: MessageSquare }, + { id: "tuner", label: "Tuner", icon: SlidersHorizontal }, ]; interface SidebarProps {