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