ai-explain.ts109 lines · main
| 1 | import { env } from '../env.js'; |
| 2 | import { log } from '../lib/logger.js'; |
| 3 | |
| 4 | import { AiNotConfiguredError } from './ai-schema-gen.js'; |
| 5 | |
| 6 | /** |
| 7 | * AI explain code — third member of the briven AI trifecta (schema + |
| 8 | * function + explain). Takes a snippet of briven schema/function code |
| 9 | * and returns a plain-english explanation framed in briven idioms (what |
| 10 | * the wrapper means, which db calls happen, where the reactivity hooks |
| 11 | * in, what would change if you flipped it from query() to mutation()). |
| 12 | * |
| 13 | * Same posture as the other two generators: forwards to Ollama on the |
| 14 | * DGX VPS; prompts and outputs are not logged; returns the shared |
| 15 | * AiNotConfiguredError when BRIVEN_OLLAMA_URL is unset so callers can |
| 16 | * collapse the 503 path. |
| 17 | */ |
| 18 | |
| 19 | export const EXPLAIN_SYSTEM_PROMPT = `You are a briven code explainer. Given a snippet of typescript using briven's schema dsl or function wrappers, explain what it does in plain english. |
| 20 | |
| 21 | Rules: |
| 22 | - Explain at the level of "a developer new to briven, comfortable with typescript". Don't restate the obvious (e.g. "this imports a function from a package"). |
| 23 | - Call out the briven-specific decisions: |
| 24 | - query() vs mutation() vs action() — what does the wrapper mean for reactivity, transactions, retries? |
| 25 | - ctx.db chains — which tables are touched, which indexes would matter? |
| 26 | - brivenError vs throwing a plain Error — what code/status does the client receive? |
| 27 | - schema column choices — when does notNull / references / unique matter? |
| 28 | - jsonb<T> type args — what does the type assertion give you on reads? |
| 29 | - If the snippet has a bug or a sharp edge, point it out (e.g. unbounded result sets, missing input validation, n+1 patterns). |
| 30 | - Keep the explanation under 250 words. Use short paragraphs and bullet lists, not walls of prose. No markdown code fences. |
| 31 | - If the snippet isn't briven code (e.g. unrelated typescript, sql, prose) say so in one sentence and stop — don't try to make sense of it.`; |
| 32 | |
| 33 | export interface AiExplainInput { |
| 34 | /** The code to explain. Capped at 8 KB at the route to keep the model context bounded. */ |
| 35 | code: string; |
| 36 | /** Optional caller note: "I'm new to briven", "I migrated from prisma", etc. Shapes the depth. */ |
| 37 | perspective?: string; |
| 38 | timeoutMs?: number; |
| 39 | } |
| 40 | |
| 41 | export interface AiExplainResult { |
| 42 | explanation: string; |
| 43 | model: string; |
| 44 | elapsedMs: number; |
| 45 | } |
| 46 | |
| 47 | export async function explainCode(input: AiExplainInput): Promise<AiExplainResult> { |
| 48 | if (!env.BRIVEN_OLLAMA_URL) { |
| 49 | throw new AiNotConfiguredError(); |
| 50 | } |
| 51 | |
| 52 | const userMessage = |
| 53 | input.perspective && input.perspective.trim().length > 0 |
| 54 | ? `Perspective: ${input.perspective.trim()}\n\nExplain this code:\n\`\`\`ts\n${input.code}\n\`\`\`` |
| 55 | : `Explain this code:\n\`\`\`ts\n${input.code}\n\`\`\``; |
| 56 | |
| 57 | // Per-feature model override per docs/AI.md — explain benefits most |
| 58 | // from a reasoning-tuned variant (e.g. deepseek-r1-distill-qwen-32b). |
| 59 | const model = env.BRIVEN_OLLAMA_MODEL_EXPLAIN ?? env.BRIVEN_OLLAMA_MODEL; |
| 60 | const t0 = Date.now(); |
| 61 | const url = `${env.BRIVEN_OLLAMA_URL.replace(/\/$/, '')}/api/generate`; |
| 62 | const headers: Record<string, string> = { 'content-type': 'application/json' }; |
| 63 | if (env.BRIVEN_OLLAMA_API_KEY) { |
| 64 | headers['x-api-key'] = env.BRIVEN_OLLAMA_API_KEY; |
| 65 | } |
| 66 | const res = await fetch(url, { |
| 67 | method: 'POST', |
| 68 | headers, |
| 69 | body: JSON.stringify({ |
| 70 | model, |
| 71 | system: EXPLAIN_SYSTEM_PROMPT, |
| 72 | prompt: userMessage, |
| 73 | // Higher than schema/function generators — explanation is prose, |
| 74 | // where a touch more variation reads as natural rather than |
| 75 | // robotic. Still well below "creative writing" temps. |
| 76 | options: { temperature: 0.4 }, |
| 77 | stream: false, |
| 78 | }), |
| 79 | signal: AbortSignal.timeout(input.timeoutMs ?? 60_000), |
| 80 | }); |
| 81 | |
| 82 | const elapsedMs = Date.now() - t0; |
| 83 | if (!res.ok) { |
| 84 | const body = await res.text().catch(() => ''); |
| 85 | log.warn('ai_explain_upstream_error', { |
| 86 | status: res.status, |
| 87 | elapsedMs, |
| 88 | bodyPreview: body.slice(0, 240), |
| 89 | }); |
| 90 | throw new Error(`Ollama returned ${res.status}`); |
| 91 | } |
| 92 | |
| 93 | const data = (await res.json()) as { response?: string }; |
| 94 | const explanation = (data.response ?? '').trim(); |
| 95 | |
| 96 | log.info('ai_explain_ok', { |
| 97 | codeLen: input.code.length, |
| 98 | perspectiveLen: input.perspective?.length ?? 0, |
| 99 | explanationLen: explanation.length, |
| 100 | model, |
| 101 | elapsedMs, |
| 102 | }); |
| 103 | |
| 104 | return { |
| 105 | explanation, |
| 106 | model, |
| 107 | elapsedMs, |
| 108 | }; |
| 109 | } |