route.ts128 lines · main
| 1 | import { NextResponse } from 'next/server'; |
| 2 | |
| 3 | import { searchDocs, type DocsCorpusEntry } from '../../../lib/docs-corpus'; |
| 4 | |
| 5 | /** |
| 6 | * AI docs assistant. Takes a free-form question, finds the top 3 docs |
| 7 | * pages by word-overlap (the same corpus that powers /api/search), and |
| 8 | * forwards a prompt to ollama with those pages as system-prompt |
| 9 | * context. Returns the model's answer + the three slugs the answer is |
| 10 | * grounded in. |
| 11 | * |
| 12 | * Public, no auth. The ollama URL + key live on this server-side route |
| 13 | * — they never reach the client. When BRIVEN_OLLAMA_URL is unset |
| 14 | * (typical for the docs container until ai.flndrn.com config lands) |
| 15 | * the endpoint returns 503 not_configured and the docs UI renders an |
| 16 | * "ask is offline" state. |
| 17 | * |
| 18 | * Same auth shape as the api workspace: X-API-Key when |
| 19 | * BRIVEN_OLLAMA_API_KEY is set, no header when on a private network |
| 20 | * with a local DGX. |
| 21 | */ |
| 22 | |
| 23 | export const dynamic = 'force-dynamic'; |
| 24 | |
| 25 | const SYSTEM_PROMPT = `You are the briven.tech docs assistant. Answer the user's question using ONLY the docs pages provided below as context. |
| 26 | |
| 27 | Rules: |
| 28 | - Cite specific pages by their slug in [brackets] when you reference them. |
| 29 | - If the context doesn't cover the question, say so plainly. Don't invent answers. |
| 30 | - Use plain english + short paragraphs. No marketing voice. No exclamation points. |
| 31 | - briven uses lowercase consistently — match that voice. |
| 32 | - Keep the answer under 200 words. The reader has the docs one click away.`; |
| 33 | |
| 34 | interface AskBody { |
| 35 | question?: unknown; |
| 36 | } |
| 37 | |
| 38 | export async function POST(req: Request): Promise<Response> { |
| 39 | const ollamaUrl = process.env.BRIVEN_OLLAMA_URL; |
| 40 | if (!ollamaUrl) { |
| 41 | return NextResponse.json( |
| 42 | { |
| 43 | code: 'not_configured', |
| 44 | message: 'AI features are disabled on this deployment (BRIVEN_OLLAMA_URL unset)', |
| 45 | }, |
| 46 | { status: 503 }, |
| 47 | ); |
| 48 | } |
| 49 | |
| 50 | const body = (await req.json().catch(() => ({}))) as AskBody; |
| 51 | const question = typeof body.question === 'string' ? body.question.trim() : ''; |
| 52 | if (question.length === 0 || question.length > 2000) { |
| 53 | return NextResponse.json( |
| 54 | { code: 'validation_failed', message: 'question must be a 1-2000 char string' }, |
| 55 | { status: 400 }, |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | // Retrieval — pick the top 3 corpus entries by word overlap. |
| 60 | const top = searchDocs(question, 3); |
| 61 | const contextBlock = renderContext(top); |
| 62 | |
| 63 | // Inference — forward to ollama with the retrieved context as the |
| 64 | // system prompt's tail. X-API-Key when BRIVEN_OLLAMA_API_KEY is set. |
| 65 | const headers: Record<string, string> = { 'content-type': 'application/json' }; |
| 66 | if (process.env.BRIVEN_OLLAMA_API_KEY) { |
| 67 | headers['x-api-key'] = process.env.BRIVEN_OLLAMA_API_KEY; |
| 68 | } |
| 69 | const model = |
| 70 | process.env.BRIVEN_OLLAMA_MODEL_DOCS ?? process.env.BRIVEN_OLLAMA_MODEL ?? 'qwen2.5-coder:latest'; |
| 71 | |
| 72 | const t0 = Date.now(); |
| 73 | let res: Response; |
| 74 | try { |
| 75 | res = await fetch(`${ollamaUrl.replace(/\/$/, '')}/api/generate`, { |
| 76 | method: 'POST', |
| 77 | headers, |
| 78 | body: JSON.stringify({ |
| 79 | model, |
| 80 | system: `${SYSTEM_PROMPT}\n\nContext (top ${top.length} matching docs pages):\n${contextBlock}`, |
| 81 | prompt: question, |
| 82 | options: { temperature: 0.3 }, |
| 83 | stream: false, |
| 84 | }), |
| 85 | signal: AbortSignal.timeout(60_000), |
| 86 | }); |
| 87 | } catch (err) { |
| 88 | return NextResponse.json( |
| 89 | { |
| 90 | code: 'upstream_error', |
| 91 | message: err instanceof Error ? err.message : 'fetch failed', |
| 92 | }, |
| 93 | { status: 502 }, |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | if (!res.ok) { |
| 98 | const upstreamBody = await res.text().catch(() => ''); |
| 99 | return NextResponse.json( |
| 100 | { |
| 101 | code: 'upstream_error', |
| 102 | status: res.status, |
| 103 | message: upstreamBody.slice(0, 240), |
| 104 | }, |
| 105 | { status: 502 }, |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | const data = (await res.json()) as { response?: string }; |
| 110 | const answer = (data.response ?? '').trim(); |
| 111 | |
| 112 | return NextResponse.json({ |
| 113 | question, |
| 114 | answer, |
| 115 | citations: top.map((c) => ({ slug: c.slug, title: c.title })), |
| 116 | model, |
| 117 | elapsedMs: Date.now() - t0, |
| 118 | }); |
| 119 | } |
| 120 | |
| 121 | function renderContext(entries: readonly DocsCorpusEntry[]): string { |
| 122 | return entries |
| 123 | .map( |
| 124 | (e) => |
| 125 | `[${e.slug}] ${e.title}\n${e.summary}\nkeywords: ${e.keywords.join(', ')}`, |
| 126 | ) |
| 127 | .join('\n\n---\n\n'); |
| 128 | } |