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,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>
);
}