Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]

5 changes: 5 additions & 0 deletions _includes/navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@
See on GitHub
</a>
</li>
<li class="nav-item">
<a class="btn btn-primary" href="/chat">
Chat
</a>
</li>
</ul>
</div>
</nav>
Expand Down
127 changes: 127 additions & 0 deletions backend/infer_api.py
Original file line number Diff line number Diff line change
@@ -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)
98 changes: 98 additions & 0 deletions backend/pc_upcert.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
layout: default
title: Chat with Docs
permalink: /chat/
---

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/python.min.js"></script>

<div id="chat-container">
<h2 class="mb-4">📚 QuTiP's Schrödy</h2>
<div id="messages" class="mb-3"></div>

<div id="input-group" class="input-group">
<input
type="text"
id="userInput"
class="form-control"
placeholder="Type your question..."
onkeypress="if(event.key === 'Enter') sendMessage()"
/>
<button class="btn btn-primary" onclick="sendMessage()">Send</button>
</div>
</div>

<script>
hljs.highlightAll();

function appendMessage(role, markdownText) {
const messagesDiv = document.getElementById("messages");
const html = marked.parse(markdownText);

const wrapper = document.createElement("div");
wrapper.classList.add("message", role);

const bubble = document.createElement("div");
bubble.classList.add("bubble");
bubble.innerHTML = html;

wrapper.appendChild(bubble);
messagesDiv.appendChild(wrapper);

messagesDiv.scrollTop = messagesDiv.scrollHeight;
hljs.highlightAll(); // Re-highlight after adding new content
}

async function sendMessage() {
const input = document.getElementById("userInput");
const userText = input.value.trim();
if (!userText) return;

appendMessage("user", userText);
input.value = "";
input.disabled = true;

try {
const resp = await fetch("http://localhost:5001/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userText }),
});

if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();

appendMessage("bot", data.response);
} catch (err) {
console.error("Error calling /api/chat:", err);
appendMessage("bot", "❗ Sorry, something went wrong. Please try again later.");
} finally {
input.disabled = false;
input.focus();
}
}

window.addEventListener("DOMContentLoaded", () => {
appendMessage(
"bot",
`👋 Hi, I'm **Shrody**, QuTiP's doc assistant!
I'm here to help you understand and explore the QuTiP documentation.
Just ask me a question about how things work—functions, features, usage examples—and I'll do my best to guide you!`
);
});
</script>
Loading