mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-05 05:11:32 -05:00
fix: resolve recalls properly in document (#3943)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Co-authored-by: Matti Nannt <mail@matthiasnannt.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -45,7 +45,7 @@ export const createAISurveyAction = authenticatedActionClient
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled(organization.billing.plan);
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
|
||||
if (!isAIEnabled) {
|
||||
throw new Error("AI is not enabled for this organization");
|
||||
|
||||
+8
-1
@@ -24,11 +24,18 @@ export const EnableInsightsBanner = ({
|
||||
const [isGeneratingInsights, setIsGeneratingInsights] = useState(false);
|
||||
|
||||
const handleInsightGeneration = async () => {
|
||||
toast.success("Generating insights for this survey. Please check back in a few minutes.", {
|
||||
duration: 3000,
|
||||
});
|
||||
setIsGeneratingInsights(true);
|
||||
toast.success(t("environments.surveys.summary.enable_ai_insights_banner_success"));
|
||||
generateInsightsForSurveyAction({ surveyId });
|
||||
};
|
||||
|
||||
if (isGeneratingInsights) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="mb-6 mt-4 flex items-center gap-4 border-slate-400 bg-white">
|
||||
<div>
|
||||
@@ -49,7 +56,7 @@ export const EnableInsightsBanner = ({
|
||||
className="shrink-0"
|
||||
onClick={handleInsightGeneration}
|
||||
loading={isGeneratingInsights}
|
||||
disabled={surveyResponseCount > maxResponseCount || isGeneratingInsights}
|
||||
disabled={surveyResponseCount > maxResponseCount}
|
||||
tooltip={
|
||||
surveyResponseCount > maxResponseCount
|
||||
? t("environments.surveys.summary.enable_ai_insights_banner_tooltip")
|
||||
|
||||
@@ -6,13 +6,19 @@ import { prisma } from "@formbricks/database";
|
||||
import { embeddingsModel, llmModel } from "@formbricks/lib/aiModels";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import {
|
||||
TCreatedDocument,
|
||||
TDocument,
|
||||
TDocumentCreateInput,
|
||||
TGenerateDocumentObjectSchema,
|
||||
ZDocumentCreateInput,
|
||||
ZGenerateDocumentObjectSchema,
|
||||
} from "@formbricks/types/documents";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export type TCreatedDocument = TDocument & {
|
||||
isSpam: boolean;
|
||||
insights: TGenerateDocumentObjectSchema["insights"];
|
||||
};
|
||||
|
||||
export const createDocument = async (
|
||||
surveyName: string,
|
||||
documentInput: TDocumentCreateInput
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Prisma } from "@prisma/client";
|
||||
import { embed } from "ai";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { embeddingsModel } from "@formbricks/lib/aiModels";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getPromptText } from "@formbricks/lib/utils/ai";
|
||||
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TCreatedDocument } from "@formbricks/types/documents";
|
||||
@@ -18,21 +20,16 @@ import {
|
||||
ZInsightCreateInput,
|
||||
} from "@formbricks/types/insights";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyQuestions,
|
||||
ZSurveyQuestions,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
export const generateInsightsForSurveyResponsesConcept = async (surveyData: {
|
||||
id: string;
|
||||
name: string;
|
||||
environmentId: string;
|
||||
questions: TSurveyQuestions;
|
||||
}): Promise<void> => {
|
||||
const startTime = Date.now();
|
||||
console.log(`Generating insights for survey responses: ${surveyData.id} at ${startTime}`);
|
||||
const { id: surveyId, name, environmentId, questions } = surveyData;
|
||||
export const generateInsightsForSurveyResponsesConcept = async (
|
||||
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">
|
||||
): Promise<void> => {
|
||||
const { id: surveyId, name, environmentId, questions } = survey;
|
||||
|
||||
validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]);
|
||||
|
||||
@@ -66,7 +63,6 @@ export const generateInsightsForSurveyResponsesConcept = async (surveyData: {
|
||||
rateLimit = rateLimitHeader ? parseInt(rateLimitHeader, 10) : undefined;
|
||||
}
|
||||
|
||||
console.log(`Rate limit for embedding API: ${rateLimit}`);
|
||||
while (!allResponsesProcessed || spillover.length > 0) {
|
||||
// If there are any spillover documents from the previous iteration, prioritize them
|
||||
let answersForDocumentCreation = [...spillover];
|
||||
@@ -85,12 +81,18 @@ export const generateInsightsForSurveyResponsesConcept = async (surveyData: {
|
||||
select: {
|
||||
id: true,
|
||||
data: true,
|
||||
variables: true,
|
||||
personId: true,
|
||||
language: true,
|
||||
},
|
||||
take: batchSize,
|
||||
skip,
|
||||
});
|
||||
|
||||
if (responses.length === 0) {
|
||||
if (
|
||||
responses.length === 0 ||
|
||||
(responses.length < batchSize && rateLimit && responses.length < rateLimit)
|
||||
) {
|
||||
allResponsesProcessed = true; // Mark as finished when no more responses are found
|
||||
}
|
||||
|
||||
@@ -100,18 +102,40 @@ export const generateInsightsForSurveyResponsesConcept = async (surveyData: {
|
||||
|
||||
skip += batchSize - responsesWithOpenTextAnswers.length;
|
||||
|
||||
responsesWithOpenTextAnswers.forEach((response) => {
|
||||
openTextQuestionsWithInsights.forEach((question) => {
|
||||
const responseText = response.data[question.id] as string;
|
||||
if (responseText) {
|
||||
const text = getPromptText(question.headline.default, responseText);
|
||||
answersForDocumentCreation.push({
|
||||
const answersForDocumentCreationPromises = await Promise.all(
|
||||
responsesWithOpenTextAnswers.map(async (response) => {
|
||||
const attributes = response.personId ? await getAttributes(response.personId) : {};
|
||||
const responseEntries = openTextQuestionsWithInsights.map((question) => {
|
||||
const responseText = response.data[question.id] as string;
|
||||
if (!responseText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headline = parseRecallInfo(
|
||||
question.headline[response.language ?? "default"],
|
||||
attributes,
|
||||
response.data,
|
||||
response.variables
|
||||
);
|
||||
|
||||
const text = getPromptText(headline, responseText);
|
||||
|
||||
return {
|
||||
responseId: response.id,
|
||||
questionId: question.id,
|
||||
text,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
return responseEntries;
|
||||
})
|
||||
);
|
||||
|
||||
const answersForDocumentCreationResult = answersForDocumentCreationPromises.flat();
|
||||
answersForDocumentCreationResult.forEach((answer) => {
|
||||
if (answer) {
|
||||
answersForDocumentCreation.push(answer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,10 +146,6 @@ export const generateInsightsForSurveyResponsesConcept = async (surveyData: {
|
||||
answersForDocumentCreation = answersForDocumentCreation.slice(0, rateLimit);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Processing ${answersForDocumentCreation.length} documents and spillover: ${spillover.length}`
|
||||
);
|
||||
|
||||
const createDocumentPromises = answersForDocumentCreation.map((answer) => {
|
||||
return createDocument(name, {
|
||||
environmentId,
|
||||
@@ -164,9 +184,6 @@ export const generateInsightsForSurveyResponsesConcept = async (surveyData: {
|
||||
environmentId: environmentId,
|
||||
surveyId: surveyId,
|
||||
});
|
||||
console.log(
|
||||
`Processed ${createdDocuments.length} documents in ${(Date.now() - startTime) / 1000} seconds`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -179,6 +196,132 @@ export const generateInsightsForSurveyResponsesConcept = async (surveyData: {
|
||||
}
|
||||
};
|
||||
|
||||
export const generateInsightsForSurveyResponses = async (
|
||||
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">
|
||||
): Promise<void> => {
|
||||
const { id: surveyId, name, environmentId, questions } = survey;
|
||||
|
||||
validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]);
|
||||
try {
|
||||
const openTextQuestionsWithInsights = questions.filter(
|
||||
(question) => question.type === TSurveyQuestionTypeEnum.OpenText && question.insightsEnabled
|
||||
);
|
||||
|
||||
const openTextQuestionIds = openTextQuestionsWithInsights.map((question) => question.id);
|
||||
|
||||
if (openTextQuestionIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetching responses
|
||||
const batchSize = 200;
|
||||
let skip = 0;
|
||||
|
||||
const totalResponseCount = await prisma.response.count({
|
||||
where: {
|
||||
surveyId,
|
||||
documents: {
|
||||
none: {},
|
||||
},
|
||||
finished: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pages = Math.ceil(totalResponseCount / batchSize);
|
||||
|
||||
for (let i = 0; i < pages; i++) {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
documents: {
|
||||
none: {},
|
||||
},
|
||||
finished: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
data: true,
|
||||
variables: true,
|
||||
personId: true,
|
||||
language: true,
|
||||
},
|
||||
take: batchSize,
|
||||
skip,
|
||||
});
|
||||
|
||||
const responsesWithOpenTextAnswers = responses.filter((response) =>
|
||||
doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response.data)
|
||||
);
|
||||
|
||||
skip += batchSize - responsesWithOpenTextAnswers.length;
|
||||
|
||||
const createDocumentPromises: Promise<TCreatedDocument | undefined>[] = [];
|
||||
|
||||
for (const response of responsesWithOpenTextAnswers) {
|
||||
const attributes = response.personId ? await getAttributes(response.personId) : {};
|
||||
|
||||
for (const question of openTextQuestionsWithInsights) {
|
||||
const responseText = response.data[question.id] as string;
|
||||
if (!responseText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headline = parseRecallInfo(
|
||||
question.headline[response.language ?? "default"],
|
||||
attributes,
|
||||
response.data,
|
||||
response.variables
|
||||
);
|
||||
|
||||
const text = getPromptText(headline, responseText);
|
||||
|
||||
const createDocumentPromise = createDocument(name, {
|
||||
environmentId,
|
||||
surveyId,
|
||||
responseId: response.id,
|
||||
questionId: question.id,
|
||||
text,
|
||||
});
|
||||
|
||||
createDocumentPromises.push(createDocumentPromise);
|
||||
}
|
||||
}
|
||||
|
||||
const createdDocuments = (await Promise.all(createDocumentPromises)).filter(
|
||||
Boolean
|
||||
) as TCreatedDocument[];
|
||||
|
||||
for (const document of createdDocuments) {
|
||||
if (document) {
|
||||
const insightPromises: Promise<void>[] = [];
|
||||
const { insights, isSpam, id, environmentId } = document;
|
||||
if (!isSpam) {
|
||||
for (const insight of insights) {
|
||||
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
|
||||
throw new Error("Insight title and description must be a string");
|
||||
}
|
||||
|
||||
// create or connect the insight
|
||||
insightPromises.push(handleInsightAssignments(environmentId, id, insight));
|
||||
}
|
||||
await Promise.all(insightPromises);
|
||||
}
|
||||
}
|
||||
}
|
||||
documentCache.revalidate({
|
||||
environmentId: environmentId,
|
||||
surveyId: surveyId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getQuestionResponseReferenceId = (surveyId: string, questionId: TSurveyQuestionId) => {
|
||||
return `${surveyId}-${questionId}`;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import "server-only";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { doesSurveyHasOpenTextQuestion, getInsightsEnabled } from "@formbricks/lib/survey/utils";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -42,17 +42,7 @@ export const generateInsightsEnabledForSurveyQuestions = async (
|
||||
> => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
try {
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
},
|
||||
});
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
@@ -72,52 +62,19 @@ export const generateInsightsEnabledForSurveyQuestions = async (
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const insightsEnabledValues = await Promise.allSettled(
|
||||
openTextQuestions.map(async (question) => {
|
||||
const insightsEnabled = await getInsightsEnabled(question);
|
||||
const updatedSurvey = await updateSurvey(survey);
|
||||
|
||||
return { id: question.id, insightsEnabled };
|
||||
})
|
||||
if (!updatedSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const doesSurveyHasInsightsEnabledQuestion = updatedSurvey.questions.some(
|
||||
(question) => question.type === "openText" && question.insightsEnabled === true
|
||||
);
|
||||
|
||||
const fullfilledInsightsEnabledValues = insightsEnabledValues.filter(
|
||||
(value) => value.status === "fulfilled"
|
||||
) as PromiseFulfilledResult<{ id: string; insightsEnabled: boolean }>[];
|
||||
|
||||
const insightsEnabledQuestionIds = fullfilledInsightsEnabledValues
|
||||
.filter((value) => value.value.insightsEnabled)
|
||||
.map((value) => value.value.id);
|
||||
|
||||
const updatedQuestions = survey.questions.map((question) => {
|
||||
if (question.type === "openText") {
|
||||
const areInsightsEnabled = insightsEnabledQuestionIds.includes(question.id);
|
||||
return {
|
||||
...question,
|
||||
insightsEnabled: areInsightsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
});
|
||||
|
||||
const updatedSurvey = await prisma.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
questions: updatedQuestions,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
},
|
||||
});
|
||||
|
||||
surveyCache.revalidate({ id: surveyId, environmentId: survey.environmentId });
|
||||
|
||||
if (insightsEnabledQuestionIds.length > 0) {
|
||||
if (doesSurveyHasInsightsEnabledQuestion) {
|
||||
return { success: true, survey: updatedSurvey };
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getIsAIEnabled } from "@/app/lib/utils";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { CRON_SECRET, IS_AI_CONFIGURED } from "@formbricks/lib/constants";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
@@ -13,6 +14,7 @@ import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
import { getPromptText } from "@formbricks/lib/utils/ai";
|
||||
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { webhookCache } from "@formbricks/lib/webhook/cache";
|
||||
import { TPipelineTrigger, ZPipelineInput } from "@formbricks/types/pipelines";
|
||||
import { TWebhook } from "@formbricks/types/webhooks";
|
||||
@@ -194,6 +196,8 @@ export const POST = async (request: Request) => {
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
|
||||
if (isAIEnabled) {
|
||||
const attributes = response.person?.id ? await getAttributes(response.person?.id) : {};
|
||||
|
||||
for (const question of survey.questions) {
|
||||
if (question.type === "openText" && question.insightsEnabled) {
|
||||
const isQuestionAnswered =
|
||||
@@ -201,7 +205,15 @@ export const POST = async (request: Request) => {
|
||||
if (!isQuestionAnswered) {
|
||||
continue;
|
||||
}
|
||||
const text = getPromptText(question.headline.default, response.data[question.id] as string);
|
||||
|
||||
const headline = parseRecallInfo(
|
||||
question.headline[response.language ?? "default"],
|
||||
attributes,
|
||||
response.data,
|
||||
response.variables
|
||||
);
|
||||
|
||||
const text = getPromptText(headline, response.data[question.id] as string);
|
||||
// TODO: check if subheadline gives more context and better embeddings
|
||||
try {
|
||||
await createDocumentAndAssignInsight(survey.name, {
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export const SingleResponseCardBody = ({
|
||||
getLocalizedValue(question.headline, "default"),
|
||||
{},
|
||||
response.data,
|
||||
survey.variables,
|
||||
response.variables,
|
||||
true
|
||||
)
|
||||
)}
|
||||
|
||||
+3
-2
@@ -218,6 +218,7 @@ export const RecallItemSelect = ({
|
||||
setShowRecallItemSelect(false);
|
||||
}}
|
||||
autoFocus={false}
|
||||
className="flex w-full cursor-pointer items-center rounded-md p-2 focus:bg-slate-200 focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp" && index === 0) {
|
||||
document.getElementById("recallItemSearchInput")?.focus();
|
||||
@@ -226,9 +227,9 @@ export const RecallItemSelect = ({
|
||||
}
|
||||
}}>
|
||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||
<div className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{getRecallLabel(recallItem.label)}
|
||||
</div>
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user