ai-schema-gen.ts125 lines · main
| 1 | import { env } from '../env.js'; |
| 2 | import { log } from '../lib/logger.js'; |
| 3 | |
| 4 | /** |
| 5 | * AI schema generator — Phase 3 differentiator. |
| 6 | * |
| 7 | * Takes a natural-language prompt ("a blog with users, posts, comments, |
| 8 | * each comment can reply to another comment") and returns a draft |
| 9 | * briven schema.ts as a single TypeScript string the user pastes into |
| 10 | * the dashboard editor. |
| 11 | * |
| 12 | * Implementation: posts to a self-hosted Ollama running Qwen 2.5-coder |
| 13 | * 32B on the DGX VPS. The platform-side endpoint applies a system |
| 14 | * prompt that pins the briven schema DSL conventions; the model |
| 15 | * returns just the code, which we surface verbatim. |
| 16 | * |
| 17 | * Privacy: prompts and outputs are NOT logged (the operator might |
| 18 | * include real business names in the prompt). Only the prompt length + |
| 19 | * elapsed-ms + status code are recorded for ops monitoring. |
| 20 | */ |
| 21 | |
| 22 | export const SCHEMA_SYSTEM_PROMPT = `You are a briven schema author. Given a short description of an app's data model, output a single TypeScript file that exports a schema definition using briven's DSL. |
| 23 | |
| 24 | Rules: |
| 25 | - Import only from '@briven/cli/schema'. |
| 26 | - Available column helpers: text(), bigint(), boolean(), timestamp(), jsonb<T>(), uuid(). |
| 27 | - Modifiers: .primaryKey(), .notNull(), .default(...), .nullable(), .references(table, column), .unique(). |
| 28 | - Every table needs a primary-key column. Prefer text() id for ULIDs. Use bigint() only for counters. |
| 29 | - Add index hints only where a non-trivial query would scan. Don't over-index. |
| 30 | - Return ONLY the schema file's contents. No prose, no markdown fences, no explanation. |
| 31 | |
| 32 | Example shape: |
| 33 | import { boolean, schema, table, text, timestamp } from '@briven/cli/schema'; |
| 34 | |
| 35 | export default schema({ |
| 36 | posts: table({ |
| 37 | id: text().primaryKey(), |
| 38 | body: text().notNull(), |
| 39 | createdAt: timestamp().default('now()').notNull(), |
| 40 | publishedAt: timestamp().nullable(), |
| 41 | }), |
| 42 | });`; |
| 43 | |
| 44 | export interface AiSchemaGenInput { |
| 45 | prompt: string; |
| 46 | /** Hard cap to keep the model from running away. Defaults to 60s. */ |
| 47 | timeoutMs?: number; |
| 48 | } |
| 49 | |
| 50 | export interface AiSchemaGenResult { |
| 51 | schema: string; |
| 52 | model: string; |
| 53 | elapsedMs: number; |
| 54 | } |
| 55 | |
| 56 | export class AiNotConfiguredError extends Error { |
| 57 | constructor() { |
| 58 | super('Ollama base URL not configured (set BRIVEN_OLLAMA_URL)'); |
| 59 | this.name = 'AiNotConfiguredError'; |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | export async function generateSchema(input: AiSchemaGenInput): Promise<AiSchemaGenResult> { |
| 64 | if (!env.BRIVEN_OLLAMA_URL) { |
| 65 | throw new AiNotConfiguredError(); |
| 66 | } |
| 67 | // Per-feature model override per docs/AI.md — falls back to the |
| 68 | // default model when the feature-specific var is unset. |
| 69 | const model = env.BRIVEN_OLLAMA_MODEL_SCHEMA ?? env.BRIVEN_OLLAMA_MODEL; |
| 70 | const t0 = Date.now(); |
| 71 | const url = `${env.BRIVEN_OLLAMA_URL.replace(/\/$/, '')}/api/generate`; |
| 72 | // why: the production "Ollama Console" proxy at ai.flndrn.com gates |
| 73 | // requests behind an X-API-Key header (NOT Authorization: Bearer — |
| 74 | // they reject Bearer with 401). A local DGX on a private net doesn't |
| 75 | // need any auth. Send the header only when configured so both shapes |
| 76 | // work. Future: if the proxy adds Bearer support, we can swap or add |
| 77 | // a BRIVEN_OLLAMA_AUTH_HEADER toggle. |
| 78 | const headers: Record<string, string> = { 'content-type': 'application/json' }; |
| 79 | if (env.BRIVEN_OLLAMA_API_KEY) { |
| 80 | headers['x-api-key'] = env.BRIVEN_OLLAMA_API_KEY; |
| 81 | } |
| 82 | const res = await fetch(url, { |
| 83 | method: 'POST', |
| 84 | headers, |
| 85 | body: JSON.stringify({ |
| 86 | model, |
| 87 | system: SCHEMA_SYSTEM_PROMPT, |
| 88 | prompt: input.prompt, |
| 89 | // Deterministic-ish output. Schema generation is structural and |
| 90 | // benefits from low temperature; the model still has room to vary |
| 91 | // wording but column shapes stay stable across re-runs. |
| 92 | options: { temperature: 0.2 }, |
| 93 | // We want one full response, not a stream. |
| 94 | stream: false, |
| 95 | }), |
| 96 | signal: AbortSignal.timeout(input.timeoutMs ?? 60_000), |
| 97 | }); |
| 98 | |
| 99 | const elapsedMs = Date.now() - t0; |
| 100 | if (!res.ok) { |
| 101 | const body = await res.text().catch(() => ''); |
| 102 | log.warn('ai_schema_gen_upstream_error', { |
| 103 | status: res.status, |
| 104 | elapsedMs, |
| 105 | bodyPreview: body.slice(0, 240), |
| 106 | }); |
| 107 | throw new Error(`Ollama returned ${res.status}`); |
| 108 | } |
| 109 | |
| 110 | const data = (await res.json()) as { response?: string }; |
| 111 | const schemaText = (data.response ?? '').trim(); |
| 112 | |
| 113 | log.info('ai_schema_gen_ok', { |
| 114 | promptLen: input.prompt.length, |
| 115 | schemaLen: schemaText.length, |
| 116 | model, |
| 117 | elapsedMs, |
| 118 | }); |
| 119 | |
| 120 | return { |
| 121 | schema: schemaText, |
| 122 | model, |
| 123 | elapsedMs, |
| 124 | }; |
| 125 | } |