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:
Piyush Gupta
2024-11-08 13:55:02 +05:30
committed by GitHub
parent 3e7c3a45c3
commit 88847a153b
11 changed files with 261 additions and 126 deletions
@@ -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");
@@ -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 };
}
+13 -1
View File
@@ -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, {
@@ -80,7 +80,7 @@ export const SingleResponseCardBody = ({
getLocalizedValue(question.headline, "default"),
{},
response.data,
survey.variables,
response.variables,
true
)
)}
@@ -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>
);
})}