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:
16
public/assets/images/personas/darren-okafor.svg
Normal file
16
public/assets/images/personas/darren-okafor.svg
Normal 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 |
16
public/assets/images/personas/elena-vasquez.svg
Normal file
16
public/assets/images/personas/elena-vasquez.svg
Normal 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 |
16
public/assets/images/personas/marcus-halverson.svg
Normal file
16
public/assets/images/personas/marcus-halverson.svg
Normal 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 |
16
public/assets/images/personas/mike-trayton.svg
Normal file
16
public/assets/images/personas/mike-trayton.svg
Normal 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 |
16
public/assets/images/personas/priya-rao.svg
Normal file
16
public/assets/images/personas/priya-rao.svg
Normal 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 |
16
public/assets/images/personas/sarah-quinn.svg
Normal file
16
public/assets/images/personas/sarah-quinn.svg
Normal 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&A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
73
src/app/admin/review-queue/[id]/ReviewActions.tsx
Normal file
73
src/app/admin/review-queue/[id]/ReviewActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/admin/review-queue/[id]/page.tsx
Normal file
106
src/app/admin/review-queue/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/app/admin/review-queue/page.tsx
Normal file
120
src/app/admin/review-queue/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/app/api/admin/review-queue/[id]/route.ts
Normal file
71
src/app/api/admin/review-queue/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
30
src/app/api/ai/rewrite/route.ts
Normal file
30
src/app/api/ai/rewrite/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/ai/submit/route.ts
Normal file
39
src/app/api/ai/submit/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/lib/ai/persona-prompts.ts
Normal file
101
src/lib/ai/persona-prompts.ts
Normal 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
71
src/lib/ai/personas.ts
Normal 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
123
src/lib/ai/quality-gates.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
164
src/lib/ai/rewrite-client.ts
Normal file
164
src/lib/ai/rewrite-client.ts
Normal 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}`);
|
||||||
|
}
|
||||||
226
src/lib/ai/rewrite-pipeline.ts
Normal file
226
src/lib/ai/rewrite-pipeline.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user