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