ai-function-gen.ts182 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 function generator — pairs with `ai-schema-gen.ts` to round out the |
| 8 | * Phase 3 AI differentiator. |
| 9 | * |
| 10 | * Takes a natural-language description ("list all posts published in the |
| 11 | * last 24 hours, newest first") plus the project's current schema as |
| 12 | * context, returns a single TypeScript file the user can drop into |
| 13 | * `briven/functions/<name>.ts`. |
| 14 | * |
| 15 | * Same posture as the schema generator: posts to Ollama running Qwen |
| 16 | * 2.5-coder on the DGX VPS; prompts and outputs are NOT logged (the |
| 17 | * operator might include real business names in the prompt). Only the |
| 18 | * prompt length + elapsed-ms + status code are recorded. |
| 19 | */ |
| 20 | |
| 21 | export const FUNCTION_SYSTEM_PROMPT = `You are a briven function author. Given a short description of an operation and the project's current schema, output a single TypeScript file that defines exactly one briven function (query, mutation, or action). |
| 22 | |
| 23 | Rules: |
| 24 | - Import the wrapper + Ctx + brivenError from '@briven/cli/server'. |
| 25 | - Pick the right wrapper: |
| 26 | - query — read-only; reactive subscriptions re-run it on dependency change. |
| 27 | - mutation — writes; transactional within a single function call. |
| 28 | - action — long-running or non-DB side effects (HTTP, email). NOT reactive, NOT in a DB transaction. |
| 29 | - The function signature is always (ctx: Ctx, args: <ArgsInterface>) => … |
| 30 | - Use ctx.db('<table>') to interact with the data plane. Common chains: |
| 31 | .select([...]).where(…).orderBy(…).limit(…).first() |
| 32 | .insert({…}).returning() |
| 33 | .update({…}).where({…}).returning() |
| 34 | .delete().where({…}) |
| 35 | - Throw brivenError('validation_failed', 'reason', { status: 400 }) for bad input. |
| 36 | Throw brivenError('not_found', 'reason', { status: 404 }) when a referenced row is missing. |
| 37 | Throw brivenError('forbidden', 'reason', { status: 403 }) for authz checks. |
| 38 | - Always validate required string args (.trim() + length check) before using them. |
| 39 | - Use ulid('<prefix>') from '@briven/shared' when minting ids — prefer table-tied prefixes (e.g. 'msg', 'rm', 'pst'). |
| 40 | - Default export the wrapped function — exactly one default export per file. |
| 41 | - Return ONLY the function file's contents. No prose, no markdown fences, no explanation. |
| 42 | |
| 43 | Example shape (a query): |
| 44 | import { brivenError, query, type Ctx } from '@briven/cli/server'; |
| 45 | |
| 46 | interface Args { |
| 47 | roomId: string; |
| 48 | limit?: number; |
| 49 | } |
| 50 | |
| 51 | export default query(async (ctx: Ctx, args: Args) => { |
| 52 | if (!args.roomId) throw new brivenError('validation_failed', 'roomId required', { status: 400 }); |
| 53 | const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); |
| 54 | return ctx |
| 55 | .db('messages') |
| 56 | .select(['id', 'roomId', 'authorName', 'body', 'createdAt']) |
| 57 | .where({ roomId: args.roomId }) |
| 58 | .orderBy('createdAt', 'desc') |
| 59 | .limit(limit); |
| 60 | }); |
| 61 | |
| 62 | Example shape (a mutation): |
| 63 | import { brivenError, mutation, type Ctx } from '@briven/cli/server'; |
| 64 | import { ulid } from '@briven/shared'; |
| 65 | |
| 66 | interface Args { |
| 67 | name: string; |
| 68 | } |
| 69 | |
| 70 | export default mutation(async (ctx: Ctx, args: Args) => { |
| 71 | const name = args.name?.trim(); |
| 72 | if (!name) throw new brivenError('validation_failed', 'name required', { status: 400 }); |
| 73 | if (name.length > 64) throw new brivenError('validation_failed', 'name too long', { status: 400 }); |
| 74 | const id = ulid('rm'); |
| 75 | const [row] = await ctx.db('rooms').insert({ id, name }).returning(); |
| 76 | return row; |
| 77 | });`; |
| 78 | |
| 79 | export interface AiFunctionGenInput { |
| 80 | prompt: string; |
| 81 | /** |
| 82 | * Optional — the project's current schema.ts contents. When provided we |
| 83 | * inject it into the user message so the model knows which tables and |
| 84 | * columns exist; without it the model has to guess at the schema shape. |
| 85 | * Cap at 8 KB so the prompt stays well inside the model's context. |
| 86 | */ |
| 87 | schemaContext?: string; |
| 88 | timeoutMs?: number; |
| 89 | } |
| 90 | |
| 91 | export interface AiFunctionGenResult { |
| 92 | function: string; |
| 93 | model: string; |
| 94 | elapsedMs: number; |
| 95 | } |
| 96 | |
| 97 | const SCHEMA_CONTEXT_MAX_BYTES = 8 * 1024; |
| 98 | |
| 99 | export async function generateFunction(input: AiFunctionGenInput): Promise<AiFunctionGenResult> { |
| 100 | if (!env.BRIVEN_OLLAMA_URL) { |
| 101 | throw new AiNotConfiguredError(); |
| 102 | } |
| 103 | |
| 104 | // Schema context is operator-controlled but pull through a hard cap |
| 105 | // anyway — the model context isn't unbounded and we'd rather truncate |
| 106 | // than fail with an opaque upstream 4xx. |
| 107 | let userMessage = input.prompt; |
| 108 | if (input.schemaContext && input.schemaContext.length > 0) { |
| 109 | const ctx = input.schemaContext.slice(0, SCHEMA_CONTEXT_MAX_BYTES); |
| 110 | userMessage = |
| 111 | `Current schema:\n\`\`\`ts\n${ctx}\n\`\`\`\n\n` + |
| 112 | `Write a function that: ${input.prompt}`; |
| 113 | } |
| 114 | |
| 115 | // Per-feature model override per docs/AI.md. |
| 116 | const model = env.BRIVEN_OLLAMA_MODEL_FUNCTION ?? env.BRIVEN_OLLAMA_MODEL; |
| 117 | const t0 = Date.now(); |
| 118 | const url = `${env.BRIVEN_OLLAMA_URL.replace(/\/$/, '')}/api/generate`; |
| 119 | const headers: Record<string, string> = { 'content-type': 'application/json' }; |
| 120 | if (env.BRIVEN_OLLAMA_API_KEY) { |
| 121 | headers['x-api-key'] = env.BRIVEN_OLLAMA_API_KEY; |
| 122 | } |
| 123 | const res = await fetch(url, { |
| 124 | method: 'POST', |
| 125 | headers, |
| 126 | body: JSON.stringify({ |
| 127 | model, |
| 128 | system: FUNCTION_SYSTEM_PROMPT, |
| 129 | prompt: userMessage, |
| 130 | // Slightly higher than the schema generator — functions have more |
| 131 | // surface area where small variation reads as natural rather than |
| 132 | // a sign of confusion. Still firmly in the deterministic range. |
| 133 | options: { temperature: 0.3 }, |
| 134 | stream: false, |
| 135 | }), |
| 136 | signal: AbortSignal.timeout(input.timeoutMs ?? 60_000), |
| 137 | }); |
| 138 | |
| 139 | const elapsedMs = Date.now() - t0; |
| 140 | if (!res.ok) { |
| 141 | const body = await res.text().catch(() => ''); |
| 142 | log.warn('ai_function_gen_upstream_error', { |
| 143 | status: res.status, |
| 144 | elapsedMs, |
| 145 | bodyPreview: body.slice(0, 240), |
| 146 | }); |
| 147 | throw new Error(`Ollama returned ${res.status}`); |
| 148 | } |
| 149 | |
| 150 | const data = (await res.json()) as { response?: string }; |
| 151 | const fnText = stripMarkdownFences((data.response ?? '').trim()); |
| 152 | |
| 153 | log.info('ai_function_gen_ok', { |
| 154 | promptLen: input.prompt.length, |
| 155 | schemaCtxLen: input.schemaContext?.length ?? 0, |
| 156 | fnLen: fnText.length, |
| 157 | model, |
| 158 | elapsedMs, |
| 159 | }); |
| 160 | |
| 161 | return { |
| 162 | function: fnText, |
| 163 | model, |
| 164 | elapsedMs, |
| 165 | }; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Strip a leading ```ts / ```typescript fence and trailing ``` if the |
| 170 | * model ignored the "no markdown fences" instruction (Qwen sometimes |
| 171 | * does for code-only responses). Keeps the schema generator strict |
| 172 | * since it ships a simpler prompt — this one's looser context invites |
| 173 | * fenced output. |
| 174 | */ |
| 175 | function stripMarkdownFences(text: string): string { |
| 176 | const fenceStart = /^```(?:typescript|ts|tsx)?\s*\n/; |
| 177 | const fenceEnd = /\n```\s*$/; |
| 178 | if (fenceStart.test(text) && fenceEnd.test(text)) { |
| 179 | return text.replace(fenceStart, '').replace(fenceEnd, ''); |
| 180 | } |
| 181 | return text; |
| 182 | } |