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:
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