From 38220bdf1b41eecfafeb6ad5d13b0a39b390fb93 Mon Sep 17 00:00:00 2001 From: Nandgopal-R Date: Tue, 10 Mar 2026 15:59:56 +0530 Subject: [PATCH] feat: add backend to generate forms --- bruno/forms/aiGenerateForm.bru | 44 +++++++ bun.lock | 3 + package.json | 1 + src/api/forms/ai-generate.ts | 230 +++++++++++++++++++++++++++++++++ src/api/forms/routes.ts | 3 + src/types/ai-generate.ts | 32 +++++ 6 files changed, 313 insertions(+) create mode 100644 bruno/forms/aiGenerateForm.bru create mode 100644 src/api/forms/ai-generate.ts create mode 100644 src/types/ai-generate.ts diff --git a/bruno/forms/aiGenerateForm.bru b/bruno/forms/aiGenerateForm.bru new file mode 100644 index 0000000..9d54b1e --- /dev/null +++ b/bruno/forms/aiGenerateForm.bru @@ -0,0 +1,44 @@ +meta { + name: aiGenerateForm + type: http + seq: 8 +} + +post { + url: http://localhost:8000/forms/ai-generate + body: json + auth: inherit +} + +body:json { + { + "prompt": "build a form for collecting faculty review with fields for faculty name, department, course taught, ratings for teaching quality, communication skills, and overall feedback" + } +} + +settings { + encodeUrl: true + timeout: 30000 +} + +docs { + # AI Generate Form + + Uses Google Gemini to generate a complete form from a text prompt. + + * **URL:** `/forms/ai-generate` + * **Method:** `POST` + * **Auth Required:** Yes + + ### Request Body + + | Field | Type | Required | Description | + | :--- | :--- | :--- | :--- | + | `prompt` | `string` | Yes | A natural language description of the form to generate. | + + ### Example Prompts + + - "build a form for collecting faculty review" + - "create a student registration form with personal and academic details" + - "make a feedback form for a workshop with ratings and comments" +} diff --git a/bun.lock b/bun.lock index e48ca57..b32f69d 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "backend", "dependencies": { "@elysiajs/cors": "^1.4.1", + "@google/generative-ai": "^0.24.1", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", "better-auth": "^1.5.4", @@ -87,6 +88,8 @@ "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + "@google/generative-ai": ["@google/generative-ai@0.24.1", "", {}, "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="], diff --git a/package.json b/package.json index 9251d8c..f8fb5c1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@elysiajs/cors": "^1.4.1", + "@google/generative-ai": "^0.24.1", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", "better-auth": "^1.5.4", diff --git a/src/api/forms/ai-generate.ts b/src/api/forms/ai-generate.ts new file mode 100644 index 0000000..509e3df --- /dev/null +++ b/src/api/forms/ai-generate.ts @@ -0,0 +1,230 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../../db/prisma"; +import { logger } from "../../logger/"; +import type { + AIFormResponse, + AiGenerateFormContext, +} from "../../types/ai-generate"; + +const GROQ_API_KEY = process.env.GROQ_API_KEY; +const GROQ_MODEL = process.env.GROQ_MODEL || "llama-3.3-70b-versatile"; +const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"; + +if (!GROQ_API_KEY) { + logger.warn("GROQ_API_KEY is not set – AI form generation will fail"); +} + +const SYSTEM_PROMPT = `You are a form builder AI for an application called FormEngine. +Given a user prompt, generate a form definition with appropriate fields. +You MUST respond with ONLY valid JSON matching this exact schema — no markdown, no explanation: + +{ + "title": "string", + "description": "string", + "fields": [ + { + "fieldName": "string (camelCase, no spaces)", + "label": "string (human-readable label)", + "fieldType": "string (one of the allowed types below)", + "fieldValueType": "string (one of: string, number, boolean, array)", + "validation": { "required": true }, + "options": ["only", "for", "choice", "fields"] + } + ] +} + +FIELD TYPE REFERENCE (use only these values for fieldType): +- text → single-line text input (fieldValueType: string) +- textarea → multi-line text input (fieldValueType: string) +- number → numeric input (fieldValueType: number) +- email → email address input (fieldValueType: string) +- phone → phone number input (fieldValueType: string) +- url → URL input (fieldValueType: string) +- select → dropdown select – MUST include "options" array (fieldValueType: string) +- radio → radio button group – MUST include "options" array (fieldValueType: string) +- checkbox → checkbox group – MUST include "options" array (fieldValueType: array) +- slider → range slider (fieldValueType: number) +- date → date picker (fieldValueType: string) +- time → time picker (fieldValueType: string) +- cgpa → CGPA input with 0-10 range (fieldValueType: number) + +VALIDATION RULES (use as keys in the "validation" object): +- required : boolean – whether the field is mandatory +- minLength : number – minimum character length (for text/textarea) +- maxLength : number – maximum character length (for text/textarea) +- min : number – minimum numeric value (for number/slider/cgpa) +- max : number – maximum numeric value (for number/slider/cgpa) +- pattern : string – regex pattern for custom validation + +IMPORTANT RULES: +1. fieldName must be camelCase with no spaces. +2. Always provide sensible default validation (at minimum "required": true for mandatory fields). +3. For select, radio, and checkbox types you MUST provide an "options" array with at least 2 items. +4. Do NOT include "options" for non-choice field types. +5. The "validation" value must be a JSON object (not a string), e.g. {"required":true}. +6. Generate between 3 and 20 fields depending on the complexity of the prompt. +7. Order the fields logically (e.g. name before email, personal info before academic info). +8. Respond with ONLY the JSON object. No markdown fences, no explanation.`; + +async function callGroq(prompt: string): Promise { + const response = await fetch(GROQ_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${GROQ_API_KEY}`, + }, + body: JSON.stringify({ + model: GROQ_MODEL, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: prompt }, + ], + response_format: { type: "json_object" }, + temperature: 0.7, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Groq API error ${response.status}: ${errorBody.slice(0, 300)}`, + ); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string } }>; + }; + + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("Groq returned empty response"); + } + + return JSON.parse(content) as AIFormResponse; +} + +export async function aiGenerateForm({ + user, + body, + set, +}: AiGenerateFormContext) { + if (!GROQ_API_KEY) { + set.status = 503; + return { + success: false, + message: "AI service is not configured", + }; + } + + // Call Groq + let parsed: AIFormResponse; + try { + parsed = await callGroq(body.prompt); + } catch (err) { + logger.error("AI API call or JSON parse failed", err); + set.status = 502; + return { + success: false, + message: "Failed to generate form from AI. Please try again.", + }; + } + + // Basic sanity check on the parsed response + if ( + !parsed.title || + !parsed.description || + !Array.isArray(parsed.fields) || + parsed.fields.length === 0 + ) { + logger.error("Gemini returned incomplete form data", { parsed }); + set.status = 502; + return { + success: false, + message: "AI returned an incomplete form. Please try a different prompt.", + }; + } + + // Persist form + fields in a single transaction with linked-list ordering + try { + const form = await prisma.$transaction(async (tx) => { + const createdForm = await tx.form.create({ + data: { + title: parsed.title, + description: parsed.description, + ownerId: user.id, + }, + }); + + let prevFieldId: string | null = null; + + for (const field of parsed.fields) { + // Parse validation – may be a JSON object or JSON-encoded string + let validationObj: Prisma.InputJsonValue | undefined; + try { + validationObj = + typeof field.validation === "string" + ? JSON.parse(field.validation) + : (field.validation as Prisma.InputJsonValue | undefined); + } catch { + validationObj = { required: true } as Prisma.InputJsonValue; + } + + const createdField = await tx.formFields.create({ + data: { + fieldName: field.fieldName, + label: field.label, + fieldType: field.fieldType, + fieldValueType: field.fieldValueType, + validation: validationObj ?? undefined, + options: field.options ?? undefined, + formId: createdForm.id, + prevFieldId, + }, + }); + + prevFieldId = createdField.id; + } + + // Return the full form with ordered fields + return tx.form.findUniqueOrThrow({ + where: { id: createdForm.id }, + include: { formFields: true }, + }); + }); + + // Order the fields by the linked list for the response + const orderedFields: typeof form.formFields = []; + let current = form.formFields.find((f) => f.prevFieldId === null); + while (current) { + orderedFields.push(current); + const currentId = current.id; + current = form.formFields.find((f) => f.prevFieldId === currentId); + } + + logger.info("AI generated form created", { + userId: user.id, + formId: form.id, + fieldCount: orderedFields.length, + }); + + return { + success: true, + message: "Form generated successfully", + data: { + id: form.id, + title: form.title, + description: form.description, + isPublished: form.isPublished, + createdAt: form.createdAt, + fields: orderedFields, + }, + }; + } catch (err) { + logger.error("Failed to save AI-generated form to database", err); + set.status = 500; + return { + success: false, + message: "Failed to save the generated form", + }; + } +} diff --git a/src/api/forms/routes.ts b/src/api/forms/routes.ts index bac39f6..eae9f70 100644 --- a/src/api/forms/routes.ts +++ b/src/api/forms/routes.ts @@ -1,10 +1,12 @@ import { Elysia } from "elysia"; +import { aiGenerateFormDTO } from "../../types/ai-generate"; import { createFormDTO, getFormByIdDTO, updateFormDTO, } from "../../types/forms"; import { requireAuth } from "../auth/requireAuth"; +import { aiGenerateForm } from "./ai-generate"; import { createForm, deleteForm, @@ -28,6 +30,7 @@ export const formRoutes = new Elysia({ prefix: "/forms" }) .use(requireAuth) .get("/", getAllForms) .post("/", createForm, createFormDTO) + .post("/ai-generate", aiGenerateForm, aiGenerateFormDTO) .get("/:formId", getFormById, getFormByIdDTO) .put("/:formId", updateForm, updateFormDTO) .delete("/:formId", deleteForm, getFormByIdDTO) diff --git a/src/types/ai-generate.ts b/src/types/ai-generate.ts new file mode 100644 index 0000000..9a0a0a9 --- /dev/null +++ b/src/types/ai-generate.ts @@ -0,0 +1,32 @@ +import { type Static, t } from "elysia"; + +export interface Context { + user: { id: string }; + set: { status?: number | string }; +} + +export const aiGenerateFormDTO = { + body: t.Object({ + prompt: t.String({ minLength: 1 }), + }), +}; + +export interface AiGenerateFormContext extends Context { + body: Static; +} + +/** Shape the AI must return */ +export interface AIFormResponse { + title: string; + description: string; + fields: AIFormField[]; +} + +export interface AIFormField { + fieldName: string; + label: string; + fieldType: string; + fieldValueType: string; + validation: Record; + options?: string[]; +}