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:
@@ -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();
|
||||
Reference in New Issue
Block a user