diff --git a/src/app/api/ask-bb-ai/route.ts b/src/app/api/ask-bb-ai/route.ts index f77e0e4..d5a7435 100644 --- a/src/app/api/ask-bb-ai/route.ts +++ b/src/app/api/ask-bb-ai/route.ts @@ -4,8 +4,9 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -const ANTHROPIC_API = "https://api.anthropic.com/v1/messages"; -const MODEL = process.env.BB_REWRITE_MODEL || "claude-opus-4-7"; +// Local Ollama on AI_002 (10.10.0.67). No paid AI services per policy. +const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://10.10.0.67:11434"; +const MODEL = process.env.BB_ASK_AI_MODEL || "llama3:latest"; const RATE_LIMIT_MAX = 30; const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; @@ -32,30 +33,26 @@ Broadcast Beat archive. Tone: trade-press, factual, lightly conversational. Plain English. Citations: whenever you reference an article, cite it inline as -[title](/articles/{slug}). When you reference an event, cite as +[title](/news/{slug}). When you reference an event, cite as [Event name](/show-coverage/{slug}). Use real slugs. Constraints: -- Never mention Anthropic, Claude, or any underlying model. +- Never reveal which AI model or vendor you run on. - Never invent facts. If you don't know, say so. -- Keep responses short and useful — 1-3 short paragraphs unless asked - for more. -- If the user asks about something outside broadcast/production - technology, politely steer them back. +- Keep responses short and useful — 1-3 short paragraphs unless asked for more. +- If the user asks about something outside broadcast/production technology, + politely steer them back. `; interface ClientMsg { role: "user" | "assistant"; content: string } export async function POST(req: NextRequest) { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) return NextResponse.json({ error: "AI not configured" }, { status: 503 }); - const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0]?.trim() || "anon"; const rl = rateLimit(ip); if (!rl.ok) { return NextResponse.json( { error: "Too many questions — try again in an hour." }, - { status: 429 } + { status: 429 }, ); } @@ -65,39 +62,52 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid message list" }, { status: 400 }); } - const lastUser = [...msgs].reverse().find((m) => m.role === "user"); - if (!lastUser) return NextResponse.json({ error: "No user message" }, { status: 400 }); + // Format as Ollama chat + const chatMessages = [ + { role: "system", content: SYSTEM_PROMPT }, + ...msgs.map((m) => ({ role: m.role, content: m.content })), + ]; - const apiBody = { - model: MODEL, - max_tokens: 1024, - system: [ - { type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }, - ], - messages: msgs.map((m) => ({ role: m.role, content: m.content })), - }; - - let res: Response; - try { - res = await fetch(ANTHROPIC_API, { - method: "POST", - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify(apiBody), - }); - } catch (err: any) { - return NextResponse.json({ error: "Upstream error: " + (err.message || String(err)) }, { status: 502 }); + // Retry up to 2x with backoff if Ollama returns 503/busy + let lastErr = ""; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const res = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: MODEL, + messages: chatMessages, + stream: false, + options: { temperature: 0.4, num_predict: 512 }, + }), + signal: AbortSignal.timeout(50_000), + }); + if (res.ok) { + const data: any = await res.json(); + const reply = (data?.message?.content || "").trim(); + if (!reply) { + lastErr = "empty response"; + continue; + } + return NextResponse.json({ reply }); + } + const txt = await res.text(); + lastErr = `Ollama ${res.status}: ${txt.slice(0, 180)}`; + if (res.status === 503 || /busy|pending requests exceeded/i.test(txt)) { + // Server busy — wait and retry + await new Promise((r) => setTimeout(r, attempt * 1500)); + continue; + } + break; // non-retriable + } catch (err: any) { + lastErr = "Upstream error: " + (err?.message || String(err)); + if (attempt < 3) await new Promise((r) => setTimeout(r, attempt * 1500)); + } } - if (!res.ok) { - const t = await res.text(); - return NextResponse.json({ error: `Upstream ${res.status}: ${t.slice(0, 200)}` }, { status: 502 }); - } - - const data: any = await res.json(); - const reply = (data.content?.[0]?.text || "").trim(); - return NextResponse.json({ reply }); + return NextResponse.json( + { error: lastErr || "AI temporarily unavailable. Please try again in a moment." }, + { status: 502 }, + ); }