Skip to content

Commit 8ea9c7a

Browse files
Merge pull request #42 from Nandgopal-R/feat/ai
feat: add backend to generate forms
2 parents 7abf1d4 + 38220bd commit 8ea9c7a

File tree

6 files changed

+313
-0
lines changed

6 files changed

+313
-0
lines changed

bruno/forms/aiGenerateForm.bru

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
meta {
2+
name: aiGenerateForm
3+
type: http
4+
seq: 8
5+
}
6+
7+
post {
8+
url: http://localhost:8000/forms/ai-generate
9+
body: json
10+
auth: inherit
11+
}
12+
13+
body:json {
14+
{
15+
"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"
16+
}
17+
}
18+
19+
settings {
20+
encodeUrl: true
21+
timeout: 30000
22+
}
23+
24+
docs {
25+
# AI Generate Form
26+
27+
Uses Google Gemini to generate a complete form from a text prompt.
28+
29+
* **URL:** `/forms/ai-generate`
30+
* **Method:** `POST`
31+
* **Auth Required:** Yes
32+
33+
### Request Body
34+
35+
| Field | Type | Required | Description |
36+
| :--- | :--- | :--- | :--- |
37+
| `prompt` | `string` | Yes | A natural language description of the form to generate. |
38+
39+
### Example Prompts
40+
41+
- "build a form for collecting faculty review"
42+
- "create a student registration form with personal and academic details"
43+
- "make a feedback form for a workshop with ratings and comments"
44+
}

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"dependencies": {
1717
"@elysiajs/cors": "^1.4.1",
18+
"@google/generative-ai": "^0.24.1",
1819
"@prisma/adapter-pg": "^7.4.2",
1920
"@prisma/client": "^7.4.2",
2021
"better-auth": "^1.5.4",

src/api/forms/ai-generate.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import type { Prisma } from "@prisma/client";
2+
import { prisma } from "../../db/prisma";
3+
import { logger } from "../../logger/";
4+
import type {
5+
AIFormResponse,
6+
AiGenerateFormContext,
7+
} from "../../types/ai-generate";
8+
9+
const GROQ_API_KEY = process.env.GROQ_API_KEY;
10+
const GROQ_MODEL = process.env.GROQ_MODEL || "llama-3.3-70b-versatile";
11+
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
12+
13+
if (!GROQ_API_KEY) {
14+
logger.warn("GROQ_API_KEY is not set – AI form generation will fail");
15+
}
16+
17+
const SYSTEM_PROMPT = `You are a form builder AI for an application called FormEngine.
18+
Given a user prompt, generate a form definition with appropriate fields.
19+
You MUST respond with ONLY valid JSON matching this exact schema — no markdown, no explanation:
20+
21+
{
22+
"title": "string",
23+
"description": "string",
24+
"fields": [
25+
{
26+
"fieldName": "string (camelCase, no spaces)",
27+
"label": "string (human-readable label)",
28+
"fieldType": "string (one of the allowed types below)",
29+
"fieldValueType": "string (one of: string, number, boolean, array)",
30+
"validation": { "required": true },
31+
"options": ["only", "for", "choice", "fields"]
32+
}
33+
]
34+
}
35+
36+
FIELD TYPE REFERENCE (use only these values for fieldType):
37+
- text → single-line text input (fieldValueType: string)
38+
- textarea → multi-line text input (fieldValueType: string)
39+
- number → numeric input (fieldValueType: number)
40+
- email → email address input (fieldValueType: string)
41+
- phone → phone number input (fieldValueType: string)
42+
- url → URL input (fieldValueType: string)
43+
- select → dropdown select – MUST include "options" array (fieldValueType: string)
44+
- radio → radio button group – MUST include "options" array (fieldValueType: string)
45+
- checkbox → checkbox group – MUST include "options" array (fieldValueType: array)
46+
- slider → range slider (fieldValueType: number)
47+
- date → date picker (fieldValueType: string)
48+
- time → time picker (fieldValueType: string)
49+
- cgpa → CGPA input with 0-10 range (fieldValueType: number)
50+
51+
VALIDATION RULES (use as keys in the "validation" object):
52+
- required : boolean – whether the field is mandatory
53+
- minLength : number – minimum character length (for text/textarea)
54+
- maxLength : number – maximum character length (for text/textarea)
55+
- min : number – minimum numeric value (for number/slider/cgpa)
56+
- max : number – maximum numeric value (for number/slider/cgpa)
57+
- pattern : string – regex pattern for custom validation
58+
59+
IMPORTANT RULES:
60+
1. fieldName must be camelCase with no spaces.
61+
2. Always provide sensible default validation (at minimum "required": true for mandatory fields).
62+
3. For select, radio, and checkbox types you MUST provide an "options" array with at least 2 items.
63+
4. Do NOT include "options" for non-choice field types.
64+
5. The "validation" value must be a JSON object (not a string), e.g. {"required":true}.
65+
6. Generate between 3 and 20 fields depending on the complexity of the prompt.
66+
7. Order the fields logically (e.g. name before email, personal info before academic info).
67+
8. Respond with ONLY the JSON object. No markdown fences, no explanation.`;
68+
69+
async function callGroq(prompt: string): Promise<AIFormResponse> {
70+
const response = await fetch(GROQ_API_URL, {
71+
method: "POST",
72+
headers: {
73+
"Content-Type": "application/json",
74+
Authorization: `Bearer ${GROQ_API_KEY}`,
75+
},
76+
body: JSON.stringify({
77+
model: GROQ_MODEL,
78+
messages: [
79+
{ role: "system", content: SYSTEM_PROMPT },
80+
{ role: "user", content: prompt },
81+
],
82+
response_format: { type: "json_object" },
83+
temperature: 0.7,
84+
}),
85+
});
86+
87+
if (!response.ok) {
88+
const errorBody = await response.text();
89+
throw new Error(
90+
`Groq API error ${response.status}: ${errorBody.slice(0, 300)}`,
91+
);
92+
}
93+
94+
const data = (await response.json()) as {
95+
choices: Array<{ message: { content: string } }>;
96+
};
97+
98+
const content = data.choices?.[0]?.message?.content;
99+
if (!content) {
100+
throw new Error("Groq returned empty response");
101+
}
102+
103+
return JSON.parse(content) as AIFormResponse;
104+
}
105+
106+
export async function aiGenerateForm({
107+
user,
108+
body,
109+
set,
110+
}: AiGenerateFormContext) {
111+
if (!GROQ_API_KEY) {
112+
set.status = 503;
113+
return {
114+
success: false,
115+
message: "AI service is not configured",
116+
};
117+
}
118+
119+
// Call Groq
120+
let parsed: AIFormResponse;
121+
try {
122+
parsed = await callGroq(body.prompt);
123+
} catch (err) {
124+
logger.error("AI API call or JSON parse failed", err);
125+
set.status = 502;
126+
return {
127+
success: false,
128+
message: "Failed to generate form from AI. Please try again.",
129+
};
130+
}
131+
132+
// Basic sanity check on the parsed response
133+
if (
134+
!parsed.title ||
135+
!parsed.description ||
136+
!Array.isArray(parsed.fields) ||
137+
parsed.fields.length === 0
138+
) {
139+
logger.error("Gemini returned incomplete form data", { parsed });
140+
set.status = 502;
141+
return {
142+
success: false,
143+
message: "AI returned an incomplete form. Please try a different prompt.",
144+
};
145+
}
146+
147+
// Persist form + fields in a single transaction with linked-list ordering
148+
try {
149+
const form = await prisma.$transaction(async (tx) => {
150+
const createdForm = await tx.form.create({
151+
data: {
152+
title: parsed.title,
153+
description: parsed.description,
154+
ownerId: user.id,
155+
},
156+
});
157+
158+
let prevFieldId: string | null = null;
159+
160+
for (const field of parsed.fields) {
161+
// Parse validation – may be a JSON object or JSON-encoded string
162+
let validationObj: Prisma.InputJsonValue | undefined;
163+
try {
164+
validationObj =
165+
typeof field.validation === "string"
166+
? JSON.parse(field.validation)
167+
: (field.validation as Prisma.InputJsonValue | undefined);
168+
} catch {
169+
validationObj = { required: true } as Prisma.InputJsonValue;
170+
}
171+
172+
const createdField = await tx.formFields.create({
173+
data: {
174+
fieldName: field.fieldName,
175+
label: field.label,
176+
fieldType: field.fieldType,
177+
fieldValueType: field.fieldValueType,
178+
validation: validationObj ?? undefined,
179+
options: field.options ?? undefined,
180+
formId: createdForm.id,
181+
prevFieldId,
182+
},
183+
});
184+
185+
prevFieldId = createdField.id;
186+
}
187+
188+
// Return the full form with ordered fields
189+
return tx.form.findUniqueOrThrow({
190+
where: { id: createdForm.id },
191+
include: { formFields: true },
192+
});
193+
});
194+
195+
// Order the fields by the linked list for the response
196+
const orderedFields: typeof form.formFields = [];
197+
let current = form.formFields.find((f) => f.prevFieldId === null);
198+
while (current) {
199+
orderedFields.push(current);
200+
const currentId = current.id;
201+
current = form.formFields.find((f) => f.prevFieldId === currentId);
202+
}
203+
204+
logger.info("AI generated form created", {
205+
userId: user.id,
206+
formId: form.id,
207+
fieldCount: orderedFields.length,
208+
});
209+
210+
return {
211+
success: true,
212+
message: "Form generated successfully",
213+
data: {
214+
id: form.id,
215+
title: form.title,
216+
description: form.description,
217+
isPublished: form.isPublished,
218+
createdAt: form.createdAt,
219+
fields: orderedFields,
220+
},
221+
};
222+
} catch (err) {
223+
logger.error("Failed to save AI-generated form to database", err);
224+
set.status = 500;
225+
return {
226+
success: false,
227+
message: "Failed to save the generated form",
228+
};
229+
}
230+
}

src/api/forms/routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Elysia } from "elysia";
2+
import { aiGenerateFormDTO } from "../../types/ai-generate";
23
import {
34
createFormDTO,
45
getFormByIdDTO,
56
updateFormDTO,
67
} from "../../types/forms";
78
import { requireAuth } from "../auth/requireAuth";
9+
import { aiGenerateForm } from "./ai-generate";
810
import {
911
createForm,
1012
deleteForm,
@@ -28,6 +30,7 @@ export const formRoutes = new Elysia({ prefix: "/forms" })
2830
.use(requireAuth)
2931
.get("/", getAllForms)
3032
.post("/", createForm, createFormDTO)
33+
.post("/ai-generate", aiGenerateForm, aiGenerateFormDTO)
3134
.get("/:formId", getFormById, getFormByIdDTO)
3235
.put("/:formId", updateForm, updateFormDTO)
3336
.delete("/:formId", deleteForm, getFormByIdDTO)

src/types/ai-generate.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type Static, t } from "elysia";
2+
3+
export interface Context {
4+
user: { id: string };
5+
set: { status?: number | string };
6+
}
7+
8+
export const aiGenerateFormDTO = {
9+
body: t.Object({
10+
prompt: t.String({ minLength: 1 }),
11+
}),
12+
};
13+
14+
export interface AiGenerateFormContext extends Context {
15+
body: Static<typeof aiGenerateFormDTO.body>;
16+
}
17+
18+
/** Shape the AI must return */
19+
export interface AIFormResponse {
20+
title: string;
21+
description: string;
22+
fields: AIFormField[];
23+
}
24+
25+
export interface AIFormField {
26+
fieldName: string;
27+
label: string;
28+
fieldType: string;
29+
fieldValueType: string;
30+
validation: Record<string, unknown>;
31+
options?: string[];
32+
}

0 commit comments

Comments
 (0)