Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
FRONTEND_URL=http://localhost:3001
OPENROUTER_API_KEY=your-key-here
AI_MODEL=nvidia/nemotron-3-ultra-550b-a55b:free
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"build": "tsc"
},
"dependencies": {
"@openrouter/sdk": "^0.13.49",
"@repo/db": "workspace:*",
"@repo/types": "workspace:*",
"axios": "^1.18.1",
Expand Down
70 changes: 61 additions & 9 deletions apps/api/src/controllers/form.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { isPrismaErrorCode } from "../utils/prisma";
import { FormDefinitionSchema } from "@repo/types";
import { CreateFormInput, UpdateFormInput } from "../lib/form-schemas";
import prisma from "../lib/db";
import { config } from "../lib/env";
import { aiClient } from "../lib/ai/client";
import { buildFormGenerationPrompt } from "../lib/ai/prompt";
import { formatZodErrors } from "@/middleware/validate";

// ============================================
Expand Down Expand Up @@ -285,21 +288,70 @@ export const togglePublish: RequestHandler = asyncHandler(

export const generateForm: RequestHandler = asyncHandler(
async (req: Request, res: Response) => {
if (!config.ai.apiKey) {
res.status(503).json({ success: false, message: "AI service not configured" });
return;
}

const { prompt } = req.body;

// TODO: Connect real LLM logic here later.
// For now, return a basic mock structure so the frontend can test.
const dummyDefinition = {
version: "1.0",
elements: [
{ id: "q1", type: "text", label: `AI generated question for: ${prompt || "General"}`, required: true }
]
};
const systemPrompt = buildFormGenerationPrompt();

const MAX_RETRIES = 1;

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {

try {
const result = await aiClient.chat.send({
chatRequest: {
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt },
],
model: config.ai.model,
stream: false,
responseFormat: { type: "json_object" },
},
}, {
timeoutMs: 30000,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const raw = result.choices[0]?.message?.content;
if (!raw) {
if (attempt < MAX_RETRIES) continue;
res.status(502).json({ success: false, message: "AI returned an empty response" });
return;
}

const parsed = JSON.parse(raw);
const validated = FormDefinitionSchema.safeParse(parsed);
if (validated.success) {
res.json({ success: true, data: { definition: validated.data } });
return;
}

if (attempt < MAX_RETRIES) continue;

res.json({ success: true, data: { definition: dummyDefinition } });
res.status(422).json({
success: false,
message: "AI response did not match the expected form schema",
errors: validated.error.flatten(),
});
return;
} catch (err) {
if (attempt < MAX_RETRIES) continue;
console.error("AI form generation failed:", err);
res.status(422).json({
success: false,
message: "AI response was invalid or failed to generate",
});
return;
}
}
}
);


// ============================================
// GET /api/v1/forms/:id/analytics
// ============================================
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/lib/ai/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { OpenRouter } from "@openrouter/sdk";
import { config } from "../env";

export const aiClient = new OpenRouter({
apiKey: config.ai.apiKey,
});
36 changes: 36 additions & 0 deletions apps/api/src/lib/ai/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Builds the system prompt for AI form generation.
* Instructs the LLM to return a valid FormDefinition JSON object.
*/
export function buildFormGenerationPrompt(): string {
return `You are an expert form builder. Generate a form based on the user's request.

You MUST respond with ONLY a valid JSON object matching this exact structure:
{
"version": "1.0",
"elements": [ ...array of form elements... ]
}

## Available element types:

1. "textInput" - Single-line text { id, type, label, required?, description?, placeholder?, maxLength? }
2. "textarea" - Multi-line text { id, type, label, required?, description?, placeholder?, rows?(2-20) }
3. "rating" - Star rating { id, type, label, required?, description?, max?(2-10, default 5) }
4. "multipleChoice" - Pick one { id, type, label, required?, description?, options: [{id, label}] } (min 2 options)
5. "checkbox" - Pick many { id, type, label, required?, description?, options: [{id, label}] } (min 1 option)
6. "dropdown" - Dropdown pick one { id, type, label, required?, description?, placeholder?, options: [{id, label}] } (min 2 options)
7. "email" - Email input { id, type, label, required?, description?, placeholder? }
8. "phone" - Phone input { id, type, label, required?, description?, placeholder? }
9. "datePicker" - Date { id, type, label, required?, description?, minDate?, maxDate? }
10. "heading" - Section heading { id, type, label, description?, level?: "h1"|"h2"|"h3" } (no required field)
11. "paragraph" - Static text { id, type, label, description? } (no required field)

## Rules:
- Every element MUST have a unique "id" that is a valid UUID v4. Generate a unique, random UUID for each element (using only hexadecimal characters 0-9 and a-f).
- Do NOT use letters beyond 'f' (such as g, h, i, j, k, l, m, etc.) in any UUID.
- Every option inside "multipleChoice", "checkbox", and "dropdown" elements MUST also have a unique "id" in valid UUID v4 format. Generate a completely unique, random UUID for each option (do NOT use simple strings like "opt1" or "agree").
- Every element MUST have a "label" (non-empty string)
- Use "heading" or "paragraph" elements to organise sections
- Generate 5-12 elements appropriate for the request
- Return ONLY the JSON — no markdown, no explanation, no code fences`;
}
4 changes: 4 additions & 0 deletions apps/api/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ export const config = {
frontend: {
url: process.env.FRONTEND_URL || "http://localhost:3001",
},
ai: {
apiKey: process.env.OPENROUTER_API_KEY || "",
model: process.env.AI_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free",
},
};
2 changes: 1 addition & 1 deletion apps/api/src/lib/form-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const SubmitResponseSchema = z.object({
});

export const GenerateFormSchema = z.object({
prompt: z.string().trim().min(1, "Prompt cannot be empty").max(1000, "Prompt is too long").optional()
prompt: z.string().trim().min(1, "Prompt cannot be empty").max(1000, "Prompt is too long")
});


Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
-- AlterTable
ALTER TABLE "user_owned_templates" DROP CONSTRAINT IF EXISTS "user_owned_templates_clonedFormId_fkey";
ALTER TABLE "user_owned_templates" ADD CONSTRAINT "user_owned_templates_clonedFormId_fkey" FOREIGN KEY ("clonedFormId") REFERENCES "forms"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Loading