feat(ui): add Adaptive Prompt Tuner tab with correction submission UI

- Add pgCorrectedModerationsTable to shared schema
- Create backend corrections module (stats, list, create endpoints)
- Add TunerPanel with Stats, History, and Submit sub-tabs
- Add AuthOverlay gate for Tuner (admin-only, same as Live)
- Set messages as default tab
- Wire Tuner into sidebar, header, and mobile tab bar

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-12 21:28:05 +07:00
co-authored by Claude
parent fbc2184c6e
commit 7f3eb7b9ac
16 changed files with 1060 additions and 5 deletions
+2
View File
@@ -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());
@@ -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<CorrectionStatsResult> {
const db = getDatabase();
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
// Total count
const [totalRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(pgCorrectedModerationsTable);
// Recent 7 days count
const [recentRow] = await db
.select({ count: sql<number>`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<string, unknown>) => ({
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<CorrectedModeration> {
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();
@@ -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<string>();
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;
}
@@ -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<typeof correctionQuerySchema>;
export type CorrectionCreate = z.infer<typeof correctionCreateSchema>;
@@ -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<CorrectionStatsResult> {
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<CorrectedModeration> {
logger.debug(
{ messageId: data.message_id },
"Creating correction",
);
return correctionsRepository.create(data);
}
}
export const correctionsService = new CorrectionsService();