Comprehensive admin + user dashboards (production-ready)

This commit is contained in:
Leon Serfaty
2026-06-07 17:54:30 -04:00
parent 155507f21a
commit f033f00379
122 changed files with 7878 additions and 805 deletions
+40
View File
@@ -0,0 +1,40 @@
import { openai } from "./openai";
const MODERATION_MODEL = process.env.OPENAI_MODERATION_MODEL ?? "omni-moderation-latest";
export interface ModerationResult {
flagged: boolean;
/** OpenAI category keys that tripped, e.g. ["hate", "violence"]. */
categories: string[];
}
/**
* Screen text with OpenAI's moderation endpoint.
*
* Fails OPEN: if the moderation call errors (outage, quota), we log and return
* `flagged: false` so a moderation hiccup never blocks the product. The result
* is advisory — callers decide whether to reject input or flag generated output.
*/
export async function moderateText(input: string): Promise<ModerationResult> {
const text = input.trim();
if (!text) return { flagged: false, categories: [] };
try {
const res = await openai().moderations.create({ model: MODERATION_MODEL, input: text });
const result = res.results[0];
if (!result) return { flagged: false, categories: [] };
const categories = Object.entries(result.categories)
.filter(([, tripped]) => tripped)
.map(([key]) => key);
return { flagged: result.flagged, categories };
} catch (err) {
console.error("[moderation] check failed — failing open", err);
return { flagged: false, categories: [] };
}
}
/** Short human-readable reason for a content flag from a moderation result. */
export function moderationReason(result: ModerationResult): string {
const cats = result.categories.length ? result.categories.join(", ") : "policy violation";
return `Automated moderation flagged: ${cats}`;
}
+71 -20
View File
@@ -7,20 +7,65 @@ import { segmentScript } from "./segment";
import { stitchMp3 } from "./stitch";
import { storage, assetKey } from "@/lib/storage";
import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/cost";
import { incrementUsage } from "@/lib/usage/meter";
import { refundUsage } from "@/lib/usage/meter";
import { isFlagEnabled } from "@/lib/flags";
import { moderateText, moderationReason } from "@/lib/ai/moderation";
import { sendEmail, emailLayout } from "@/lib/email";
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
import type { GenerationType } from "@/lib/queue/jobs";
import type { UsageMetric } from "@/lib/billing/plans";
type EpisodeWithRelations = Prisma.EpisodeGetPayload<{
include: { speakers: true; user: true };
}>;
/**
* Usage metrics RESERVED by the enqueuing caller for a given generation `type`.
* The worker uses this only to REFUND on terminal failure — it never increments
* (see the metering invariant in lib/usage/meter.ts). Must stay in sync with the
* reservations made in the create/regenerate paths.
*/
export function reservedMetricsFor(type: GenerationType): UsageMetric[] {
switch (type) {
case "full":
return ["script", "audio", "art"];
case "script":
return ["script"];
case "audio":
return ["audio"];
case "art":
return ["art"];
default:
return [];
}
}
/** Refund every metric reserved for `type` (used by the worker on terminal failure). */
export async function refundEpisodeUsage(
episodeId: string,
type: GenerationType
): Promise<void> {
const episode = await prisma.episode.findUnique({
where: { id: episodeId },
select: { userId: true, organizationId: true },
});
if (!episode) return;
const ownerId = episode.organizationId ?? episode.userId;
const ownerType = episode.organizationId ? "organization" : "user";
for (const metric of reservedMetricsFor(type)) {
await refundUsage(ownerId, ownerType, metric);
}
}
/**
* The episode generation pipeline, run by the worker.
* Stages: script → segment → synthesize → stitch → art → save → meter.
* Stages: script → segment → synthesize → stitch → art → save.
* `type` selects which stages run (full, or a single re-generation).
*
* NOTE: usage is NOT metered here. The enqueuing caller already RESERVED the
* relevant metrics (script/audio/art) up front; on terminal failure the worker
* refunds them. See the metering invariant in lib/usage/meter.ts.
*/
export async function runEpisodeGeneration(
episodeId: string,
@@ -29,23 +74,18 @@ export async function runEpisodeGeneration(
const episode = await loadEpisode(episodeId);
const config = toConfig(episode);
const did = { script: false, audio: false, art: false };
if (type === "full" || type === "script") {
await generateScript(episode, config);
did.script = true;
}
if (type === "full" || type === "script" || type === "audio") {
await generateAudio(episode);
did.audio = true;
}
if (type === "full" || type === "art") {
await generateArt(episode);
did.art = true;
}
await setEpisodeStatus(episodeId, "SAVING", { stage: "Finalizing your episode" });
await meter(episode, did);
// No metering here: usage was reserved at enqueue time. See meter.ts invariant.
await setEpisodeStatus(episodeId, "READY", { stage: "Done" });
await notifyReady(episode);
}
@@ -105,6 +145,29 @@ async function generateScript(episode: EpisodeWithRelations, config: EpisodeConf
episodeId: episode.id,
userId: episode.userId,
});
await flagScriptIfFlagged(episode.id, script);
}
/**
* Screen the generated script with automated moderation. On a violation we queue
* a ContentFlag for admin review (rather than hard-failing the episode the user
* asked for); the admin moderation queue is the consumer. No-op when the
* moderation flag is off or an open flag already exists for the episode.
*/
async function flagScriptIfFlagged(episodeId: string, script: StructuredScript) {
if (!(await isFlagEnabled("ai_moderation_enabled"))) return;
const text = script.sections.flatMap((s) => s.turns.map((t) => t.text)).join("\n");
const result = await moderateText(text);
if (!result.flagged) return;
const existing = await prisma.contentFlag.findFirst({ where: { episodeId, status: "open" } });
if (existing) return;
await prisma.contentFlag.create({
data: { episodeId, reason: moderationReason(result), source: "moderation", severity: "high" },
});
console.warn(`[moderation] flagged episode ${episodeId}: ${result.categories.join(", ")}`);
}
// ─────────────── Stages 24: segment → synthesize → stitch ───────────────
@@ -197,18 +260,6 @@ async function generateArt(episode: EpisodeWithRelations) {
});
}
// ─────────────── Stage 7: meter ───────────────
async function meter(
episode: EpisodeWithRelations,
did: { script: boolean; audio: boolean; art: boolean }
) {
const ownerId = episode.organizationId ?? episode.userId;
const ownerType = episode.organizationId ? "organization" : "user";
if (did.script) await incrementUsage(ownerId, ownerType, "script");
if (did.audio) await incrementUsage(ownerId, ownerType, "audio");
if (did.art) await incrementUsage(ownerId, ownerType, "art");
}
async function notifyReady(episode: EpisodeWithRelations) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
try {