route.ts128 lines · main
1import { NextResponse } from 'next/server';
2
3import { 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
23export const dynamic = 'force-dynamic';
24
25const SYSTEM_PROMPT = `You are the briven.tech docs assistant. Answer the user's question using ONLY the docs pages provided below as context.
26
27Rules:
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
34interface AskBody {
35 question?: unknown;
36}
37
38export 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
121function 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}