ai-function-gen.ts182 lines · main
1import { env } from '../env.js';
2import { log } from '../lib/logger.js';
3
4import { 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
21export 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
23Rules:
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
43Example shape (a query):
44import { brivenError, query, type Ctx } from '@briven/cli/server';
45
46interface Args {
47 roomId: string;
48 limit?: number;
49}
50
51export 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
62Example shape (a mutation):
63import { brivenError, mutation, type Ctx } from '@briven/cli/server';
64import { ulid } from '@briven/shared';
65
66interface Args {
67 name: string;
68}
69
70export 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
79export 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
91export interface AiFunctionGenResult {
92 function: string;
93 model: string;
94 elapsedMs: number;
95}
96
97const SCHEMA_CONTEXT_MAX_BYTES = 8 * 1024;
98
99export 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 */
175function 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}