ai-schema-gen.ts125 lines · main
1import { env } from '../env.js';
2import { 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
22export 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
24Rules:
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
32Example shape:
33import { boolean, schema, table, text, timestamp } from '@briven/cli/schema';
34
35export 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
44export interface AiSchemaGenInput {
45 prompt: string;
46 /** Hard cap to keep the model from running away. Defaults to 60s. */
47 timeoutMs?: number;
48}
49
50export interface AiSchemaGenResult {
51 schema: string;
52 model: string;
53 elapsedMs: number;
54}
55
56export class AiNotConfiguredError extends Error {
57 constructor() {
58 super('Ollama base URL not configured (set BRIVEN_OLLAMA_URL)');
59 this.name = 'AiNotConfiguredError';
60 }
61}
62
63export 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}