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,19 +3,33 @@ import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin'; import { createAdminClient } from '@/lib/supabase/admin';
import { hybridAI } from '@/lib/ai/hybridRouter'; import { hybridAI } from '@/lib/ai/hybridRouter';
// ─── Pen name rotation ──────────────────────────────────────────────────────── // ─── Byline ───────────────────────────────────────────────────────────────────
const BB_PEN_NAMES = [ // 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', 'Michael Strand', 'David Harlow', 'Karen Fielding', 'James Mercer',
'Peter Calloway', 'Sandra Voss', 'Brian Kowalski', 'Laura Pennington', 'Peter Calloway', 'Sandra Voss', 'Brian Kowalski', 'Laura Pennington',
'Thomas Reeves', 'Christine Vale', 'Marcus Webb', 'Ellen Forsythe', '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 { function pickPenName(siteId: number, recentNames: string[]): string {
const pool = siteId === 2 ? AV_PEN_NAMES : BB_PEN_NAMES; // AV Beat: rotate its small pen pool
// Avoid last 3 used names if (siteId === 2) {
const pool = AV_PEN_NAMES;
const available = pool.filter(n => !recentNames.slice(-3).includes(n)); const available = pool.filter(n => !recentNames.slice(-3).includes(n));
return available[Math.floor(Math.random() * available.length)] || pool[0]; 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 ──────────────────────────────────────── // ─── Company extraction via Hybrid AI ────────────────────────────────────────

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 = { export const ollamaProvider: RewriteProvider = {
name: "ollama", 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 { 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 === "anthropic") return anthropicProvider;
if (want === "ollama") return ollamaProvider; if (want === "ollama") return ollamaProvider;
if (want === "openrouter") return openrouterProvider; if (want === "openrouter") return openrouterProvider;