Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -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 2–4: 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 {
|
||||
|
||||
Reference in New Issue
Block a user