From 4fe1e8324f610336b730a5db10e4c062ec4c36ab Mon Sep 17 00:00:00 2001 From: gadhvirushiraj Date: Sat, 7 Jun 2025 16:04:57 +0530 Subject: [PATCH 1/4] added chatbot --- backend/infer_api.py | 127 +++++++++++++++++++++++++++++++++++++++ backend/pc_upcert.py | 98 ++++++++++++++++++++++++++++++ backend/requirements.txt | 9 +++ 3 files changed, 234 insertions(+) create mode 100644 backend/infer_api.py create mode 100644 backend/pc_upcert.py create mode 100644 backend/requirements.txt diff --git a/backend/infer_api.py b/backend/infer_api.py new file mode 100644 index 0000000..c33398c --- /dev/null +++ b/backend/infer_api.py @@ -0,0 +1,127 @@ +import os +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from sentence_transformers import SentenceTransformer +from pinecone import Pinecone +from groq import Groq +from fastapi.middleware.cors import CORSMiddleware + +os.environ["TOKENIZERS_PARALLELISM"] = "false" + +# ----------------- API KEYS ----------------- +# Pinecone +PINECONE_API_KEY = "enter_key" +PINECONE_ENV = "us-east-1" +PINECONE_INDEX_NAME = "qutip-shrody" + + +# Groq settings +GROQ_API_KEY = os.getenv("GROQ_API_KEY", "enter_key") +GROQ_MODEL_NAME = "llama-3.1-8b-instant" #"llama-3.3-70b-versatile" + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# embedding +embedder = SentenceTransformer("intfloat/e5-small-v2") + +# init pinecone +pc = Pinecone(api_key=PINECONE_API_KEY, environment=PINECONE_ENV) +index = pc.Index(PINECONE_INDEX_NAME) + +# init groq +groq_client = Groq(api_key=GROQ_API_KEY) + +class ChatRequest(BaseModel): + message: str + +def embed_text(text: str) -> list[float]: + """ + Returns a normalized embedding for the given text. + """ + embedding = embedder.encode(text, normalize_embeddings=True) + return embedding.tolist() + +def retrieve_relevant_docs(query_vector: list[float], top_k: int = 5) -> list[str]: + """ + Performs a Pinecone vector search. Returns a list of chunk_text strings + (as stored under metadata["chunk_text"]) for the top_k matches. + """ + response = index.query( + namespace="__default__", + vector=query_vector, + top_k=top_k, + include_metadata=True + ) + + docs = [] + for match in response.get("matches", []): + metadata = match.get("metadata", {}) + if "chunk_text" in metadata: + docs.append(metadata["chunk_text"]) + return docs + +@app.post("/api/chat") +async def chat_endpoint(request: ChatRequest): + user_question = request.message.strip() + if not user_question: + raise HTTPException(status_code=400, detail="Empty message") + + try: + q_vec = embed_text(user_question) + raw_response = index.query( + namespace="__default__", + vector=q_vec, + top_k=5, + include_metadata=True + ) + docs = [] + for match in raw_response.get("matches", []): + if match.get("score", 0) > 0.5: # can be tuned further + metadata = match.get("metadata", {}) + if "chunk_text" in metadata: + docs.append(metadata["chunk_text"]) + + # 4) Combine all chunks into a single “context” block + context = "\n".join(docs) if docs else "" + + print("Filtered Context:\n", context) + + # 5) Build messages: context goes in system message only + system_message = { + "role": "system", + "content": ( + "You are Shrody, an expert assistant for QuTiP's (its an open-source python library for simulating the dynamics of open quantum systems) documentation. " + "Be friendly, clear, and use bit of emoji. Use only the following context to answer the user's question:\n\n" + f"{context}\n\n" + "Always be concise and accurate. If the context does not contain the answer, kindly say so. Don't guess or invent information." + ) + } + + user_message = { + "role": "user", + "content": user_question + } + + # 6) Call Groq’s chat endpoint + chat_completion = groq_client.chat.completions.create( + messages=[system_message, user_message], + model=GROQ_MODEL_NAME, + ) + + generated_answer = chat_completion.choices[0].message.content + return {"response": generated_answer} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +if __name__ == "__main__": + import uvicorn + # Make sure the module name matches your filename (e.g. if this file is infer_api.py): + uvicorn.run("infer_api:app", port=5001, reload=True) diff --git a/backend/pc_upcert.py b/backend/pc_upcert.py new file mode 100644 index 0000000..e3b6657 --- /dev/null +++ b/backend/pc_upcert.py @@ -0,0 +1,98 @@ +import os +import uuid +from collections import defaultdict + +import pandas as pd +from unstructured.partition.pdf import partition_pdf +from unstructured.documents.elements import Table +from sentence_transformers import SentenceTransformer +from pinecone import Pinecone +from tqdm import tqdm + +# ----------------- API KEYS ----------------- +# Pinecone +PINECONE_API_KEY = "enter-key" +PINECONE_ENV = "us-east-1" +PINECONE_INDEX_NAME = "qutip-shrody" + + +# 1. Extract one chunk per PDF page, skip first 10 pages, include tables if present +def extract_chunks_by_page(pdf_path, skip_pages=10): + elements = partition_pdf(filename=pdf_path) + pages = defaultdict(list) + tables = defaultdict(list) + + # Group text & tables by page number + for el in elements: + page_no = getattr(el.metadata, "page_number", None) + if page_no is None or page_no <= skip_pages: + continue + + if isinstance(el, Table): + try: + df = el.to_dataframe() + md = df.to_markdown(index=False) + tables[page_no].append(md) + except Exception: + tables[page_no].append(el.text.strip()) + elif el.category not in ("PageBreak", "Image"): + text = el.text.strip() + if text: + pages[page_no].append(text) + + # Build one chunk per page + chunks = [] + for page_no in sorted(set(pages) | set(tables)): + parts = [] + if pages.get(page_no): + parts.append("\n\n".join(pages[page_no])) + if tables.get(page_no): + for tbl_md in tables[page_no]: + parts.append("\n\n**Table:**\n\n" + tbl_md) + joined = "\n\n".join(parts).strip() + if joined: + chunks.append({ + "text": joined, + "page_number": page_no + }) + return chunks + +# 2. Load embedding model +model = SentenceTransformer("intfloat/e5-small-v2") +def get_embedding(text): + return model.encode([text], normalize_embeddings=True)[0] + +# 3. Initialize Pinecone (replace with your key & environment) +pc = Pinecone(api_key=PINECONE_API_KEY, environment=PINECONE_ENV) +index = pc.Index(PINECONE_INDEX_NAME) + +# 4. Embed and upsert into Pinecone +def upsert_pdf_by_page(pdf_path, base_doc_id="mydoc"): + chunks = extract_chunks_by_page(pdf_path, skip_pages=10) + print(f"🧩 Extracted {len(chunks)} page-level chunks (skipped first 10 pages).") + + vectors = [] + for chunk in tqdm(chunks, desc="Embedding pages"): + chunk_id = f"{base_doc_id}-page{chunk['page_number']}" + emb = get_embedding(chunk["text"]) + vectors.append({ + "id": chunk_id, + "values": emb, + "metadata": { + "source": base_doc_id, + "page_number": chunk["page_number"], + "chunk_text": chunk["text"] + } + }) + + # Batch upserts + batch_size = 50 + for start in range(0, len(vectors), batch_size): + batch = vectors[start:start+batch_size] + index.upsert(vectors=batch) + print(f"✅ Upserted pages {start}–{start + len(batch) - 1}") + +if __name__ == "__main__": + pdf_file = "qutip.pdf" + doc_id = "qutip-5.1" + upsert_pdf_by_page(pdf_file, base_doc_id=doc_id) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..bdaff99 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.12 +groq==0.26.0 +pandas==2.3.0 +pinecone==7.0.2 +pydantic==2.11.5 +sentence_transformers==4.1.0 +tqdm==4.67.0 +unstructured[full-docs]==0.17.2 +uvicorn==0.34.3 From 8afbba3bf92fd5c00966591c95c1e5fd376fa3b9 Mon Sep 17 00:00:00 2001 From: gadhvirushiraj Date: Sat, 7 Jun 2025 16:05:10 +0530 Subject: [PATCH 2/4] chatbot UI --- chat.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 chat.md diff --git a/chat.md b/chat.md new file mode 100644 index 0000000..46062e3 --- /dev/null +++ b/chat.md @@ -0,0 +1,86 @@ +--- +layout: default +title: Chat with Docs +permalink: /chat/ +--- + + + + + + +
+

📚 QuTiP's Schrödy

+
+ +
+ + +
+
+ + From d11fc8b9b2803b38146d4dd00e51efcbfcde6b7d Mon Sep 17 00:00:00 2001 From: gadhvirushiraj Date: Sat, 7 Jun 2025 16:05:30 +0530 Subject: [PATCH 3/4] added dockerfile --- Dockerfile | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..983dea1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# Base image: Ruby with necessary dependencies for Jekyll +FROM ruby:3.2 + +# Install dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Set the working directory inside the container +WORKDIR /usr/src/app + +# Copy Gemfile into the container (necessary for `bundle install`) +COPY Gemfile ./ + +# Install bundler and dependencies +RUN gem install bundler:2.3.26 && bundle install + +# Expose port 4000 for Jekyll server +EXPOSE 4000 + +# Command to serve the Jekyll site +CMD ["bundle", "exec", "jekyll", "serve", "--host", "0.0.0.0", "--watch"] + From c1344f8e3a5f8bd451d9d0b5ec1aa2934e230b19 Mon Sep 17 00:00:00 2001 From: gadhvirushiraj Date: Sat, 7 Jun 2025 17:00:25 +0530 Subject: [PATCH 4/4] added chatbot --- _includes/navbar.html | 5 +++ css/site.css | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/_includes/navbar.html b/_includes/navbar.html index faf0f46..0eb5486 100644 --- a/_includes/navbar.html +++ b/_includes/navbar.html @@ -91,6 +91,11 @@ See on GitHub + diff --git a/css/site.css b/css/site.css index 93e6bb5..e1fdd01 100644 --- a/css/site.css +++ b/css/site.css @@ -308,4 +308,78 @@ a.btn:focus { .footer-list li:first-child { font-weight: bold; +} + + +#chat-container { + max-width: 1200px; + margin: 2rem auto; +} + +#messages { + height: 400px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 0.5rem; + padding: 1rem; + background: #fafafa; +} + +.message { + margin-bottom: 1rem; + line-height: 1.4; +} + +.message.user { + text-align: right; +} + +.message.bot { + text-align: left; +} + +.message .bubble { + display: inline-block; + max-width: 80%; + padding: 0.75rem 1rem; + border-radius: 0.5rem; +} + +.message .bubble p { + margin: 0; +} + +.message.user .bubble { + background: #1e90b3; + color: white; +} + +.message.bot .bubble { + background: #e9ecef; + color: #212529; +} + +.message .bubble pre { + background: #f0f0f0; + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-top: 0.5rem; +} + +.message .bubble code { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + font-size: 0.9rem; +} + +#input-group { + margin-top: 1rem; +} + +#input-group input { + width: calc(100% - 100px); +} + +#input-group button { + width: 80px; } \ No newline at end of file