ai: implement ollama rewrite provider + ryan-salazar byline default
The press-release rewriter (ai_rewrite_jobs) was stuck because: - BB_REWRITE_PROVIDER defaulted to "anthropic" in code, AND - production env still had BB_REWRITE_PROVIDER=anthropic, AND - ollamaProvider.rewrite() was a stub that threw "not implemented". Every job since 2026-01-26 hit Anthropic 400 "credit balance too low" — 12 failed_api in a row. Changes: - Implement ollamaProvider against /v1/chat/completions on the local Ollama (works against AI_001:11434 / llama3.1:70b). Same JSON contract as the Anthropic provider; gets prompt_tokens / completion_tokens out of Ollama's usage block. - Flip the default provider from "anthropic" → "ollama" in getProvider(), so the rewriter works out of the box now that the Ollama implementation exists. - Make the BB auto-story byline configurable via env. Default flips from a 12-name pen rotation to a single "Ryan Salazar" byline for Broadcast Beat (per editorial directive). AV Beat keeps its own pen pool. Set BB_AUTOSTORY_BYLINE=rotate to restore the old behaviour. Production env still needs (set in Coolify dashboard): OLLAMA_ENDPOINT=http://10.10.0.66:11434 OLLAMA_MODEL_DEFAULT=llama3.1:70b BB_REWRITE_PROVIDER=ollama BB_REWRITE_MODEL=llama3.1:70b (remove ANTHROPIC_API_KEY)
This commit is contained in:
@@ -3,20 +3,34 @@ import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { hybridAI } from '@/lib/ai/hybridRouter';
|
||||
|
||||
// ─── Pen name rotation ────────────────────────────────────────────────────────
|
||||
const BB_PEN_NAMES = [
|
||||
// ─── Byline ───────────────────────────────────────────────────────────────────
|
||||
// All auto-generated BB stories publish under Ryan Salazar's byline (founder).
|
||||
// AV Beat retains its own pen pool. To re-enable the BB rotation, set
|
||||
// BB_AUTOSTORY_BYLINE="rotate" — env override so we don't need a code change
|
||||
// when the editorial policy changes.
|
||||
const AV_PEN_NAMES = ['Rex Chandler', 'Dana Flux', 'Derek Wainwright', 'Sloane Rigging', 'Chip Crosspoint', 'Blair Presenter', 'Jordan Lumen'];
|
||||
const BB_PEN_NAMES_LEGACY = [
|
||||
'Michael Strand', 'David Harlow', 'Karen Fielding', 'James Mercer',
|
||||
'Peter Calloway', 'Sandra Voss', 'Brian Kowalski', 'Laura Pennington',
|
||||
'Thomas Reeves', 'Christine Vale', 'Marcus Webb', 'Ellen Forsythe',
|
||||
];
|
||||
const AV_PEN_NAMES = ['Rex Chandler', 'Dana Flux', 'Derek Wainwright', 'Sloane Rigging', 'Chip Crosspoint', 'Blair Presenter', 'Jordan Lumen'];
|
||||
const BB_AUTOSTORY_BYLINE = process.env.BB_AUTOSTORY_BYLINE || 'Ryan Salazar';
|
||||
|
||||
function pickPenName(siteId: number, recentNames: string[]): string {
|
||||
const pool = siteId === 2 ? AV_PEN_NAMES : BB_PEN_NAMES;
|
||||
// Avoid last 3 used names
|
||||
// AV Beat: rotate its small pen pool
|
||||
if (siteId === 2) {
|
||||
const pool = AV_PEN_NAMES;
|
||||
const available = pool.filter(n => !recentNames.slice(-3).includes(n));
|
||||
return available[Math.floor(Math.random() * available.length)] || pool[0];
|
||||
}
|
||||
// Broadcast Beat: fixed byline (Ryan) unless overridden to "rotate".
|
||||
if (BB_AUTOSTORY_BYLINE.toLowerCase() === 'rotate') {
|
||||
const pool = BB_PEN_NAMES_LEGACY;
|
||||
const available = pool.filter(n => !recentNames.slice(-3).includes(n));
|
||||
return available[Math.floor(Math.random() * available.length)] || pool[0];
|
||||
}
|
||||
return BB_AUTOSTORY_BYLINE;
|
||||
}
|
||||
|
||||
// ─── Company extraction via Hybrid AI ────────────────────────────────────────
|
||||
async function extractCompanies(title: string, content: string): Promise<Array<{ name: string; website_guess: string }>> {
|
||||
|
||||
@@ -141,10 +141,113 @@ export const anthropicProvider: RewriteProvider = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Ollama provider ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Talks to a local Ollama instance via the OpenAI-compatible
|
||||
// /v1/chat/completions endpoint. Env:
|
||||
// OLLAMA_ENDPOINT e.g. http://10.10.0.66:11434 (NO trailing /v1)
|
||||
// OLLAMA_MODEL_DEFAULT e.g. llama3.1:70b
|
||||
// OLLAMA_API_KEY optional bearer
|
||||
// BB_REWRITE_MODEL overrides the model just for the rewriter
|
||||
export const ollamaProvider: RewriteProvider = {
|
||||
name: "ollama",
|
||||
async rewrite(): Promise<RewriteResult> {
|
||||
throw new Error("ollama provider not implemented yet — set BB_REWRITE_PROVIDER=anthropic");
|
||||
|
||||
async rewrite(req: RewriteRequest): Promise<RewriteResult> {
|
||||
const endpoint = (process.env.OLLAMA_ENDPOINT || "").replace(/\/$/, "");
|
||||
if (!endpoint) throw new Error("OLLAMA_ENDPOINT not set in env");
|
||||
const model =
|
||||
process.env.BB_REWRITE_MODEL ||
|
||||
process.env.OLLAMA_MODEL_DEFAULT ||
|
||||
"llama3.1:70b";
|
||||
|
||||
const systemBlock =
|
||||
buildSystemPrompt(req.persona) + "\n\n=== STYLE GUIDE ===\n" + STYLE_GUIDE;
|
||||
|
||||
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 (no markdown fences, no commentary):",
|
||||
` "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 headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
const apiKey = process.env.OLLAMA_API_KEY;
|
||||
if (apiKey) headers["authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
const res = await fetch(`${endpoint}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemBlock },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
// Ollama recognises response_format for JSON-mode on recent builds; the
|
||||
// legacy `format:"json"` option also works on /api/generate but the
|
||||
// OpenAI-compatible surface uses response_format.
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.6,
|
||||
max_tokens: MAX_OUTPUT_TOKENS,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
throw new Error(`Ollama API ${res.status}: ${errText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
const data: any = await res.json();
|
||||
const text = (data?.choices?.[0]?.message?.content || "").toString();
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = parseJsonFromText(text);
|
||||
} catch (e: any) {
|
||||
throw new Error(
|
||||
`Could not parse Ollama output as JSON: ${e.message}; head=${text.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed.title || !parsed.body) {
|
||||
throw new Error("Ollama 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 || model,
|
||||
inputTokens: data?.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: data?.usage?.completion_tokens ?? 0,
|
||||
// Ollama doesn't surface a cache-token concept; report zeros.
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -155,8 +258,12 @@ export const openrouterProvider: RewriteProvider = {
|
||||
},
|
||||
};
|
||||
|
||||
// Default provider is Ollama. Anthropic stays available for explicit opt-in
|
||||
// via BB_REWRITE_PROVIDER=anthropic, but we no longer route the daily jobs
|
||||
// at it because the billing account is empty. See ai_rewrite_jobs.failure_reason
|
||||
// — every attempt since 2026-01-26 was a 400 "credit balance too low".
|
||||
export function getProvider(name?: string): RewriteProvider {
|
||||
const want = (name || process.env.BB_REWRITE_PROVIDER || "anthropic").toLowerCase();
|
||||
const want = (name || process.env.BB_REWRITE_PROVIDER || "ollama").toLowerCase();
|
||||
if (want === "anthropic") return anthropicProvider;
|
||||
if (want === "ollama") return ollamaProvider;
|
||||
if (want === "openrouter") return openrouterProvider;
|
||||
|
||||
Reference in New Issue
Block a user