assistant.ts237 lines · main
| 1 | import { brivenError, ValidationError } from '@briven/shared'; |
| 2 | |
| 3 | import { ollamaChat } from './ollama.js'; |
| 4 | import { |
| 5 | createTable, |
| 6 | getFullSchema, |
| 7 | insertRow, |
| 8 | STUDIO_COLUMN_TYPES, |
| 9 | type StudioColumnSpec, |
| 10 | type StudioColumnType, |
| 11 | } from './studio.js'; |
| 12 | |
| 13 | /** |
| 14 | * Briven's in-product database assistant. |
| 15 | * |
| 16 | * Flow is deliberately two-step so a non-technical user is never surprised: |
| 17 | * 1. planDatabase() — ask Ollama for a JSON "build plan" (tables, columns, |
| 18 | * sample rows). NOTHING is written. The dashboard shows it for review. |
| 19 | * 2. applyPlan() — once the user clicks "build it", we run the plan |
| 20 | * through the SAME validated studio operations a human uses |
| 21 | * (createTable / insertRow). The model never touches the database; |
| 22 | * it only proposes, we execute through tested code. |
| 23 | * |
| 24 | * We always add the `id` (uuid pk) and `created_at` columns ourselves, so |
| 25 | * every table is valid no matter what the model returns. |
| 26 | */ |
| 27 | |
| 28 | export interface PlannedColumn { |
| 29 | readonly name: string; |
| 30 | readonly type: StudioColumnType; |
| 31 | readonly notNull: boolean; |
| 32 | } |
| 33 | |
| 34 | export interface PlannedTable { |
| 35 | readonly name: string; |
| 36 | readonly description: string; |
| 37 | readonly columns: readonly PlannedColumn[]; |
| 38 | readonly sampleRows: ReadonlyArray<Record<string, unknown>>; |
| 39 | } |
| 40 | |
| 41 | export interface AssistantPlan { |
| 42 | readonly summary: string; |
| 43 | readonly tables: readonly PlannedTable[]; |
| 44 | } |
| 45 | |
| 46 | export interface ApplyResult { |
| 47 | readonly summary: string; |
| 48 | readonly created: ReadonlyArray<{ table: string; columns: number; rows: number }>; |
| 49 | readonly skipped: readonly string[]; |
| 50 | } |
| 51 | |
| 52 | const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/; |
| 53 | const MAX_TABLES = 8; |
| 54 | const MAX_COLS = 30; |
| 55 | const MAX_SAMPLE_ROWS = 10; |
| 56 | |
| 57 | /** Columns Briven adds itself — never accepted from the model. */ |
| 58 | const RESERVED = new Set(['id', 'created_at', 'createdat']); |
| 59 | |
| 60 | const SYSTEM_PROMPT = [ |
| 61 | "You are Briven's database design assistant for NON-TECHNICAL people.", |
| 62 | 'Turn the user\'s plain-English description into a simple, sensible database.', |
| 63 | '', |
| 64 | 'Reply with STRICT JSON only — no prose, no markdown — in exactly this shape:', |
| 65 | '{', |
| 66 | ' "summary": "one friendly sentence describing what you designed",', |
| 67 | ' "tables": [', |
| 68 | ' {', |
| 69 | ' "name": "snake_case_table_name",', |
| 70 | ' "description": "short plain-English purpose",', |
| 71 | ' "columns": [', |
| 72 | ' { "name": "snake_case_column", "type": "text", "notNull": true }', |
| 73 | ' ],', |
| 74 | ' "sampleRows": [ { "snake_case_column": "an example value" } ]', |
| 75 | ' }', |
| 76 | ' ]', |
| 77 | '}', |
| 78 | '', |
| 79 | `Allowed column "type" values ONLY: ${STUDIO_COLUMN_TYPES.join(', ')}.`, |
| 80 | 'Rules:', |
| 81 | '- Use snake_case for every table and column name (letters, numbers, underscore).', |
| 82 | '- Do NOT add an "id" or "created_at" column — Briven adds those automatically.', |
| 83 | '- Keep each table to 3-6 meaningful columns. At most 6 tables.', |
| 84 | '- Give every table 2-3 realistic sample rows (omit id/created_at).', |
| 85 | '- For links between tables, add a column like "customer_id" of type "uuid".', |
| 86 | '- Prefer "text" for names/notes, "integer" for counts, "numeric" for money,', |
| 87 | ' "boolean" for yes/no, "timestamptz" for dates/times.', |
| 88 | ].join('\n'); |
| 89 | |
| 90 | function asType(v: unknown): StudioColumnType | null { |
| 91 | return typeof v === 'string' && (STUDIO_COLUMN_TYPES as readonly string[]).includes(v) |
| 92 | ? (v as StudioColumnType) |
| 93 | : null; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Validate + normalise an untrusted plan object (from the model OR echoed |
| 98 | * back by the client). Drops anything malformed rather than trusting it. |
| 99 | * Throws a friendly ValidationError if nothing usable survives. |
| 100 | */ |
| 101 | export function normalizePlan(raw: unknown): AssistantPlan { |
| 102 | const obj = (raw ?? {}) as { summary?: unknown; tables?: unknown }; |
| 103 | const summary = typeof obj.summary === 'string' ? obj.summary.slice(0, 400) : ''; |
| 104 | const rawTables = Array.isArray(obj.tables) ? obj.tables : []; |
| 105 | const tables: PlannedTable[] = []; |
| 106 | |
| 107 | for (const t of rawTables.slice(0, MAX_TABLES)) { |
| 108 | if (!t || typeof t !== 'object') continue; |
| 109 | const tt = t as { |
| 110 | name?: unknown; |
| 111 | description?: unknown; |
| 112 | columns?: unknown; |
| 113 | sampleRows?: unknown; |
| 114 | }; |
| 115 | const name = typeof tt.name === 'string' ? tt.name.trim().toLowerCase() : ''; |
| 116 | if (!NAME_RE.test(name) || name.startsWith('_briven_')) continue; |
| 117 | |
| 118 | const seen = new Set<string>(); |
| 119 | const columns: PlannedColumn[] = []; |
| 120 | for (const c of Array.isArray(tt.columns) ? tt.columns.slice(0, MAX_COLS) : []) { |
| 121 | if (!c || typeof c !== 'object') continue; |
| 122 | const cc = c as { name?: unknown; type?: unknown; notNull?: unknown }; |
| 123 | const cname = typeof cc.name === 'string' ? cc.name.trim().toLowerCase() : ''; |
| 124 | const ctype = asType(cc.type); |
| 125 | if (!NAME_RE.test(cname) || !ctype) continue; |
| 126 | if (RESERVED.has(cname) || seen.has(cname)) continue; |
| 127 | seen.add(cname); |
| 128 | columns.push({ name: cname, type: ctype, notNull: cc.notNull === true }); |
| 129 | } |
| 130 | if (columns.length === 0) continue; |
| 131 | |
| 132 | const colNames = new Set(columns.map((c) => c.name)); |
| 133 | const sampleRows: Record<string, unknown>[] = []; |
| 134 | for (const r of Array.isArray(tt.sampleRows) ? tt.sampleRows.slice(0, MAX_SAMPLE_ROWS) : []) { |
| 135 | if (!r || typeof r !== 'object') continue; |
| 136 | const row: Record<string, unknown> = {}; |
| 137 | for (const [k, v] of Object.entries(r as Record<string, unknown>)) { |
| 138 | const key = k.trim().toLowerCase(); |
| 139 | if (colNames.has(key) && v !== undefined) row[key] = v; |
| 140 | } |
| 141 | if (Object.keys(row).length > 0) sampleRows.push(row); |
| 142 | } |
| 143 | |
| 144 | tables.push({ |
| 145 | name, |
| 146 | description: typeof tt.description === 'string' ? tt.description.slice(0, 200) : '', |
| 147 | columns, |
| 148 | sampleRows, |
| 149 | }); |
| 150 | } |
| 151 | |
| 152 | if (tables.length === 0) { |
| 153 | throw new ValidationError('the assistant could not turn that into a database — try rephrasing', {}); |
| 154 | } |
| 155 | return { summary, tables }; |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * Ask the brain for a build plan. Read-only — writes nothing. The current |
| 160 | * schema is passed as context so the model extends rather than collides. |
| 161 | */ |
| 162 | export async function planDatabase(projectId: string, prompt: string): Promise<AssistantPlan> { |
| 163 | if (typeof prompt !== 'string' || prompt.trim() === '') { |
| 164 | throw new ValidationError('describe what you want to track', {}); |
| 165 | } |
| 166 | const schema = await getFullSchema(projectId); |
| 167 | const existing = schema.tables.map((t) => t.name); |
| 168 | const context = |
| 169 | existing.length > 0 |
| 170 | ? `The project already has these tables (do not recreate them): ${existing.join(', ')}.` |
| 171 | : 'The project is empty — this is the first design.'; |
| 172 | |
| 173 | const content = await ollamaChat( |
| 174 | [ |
| 175 | { role: 'system', content: SYSTEM_PROMPT }, |
| 176 | { role: 'user', content: `${context}\n\nWhat the user wants:\n${prompt.slice(0, 2000)}` }, |
| 177 | ], |
| 178 | { json: true, temperature: 0.2 }, |
| 179 | ); |
| 180 | |
| 181 | let parsed: unknown; |
| 182 | try { |
| 183 | parsed = JSON.parse(content); |
| 184 | } catch { |
| 185 | throw new brivenError('assistant_bad_json', 'the assistant gave an answer we could not read — try again', { |
| 186 | status: 502, |
| 187 | }); |
| 188 | } |
| 189 | return normalizePlan(parsed); |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Execute an (already reviewed) plan through the validated studio ops. |
| 194 | * Each table gets an auto `id` (uuid pk) + `created_at`. Tables that already |
| 195 | * exist are skipped, not overwritten. Sample-row failures are tolerated so |
| 196 | * one bad value can't abort the whole build. |
| 197 | */ |
| 198 | export async function applyPlan(projectId: string, rawPlan: unknown): Promise<ApplyResult> { |
| 199 | const plan = normalizePlan(rawPlan); |
| 200 | const schema = await getFullSchema(projectId); |
| 201 | const existing = new Set(schema.tables.map((t) => t.name)); |
| 202 | |
| 203 | const created: Array<{ table: string; columns: number; rows: number }> = []; |
| 204 | const skipped: string[] = []; |
| 205 | |
| 206 | for (const table of plan.tables) { |
| 207 | if (existing.has(table.name)) { |
| 208 | skipped.push(table.name); |
| 209 | continue; |
| 210 | } |
| 211 | const columns: StudioColumnSpec[] = [ |
| 212 | { name: 'id', type: 'uuid', primaryKey: true, defaultExpr: 'gen_random_uuid()' }, |
| 213 | ...table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull })), |
| 214 | { name: 'created_at', type: 'timestamptz', notNull: true, defaultExpr: 'now()' }, |
| 215 | ]; |
| 216 | await createTable({ projectId, tableName: table.name, columns }); |
| 217 | existing.add(table.name); |
| 218 | |
| 219 | let rows = 0; |
| 220 | for (const sample of table.sampleRows) { |
| 221 | const values: Record<string, unknown> = {}; |
| 222 | for (const col of table.columns) { |
| 223 | if (sample[col.name] !== undefined) values[col.name] = sample[col.name]; |
| 224 | } |
| 225 | if (Object.keys(values).length === 0) continue; |
| 226 | try { |
| 227 | await insertRow({ projectId, tableName: table.name, values }); |
| 228 | rows += 1; |
| 229 | } catch { |
| 230 | // tolerate a single bad sample row; keep building |
| 231 | } |
| 232 | } |
| 233 | created.push({ table: table.name, columns: columns.length, rows }); |
| 234 | } |
| 235 | |
| 236 | return { summary: plan.summary, created, skipped }; |
| 237 | } |