mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
add llm functionality
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { generateText } from "ai";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { AuthOptions } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { sendEmbedSurveyPreviewEmail } from "@formbricks/email";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEmbeddingsByTypeAndReferenceId } from "@formbricks/lib/embedding/service";
|
||||
import { getQuestionResponseReferenceId } from "@formbricks/lib/embedding/utils";
|
||||
import { llmModel } from "@formbricks/lib/ai";
|
||||
import { clusterDocuments } from "@formbricks/lib/document/kmeans";
|
||||
import { getDocumentsByTypeAndReferenceId } from "@formbricks/lib/document/service";
|
||||
import { getQuestionResponseReferenceId } from "@formbricks/lib/document/utils";
|
||||
import { getOrganizationIdFromSurveyId } from "@formbricks/lib/organization/utils";
|
||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -160,12 +160,37 @@ export const getOpenTextSummaryAction = authenticatedActionClient
|
||||
rules: ["survey", "read"],
|
||||
});
|
||||
|
||||
const embeddings = await getEmbeddingsByTypeAndReferenceId(
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const documents = await getDocumentsByTypeAndReferenceId(
|
||||
"questionResponse",
|
||||
getQuestionResponseReferenceId(parsedInput.surveyId, parsedInput.questionId)
|
||||
);
|
||||
|
||||
console.log(embeddings);
|
||||
const topics = await clusterDocuments(documents, 3);
|
||||
|
||||
return;
|
||||
const question = survey.questions.find((q) => q.id === parsedInput.questionId);
|
||||
const prompt = `You are an AI research assistant and answer the question: "${question?.headline.default}". Please provide a short summary sentence and provide 3 bullet points for insights you got from these samples:\n${topics.map((t) => t.centralDocument.text).join("\n")}`;
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: llmModel,
|
||||
prompt,
|
||||
});
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Failed to generate summary");
|
||||
}
|
||||
|
||||
/* return `Users report mixed experiences with the app's performance, with particular concerns about the dashboard's load time and afternoon slowdowns.
|
||||
|
||||
### Insights:
|
||||
1. **Dashboard Performance**: The most common feedback is that the dashboard is slow to load, impacting user experience.
|
||||
2. **Afternoon Slowdown**: Several users notice that the app slows down in the afternoon, while it runs smoothly in the morning.
|
||||
3. **Varied Experiences**: Some users do not experience any performance issues, indicating that the problem may not be universal.`; */
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { getOpenTextSummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { LoadingSpinner } from "@formbricks/ui/LoadingSpinner";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
@@ -14,6 +17,7 @@ interface OpenTextSummaryProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAiEnabled: boolean;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({
|
||||
@@ -21,8 +25,11 @@ export const OpenTextSummary = ({
|
||||
environmentId,
|
||||
survey,
|
||||
attributeClasses,
|
||||
isAiEnabled,
|
||||
}: OpenTextSummaryProps) => {
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [isLoadingAiSummary, setIsLoadingAiSummary] = useState(false);
|
||||
const [aiSummary, setAiSummary] = useState<string | null>(null);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
@@ -32,8 +39,19 @@ export const OpenTextSummary = ({
|
||||
};
|
||||
|
||||
const getOpenTextSummary = async () => {
|
||||
setIsLoadingAiSummary(true);
|
||||
// This function is not implemented yet
|
||||
await getOpenTextSummaryAction(survey.id, questionSummary.question.id);
|
||||
const res = await getOpenTextSummaryAction({
|
||||
surveyId: survey.id,
|
||||
questionId: questionSummary.question.id,
|
||||
});
|
||||
const openTextSummary = res?.data;
|
||||
if (openTextSummary) {
|
||||
setAiSummary(openTextSummary);
|
||||
} else {
|
||||
setAiSummary("No summary available");
|
||||
}
|
||||
setIsLoadingAiSummary(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -43,7 +61,32 @@ export const OpenTextSummary = ({
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<Button onClick={() => getOpenTextSummary()}>Create Summary</Button>
|
||||
<div className="p-4">
|
||||
{isAiEnabled && (
|
||||
<>
|
||||
<Alert variant="info">
|
||||
<AlertTitle>✨ AI Summary</AlertTitle>
|
||||
{isLoadingAiSummary && <LoadingSpinner />}
|
||||
{!isLoadingAiSummary && aiSummary && (
|
||||
<>
|
||||
<hr className="my-4 text-slate-200" />
|
||||
<AlertDescription>
|
||||
<Markdown>{aiSummary}</Markdown>
|
||||
</AlertDescription>
|
||||
</>
|
||||
)}
|
||||
<hr className="my-4 text-slate-200" />
|
||||
{questionSummary.responseCount < 10 ? (
|
||||
<p className="text-sm">This question needs at least 10 responses to access AI summaries</p>
|
||||
) : (
|
||||
<Button onClick={() => getOpenTextSummary()} disabled={isLoadingAiSummary}>
|
||||
Generate Summary
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">User</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ interface SummaryListProps {
|
||||
survey: TSurvey;
|
||||
totalResponseCount: number;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAiEnabled: boolean;
|
||||
}
|
||||
|
||||
export const SummaryList = ({
|
||||
@@ -46,6 +47,7 @@ export const SummaryList = ({
|
||||
survey,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
isAiEnabled,
|
||||
}: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const widgetSetupCompleted =
|
||||
@@ -129,6 +131,7 @@ export const SummaryList = ({
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={isAiEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface SummaryPageProps {
|
||||
user?: TUser;
|
||||
totalResponseCount: number;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAiEnabled: boolean;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -55,6 +56,7 @@ export const SummaryPage = ({
|
||||
webAppUrl,
|
||||
totalResponseCount,
|
||||
attributeClasses,
|
||||
isAiEnabled,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
@@ -161,6 +163,7 @@ export const SummaryPage = ({
|
||||
environment={environment}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={isAiEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -60,6 +60,9 @@ const Page = async ({ params }) => {
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const isAiEnabled =
|
||||
IS_FORMBRICKS_CLOUD &&
|
||||
(organization.billing.plan === "scale" || organization.billing.plan === "enterprise");
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -89,6 +92,7 @@ const Page = async ({ params }) => {
|
||||
user={user}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={isAiEnabled}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,9 @@ import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/email";
|
||||
import { embeddingsModel } from "@formbricks/lib/ai";
|
||||
import { IS_AI_ENABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { CRON_SECRET } from "@formbricks/lib/constants";
|
||||
import { createEmbedding } from "@formbricks/lib/embedding/service";
|
||||
import { getQuestionResponseReferenceId } from "@formbricks/lib/embedding/utils";
|
||||
import { CRON_SECRET, IS_AI_ENABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { createDocument } from "@formbricks/lib/document/service";
|
||||
import { getQuestionResponseReferenceId } from "@formbricks/lib/document/utils";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
@@ -167,17 +166,11 @@ export const POST = async (request: Request) => {
|
||||
|
||||
// generate embeddings for all open text question responses for enterprise and scale plans
|
||||
const hasSurveyOpenTextQuestions = survey.questions.some((question) => question.type === "openText");
|
||||
console.log("hasSurveyOpenTextQuestions", hasSurveyOpenTextQuestions);
|
||||
console.log("is Cloud", hasSurveyOpenTextQuestions && IS_FORMBRICKS_CLOUD && IS_AI_ENABLED);
|
||||
if (hasSurveyOpenTextQuestions && IS_FORMBRICKS_CLOUD && IS_AI_ENABLED) {
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
console.log(
|
||||
"valid billing plan",
|
||||
organization.billing.plan === "enterprise" || organization.billing.plan === "scale"
|
||||
);
|
||||
if (organization.billing.plan === "enterprise" || organization.billing.plan === "scale") {
|
||||
for (const question of survey.questions) {
|
||||
if (question.type === "openText") {
|
||||
@@ -186,13 +179,17 @@ export const POST = async (request: Request) => {
|
||||
if (!isQuestionAnswered) {
|
||||
continue;
|
||||
}
|
||||
const text = `${question.headline.default} Answer: ${response.data[question.id]}`;
|
||||
const { embedding } = await embed({
|
||||
model: embeddingsModel,
|
||||
value: `${question.headline.default} Answer: ${response.data[question.id]}`,
|
||||
value: text,
|
||||
});
|
||||
await createEmbedding({
|
||||
console.log("creating embedding for question response", question.id);
|
||||
await createDocument({
|
||||
environmentId,
|
||||
referenceId: getQuestionResponseReferenceId(survey.id, question.id),
|
||||
type: "questionResponse",
|
||||
text,
|
||||
vector: embedding,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const Page = async ({ params }) => {
|
||||
webAppUrl={WEBAPP_URL}
|
||||
totalResponseCount={totalResponseCount}
|
||||
attributeClasses={attributeClasses}
|
||||
isAiEnabled={false} // Disable AI for sharing page for now
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.0",
|
||||
"lucide-react": "^0.427.0",
|
||||
"markdown-to-jsx": "^7.5.0",
|
||||
"mime": "^4.0.4",
|
||||
"next": "14.2.5",
|
||||
"next-safe-action": "^7.6.2",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
-- CreateExtension
|
||||
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmbeddingType" AS ENUM ('questionResponse');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Embedding" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"environmentId" TEXT NOT NULL,
|
||||
"type" "EmbeddingType" NOT NULL,
|
||||
"referenceId" TEXT NOT NULL,
|
||||
"vector" vector(512),
|
||||
|
||||
CONSTRAINT "Embedding_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Embedding_type_referenceId_idx" ON "Embedding"("type", "referenceId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Embedding" ADD CONSTRAINT "Embedding_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- CreateExtension
|
||||
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentType" AS ENUM ('questionResponse');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Document" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"environmentId" TEXT NOT NULL,
|
||||
"type" "DocumentType" NOT NULL,
|
||||
"referenceId" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"vector" vector(512),
|
||||
|
||||
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_type_referenceId_idx" ON "Document"("type", "referenceId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -415,7 +415,7 @@ model Environment {
|
||||
tags Tag[]
|
||||
segments Segment[]
|
||||
integration Integration[]
|
||||
embeddings Embedding[]
|
||||
documents Document[]
|
||||
|
||||
@@index([productId])
|
||||
}
|
||||
@@ -661,18 +661,19 @@ model SurveyLanguage {
|
||||
@@index([languageId])
|
||||
}
|
||||
|
||||
enum EmbeddingType {
|
||||
enum DocumentType {
|
||||
questionResponse
|
||||
}
|
||||
|
||||
model Embedding {
|
||||
model Document {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
environmentId String
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
type EmbeddingType
|
||||
type DocumentType
|
||||
referenceId String
|
||||
text String
|
||||
vector Unsupported("vector(512)")?
|
||||
|
||||
@@index([type, referenceId])
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createAzure } from "@ai-sdk/azure";
|
||||
import { env } from "./env";
|
||||
|
||||
const azure = createAzure({
|
||||
resourceName: env.AI_AZURE_RESSOURCE_NAME, // Azure resource name
|
||||
apiKey: env.AI_AZURE_API_KEY, // Azure API key
|
||||
});
|
||||
export const llmModel = createAzure({
|
||||
resourceName: env.AI_AZURE_LLM_RESSOURCE_NAME, // Azure resource name
|
||||
apiKey: env.AI_AZURE_LLM_API_KEY, // Azure API key
|
||||
})(env.AI_AZURE_LLM_DEPLOYMENT_ID || "llm");
|
||||
|
||||
export const llmModel = azure(env.AI_AZURE_LLM_DEPLOYMENT_ID || "llm");
|
||||
export const embeddingsModel = azure.embedding(env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID || "embeddings", {
|
||||
export const embeddingsModel = createAzure({
|
||||
resourceName: env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME, // Azure resource name
|
||||
apiKey: env.AI_AZURE_EMBEDDINGS_API_KEY, // Azure API key
|
||||
}).embedding(env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID || "embeddings", {
|
||||
dimensions: 512,
|
||||
});
|
||||
|
||||
@@ -211,7 +211,10 @@ export const BILLING_LIMITS = {
|
||||
} as const;
|
||||
|
||||
export const IS_AI_ENABLED = !!(
|
||||
env.AI_AZURE_RESSOURCE_NAME &&
|
||||
env.AI_AZURE_API_KEY &&
|
||||
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID
|
||||
env.AI_AZURE_EMBEDDINGS_API_KEY &&
|
||||
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID &&
|
||||
env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME &&
|
||||
env.AI_AZURE_LLM_API_KEY &&
|
||||
env.AI_AZURE_LLM_DEPLOYMENT_ID &&
|
||||
env.AI_AZURE_LLM_RESSOURCE_NAME
|
||||
);
|
||||
|
||||
@@ -6,13 +6,13 @@ interface RevalidateProps {
|
||||
referenceId?: string;
|
||||
}
|
||||
|
||||
export const embeddingCache = {
|
||||
export const documentCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `embeddings-${id}`;
|
||||
return `documents-${id}`;
|
||||
},
|
||||
byTypeAndReferenceId(type: string, id: string) {
|
||||
return `embeddings-${type}-${id}`;
|
||||
return `documents-${type}-${id}`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, type, referenceId }: RevalidateProps): void {
|
||||
132
packages/lib/document/kmeans.ts
Normal file
132
packages/lib/document/kmeans.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { TDocument } from "@formbricks/types/documents";
|
||||
|
||||
class KMeans {
|
||||
private k: number;
|
||||
private maxIterations: number;
|
||||
private centroids: number[][];
|
||||
|
||||
constructor(k: number, maxIterations: number = 100) {
|
||||
this.k = k;
|
||||
this.maxIterations = maxIterations;
|
||||
}
|
||||
|
||||
fit(documents: TDocument[]): number[] {
|
||||
// Extract the vectors from the documents
|
||||
const points = documents.map((document) => document.vector);
|
||||
|
||||
// Initialize centroids randomly
|
||||
this.centroids = this.initializeCentroids(points);
|
||||
|
||||
for (let i = 0; i < this.maxIterations; i++) {
|
||||
const clusters: TDocument[][] = Array.from({ length: this.k }, () => []);
|
||||
|
||||
// Assign documents to nearest centroid
|
||||
for (const document of documents) {
|
||||
const closestCentroidIndex = this.getClosestCentroidIndex(document.vector);
|
||||
clusters[closestCentroidIndex].push(document);
|
||||
}
|
||||
|
||||
// Update centroids
|
||||
const newCentroids = clusters.map((cluster) =>
|
||||
cluster.length > 0
|
||||
? this.calculateCentroid(cluster.map((d) => d.vector))
|
||||
: this.centroids[clusters.indexOf(cluster)]
|
||||
);
|
||||
|
||||
// Check for convergence
|
||||
if (this.hasConverged(newCentroids)) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.centroids = newCentroids;
|
||||
}
|
||||
|
||||
// Assign final clusters
|
||||
return documents.map((document) => this.getClosestCentroidIndex(document.vector));
|
||||
}
|
||||
|
||||
private initializeCentroids(points: number[][]): number[][] {
|
||||
const centroids: number[][] = [];
|
||||
const used = new Set<number>();
|
||||
|
||||
while (centroids.length < this.k) {
|
||||
const index = Math.floor(Math.random() * points.length);
|
||||
if (!used.has(index)) {
|
||||
centroids.push([...points[index]]);
|
||||
used.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
return centroids;
|
||||
}
|
||||
|
||||
private getClosestCentroidIndex(point: number[]): number {
|
||||
let minDistance = Infinity;
|
||||
let closestIndex = 0;
|
||||
|
||||
for (let i = 0; i < this.centroids.length; i++) {
|
||||
const distance = this.euclideanDistance(point, this.centroids[i]);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
private calculateCentroid(cluster: number[][]): number[] {
|
||||
const dimensions = cluster[0].length;
|
||||
const centroid = new Array(dimensions).fill(0);
|
||||
|
||||
for (const point of cluster) {
|
||||
for (let i = 0; i < dimensions; i++) {
|
||||
centroid[i] += point[i];
|
||||
}
|
||||
}
|
||||
|
||||
return centroid.map((sum) => sum / cluster.length);
|
||||
}
|
||||
|
||||
private euclideanDistance(a: number[], b: number[]): number {
|
||||
return Math.sqrt(a.reduce((sum, val, i) => sum + Math.pow(val - b[i], 2), 0));
|
||||
}
|
||||
|
||||
private hasConverged(newCentroids: number[][]): boolean {
|
||||
return newCentroids.every((centroid, i) => this.euclideanDistance(centroid, this.centroids[i]) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clusterDocuments(documents: TDocument[], numTopics: number) {
|
||||
// 2. Perform clustering
|
||||
const kmeans = new KMeans(numTopics);
|
||||
const clusterAssignments = kmeans.fit(documents);
|
||||
|
||||
// 3. Analyze clusters
|
||||
const clusters: TDocument[][] = Array.from({ length: numTopics }, () => []);
|
||||
documents.forEach((document, index) => {
|
||||
clusters[clusterAssignments[index]].push(document);
|
||||
});
|
||||
|
||||
// 4. Find central documents and extract responses
|
||||
const topics = await Promise.all(
|
||||
clusters.map(async (cluster, index) => {
|
||||
const centroid = kmeans["centroids"][index];
|
||||
const centralDocument = cluster.reduce(
|
||||
(central, document) => {
|
||||
const distance = kmeans["euclideanDistance"](document.vector, centroid);
|
||||
return distance < central.distance ? { document, distance } : central;
|
||||
},
|
||||
{ document: cluster[0], distance: Infinity }
|
||||
).document;
|
||||
|
||||
return {
|
||||
topicId: index,
|
||||
centralDocument: centralDocument,
|
||||
size: cluster.length,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return topics;
|
||||
}
|
||||
@@ -4,48 +4,50 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import {
|
||||
TEmbedding,
|
||||
TEmbeddingCreateInput,
|
||||
ZEmbeddingCreateInput,
|
||||
ZEmbeddingType,
|
||||
} from "@formbricks/types/embedding";
|
||||
TDocument,
|
||||
TDocumentCreateInput,
|
||||
ZDocumentCreateInput,
|
||||
ZDocumentType,
|
||||
} from "@formbricks/types/documents";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { cache } from "../cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { embeddingCache } from "./cache";
|
||||
import { documentCache } from "./cache";
|
||||
|
||||
export type TPrismaEmbedding = Omit<TEmbedding, "vector"> & {
|
||||
export type TPrismaDocument = Omit<TDocument, "vector"> & {
|
||||
vector: string;
|
||||
};
|
||||
|
||||
export const createEmbedding = async (embeddingInput: TEmbeddingCreateInput): Promise<TEmbedding> => {
|
||||
validateInputs([embeddingInput, ZEmbeddingCreateInput]);
|
||||
export const createDocument = async (documentInput: TDocumentCreateInput): Promise<TDocument> => {
|
||||
validateInputs([documentInput, ZDocumentCreateInput]);
|
||||
|
||||
try {
|
||||
const { vector, ...data } = embeddingInput;
|
||||
const { vector, ...data } = documentInput;
|
||||
|
||||
const prismaEmbedding = await prisma.embedding.create({
|
||||
const prismaDocument = await prisma.document.create({
|
||||
data,
|
||||
});
|
||||
|
||||
const embedding = {
|
||||
...prismaEmbedding,
|
||||
const document = {
|
||||
...prismaDocument,
|
||||
vector,
|
||||
};
|
||||
|
||||
// update vector
|
||||
const vectorString = `[${vector.join(",")}]`;
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "Embedding"
|
||||
UPDATE "Document"
|
||||
SET "vector" = ${vectorString}::vector(512)
|
||||
WHERE "id" = ${embedding.id};
|
||||
WHERE "id" = ${document.id};
|
||||
`;
|
||||
|
||||
embeddingCache.revalidate({
|
||||
referenceId: embedding.referenceId,
|
||||
documentCache.revalidate({
|
||||
id: document.id,
|
||||
type: document.type,
|
||||
referenceId: document.referenceId,
|
||||
});
|
||||
|
||||
return embedding;
|
||||
return document;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -54,39 +56,40 @@ export const createEmbedding = async (embeddingInput: TEmbeddingCreateInput): Pr
|
||||
}
|
||||
};
|
||||
|
||||
export const getEmbeddingsByTypeAndReferenceId = reactCache(
|
||||
(type: string, referenceId: string): Promise<TEmbedding[]> =>
|
||||
export const getDocumentsByTypeAndReferenceId = reactCache(
|
||||
(type: string, referenceId: string): Promise<TDocument[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([type, ZEmbeddingType], [referenceId, ZString]);
|
||||
validateInputs([type, ZDocumentType], [referenceId, ZString]);
|
||||
|
||||
try {
|
||||
const prismaEmbeddings: TPrismaEmbedding[] = await prisma.$queryRaw`
|
||||
const prismaDocuments: TPrismaDocument[] = await prisma.$queryRaw`
|
||||
SELECT
|
||||
id,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
type,
|
||||
text,
|
||||
"referenceId",
|
||||
vector::text
|
||||
FROM "Embedding" e
|
||||
WHERE e."type" = ${type}::"EmbeddingType"
|
||||
FROM "Document" e
|
||||
WHERE e."type" = ${type}::"DocumentType"
|
||||
AND e."referenceId" = ${referenceId}
|
||||
`;
|
||||
|
||||
const embeddings = prismaEmbeddings.map((prismaEmbedding) => {
|
||||
// Convert the string representation of the embedding back to an array of numbers
|
||||
const vector = prismaEmbedding.vector
|
||||
const documents = prismaDocuments.map((prismaDocument) => {
|
||||
// Convert the string representation of the vector back to an array of numbers
|
||||
const vector = prismaDocument.vector
|
||||
.slice(1, -1) // Remove the surrounding square brackets
|
||||
.split(",") // Split the string into an array of strings
|
||||
.map(Number); // Convert each string to a number
|
||||
return {
|
||||
...prismaEmbedding,
|
||||
...prismaDocument,
|
||||
vector,
|
||||
};
|
||||
});
|
||||
|
||||
return embeddings;
|
||||
return documents;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
console.error(error);
|
||||
@@ -95,9 +98,9 @@ export const getEmbeddingsByTypeAndReferenceId = reactCache(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getEmbeddingsByTypeAndReferenceId-${type}-${referenceId}`],
|
||||
[`getDocumentsByTypeAndReferenceId-${type}-${referenceId}`],
|
||||
{
|
||||
tags: [embeddingCache.tag.byTypeAndReferenceId(type, referenceId)],
|
||||
tags: [documentCache.tag.byTypeAndReferenceId(type, referenceId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -7,10 +7,12 @@ export const env = createEnv({
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
AI_AZURE_API_KEY: z.string().optional(),
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: z.string().optional(),
|
||||
AI_AZURE_LLM_API_KEY: z.string().optional(),
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: z.string().optional(),
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: z.string().optional(),
|
||||
AI_AZURE_RESSOURCE_NAME: z.string().optional(),
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: z.string().optional(),
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: z.string().optional(),
|
||||
AIRTABLE_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -117,10 +119,12 @@ export const env = createEnv({
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
AI_AZURE_API_KEY: process.env.AI_AZURE_API_KEY,
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: process.env.AI_AZURE_EMBEDDINGS_API_KEY,
|
||||
AI_AZURE_LLM_API_KEY: process.env.AI_AZURE_LLM_API_KEY,
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: process.env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID,
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: process.env.AI_AZURE_LLM_DEPLOYMENT_ID,
|
||||
AI_AZURE_RESSOURCE_NAME: process.env.AI_AZURE_RESSOURCE_NAME,
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: process.env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME,
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: process.env.AI_AZURE_LLM_RESSOURCE_NAME,
|
||||
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
|
||||
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
|
||||
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
|
||||
|
||||
28
packages/types/documents.ts
Normal file
28
packages/types/documents.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "./environment";
|
||||
|
||||
export const ZDocumentType = z.enum(["questionResponse"]);
|
||||
|
||||
export type TDocumentType = z.infer<typeof ZDocumentType>;
|
||||
|
||||
export const ZDocument = z.object({
|
||||
environmentId: ZId,
|
||||
referenceId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
type: ZDocumentType,
|
||||
text: z.string(),
|
||||
vector: z.array(z.number()).length(512),
|
||||
});
|
||||
|
||||
export type TDocument = z.infer<typeof ZDocument>;
|
||||
|
||||
export const ZDocumentCreateInput = z.object({
|
||||
environmentId: ZId,
|
||||
type: ZDocumentType,
|
||||
referenceId: z.string(),
|
||||
text: z.string(),
|
||||
vector: z.array(z.number()).length(512),
|
||||
});
|
||||
|
||||
export type TDocumentCreateInput = z.infer<typeof ZDocumentCreateInput>;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZEmbeddingType = z.enum(["questionResponse"]);
|
||||
|
||||
export const ZEmbedding = z.object({
|
||||
referenceId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
type: ZEmbeddingType,
|
||||
vector: z.array(z.number()).length(512),
|
||||
});
|
||||
|
||||
export type TEmbedding = z.infer<typeof ZEmbedding>;
|
||||
|
||||
export const ZEmbeddingCreateInput = z.object({
|
||||
type: ZEmbeddingType,
|
||||
referenceId: z.string(),
|
||||
vector: z.array(z.number()).length(512),
|
||||
});
|
||||
|
||||
export type TEmbeddingCreateInput = z.infer<typeof ZEmbeddingCreateInput>;
|
||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@@ -380,7 +380,7 @@ importers:
|
||||
version: 0.6.2
|
||||
'@vercel/speed-insights':
|
||||
specifier: ^1.0.12
|
||||
version: 1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
version: 1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(svelte@4.2.18)(vue@3.4.38)
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
@@ -411,6 +411,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.427.0
|
||||
version: 0.427.0(react@18.3.1)
|
||||
markdown-to-jsx:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0(react@18.3.1)
|
||||
mime:
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4
|
||||
@@ -546,13 +549,13 @@ importers:
|
||||
devDependencies:
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0(prettier@3.3.3)
|
||||
version: 4.3.0(@vue/compiler-sfc@3.4.38)(prettier@3.3.3)
|
||||
prettier:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.6
|
||||
version: 0.6.6(@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.3.3))(prettier@3.3.3)
|
||||
version: 0.6.6(@trivago/prettier-plugin-sort-imports@4.3.0(@vue/compiler-sfc@3.4.38)(prettier@3.3.3))(prettier@3.3.3)
|
||||
|
||||
packages/config-tailwind:
|
||||
devDependencies:
|
||||
@@ -8785,6 +8788,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 0.14.0'
|
||||
|
||||
markdown-to-jsx@7.5.0:
|
||||
resolution: {integrity: sha512-RrBNcMHiFPcz/iqIj0n3wclzHXjwS7mzjBNWecKKVhNTIxQepIix6Il/wZCn2Cg5Y1ow2Qi84+eJrryFRWBEWw==}
|
||||
engines: {node: '>= 10'}
|
||||
peerDependencies:
|
||||
react: '>= 0.14.0'
|
||||
|
||||
marked@7.0.4:
|
||||
resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -17610,7 +17619,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.1.0
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.3.3)':
|
||||
'@trivago/prettier-plugin-sort-imports@4.3.0(@vue/compiler-sfc@3.4.38)(prettier@3.3.3)':
|
||||
dependencies:
|
||||
'@babel/generator': 7.17.7
|
||||
'@babel/parser': 7.24.7
|
||||
@@ -17619,6 +17628,8 @@ snapshots:
|
||||
javascript-natural-sort: 0.7.1
|
||||
lodash: 4.17.21
|
||||
prettier: 3.3.3
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.4.38
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -18169,10 +18180,12 @@ snapshots:
|
||||
satori: 0.10.9
|
||||
yoga-wasm-web: 0.3.3
|
||||
|
||||
'@vercel/speed-insights@1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
'@vercel/speed-insights@1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(svelte@4.2.18)(vue@3.4.38)':
|
||||
optionalDependencies:
|
||||
next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
svelte: 4.2.18
|
||||
vue: 3.4.38
|
||||
|
||||
'@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)(vitest@2.0.5)':
|
||||
dependencies:
|
||||
@@ -18361,6 +18374,13 @@ snapshots:
|
||||
'@vue/shared': 3.4.38
|
||||
vue: 3.4.38(typescript@5.5.4)
|
||||
|
||||
'@vue/server-renderer@3.4.38(vue@3.4.38)':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.4.38
|
||||
'@vue/shared': 3.4.38
|
||||
vue: 3.4.38
|
||||
optional: true
|
||||
|
||||
'@vue/shared@3.4.29': {}
|
||||
|
||||
'@vue/shared@3.4.38': {}
|
||||
@@ -21869,6 +21889,10 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
markdown-to-jsx@7.5.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
marked@7.0.4: {}
|
||||
|
||||
md-to-react-email@5.0.2(react@18.3.1):
|
||||
@@ -23225,11 +23249,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
prettier: 3.3.3
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.6(@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.3.3))(prettier@3.3.3):
|
||||
prettier-plugin-tailwindcss@0.6.6(@trivago/prettier-plugin-sort-imports@4.3.0(@vue/compiler-sfc@3.4.38)(prettier@3.3.3))(prettier@3.3.3):
|
||||
dependencies:
|
||||
prettier: 3.3.3
|
||||
optionalDependencies:
|
||||
'@trivago/prettier-plugin-sort-imports': 4.3.0(prettier@3.3.3)
|
||||
'@trivago/prettier-plugin-sort-imports': 4.3.0(@vue/compiler-sfc@3.4.38)(prettier@3.3.3)
|
||||
|
||||
prettier@2.8.8: {}
|
||||
|
||||
@@ -25525,6 +25549,15 @@ snapshots:
|
||||
semver: 7.6.2
|
||||
typescript: 5.5.4
|
||||
|
||||
vue@3.4.38:
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.4.38
|
||||
'@vue/compiler-sfc': 3.4.38
|
||||
'@vue/runtime-dom': 3.4.38
|
||||
'@vue/server-renderer': 3.4.38(vue@3.4.38)
|
||||
'@vue/shared': 3.4.38
|
||||
optional: true
|
||||
|
||||
vue@3.4.38(typescript@5.5.4):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.4.38
|
||||
|
||||
@@ -51,10 +51,12 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"],
|
||||
"env": [
|
||||
"AI_AZURE_API_KEY",
|
||||
"AI_AZURE_EMBEDDINGS_API_KEY",
|
||||
"AI_AZURE_LLM_API_KEY",
|
||||
"AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID",
|
||||
"AI_AZURE_EMBEDDINGS_RESSOURCE_NAME",
|
||||
"AI_AZURE_LLM_DEPLOYMENT_ID",
|
||||
"AI_AZURE_RESSOURCE_NAME",
|
||||
"AI_AZURE_LLM_RESSOURCE_NAME",
|
||||
"AIRTABLE_CLIENT_ID",
|
||||
"ASSET_PREFIX_URL",
|
||||
"AZUREAD_CLIENT_ID",
|
||||
|
||||
Reference in New Issue
Block a user