Phase B: AI press-release rewrite pipeline

Pluggable rewrite client (Anthropic default, ollama/openrouter stubs)
using claude-opus-4-7 with prompt caching on system prompt + style guide.
Routes the raw submission through:
  1. bb.ai_original_submissions  — raw never edited
  2. bb.ai_rewrite_jobs          — every attempt logged with token counts
  3. quality gates               — AI-tell banned-word check + heuristic
                                   AI-detector score (threshold 0.65)
  4. regenerate up to 3x         — on banned words or detector trip
  5. bb.ai_rewritten_articles    — status ai_rewritten_pending_review
  6. /admin/review-queue          — list + detail page with approve / reject /
                                   needs-human actions; on approve, the
                                   original submission is FK-linked to the
                                   published rewrite

New files only — no existing routes or contributor flow modified.
Persona auto-selection routes by beat keywords; 6 personas seeded
(marcus-halverson, elena-vasquez, darren-okafor, sarah-quinn, mike-trayton,
priya-rao) with SVG monogram avatars in public/assets/images/personas/.

Smoke test (Matrox / ST 2110 sample press release):
- persona auto-routed to darren-okafor (protocols-specs)
- anthropic 22.3s, in=2616 / out=1372 tokens
- gates passed first try, score=0.000, no banned words
- review_queue row created

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-05-14 18:38:27 +00:00
parent 8a2ca37134
commit 3bd9a716b2
17 changed files with 1220 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Darren Okafor">
<defs>
<linearGradient id="do-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#064e3b"/>
<stop offset="1" stop-color="#047857"/>
</linearGradient>
<linearGradient id="do-ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6ee7b7"/>
<stop offset="1" stop-color="#059669"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="128" fill="url(#do-bg)"/>
<circle cx="128" cy="128" r="118" fill="none" stroke="url(#do-ring)" stroke-width="3" opacity="0.45"/>
<text x="128" y="148" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="92" font-weight="700" fill="#d1fae5" letter-spacing="-2">DO</text>
<text x="128" y="200" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="600" fill="#6ee7b7" letter-spacing="3">PROTOCOLS / SPECS</text>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Elena Vasquez">
<defs>
<linearGradient id="ev-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b0764"/>
<stop offset="1" stop-color="#9333ea"/>
</linearGradient>
<linearGradient id="ev-ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f0abfc"/>
<stop offset="1" stop-color="#a21caf"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="128" fill="url(#ev-bg)"/>
<circle cx="128" cy="128" r="118" fill="none" stroke="url(#ev-ring)" stroke-width="3" opacity="0.45"/>
<text x="128" y="148" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="92" font-weight="700" fill="#fae8ff" letter-spacing="-2">EV</text>
<text x="128" y="200" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="600" fill="#d8b4fe" letter-spacing="3">STREAMING / OTT</text>
</svg>

After

Width:  |  Height:  |  Size: 994 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Marcus Halverson">
<defs>
<linearGradient id="mh-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0f172a"/>
<stop offset="1" stop-color="#1e3a5f"/>
</linearGradient>
<linearGradient id="mh-ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#60a5fa"/>
<stop offset="1" stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="128" fill="url(#mh-bg)"/>
<circle cx="128" cy="128" r="118" fill="none" stroke="url(#mh-ring)" stroke-width="3" opacity="0.45"/>
<text x="128" y="148" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="92" font-weight="700" fill="#e2e8f0" letter-spacing="-2">MH</text>
<text x="128" y="200" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="600" fill="#94a3b8" letter-spacing="3">INDUSTRY VETERAN</text>
</svg>

After

Width:  |  Height:  |  Size: 998 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Mike Trayton">
<defs>
<linearGradient id="mt-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7f1d1d"/>
<stop offset="1" stop-color="#dc2626"/>
</linearGradient>
<linearGradient id="mt-ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fca5a5"/>
<stop offset="1" stop-color="#b91c1c"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="128" fill="url(#mt-bg)"/>
<circle cx="128" cy="128" r="118" fill="none" stroke="url(#mt-ring)" stroke-width="3" opacity="0.45"/>
<text x="128" y="148" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="92" font-weight="700" fill="#fee2e2" letter-spacing="-2">MT</text>
<text x="128" y="200" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="600" fill="#fca5a5" letter-spacing="3">LIVE / SPORTS</text>
</svg>

After

Width:  |  Height:  |  Size: 991 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Priya Rao">
<defs>
<linearGradient id="pr-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0c4a6e"/>
<stop offset="1" stop-color="#0891b2"/>
</linearGradient>
<linearGradient id="pr-ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#67e8f9"/>
<stop offset="1" stop-color="#0e7490"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="128" fill="url(#pr-bg)"/>
<circle cx="128" cy="128" r="118" fill="none" stroke="url(#pr-ring)" stroke-width="3" opacity="0.45"/>
<text x="128" y="148" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="92" font-weight="700" fill="#cffafe" letter-spacing="-2">PR</text>
<text x="128" y="200" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="600" fill="#67e8f9" letter-spacing="3">POST-PRODUCTION</text>
</svg>

After

Width:  |  Height:  |  Size: 990 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Sarah Quinn">
<defs>
<linearGradient id="sq-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#451a03"/>
<stop offset="1" stop-color="#b45309"/>
</linearGradient>
<linearGradient id="sq-ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fcd34d"/>
<stop offset="1" stop-color="#d97706"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="128" fill="url(#sq-bg)"/>
<circle cx="128" cy="128" r="118" fill="none" stroke="url(#sq-ring)" stroke-width="3" opacity="0.45"/>
<text x="128" y="148" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="92" font-weight="700" fill="#fef3c7" letter-spacing="-2">SQ</text>
<text x="128" y="200" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="14" font-weight="600" fill="#fcd34d" letter-spacing="3">BUSINESS / M&amp;A</text>
</svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@@ -0,0 +1,73 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
interface Props {
id: string;
currentStatus: string;
}
export default function ReviewActions({ id, currentStatus }: Props) {
const [note, setNote] = useState("");
const [pending, startTransition] = useTransition();
const [err, setErr] = useState<string | null>(null);
const router = useRouter();
async function act(action: "approve" | "reject" | "needs_human") {
setErr(null);
const res = await fetch(`/api/admin/review-queue/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, note }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setErr(body.error || `HTTP ${res.status}`);
return;
}
startTransition(() => {
router.refresh();
router.push("/admin/review-queue");
});
}
return (
<div className="rounded border border-[#1f2937] bg-[#0b0f17] p-4">
<div className="text-xs uppercase tracking-wider text-[#9ca3af] mb-2">
Current status: <span className="text-[#e5e7eb]">{currentStatus}</span>
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Reviewer note (optional)"
className="w-full rounded border border-[#1f2937] bg-[#070a10] p-2 text-sm text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
rows={2}
/>
<div className="mt-3 flex flex-wrap gap-3 text-sm">
<button
onClick={() => act("approve")}
disabled={pending}
className="rounded bg-[#059669] px-4 py-2 font-medium text-white hover:bg-[#10b981] disabled:opacity-50"
>
Approve & publish
</button>
<button
onClick={() => act("needs_human")}
disabled={pending}
className="rounded bg-[#b45309] px-4 py-2 font-medium text-white hover:bg-[#d97706] disabled:opacity-50"
>
Needs human rewrite
</button>
<button
onClick={() => act("reject")}
disabled={pending}
className="rounded bg-[#b91c1c] px-4 py-2 font-medium text-white hover:bg-[#dc2626] disabled:opacity-50"
>
Reject
</button>
</div>
{err && <div className="mt-2 text-xs text-[#fca5a5]">{err}</div>}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { createAdminClient } from "@/lib/supabase/admin";
import ReviewActions from "./ReviewActions";
export const dynamic = "force-dynamic";
async function loadDetail(id: string) {
const sb = createAdminClient();
const { data: art } = await sb
.from("ai_rewritten_articles")
.select("*")
.eq("id", id)
.maybeSingle();
if (!art) return null;
const { data: persona } = await sb
.from("ai_personas")
.select("name, slug, avatar_url, beat")
.eq("id", (art as any).persona_id)
.maybeSingle();
const { data: original } = await sb
.from("ai_original_submissions")
.select("raw_title, raw_content, contributor_name, contributor_email, submitted_at, source")
.eq("id", (art as any).original_submission_id)
.maybeSingle();
const { data: job } = await sb
.from("ai_rewrite_jobs")
.select("attempt_number, ai_detector_score, banned_words_found, model, input_tokens, output_tokens, cache_read_tokens")
.eq("id", (art as any).rewrite_job_id)
.maybeSingle();
return { art, persona, original, job };
}
export default async function ReviewDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const detail = await loadDetail(id);
if (!detail) notFound();
const { art, persona, original, job } = detail as any;
return (
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
<Link href="/admin/review-queue" className="text-sm text-[#60a5fa] hover:underline">
Back to queue
</Link>
<header className="mt-4 mb-6 border-b border-[#222] pb-4">
<div className="flex items-center gap-3 text-xs uppercase tracking-wider text-[#60a5fa]">
<span>{persona?.name || "—"}</span>
<span className="text-[#4b5563]">·</span>
<span>{art.category}</span>
<span className="text-[#4b5563]">·</span>
<span>{art.status}</span>
</div>
<h1 className="mt-2 text-3xl font-bold tracking-tight">{art.title}</h1>
<p className="mt-2 text-[#9ca3af]">{art.excerpt}</p>
</header>
<ReviewActions id={art.id} currentStatus={art.status} />
<section className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[#9ca3af] mb-3">
Rewritten ({persona?.name})
</h2>
<article
className="prose prose-invert max-w-none text-sm"
dangerouslySetInnerHTML={{ __html: art.body }}
/>
<div className="mt-4 flex flex-wrap gap-2 text-xs">
{(art.tags || []).map((t: string) => (
<span key={t} className="rounded bg-[#1f2937] px-2 py-0.5">{t}</span>
))}
</div>
</div>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-[#9ca3af] mb-3">
Original submission
</h2>
<div className="text-xs text-[#6b7280] mb-3">
{original?.contributor_name || "(no name)"} · {original?.contributor_email || "(no email)"} ·{" "}
{original?.source} · {original?.submitted_at && new Date(original.submitted_at).toLocaleString()}
</div>
<h3 className="text-base font-semibold mb-2">{original?.raw_title}</h3>
<pre className="whitespace-pre-wrap text-sm text-[#9ca3af] bg-[#0b0f17] border border-[#1f2937] rounded p-4 max-h-[600px] overflow-auto">
{original?.raw_content}
</pre>
</div>
</section>
<section className="mt-8 rounded border border-[#1f2937] bg-[#0b0f17] p-4 text-xs text-[#9ca3af]">
<h2 className="font-semibold uppercase tracking-wider mb-2">Rewrite job</h2>
<ul className="space-y-1">
<li>Attempt: {job?.attempt_number}</li>
<li>Model: {job?.model}</li>
<li>AI-detector score: {job?.ai_detector_score?.toFixed?.(3) ?? "—"}</li>
<li>Banned words found: {(job?.banned_words_found || []).join(", ") || "none"}</li>
<li>
Tokens: input {job?.input_tokens ?? 0} / output {job?.output_tokens ?? 0} / cache-read{" "}
{job?.cache_read_tokens ?? 0}
</li>
</ul>
</section>
</main>
);
}

View File

@@ -0,0 +1,120 @@
import Link from "next/link";
import { createAdminClient } from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
export const revalidate = 0;
interface RewriteRow {
id: string;
slug: string;
title: string;
excerpt: string;
category: string;
tags: string[];
status: string;
created_at: string;
persona_id: string;
original_submission_id: string;
}
interface PersonaRow {
id: string;
slug: string;
name: string;
avatar_url: string | null;
beat: string | null;
}
async function loadQueue() {
const sb = createAdminClient();
const { data: rewrites } = await sb
.from("ai_rewritten_articles")
.select("id, slug, title, excerpt, category, tags, status, created_at, persona_id, original_submission_id")
.eq("status", "ai_rewritten_pending_review")
.order("created_at", { ascending: false })
.limit(50);
const { data: personas } = await sb
.from("ai_personas")
.select("id, slug, name, avatar_url, beat");
const personaById = new Map<string, PersonaRow>();
for (const p of (personas || []) as PersonaRow[]) personaById.set(p.id, p);
return {
rewrites: (rewrites || []) as RewriteRow[],
personaById,
};
}
export default async function ReviewQueuePage() {
const { rewrites, personaById } = await loadQueue();
return (
<main className="mx-auto max-w-5xl px-6 py-10 text-[#e5e7eb]">
<header className="mb-8 border-b border-[#222] pb-4">
<h1 className="text-3xl font-bold tracking-tight">AI Review Queue</h1>
<p className="mt-2 text-sm text-[#9ca3af]">
AI-rewritten articles pending editor approval. {rewrites.length} pending.
</p>
</header>
{rewrites.length === 0 ? (
<div className="rounded border border-[#1f2937] bg-[#0b0f17] p-8 text-center text-[#9ca3af]">
No rewrites in the queue.
</div>
) : (
<ul className="space-y-4">
{rewrites.map((r) => {
const persona = personaById.get(r.persona_id);
return (
<li
key={r.id}
className="rounded border border-[#1f2937] bg-[#0b0f17] p-5 hover:border-[#3b82f6] transition-colors"
>
<div className="flex items-start gap-4">
{persona?.avatar_url ? (
<img
src={persona.avatar_url}
alt={persona.name}
width={56}
height={56}
className="rounded-full border border-[#1f2937]"
/>
) : (
<div className="h-14 w-14 rounded-full bg-[#1f2937]" />
)}
<div className="flex-1 min-w-0">
<div className="text-xs uppercase tracking-wider text-[#60a5fa]">
{persona?.name || "—"} · {r.category}
</div>
<h2 className="text-xl font-semibold mt-1">{r.title}</h2>
<p className="text-sm text-[#9ca3af] mt-1 line-clamp-2">{r.excerpt}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
{(r.tags || []).map((t) => (
<span key={t} className="rounded bg-[#1f2937] px-2 py-0.5">
{t}
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs">
<Link
href={`/admin/review-queue/${r.id}`}
className="rounded bg-[#1d4ed8] px-3 py-1.5 font-medium text-white hover:bg-[#2563eb]"
>
Review
</Link>
<span className="text-[#6b7280]">
Submitted {new Date(r.created_at).toLocaleString()}
</span>
</div>
</div>
</div>
</li>
);
})}
</ul>
)}
</main>
);
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
async function requireAdmin() {
const sb = await createClient();
const { data: { user } } = await sb.auth.getUser();
if (!user) return { ok: false as const, status: 401, error: "Unauthorized" };
const { data: profile } = await sb
.from("user_profiles")
.select("role, is_active")
.eq("id", user.id)
.single();
if (!profile?.is_active) return { ok: false as const, status: 403, error: "Inactive" };
if (!["administrator", "admin", "editor"].includes(profile.role)) {
return { ok: false as const, status: 403, error: "Insufficient permissions" };
}
return { ok: true as const, userId: user.id };
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireAdmin();
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { id } = await params;
const { action, note } = (await req.json()) as { action: string; note?: string };
const admin = createAdminClient();
const nowIso = new Date().toISOString();
let nextStatus = "";
let extras: Record<string, any> = {};
if (action === "approve") {
nextStatus = "published";
extras.published_at = nowIso;
} else if (action === "reject") {
nextStatus = "rejected";
} else if (action === "needs_human") {
nextStatus = "needs_human_rewrite";
} else {
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
const { data, error } = await admin
.from("ai_rewritten_articles")
.update({
status: nextStatus,
reviewer_id: auth.userId,
reviewer_note: note || null,
reviewed_at: nowIso,
updated_at: nowIso,
...extras,
})
.eq("id", id)
.select("id, original_submission_id")
.single();
if (error || !data) return NextResponse.json({ error: error?.message || "Not found" }, { status: 500 });
if (action === "approve") {
await admin
.from("ai_original_submissions")
.update({ published_rewrite_id: (data as any).id })
.eq("id", (data as any).original_submission_id);
}
return NextResponse.json({ ok: true, id: (data as any).id, status: nextStatus });
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { rewriteSubmission } from "@/lib/ai/rewrite-pipeline";
export const runtime = "nodejs";
export const maxDuration = 300;
export async function POST(req: NextRequest) {
try {
const internalKey = process.env.BB_INTERNAL_INGEST_KEY;
if (internalKey) {
const got = req.headers.get("x-internal-key");
if (got !== internalKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
const { submissionId, personaSlug } = (await req.json()) as {
submissionId: string;
personaSlug?: string;
};
if (!submissionId) {
return NextResponse.json({ error: "submissionId required" }, { status: 400 });
}
const outcome = await rewriteSubmission({ submissionId, personaSlug });
return NextResponse.json({ ok: true, outcome });
} catch (err: any) {
return NextResponse.json({ error: err.message || String(err) }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { ingestRawSubmission, rewriteSubmission } from "@/lib/ai/rewrite-pipeline";
export const runtime = "nodejs";
export const maxDuration = 300;
interface SubmitBody {
rawTitle: string;
rawContent: string;
rawHtml?: string;
contributorEmail?: string;
contributorName?: string;
personaSlug?: string;
source?: string;
}
export async function POST(req: NextRequest) {
try {
const internalKey = process.env.BB_INTERNAL_INGEST_KEY;
if (internalKey) {
const got = req.headers.get("x-internal-key");
if (got !== internalKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
const body = (await req.json()) as SubmitBody;
if (!body.rawTitle || !body.rawContent) {
return NextResponse.json({ error: "rawTitle and rawContent required" }, { status: 400 });
}
const submissionId = await ingestRawSubmission(body);
const outcome = await rewriteSubmission({ submissionId, personaSlug: body.personaSlug });
return NextResponse.json({ ok: true, submissionId, outcome });
} catch (err: any) {
return NextResponse.json({ error: err.message || String(err) }, { status: 500 });
}
}

View File

@@ -0,0 +1,101 @@
import type { Persona } from "./personas";
export const BANNED_AI_TELLS: string[] = [
"delve", "delves", "delving",
"leverage", "leverages", "leveraging",
"unprecedented",
"in the realm of", "in the world of", "in the landscape of", "in the era of",
"navigate", "navigating",
"robust",
"groundbreaking",
"cutting-edge",
"revolutionize", "revolutionizes", "revolutionizing", "revolutionary",
"game-changer", "game-changing", "game changer",
"paradigm shift",
"synergy", "synergize", "synergistic",
"harness", "harnessing",
"tapestry",
"embark", "embarking",
"intricate",
"myriad",
"underscore", "underscores", "underscoring",
"moreover",
"furthermore",
"additionally,",
"in conclusion,",
"it is important to note",
"it is worth noting",
"in today's fast-paced",
"ever-evolving",
"seamless", "seamlessly",
"unlock", "unlocking",
"elevate", "elevating",
"empower", "empowering",
"transformative",
"holistic",
"vibrant",
"testament to",
"stands as a testament",
"in summary,",
];
export const STYLE_GUIDE = `BROADCASTBEAT STYLE GUIDE (cached)
Editorial voice:
- Trade-press journalism for broadcast and post-production professionals.
- Lead with the news, not the company. Inverted pyramid.
- Plain English. Active verbs. Subject-verb-object.
- AP style for dates, numbers, titles. Spell out single-digit numbers; numerals from 10 up. Dollar figures use "$" and abbreviated suffixes (M, B). Years in numerals.
- First reference: Full company name. Second reference: Short form (e.g., "Matrox Video" → "Matrox").
- People: First+last on first reference with role; last name only after.
- Quotes: Direct quotes only when they appear verbatim in the source. Paraphrase otherwise. Never invent.
- Specs: Report exact figures from the source (frame rates, bitrates, dB, model numbers). Do not round.
- Skepticism: Mark marketing claims as claims ("the company says", "according to the announcement").
- Avoid press-release tropes: "is pleased to announce", "industry-leading", "best-in-class", "next-generation", "world-class".
- Avoid AI tells (see hard rules in user message).
- Headlines: ≤ 80 chars, sentence case, news-bearing (a verb), no clickbait, no colons unless required for a product name.
- Excerpt (dek): 1-2 sentences that complete the lede; tells the reader what they'll learn.
Structure for a typical NAB or product press release:
1. Lede (1-2 sentences): what was announced, by whom, why it matters.
2. Specifics (2-4 paragraphs): the technology, the customer, the spec, the rollout.
3. Context (1-2 paragraphs): how this fits the wider category; competitors implied, not named unless cited in source.
4. Quote or company colour (1 paragraph, optional).
5. Availability & price (1 paragraph, if disclosed).
6. Closing context (optional).
HTML allowed: <p>, <h2>, <h3>, <ul>, <ol>, <li>, <strong>, <em>, <a>, <blockquote>. No <script>, <style>, <iframe>, <img> (the CMS attaches images separately).
Length:
- Thin source (≤ 250 words of original PR): 300-450 words.
- Standard PR (250-600 words): 450-700 words.
- Long PR with multiple products / case studies: 700-1200 words.
- Never pad. Better short and tight than long and thin.
`;
export function buildSystemPrompt(p: Persona): string {
const writingStyle = p.writing_style || "Trade-press, plain English, AP style.";
const bio = p.bio || "BroadcastBeat staff writer.";
const beat = p.beat || "broadcast technology";
return `You are ${p.name}, a staff journalist for BroadcastBeat covering ${beat}.
Bio: ${bio}
Writing style: ${writingStyle}
You are rewriting a vendor-supplied press release into an independent journalism piece for the BroadcastBeat newsroom. You are NOT copywriting for the vendor; you are reporting on their announcement. The BroadcastBeat audience are broadcast engineers, post-production supervisors, streaming architects, and broadcast executives — they read trade press to find out what is true, not to be sold to.
Hard rules:
- Never use the literal text from the source as the lede. Rewrite the lede.
- Never use "we", "us", or "our" — you are a reporter, not a company spokesperson.
- Never use first-person.
- Never use "in today's fast-paced world" or any time/space cliché openers.
- Never repeat the company's marketing slogan or tagline.
- Never use the press-release headline as the article headline.
- Cite figures and product specs exactly as given in the source. If the source omits a figure, do not invent one.
- When the source includes a quote, paraphrase it unless it is short and concrete. If you keep a direct quote, attribute it precisely.
Output format: JSON only. No prose, no preamble, no markdown fences. Just a JSON object as specified by the user.
`;
}

71
src/lib/ai/personas.ts Normal file
View File

@@ -0,0 +1,71 @@
import { createAdminClient } from "@/lib/supabase/admin";
export interface Persona {
id: string;
slug: string;
name: string;
gender: string | null;
beat: string | null;
avatar_url: string | null;
bio: string | null;
writing_style: string | null;
active: boolean;
}
export async function listPersonas(activeOnly = true): Promise<Persona[]> {
const sb = createAdminClient();
const q = sb.from("ai_personas").select("*");
const { data, error } = activeOnly ? await q.eq("active", true) : await q;
if (error) throw new Error(`listPersonas: ${error.message}`);
return (data || []) as Persona[];
}
export async function getPersonaBySlug(slug: string): Promise<Persona | null> {
const sb = createAdminClient();
const { data, error } = await sb.from("ai_personas").select("*").eq("slug", slug).maybeSingle();
if (error) throw new Error(`getPersonaBySlug: ${error.message}`);
return (data as Persona) || null;
}
export async function pickPersonaForContent(text: string): Promise<Persona> {
const all = await listPersonas(true);
if (all.length === 0) throw new Error("No active personas in bb.ai_personas");
const haystack = text.toLowerCase();
const beatScores: Record<string, number> = {
"industry-veteran": 0,
"streaming-ott": 0,
"protocols-specs": 0,
"business-m-and-a": 0,
"live-sports": 0,
"post-production": 0,
};
const cues: Record<string, string[]> = {
"streaming-ott": ["streaming", "ott", "cdn", "drm", "encoder", "transcode", "hls", "dash", "vod"],
"protocols-specs": ["st 2110", "smpte", "ndi", "jpeg xs", "rist", "srt", "protocol", "spec", "rfc"],
"business-m-and-a": ["acquisition", "merger", "acquir", "valuation", "earnings", "revenue", "ceo", "cfo", "ipo", "layoff", "raised", "series"],
"live-sports": ["live", "sports", "premier league", "nfl", "nba", "super bowl", "olympic", "remi", "ob truck", "world feed", "stadium"],
"post-production": ["post-production", "post production", "color grade", "edit", "vfx", "render", "aces", "dailies", "virtual production", "mam"],
"industry-veteran": ["legacy", "sdi", "baseband", "router", "frame sync", "infrastructure", "plant"],
};
for (const [beat, words] of Object.entries(cues)) {
for (const w of words) {
if (haystack.includes(w)) beatScores[beat] += 1;
}
}
let bestBeat = "industry-veteran";
let bestScore = -1;
for (const [beat, score] of Object.entries(beatScores)) {
if (score > bestScore) {
bestScore = score;
bestBeat = beat;
}
}
const match = all.find((p) => p.beat === bestBeat);
if (match) return match;
return all[Math.floor(Math.random() * all.length)];
}

123
src/lib/ai/quality-gates.ts Normal file
View File

@@ -0,0 +1,123 @@
import { createAdminClient } from "@/lib/supabase/admin";
import { BANNED_AI_TELLS } from "./persona-prompts";
export interface QualityGateResult {
passed: boolean;
bannedWordsFound: string[];
aiDetectorScore: number;
reasons: string[];
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
export function findBannedAiTells(text: string): string[] {
const lower = " " + text.toLowerCase() + " ";
const hits: string[] = [];
for (const phrase of BANNED_AI_TELLS) {
const p = phrase.toLowerCase();
const needsWordBoundary = !p.includes(" ") && !p.endsWith(",");
const pattern = needsWordBoundary
? new RegExp(`\\b${escapeRegex(p)}\\b`)
: new RegExp(escapeRegex(p));
if (pattern.test(lower)) hits.push(phrase);
}
return Array.from(new Set(hits));
}
async function findCustomBannedTerms(text: string): Promise<string[]> {
try {
const sb = createAdminClient();
const { data } = await sb.from("banned_terms").select("term, ban_level, is_active").eq("is_active", true);
const hits: string[] = [];
const lower = " " + text.toLowerCase() + " ";
for (const row of data || []) {
const term = String((row as any).term || "").toLowerCase();
if (!term) continue;
const pattern = new RegExp(`\\b${escapeRegex(term)}\\b`);
if (pattern.test(lower)) hits.push((row as any).term);
}
return hits;
} catch {
return [];
}
}
export function aiDetectorHeuristic(text: string): number {
const stripped = stripHtml(text);
const words = stripped.split(/\s+/).filter(Boolean);
if (words.length < 50) return 0.2;
let score = 0;
const sentences = stripped.split(/(?<=[.!?])\s+/).filter((s) => s.length > 4);
const lower = stripped.toLowerCase();
let aiTellCount = 0;
for (const phrase of BANNED_AI_TELLS) {
const p = phrase.toLowerCase();
const re = p.includes(" ") || p.endsWith(",")
? new RegExp(escapeRegex(p), "g")
: new RegExp(`\\b${escapeRegex(p)}\\b`, "g");
const m = lower.match(re);
if (m) aiTellCount += m.length;
}
score += Math.min(0.4, aiTellCount * 0.08);
if (sentences.length > 0) {
const avgLen = words.length / sentences.length;
const variance =
sentences.reduce((acc, s) => {
const w = s.split(/\s+/).length;
return acc + (w - avgLen) ** 2;
}, 0) / sentences.length;
const stddev = Math.sqrt(variance);
if (stddev < 4) score += 0.2;
if (avgLen > 28) score += 0.1;
}
const longSentenceRatio =
sentences.filter((s) => s.split(/\s+/).length > 30).length / Math.max(1, sentences.length);
if (longSentenceRatio > 0.4) score += 0.15;
const tropeCount = (lower.match(/it is (worth|important) noting/g) || []).length
+ (lower.match(/in conclusion/g) || []).length
+ (lower.match(/in summary/g) || []).length;
score += Math.min(0.2, tropeCount * 0.1);
return Math.min(1, Math.max(0, score));
}
export async function runQualityGates(
title: string,
body: string,
excerpt: string,
detectorThreshold = 0.65
): Promise<QualityGateResult> {
const all = `${title}\n\n${excerpt}\n\n${body}`;
const aiTells = findBannedAiTells(all);
const custom = await findCustomBannedTerms(all);
const bannedWords = Array.from(new Set([...aiTells, ...custom]));
const detectorScore = aiDetectorHeuristic(body);
const reasons: string[] = [];
if (bannedWords.length > 0) {
reasons.push(`banned phrases: ${bannedWords.slice(0, 5).join(", ")}${bannedWords.length > 5 ? " …" : ""}`);
}
if (detectorScore > detectorThreshold) {
reasons.push(`AI-detector score ${detectorScore.toFixed(2)} > threshold ${detectorThreshold.toFixed(2)}`);
}
return {
passed: reasons.length === 0,
bannedWordsFound: bannedWords,
aiDetectorScore: detectorScore,
reasons,
};
}

View File

@@ -0,0 +1,164 @@
/**
* Pluggable LLM rewrite client.
*
* Provider interface so we can swap Anthropic ↔ Ollama ↔ OpenRouter without
* rewriting the orchestrator. Default provider is "anthropic" using
* claude-opus-4-7 with prompt caching on the system block.
*/
import type { Persona } from "./personas";
import { buildSystemPrompt, BANNED_AI_TELLS, STYLE_GUIDE } from "./persona-prompts";
export interface RewriteRequest {
persona: Persona;
rawTitle: string;
rawContent: string;
}
export interface RewriteResult {
title: string;
excerpt: string;
body: string;
category: string;
tags: string[];
model: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
}
export interface RewriteProvider {
name: string;
rewrite(req: RewriteRequest): Promise<RewriteResult>;
}
const ANTHROPIC_API = "https://api.anthropic.com/v1/messages";
const DEFAULT_MODEL = process.env.BB_REWRITE_MODEL || "claude-opus-4-7";
const MAX_OUTPUT_TOKENS = 4096;
function parseJsonFromText(text: string): any {
const trimmed = text.trim();
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
const candidate = fenced ? fenced[1] : trimmed;
return JSON.parse(candidate);
}
export const anthropicProvider: RewriteProvider = {
name: "anthropic",
async rewrite(req: RewriteRequest): Promise<RewriteResult> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not set in env");
const systemBlock = buildSystemPrompt(req.persona);
const userPrompt = [
`Below is a raw press release submitted by an industry contact. Rewrite it as a journalism piece in the voice of ${req.persona.name} (${req.persona.beat}).`,
"",
"Output STRICTLY as a JSON object with these keys:",
` "title" (string — your rewritten headline, NOT the original press-release headline)`,
` "excerpt" (string — 1-2 sentence dek, max 240 chars)`,
` "body" (string — HTML <p>, <h2>, <ul>, <strong>, <em>, <a> only; no <script>; no <style>; 400-1200 words for a typical PR)`,
` "category" (string — one of: News, Live Production, Streaming, Post Production, Sports, Business, Technology)`,
` "tags" (array of 3-7 short string tags)`,
"",
"Hard rules:",
` - Do NOT use any of these AI-tell words: ${BANNED_AI_TELLS.slice(0, 30).join(", ")} … (full list in your system prompt).`,
" - Do not invent quotes that are not in the source. Paraphrase real quotes; use direct quotes only when they appear verbatim in the source.",
" - Do not invent dates, dollar figures, or specs not in the source.",
" - If the source is thin, write a SHORT article (300-450 words). Do not pad.",
" - Lead with the news, not the company. Inverted pyramid.",
" - Subhead the article every 200-300 words with <h2>.",
"",
"RAW PRESS RELEASE BEGINS:",
`Title: ${req.rawTitle}`,
"",
req.rawContent,
"RAW PRESS RELEASE ENDS.",
"",
"Now produce the JSON.",
].join("\n");
const body = {
model: DEFAULT_MODEL,
max_tokens: MAX_OUTPUT_TOKENS,
system: [
{
type: "text",
text: systemBlock,
cache_control: { type: "ephemeral" },
},
{
type: "text",
text: STYLE_GUIDE,
cache_control: { type: "ephemeral" },
},
],
messages: [{ role: "user", content: userPrompt }],
};
const res = await fetch(ANTHROPIC_API, {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Anthropic API ${res.status}: ${errText.slice(0, 500)}`);
}
const data: any = await res.json();
const text = (data.content?.[0]?.text || "").toString();
let parsed: any;
try {
parsed = parseJsonFromText(text);
} catch (e: any) {
throw new Error(`Could not parse model output as JSON: ${e.message}; head=${text.slice(0, 300)}`);
}
if (!parsed.title || !parsed.body) {
throw new Error("Model output missing required 'title' or 'body'");
}
return {
title: String(parsed.title).trim(),
excerpt: String(parsed.excerpt || "").trim(),
body: String(parsed.body).trim(),
category: String(parsed.category || "News").trim(),
tags: Array.isArray(parsed.tags) ? parsed.tags.map((t: any) => String(t)) : [],
model: data.model || DEFAULT_MODEL,
inputTokens: data.usage?.input_tokens ?? 0,
outputTokens: data.usage?.output_tokens ?? 0,
cacheReadTokens: data.usage?.cache_read_input_tokens ?? 0,
cacheCreationTokens: data.usage?.cache_creation_input_tokens ?? 0,
};
},
};
export const ollamaProvider: RewriteProvider = {
name: "ollama",
async rewrite(): Promise<RewriteResult> {
throw new Error("ollama provider not implemented yet — set BB_REWRITE_PROVIDER=anthropic");
},
};
export const openrouterProvider: RewriteProvider = {
name: "openrouter",
async rewrite(): Promise<RewriteResult> {
throw new Error("openrouter provider not implemented yet — set BB_REWRITE_PROVIDER=anthropic");
},
};
export function getProvider(name?: string): RewriteProvider {
const want = (name || process.env.BB_REWRITE_PROVIDER || "anthropic").toLowerCase();
if (want === "anthropic") return anthropicProvider;
if (want === "ollama") return ollamaProvider;
if (want === "openrouter") return openrouterProvider;
throw new Error(`Unknown rewrite provider: ${want}`);
}

View File

@@ -0,0 +1,226 @@
import { createAdminClient } from "@/lib/supabase/admin";
import { getProvider } from "./rewrite-client";
import { getPersonaBySlug, pickPersonaForContent, type Persona } from "./personas";
import { runQualityGates } from "./quality-gates";
export interface RewriteOutcome {
submissionId: string;
rewriteJobId: string;
rewrittenArticleId: string | null;
status: "succeeded" | "failed_quality" | "failed_api";
attempts: number;
personaSlug: string;
bannedWordsFound: string[];
aiDetectorScore: number | null;
failureReason: string | null;
result?: {
title: string;
slug: string;
excerpt: string;
body: string;
category: string;
tags: string[];
};
}
function slugify(title: string): string {
const base = title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 96);
return `${base || "article"}-${Date.now().toString(36)}`;
}
async function readConfigInt(key: string, def: number): Promise<number> {
try {
const sb = createAdminClient();
const { data } = await sb.from("ai_rewrite_config").select("value").eq("key", key).maybeSingle();
const v = (data as any)?.value;
if (typeof v === "number") return v;
if (typeof v === "string") {
const n = Number(v);
if (!Number.isNaN(n)) return n;
}
if (v && typeof v === "object" && "value" in v) {
const n = Number((v as any).value);
if (!Number.isNaN(n)) return n;
}
} catch {}
return def;
}
async function readConfigFloat(key: string, def: number): Promise<number> {
return readConfigInt(key, def);
}
export interface RewriteOptions {
submissionId: string;
personaSlug?: string;
}
export async function rewriteSubmission(opts: RewriteOptions): Promise<RewriteOutcome> {
const sb = createAdminClient();
const { data: subRow, error: subErr } = await sb
.from("ai_original_submissions")
.select("*")
.eq("id", opts.submissionId)
.single();
if (subErr || !subRow) throw new Error(`Submission ${opts.submissionId} not found: ${subErr?.message}`);
const sub: any = subRow;
let persona: Persona | null = null;
if (opts.personaSlug) persona = await getPersonaBySlug(opts.personaSlug);
if (!persona) persona = await pickPersonaForContent(`${sub.raw_title}\n${sub.raw_content}`);
const maxAttempts = await readConfigInt("max_regeneration_attempts", 3);
const detectorThreshold = await readConfigFloat("ai_detector_threshold", 0.65);
const provider = getProvider();
let attempt = 0;
let lastJobId = "";
let lastBanned: string[] = [];
let lastScore: number | null = null;
let lastReason: string | null = null;
while (attempt < maxAttempts) {
attempt += 1;
const { data: jobRow, error: jobErr } = await sb
.from("ai_rewrite_jobs")
.insert({
original_submission_id: sub.id,
persona_id: persona.id,
attempt_number: attempt,
status: "pending",
})
.select()
.single();
if (jobErr || !jobRow) throw new Error(`Could not create rewrite job: ${jobErr?.message}`);
lastJobId = (jobRow as any).id;
let rewrite;
try {
rewrite = await provider.rewrite({
persona,
rawTitle: sub.raw_title,
rawContent: sub.raw_content,
});
} catch (err: any) {
lastReason = err.message || String(err);
await sb.from("ai_rewrite_jobs").update({
status: "failed_api",
failure_reason: lastReason,
finished_at: new Date().toISOString(),
}).eq("id", lastJobId);
continue;
}
const gates = await runQualityGates(rewrite.title, rewrite.body, rewrite.excerpt, detectorThreshold);
await sb.from("ai_rewrite_jobs").update({
generated_title: rewrite.title,
generated_excerpt: rewrite.excerpt,
generated_body: rewrite.body,
generated_category: rewrite.category,
generated_tags: rewrite.tags,
ai_detector_score: gates.aiDetectorScore,
banned_words_found: gates.bannedWordsFound,
model: rewrite.model,
input_tokens: rewrite.inputTokens,
output_tokens: rewrite.outputTokens,
cache_read_tokens: rewrite.cacheReadTokens,
status: gates.passed ? "succeeded" : "failed_quality",
failure_reason: gates.passed ? null : gates.reasons.join("; "),
finished_at: new Date().toISOString(),
}).eq("id", lastJobId);
lastBanned = gates.bannedWordsFound;
lastScore = gates.aiDetectorScore;
lastReason = gates.passed ? null : gates.reasons.join("; ");
if (!gates.passed) continue;
const slug = slugify(rewrite.title);
const { data: art, error: artErr } = await sb
.from("ai_rewritten_articles")
.insert({
original_submission_id: sub.id,
rewrite_job_id: lastJobId,
persona_id: persona.id,
slug,
title: rewrite.title,
excerpt: rewrite.excerpt,
body: rewrite.body,
category: rewrite.category,
tags: rewrite.tags,
status: "ai_rewritten_pending_review",
})
.select()
.single();
if (artErr || !art) throw new Error(`Could not save rewritten article: ${artErr?.message}`);
return {
submissionId: sub.id,
rewriteJobId: lastJobId,
rewrittenArticleId: (art as any).id,
status: "succeeded",
attempts: attempt,
personaSlug: persona.slug,
bannedWordsFound: gates.bannedWordsFound,
aiDetectorScore: gates.aiDetectorScore,
failureReason: null,
result: {
title: rewrite.title,
slug,
excerpt: rewrite.excerpt,
body: rewrite.body,
category: rewrite.category,
tags: rewrite.tags,
},
};
}
return {
submissionId: sub.id,
rewriteJobId: lastJobId,
rewrittenArticleId: null,
status: lastReason && lastReason.startsWith("Anthropic API") ? "failed_api" : "failed_quality",
attempts: attempt,
personaSlug: persona.slug,
bannedWordsFound: lastBanned,
aiDetectorScore: lastScore,
failureReason: lastReason,
};
}
export async function ingestRawSubmission(input: {
rawTitle: string;
rawContent: string;
rawHtml?: string;
contributorEmail?: string;
contributorName?: string;
source?: string;
metadata?: Record<string, unknown>;
}): Promise<string> {
const sb = createAdminClient();
const { data, error } = await sb
.from("ai_original_submissions")
.insert({
raw_title: input.rawTitle,
raw_content: input.rawContent,
raw_html: input.rawHtml || null,
contributor_email: input.contributorEmail || null,
contributor_name: input.contributorName || null,
source: input.source || "contributor_publish",
metadata: input.metadata || {},
})
.select("id")
.single();
if (error || !data) throw new Error(`ingestRawSubmission: ${error?.message}`);
return (data as any).id as string;
}