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
37 changes: 37 additions & 0 deletions apps/web/app/api/v3/lib/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { describe, expect, test } from "vitest";
import {
createdResponse,
noContentResponse,
problemAIUnavailable,
problemBadGateway,
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
problemUnprocessableContent,
successListResponse,
successResponse,
} from "./response";
Expand Down Expand Up @@ -43,6 +46,40 @@ describe("v3 problem responses", () => {
expect(body.instance).toBe("/api/x");
});

test("problemAIUnavailable preserves AI error codes", async () => {
const res = problemAIUnavailable("r-ai", "AI is disabled", "ai_smart_tools_disabled", "/api/ai");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.title).toBe("AI Unavailable");
expect(body.code).toBe("ai_smart_tools_disabled");
expect(body.instance).toBe("/api/ai");
});

test("problemAIUnavailable returns 503 for instance configuration gaps", async () => {
const res = problemAIUnavailable("r-ai", "AI is not configured", "ai_instance_not_configured");
expect(res.status).toBe(503);
});

test("problemUnprocessableContent includes validation details", async () => {
const res = problemUnprocessableContent("r-422", "Generated payload is invalid", {
invalid_params: [{ name: "blocks.0.elements", reason: "At least one element is required" }],
code: "ai_generated_payload_invalid",
});
expect(res.status).toBe(422);
const body = await res.json();
expect(body.code).toBe("ai_generated_payload_invalid");
expect(body.invalid_params).toEqual([
{ name: "blocks.0.elements", reason: "At least one element is required" },
]);
});

test("problemBadGateway", async () => {
const res = problemBadGateway("r-502", "Provider failed");
expect(res.status).toBe(502);
const body = await res.json();
expect(body.code).toBe("bad_gateway");
});

test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
Expand Down
33 changes: 33 additions & 0 deletions apps/web/app/api/v3/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ export function problemForbidden(
});
}

export function problemAIUnavailable(
requestId: string,
detail: string,
code: string,
instance?: string
): Response {
const status = code === "ai_instance_not_configured" ? 503 : 403;

return problemResponse(status, "AI Unavailable", detail, requestId, {
code,
instance,
});
}

export function problemUnprocessableContent(
requestId: string,
detail: string,
options?: { invalid_params?: InvalidParam[]; instance?: string; code?: string }
): Response {
return problemResponse(422, "Unprocessable Content", detail, requestId, {
code: options?.code ?? "unprocessable_content",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}

export function problemBadGateway(requestId: string, detail: string, instance?: string): Response {
return problemResponse(502, "Bad Gateway", detail, requestId, {
code: "bad_gateway",
instance,
});
}

/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
Expand Down
23 changes: 23 additions & 0 deletions apps/web/app/api/v3/surveys/generate/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";

export const V3_SURVEY_GENERATE_PROMPT_MIN_LENGTH = 4;
export const V3_SURVEY_GENERATE_PROMPT_DETAIL_MIN_LENGTH = 24;
export const V3_SURVEY_GENERATE_PROMPT_DETAIL_MIN_WORDS = 4;
export const V3_SURVEY_GENERATE_PROMPT_MAX_LENGTH = 1200;

export const GENERATED_SURVEY_MIN_BLOCKS = 1;
export const GENERATED_SURVEY_MAX_BLOCKS = 8;
export const GENERATED_SURVEY_MIN_QUESTIONS_PER_BLOCK = 1;
export const GENERATED_SURVEY_MAX_QUESTIONS_PER_BLOCK = 4;
export const GENERATED_SURVEY_ELEMENT_TYPES = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.CSAT,
TSurveyElementTypeEnum.CES,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.Matrix,
TSurveyElementTypeEnum.Date,
] as const;
74 changes: 74 additions & 0 deletions apps/web/app/api/v3/surveys/generate/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import {
GENERATED_SURVEY_ELEMENT_TYPES,
GENERATED_SURVEY_MAX_BLOCKS,
GENERATED_SURVEY_MAX_QUESTIONS_PER_BLOCK,
GENERATED_SURVEY_MIN_BLOCKS,
GENERATED_SURVEY_MIN_QUESTIONS_PER_BLOCK,
} from "./constants";
import { buildV3SurveyGenerationPrompt, buildV3SurveyGenerationSystemPrompt } from "./prompt";
import { V3_SURVEY_GENERATE_ALLOWED_LOCALES } from "./schemas";

describe("v3 survey generation prompt", () => {
test("keeps the system prompt provider neutral and bounded to supported survey capabilities", () => {
const system = buildV3SurveyGenerationSystemPrompt(V3_SURVEY_GENERATE_ALLOWED_LOCALES, "link");

expect(system).toContain("Formbricks survey drafts");
expect(system).toContain(`Use only these question types: ${GENERATED_SURVEY_ELEMENT_TYPES.join(", ")}`);
expect(system).toContain("csat");
expect(system).toContain("ces");
expect(system).toContain("ranking");
expect(system).toContain("matrix");
expect(system).toContain("date");
expect(system).toContain("Do not use file uploads");
expect(system).toContain("link survey draft");
expect(system).toContain('string values "5", "7", or "10"');
expect(system).toContain('For csat questions, set range to "5"');
expect(system).toContain("Rating-like questions must be single-question blocks");
expect(system).toContain("rating, csat, ces, nps, matrix, and ranking");
expect(system).toContain(
`Group questions into ${GENERATED_SURVEY_MIN_BLOCKS} to ${GENERATED_SURVEY_MAX_BLOCKS} blocks`
);
expect(system).toContain(
`Use ${GENERATED_SURVEY_MIN_QUESTIONS_PER_BLOCK} to ${GENERATED_SURVEY_MAX_QUESTIONS_PER_BLOCK} questions per block`
);
expect(system.toLowerCase()).not.toContain("openai");
});

test("instructs the model to match the prompt language and fall back to the preferred language", () => {
const system = buildV3SurveyGenerationSystemPrompt(V3_SURVEY_GENERATE_ALLOWED_LOCALES, "link");
const prompt = buildV3SurveyGenerationPrompt(
"Mide product-market fit para usuarios activos",
"link",
"es-ES",
V3_SURVEY_GENERATE_ALLOWED_LOCALES
);
const allowedSurveyLanguages = `Allowed survey languages: ${V3_SURVEY_GENERATE_ALLOWED_LOCALES.join(", ")}.`;

expect(system).toContain("same language as the user's request");
expect(system).toContain(
"Return the survey language exactly as one of the allowed survey language codes"
);
expect(system).toContain(allowedSurveyLanguages);
expect(prompt).toContain("Use the same language as the request");
expect(prompt).toContain("If the request language is unclear");
expect(prompt).toContain("include a short button label");
expect(prompt).toContain(allowedSurveyLanguages);
expect(prompt).toContain("Preferred survey language: es-ES");
expect(prompt).toContain("exactly one allowed survey language code");
expect(prompt).toContain("Keep rating-like questions in their own single-question blocks");
});

test("includes the user request without adding vendor-specific instructions", () => {
const prompt = buildV3SurveyGenerationPrompt(
"Collect product onboarding feedback",
"link",
"en-US",
V3_SURVEY_GENERATE_ALLOWED_LOCALES
);

expect(prompt).toContain("Create a draft link survey");
expect(prompt).toContain("Collect product onboarding feedback");
expect(prompt.toLowerCase()).not.toContain("openai");
});
});
67 changes: 67 additions & 0 deletions apps/web/app/api/v3/surveys/generate/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
GENERATED_SURVEY_ELEMENT_TYPES,
GENERATED_SURVEY_MAX_BLOCKS,
GENERATED_SURVEY_MAX_QUESTIONS_PER_BLOCK,
GENERATED_SURVEY_MIN_BLOCKS,
GENERATED_SURVEY_MIN_QUESTIONS_PER_BLOCK,
} from "./constants";

export function buildV3SurveyGenerationSystemPrompt(
allowedLocales: readonly string[],
surveyType: string
): string {
return [
"You generate concise Formbricks survey drafts.",
"Return only data that matches the provided schema.",
"Write all generated user-facing survey content in the same language as the user's request. " +
"Detect the language from the request; if it clearly matches an allowed survey language, use that language. " +
"If uncertain or no allowed survey language is a confident match, " +
"use the preferred survey language from the user prompt.",
"Return the survey language exactly as one of the allowed survey language codes from the user prompt.",
`Allowed survey languages: ${allowedLocales.join(", ")}.`,
"Keep surveys focused: 3 to 6 questions is usually enough.",
`Group questions into ${GENERATED_SURVEY_MIN_BLOCKS} to ${GENERATED_SURVEY_MAX_BLOCKS} blocks. ` +
"Use one block for simple requests. Use multiple blocks when the user asks for sections, " +
"pages, blocks, or clearly separate topics.",
"Give each block a short, meaningful name. " +
`Use ${GENERATED_SURVEY_MIN_QUESTIONS_PER_BLOCK} to ${GENERATED_SURVEY_MAX_QUESTIONS_PER_BLOCK} questions per block.`,
"Rating-like questions must be single-question blocks: rating, csat, ces, nps, matrix, and ranking. " +
"Do not place rating-like questions together in the same block.",
`Use only these question types: ${GENERATED_SURVEY_ELEMENT_TYPES.join(", ")}.`,
"Use csat for satisfaction, ces for effort, nps for loyalty, ranking for priorities, " +
"matrix for repeated row-and-column ratings, and date only when the requested feedback needs a date.",
'For rating questions, set range to one of the string values "5", "7", or "10".',
'For csat questions, set range to "5". For ces questions, set range to "5" or "7".',
"Prefer clear, neutral question wording and short answer choices.",
"Use required questions sparingly. Do not ask for sensitive personal data unless the prompt explicitly asks for it.",
"Do not use file uploads, picture selection, address/contact collection, scheduling, CTA, consent, " +
"embedded external forms, or any other question type outside the allowed list.",
"Do not include branching, variables, hidden fields, URLs, files, scripts, markdown, HTML, or tracking instructions.",
`The final product will be created as a ${surveyType} survey draft.`,
].join("\n");
}

export function buildV3SurveyGenerationPrompt(
prompt: string,
surveyType: string,
preferredLanguage: string,
allowedLocales: readonly string[]
): string {
return [
`Create a draft ${surveyType} survey from this request.`,
"Include a name, optional description, useful questions, and a simple ending.",
"If the request is broad, choose a practical customer-feedback survey structure.",
"Return the questions inside blocks. Use multiple blocks only when useful or requested.",
"Keep rating-like questions in their own single-question blocks: rating, csat, ces, nps, matrix, and ranking.",
"Use the same language as the request for all generated survey text. " +
"If the request language is unclear or cannot be matched to an allowed survey language, " +
"use the preferred survey language.",
"If you enable the welcome card, include a short button label in the same language.",
`Allowed survey languages: ${allowedLocales.join(", ")}.`,
`Preferred survey language: ${preferredLanguage}.`,
"Set the returned language to exactly one allowed survey language code.",
"",
"User request:",
prompt,
].join("\n");
}
105 changes: 105 additions & 0 deletions apps/web/app/api/v3/surveys/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemAIUnavailable,
problemBadGateway,
problemBadRequest,
problemNotFound,
problemUnprocessableContent,
successResponse,
} from "@/app/api/v3/lib/response";
import { AI_ERROR_CODES, type TAIErrorCode } from "@/lib/ai/service";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { ZV3SurveyGenerateBody } from "./schemas";
import {
V3SurveyGeneratePromptError,
V3SurveyGeneratedPayloadValidationError,
generateV3SurveyCreatePayloadFromPrompt,
} from "./service";

const AI_UNAVAILABLE_DETAILS: Record<TAIErrorCode, string> = {
[AI_ERROR_CODES.FEATURES_NOT_ENABLED]: "AI smart tools are not available for this organization.",
[AI_ERROR_CODES.SMART_TOOLS_DISABLED]: "AI smart tools are disabled for this organization.",
[AI_ERROR_CODES.INSTANCE_NOT_CONFIGURED]: "AI is not configured for this Formbricks instance.",
};

function isAIErrorCode(value: string): value is TAIErrorCode {
return Object.values(AI_ERROR_CODES).includes(value as TAIErrorCode);
}

export const POST = withV3ApiWrapper({
auth: "both",
customRateLimitConfig: rateLimitConfigs.api.v3SurveyGenerate,
schemas: {
body: ZV3SurveyGenerateBody,
},
handler: async ({ authentication, parsedInput, requestId, instance }) => {
const { body } = parsedInput;
const workspaceAccess = await requireV3WorkspaceAccess(
authentication,
body.workspaceId,
"readWrite",
requestId,
instance
);

if (workspaceAccess instanceof Response) {
return workspaceAccess;
}

try {
const result = await generateV3SurveyCreatePayloadFromPrompt({
organizationId: workspaceAccess.organizationId,
input: body,
});

return successResponse(result, { requestId });
} catch (error) {
if (error instanceof V3SurveyGeneratePromptError) {
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: error.invalidParams,
});
}

if (error instanceof OperationNotAllowedError && isAIErrorCode(error.message)) {
return problemAIUnavailable(
requestId,
AI_UNAVAILABLE_DETAILS[error.message],
error.message,
instance
);
}

if (error instanceof V3SurveyGeneratedPayloadValidationError) {
return problemUnprocessableContent(requestId, error.message, {
instance,
code: "ai_generated_payload_invalid",
invalid_params: error.invalidParams,
});
}

if (error instanceof ResourceNotFoundError) {
return problemNotFound(requestId, "Organization", workspaceAccess.organizationId, instance);
}

logger.error(
{
err: error,
requestId,
workspaceId: body.workspaceId,
organizationId: workspaceAccess.organizationId,
},
"Failed to generate v3 survey create payload"
);

return problemBadGateway(
requestId,
"The AI provider could not generate a valid survey draft. Try again or add more detail.",
instance
);
}
},
});
Loading
Loading