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:
Ryan Salazar
2026-05-20 06:42:22 +00:00
parent f7c462891f
commit 80b307ba92
2 changed files with 131 additions and 10 deletions

View File

@@ -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 }>> {

View File

@@ -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;