Skip to content

Commit

Permalink
Merge pull request #28 from OSU-App-Club/nyumat/rag-implementation
Browse files Browse the repository at this point in the history
feat: full-stack RAG pipeline for PDF ingestion and contextual chat capabilities
  • Loading branch information
owenkrause authored Nov 18, 2024
2 parents d6badff + b2445c0 commit 84fbe2b
Show file tree
Hide file tree
Showing 35 changed files with 1,755 additions and 1,451 deletions.
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,6 @@ Beavs AI is an application that provides an AI Chatbot that is knowledgeable abo

## Scripts

<!-- Yeah this is messy, but please don't modify it! (since it works) If you do, paper-trail on Discord. Thanks! -->

| Script | Description |
| -------------- | ---------------------------------------------------------------------------------------------- |
| `dev` | Runs database generation, migration, and starts the Next.js dev server with Turbopack enabled. |
Expand All @@ -76,6 +74,7 @@ Beavs AI is an application that provides an AI Chatbot that is knowledgeable abo
| `db:generate` | Generates Prisma client based on schema and `.env.local` configuration. |
| `db:migrate` | Applies migrations for development using `.env.local` configuration. |
| `db:studio` | Opens Prisma Studio for database management. |
| `db:seed` | Seeds the database with initial data. |

## Troubleshooting

Expand Down Expand Up @@ -118,21 +117,21 @@ If you are new to web development, git, or any of the tools mentioned above, don

_No one is born a master, so don't be afraid to ask questions!_

### Level-based Learning for Beavs AI
## Level-based Learning for Beavs AI

Choose tasks aligned with your skill level to contribute effectively to Beavs AI:

#### Beginner
### Beginner

- **Frontend**: Build UI components for displaying syllabus content, style the interface to align with OSU branding, or implement basic API calls to fetch syllabus data.
- **Backend**: Create straightforward API endpoints for querying syllabus content, validate and handle basic user inputs, or manage simple data interactions with PostgreSQL.
- **Backend**: Create straightforward API endpoints for querying file content, validate and handle basic user inputs, or manage simple data interactions with PostgreSQL.

#### Intermediate
### Intermediate

- **Frontend**: Implement interactive UI components for search functionality, handle input validation for queries, or manage component state effectively with React hooks.
- **Backend**: Integrate with Pinecone for semantic search, manage user sessions with secure practices, or develop API endpoints that retrieve and format contextually relevant content for RAG responses.

#### Advanced
### Advanced

- **Frontend**: Optimize the rendering of search results for low-latency response times, implement advanced state management for caching syllabus queries, or introduce features that enhance user experience (e.g., query history, autocomplete).
- **Backend**: Optimize Pinecone and database queries for speed and scalability, implement complex business logic for query preprocessing and RAG workflows, or enhance backend efficiency by streamlining Langchain workflows to dynamically handle complex queries with large language models.
Expand Down
2,079 changes: 901 additions & 1,178 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@langchain/community": "^0.3.12",
"@langchain/core": "^0.3.17",
"@langchain/openai": "^0.3.12",
"@langchain/pinecone": "^0.1.1",
"@pinecone-database/pinecone": "^4.0.0",
"@prisma/client": "^5.21.1",
"@radix-ui/react-accordion": "^1.2.1",
Expand Down Expand Up @@ -68,7 +69,6 @@
"embla-carousel-react": "^8.3.0",
"framer-motion": "^11.11.11",
"input-otp": "^1.2.4",
"langchain": "^0.3.5",
"lucide-react": "^0.453.0",
"next": "15.0.1",
"next-auth": "^5.0.0-beta.25",
Expand Down
2 changes: 2 additions & 0 deletions prisma/migrations/20241109030313_add_doc_ids/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "course_materials" ADD COLUMN "documentIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
2 changes: 2 additions & 0 deletions prisma/migrations/20241109030954_is_indexed/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "course_materials" ADD COLUMN "is_indexed" BOOLEAN NOT NULL DEFAULT false;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ model CourseMaterial {
visibility Visibility @default(PRIVATE)
Chat Chat[]
user User? @relation(fields: [userId], references: [id])
documentIds String[] @default([])
isIndexed Boolean @default(false) @map("is_indexed")
@@map("course_materials")
}
Expand Down
2 changes: 1 addition & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { r2Client } from "@/lib/cloudFlareClient";
import { r2Client } from "@/lib/cloudflare";
import { prisma } from "@/lib/prisma";
import { GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
Expand Down
Binary file added public/bot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 20 additions & 61 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,40 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { CreateMessageInput } from "@/lib/models";
import {
getFileContext,
saveMessagesInTransaction,
} from "@/lib/retrieval-augmentation-gen";
import { buildPrompt } from "@/lib/utils";
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";

/**
* POST /api/chat
* Processes a message and returns a response.x
* @param req The incoming request.
* @returns A response indicating the status of the operation.
* @throws If an error occurs while processing the request.
*/
export async function POST(req: Request) {
try {
const session = await auth();

if (!session?.user?.id)
return new Response("Unauthorized", { status: 401 });

if (req.headers.get("content-type") !== "application/json")
return new Response("Invalid content type", { status: 400 });

const { messages, chatId, fileContext } = await req.json();

const chat = await prisma.chat.findUnique({
where: {
id: chatId,
userId: session.user.id,
},
include: {
CourseMaterial: true,
},
});

if (!chat) return new Response("Chat not found", { status: 404 });

const userMessage = messages[messages.length - 1];

if (userMessage.role === "user") {
const existingMessage = await prisma.message.findFirst({
where: {
chatId,
content: userMessage.content,
role: "user",
},
});

if (!existingMessage) {
await prisma.message.create({
data: {
chatId,
content: userMessage.content,
role: userMessage.role,
},
});
}
}

const systemPrompt = {
role: "system",
content: `AI assistant is a brand new, powerful, human-like artificial intelligence.
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
AI is a well-behaved and well-mannered individual.
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
AI assistant is a big fan of Pinecone and Vercel.
START CONTEXT BLOCK
${fileContext || ""}
END OF CONTEXT BLOCK
AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation.
If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question".
AI assistant will not apologize for previous responses, but instead will indicated new information was gained.
AI assistant will not invent anything that is not drawn directly from the context.`,
};
const { messages, chatId, fileId }: CreateMessageInput = await req.json();
const fileContext = fileId
? await getFileContext(messages[messages.length - 1])
: "";

const systemPrompt = buildPrompt(fileContext);
const response = await streamText({
model: openai("gpt-4o-mini"),
messages: [systemPrompt, ...messages],
async onFinish({ text }) {
await prisma.message.create({
data: {
chatId,
content: text,
role: "assistant",
},
});
saveMessagesInTransaction({ messages, chatId, text });
},
});

Expand Down
124 changes: 94 additions & 30 deletions src/app/api/embeddings/route.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,109 @@
import { PdfRecord } from "@/lib/models";
import { deleteDocumentFromPinecone, queryDocuments } from "@/lib/pinecone";
import {
handleEmbeddingAndStorage,
processDocument,
syncDocumentWithDb,
} from "@/lib/retrieval-augmentation-gen";
import { NextResponse } from "next/server";
import { index } from "@/lib/pineconeClient"; // adjust the import path as needed
import { createEmbedding } from "@/lib/openAiClient"; // adjust the import path as needed

/**
* POST /api/embeddings
* Processes a PDF file into chunks, embeds the text of each chunk, and stores the chunks in the Pinecone index.
* @param request The incoming request.
* @returns A response indicating the status of the operation.
* @throws If an error occurs while processing the PDF.
*/
export async function POST(request: Request) {
const { id, text } = await request.json();
try {
const requestBody = await request.json();
const args: PdfRecord = requestBody.data;
if (!args.fileName)
return NextResponse.json(
{ error: "fileName is required" },
{ status: 400 },
);

if (!id || !text) {
return NextResponse.json({ error: "Missing id or text" }, { status: 400 });
}

// Generate the embedding
const embedding = await createEmbedding(text);
const chunks = await processDocument(args);
const [taskOne, taskTwo] = await Promise.allSettled([
await handleEmbeddingAndStorage(chunks),
await syncDocumentWithDb(args, chunks),
]);

// Upsert the embedding into Pinecone
await index
.namespace("ns1")
.upsert([{ id, values: embedding, metadata: { text } }]);
if (taskOne.status === "rejected" || taskTwo.status === "rejected") {
return NextResponse.json(
{ error: "Failed to process PDF" },
{ status: 500 },
);
}

return NextResponse.json({ message: "Embedding uploaded successfully" });
return NextResponse.json({
message: "PDF processed and stored successfully",
chunks: chunks.length,
});
} catch (error) {
console.error("Error processing PDF:", error);
return NextResponse.json(
{ error: "Failed to process PDF" },
{ status: 500 },
);
}
}

/**
* GET /api/embeddings
* Searches for documents similar to the given query.
* @param request The incoming request.
* @returns A response containing the search results.
* @throws If an error occurs while searching for documents.
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const queryText = searchParams.get("text");
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get("query");
if (!query)
return NextResponse.json(
{ error: "Query parameter is required" },
{ status: 400 },
);

if (!queryText) {
// Relevant chunks are stored in the Pinecone index
const results = await queryDocuments(query);
return NextResponse.json({ results });
} catch (error) {
console.error("Error searching documents:", error);
return NextResponse.json(
{ error: "Query text is required" },
{ status: 400 },
{ error: "Failed to search documents" },
{ status: 500 },
);
}
}

// Generate the embedding for the query
const queryVector = await createEmbedding(queryText);

// Search for similar embeddings in Pinecone
const response = await index.namespace("ns1").query({
topK: 1,
vector: queryVector,
includeValues: true,
includeMetadata: true,
});
return NextResponse.json(response.matches[0].metadata);
/**
* DELETE /api/embeddings
* Deletes the documents associated with the given chat ID.
* @param request The incoming request.
* @returns A response indicating the status of the operation.
* @throws If an error occurs while deleting the documents.
*/
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("chatId");
if (!id)
return NextResponse.json(
{ error: "ChatID parameter is required" },
{ status: 400 },
);

await deleteDocumentFromPinecone(id);

return NextResponse.json({ message: "Document deleted successfully" });
} catch (error) {
console.error("Error deleting document:", error);
return NextResponse.json(
{ error: "Failed to delete document" },
{ status: 500 },
);
}
}
2 changes: 1 addition & 1 deletion src/app/api/files/[id]/sign/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getPresignedUrl } from "@/lib/cloudFlareClient";
import { getPresignedUrl } from "@/lib/cloudflare";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";

Expand Down
57 changes: 57 additions & 0 deletions src/app/api/stats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";

export const revalidate = 30;

/**
* Fetches user upload statistics
* @returns
* @throws
* - 500 if failed to fetch the data
* - 200 with the users file statistics
* @example
* GET /api/stats
*/
export async function GET() {
try {
const session = await auth();
const userId = session?.user?.id;
if (!userId) throw new Error("User ID is required");

const user = await prisma.user.findUnique({
where: { id: userId },
include: { CourseMaterial: true },
});

if (!user) throw new Error("User not found");

const totalFiles = user.CourseMaterial.length;
const totalPages = user.CourseMaterial.reduce(
(acc, material) => acc + material.pages,
0,
);
const averageFileSize =
totalFiles > 0
? user.CourseMaterial.reduce(
(acc, material) => acc + material.fileSize,
0,
) / totalFiles
: 0;

const stats = {
totalFiles,
totalPages,
averageFileSize,
storageUsed: user.storageUsed,
};

return NextResponse.json({ stats });
} catch (error) {
console.error("Failed to fetch files", error);
return NextResponse.json(
{ error: "Failed to fetch files" },
{ status: 500 },
);
}
}
Loading

0 comments on commit 84fbe2b

Please sign in to comment.