mothy-08 commited on
Commit ·
aaa4ec9
1
Parent(s): 89ec44c
Initial commit
Browse files- README.md +39 -0
- api/config.py +43 -0
- api/crawler.py +90 -0
- api/schemas.py +16 -0
- api/server.py +132 -0
- api/utils.py +60 -0
- api/vectorstore.py +70 -0
- requirements.txt +101 -0
README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🌐 RAGBot
|
| 2 |
+
|
| 3 |
+
A cloud-native, multi-tenant AI chatbot that instantly learns the content of any website and answers questions in real-time. Built for the Cloud Computing final project.
|
| 4 |
+
|
| 5 |
+
## 🚀 Key Features
|
| 6 |
+
|
| 7 |
+
* **Universal Ingestion:** Crawls and indexes any provided URL (e.g., University sites, NGOs, Government portals) on-demand.
|
| 8 |
+
* **SaaS Architecture:** Uses **Multi-Tenancy** via Vector Database Namespacing to isolate customer data within a single index.
|
| 9 |
+
* **RAG Pipeline:** Combines **Semantic Search** (Pinecone) with **LLM Generation** (Gemini 2.0 Flash) for hallucination-free answers.
|
| 10 |
+
* **Client-Server Model:** Decoupled FastAPI Backend and Chrome Extension Frontend.
|
| 11 |
+
* **Asynchronous Processing:** Background workers handle heavy scraping tasks without blocking the UI.
|
| 12 |
+
|
| 13 |
+
## 🛠️ Tech Stack
|
| 14 |
+
|
| 15 |
+
### **Backend (Cloud Engine)**
|
| 16 |
+
* **Framework:** FastAPI (Python)
|
| 17 |
+
* **Vector Database:** Pinecone (Serverless AWS)
|
| 18 |
+
* **LLM:** Google Gemini 2.0 Flash
|
| 19 |
+
* **Crawler:** Trafilatura (Sitemap & Content Discovery)
|
| 20 |
+
* **Embeddings:** Sentence-Transformers (`all-MiniLM-L6-v2`)
|
| 21 |
+
|
| 22 |
+
### **Frontend (Client)**
|
| 23 |
+
* **Interface:** Google Chrome Extension (Manifest V3)
|
| 24 |
+
* **Interaction:** Real-time Polling & Dynamic UI
|
| 25 |
+
|
| 26 |
+
## 📂 Project Structure
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
├── server.py # Main FastAPI entry point
|
| 30 |
+
├── crawler.py # Logic for sitemap parsing & scraping
|
| 31 |
+
├── vectorstore.py # Pinecone batching & management
|
| 32 |
+
├── config.py # Environment & Logging setup
|
| 33 |
+
├── utils.py # Security & URL validation
|
| 34 |
+
├── schemas.py # Pydantic data models
|
| 35 |
+
├── extension/ # Chrome Extension Source Code
|
| 36 |
+
│ ├── manifest.json
|
| 37 |
+
│ ├── popup.html
|
| 38 |
+
│ └── popup.js
|
| 39 |
+
└── requirements.txt # Python Dependencies
|
api/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import logging
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from vectorstore import VectorStoreManager
|
| 6 |
+
import google.generativeai as genai
|
| 7 |
+
|
| 8 |
+
logging.basicConfig(
|
| 9 |
+
level=logging.INFO,
|
| 10 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 11 |
+
handlers=[logging.StreamHandler(sys.stdout)],
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
| 19 |
+
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", "")
|
| 20 |
+
INDEX_NAME = "production-rag-bot"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def log_critical(api_key: str):
|
| 24 |
+
logger.critical(f"CRITICAL: {api_key} is missing from .env")
|
| 25 |
+
sys.exit(1)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
if not GOOGLE_API_KEY:
|
| 29 |
+
log_critical("PINECONE_API_KEY")
|
| 30 |
+
|
| 31 |
+
if not PINECONE_API_KEY:
|
| 32 |
+
log_critical("PINECONE_API_KEY")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
vs = VectorStoreManager(api_key=PINECONE_API_KEY, index_name=INDEX_NAME)
|
| 37 |
+
|
| 38 |
+
genai.configure(api_key=GOOGLE_API_KEY) # type: ignore
|
| 39 |
+
flash = genai.GenerativeModel("gemini-2.0-flash") # type: ignore
|
| 40 |
+
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.critical(f"Startup Failure: {e}")
|
| 43 |
+
sys.exit(1)
|
api/crawler.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import trafilatura
|
| 3 |
+
from trafilatura.sitemaps import sitemap_search
|
| 4 |
+
from utils import logger, is_valid_url
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def smart_chunk(text: str, chunk_size=500) -> list[str]:
|
| 8 |
+
"""
|
| 9 |
+
Respects semantic boundaries.
|
| 10 |
+
Splits by Paragraphs (\n\n) -> Sentences (. ) -> Characters.
|
| 11 |
+
"""
|
| 12 |
+
if not text:
|
| 13 |
+
return []
|
| 14 |
+
|
| 15 |
+
chunks = []
|
| 16 |
+
# split by paragraphs first (strongest delimiter)
|
| 17 |
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
| 18 |
+
|
| 19 |
+
current_chunk = ""
|
| 20 |
+
|
| 21 |
+
for para in paragraphs:
|
| 22 |
+
# If adding this paragraph exceeds size, push current chunk and start new
|
| 23 |
+
if len(current_chunk) + len(para) > chunk_size:
|
| 24 |
+
if current_chunk:
|
| 25 |
+
chunks.append(current_chunk.strip())
|
| 26 |
+
|
| 27 |
+
# If the paragraph itself is massive, we must split it by sentence
|
| 28 |
+
if len(para) > chunk_size:
|
| 29 |
+
sentences = para.split(". ")
|
| 30 |
+
current_chunk = ""
|
| 31 |
+
for sent in sentences:
|
| 32 |
+
if len(current_chunk) + len(sent) > chunk_size:
|
| 33 |
+
chunks.append(current_chunk.strip())
|
| 34 |
+
current_chunk = sent + ". "
|
| 35 |
+
else:
|
| 36 |
+
current_chunk += sent + ". "
|
| 37 |
+
else:
|
| 38 |
+
current_chunk = para + "\n" # Start new chunk with this paragraph
|
| 39 |
+
else:
|
| 40 |
+
current_chunk += para + "\n"
|
| 41 |
+
|
| 42 |
+
if current_chunk:
|
| 43 |
+
chunks.append(current_chunk.strip())
|
| 44 |
+
|
| 45 |
+
return chunks
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def crawl_website(base_url: str, limit: int = 15):
|
| 49 |
+
"""
|
| 50 |
+
Generator function that yields processed chunks one by one.
|
| 51 |
+
"""
|
| 52 |
+
logger.info(f"Starting crawl for {base_url}")
|
| 53 |
+
|
| 54 |
+
# 1. Discovery
|
| 55 |
+
urls = sitemap_search(base_url)
|
| 56 |
+
if not urls:
|
| 57 |
+
logger.warning("No sitemap found. Fallback to base URL.")
|
| 58 |
+
urls = [base_url]
|
| 59 |
+
|
| 60 |
+
# 2. Filtering
|
| 61 |
+
valid_urls = [u for u in urls if is_valid_url(u, base_url)]
|
| 62 |
+
logger.info(f"Found {len(urls)} URLs, {len(valid_urls)} valid.")
|
| 63 |
+
|
| 64 |
+
# 3. Crawl Loop
|
| 65 |
+
count = 0
|
| 66 |
+
for link in valid_urls:
|
| 67 |
+
if count >= limit:
|
| 68 |
+
break
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
# Etiquette: Sleep 0.5s between requests
|
| 72 |
+
time.sleep(0.5)
|
| 73 |
+
|
| 74 |
+
downloaded = trafilatura.fetch_url(link)
|
| 75 |
+
if not downloaded:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
text = trafilatura.extract(downloaded, include_comments=False)
|
| 79 |
+
if not text:
|
| 80 |
+
continue
|
| 81 |
+
|
| 82 |
+
chunks = smart_chunk(text)
|
| 83 |
+
|
| 84 |
+
# Yield result to main app
|
| 85 |
+
yield link, chunks
|
| 86 |
+
count += 1
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Failed to crawl {link}: {e}")
|
| 90 |
+
continue
|
api/schemas.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing_extensions import Annotated
|
| 2 |
+
from pydantic import BaseModel, Field, StringConstraints, HttpUrl
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ChatRequest(BaseModel):
|
| 6 |
+
message: Annotated[
|
| 7 |
+
str,
|
| 8 |
+
StringConstraints(
|
| 9 |
+
strip_whitespace=True, strict=True, min_length=1, max_length=4000
|
| 10 |
+
),
|
| 11 |
+
]
|
| 12 |
+
url: Annotated[HttpUrl, Field(description="A valid HTTP/HTTPS URL only.")]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class IngestRequest(BaseModel):
|
| 16 |
+
url: Annotated[HttpUrl, Field(description="A valid HTTP/HTTPS URL only.")]
|
api/server.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, BackgroundTasks, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
|
| 4 |
+
from utils import get_namespace_id, logger
|
| 5 |
+
from crawler import crawl_website
|
| 6 |
+
from schemas import ChatRequest, IngestRequest
|
| 7 |
+
from config import vs, flash
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="RAG Chatbot API", version="1.0")
|
| 10 |
+
|
| 11 |
+
app.add_middleware(
|
| 12 |
+
CORSMiddleware,
|
| 13 |
+
allow_origins=["*"],
|
| 14 |
+
allow_credentials=False,
|
| 15 |
+
allow_methods=["*"],
|
| 16 |
+
allow_headers=["*"],
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def background_ingest_task(url: str, namespace_id: str):
|
| 21 |
+
logger.info(f"Background crawl STARTED for: {url}")
|
| 22 |
+
try:
|
| 23 |
+
# Limit set to 25 to balance speed/completeness
|
| 24 |
+
crawler_gen = crawl_website(url, limit=25)
|
| 25 |
+
|
| 26 |
+
buffer = []
|
| 27 |
+
for source_url, chunks in crawler_gen:
|
| 28 |
+
for chunk in chunks:
|
| 29 |
+
buffer.append({"text": chunk, "source": source_url})
|
| 30 |
+
|
| 31 |
+
if len(buffer) >= 50:
|
| 32 |
+
vs.batch_upsert(buffer, namespace_id)
|
| 33 |
+
buffer = []
|
| 34 |
+
|
| 35 |
+
if buffer:
|
| 36 |
+
vs.batch_upsert(buffer, namespace_id)
|
| 37 |
+
|
| 38 |
+
logger.info(f"Background crawl COMPLETED for {namespace_id}")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Background task failed for {url}: {e}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@app.get("/")
|
| 44 |
+
def check_health():
|
| 45 |
+
return {"status": "online", "system": "RAG-Chatbot v2.0"}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.post("/check")
|
| 49 |
+
def check_endpoint(req: IngestRequest):
|
| 50 |
+
url_str = str(req.url)
|
| 51 |
+
namespace_id = get_namespace_id(url_str)
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
stats = vs.index.describe_index_stats()
|
| 55 |
+
exists = namespace_id in stats.namespaces
|
| 56 |
+
|
| 57 |
+
count = 0
|
| 58 |
+
if exists:
|
| 59 |
+
count = stats.namespaces[namespace_id].vector_count
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
"exists": exists and count > 0,
|
| 63 |
+
"namespace": namespace_id,
|
| 64 |
+
"vector_count": count,
|
| 65 |
+
}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Check failed: {e}")
|
| 68 |
+
return {"exists": False, "error": str(e)}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.post("/ingest")
|
| 72 |
+
def ingest_endpoint(req: IngestRequest, background_tasks: BackgroundTasks):
|
| 73 |
+
url_str = str(req.url)
|
| 74 |
+
namespace_id = get_namespace_id(url_str)
|
| 75 |
+
|
| 76 |
+
background_tasks.add_task(background_ingest_task, url_str, namespace_id)
|
| 77 |
+
logger.info(f"Background ingest dispatched: {url_str} -> {namespace_id}")
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
"status": "processing",
|
| 81 |
+
"message": "Ingestion started in background.",
|
| 82 |
+
"namespace": namespace_id,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.post("/chat")
|
| 87 |
+
def chat_endpoint(req: ChatRequest):
|
| 88 |
+
url_str = str(req.url)
|
| 89 |
+
namespace_id = get_namespace_id(url_str)
|
| 90 |
+
|
| 91 |
+
# 1. Retrieval
|
| 92 |
+
results = vs.query_namespace(req.message, namespace_id)
|
| 93 |
+
|
| 94 |
+
contexts = []
|
| 95 |
+
sources = set()
|
| 96 |
+
|
| 97 |
+
if results and results.matches:
|
| 98 |
+
for match in results.matches:
|
| 99 |
+
if match.metadata:
|
| 100 |
+
text = match.metadata.get("text", "")
|
| 101 |
+
src = match.metadata.get("source", None)
|
| 102 |
+
if text:
|
| 103 |
+
contexts.append(text)
|
| 104 |
+
if src:
|
| 105 |
+
sources.add(src)
|
| 106 |
+
|
| 107 |
+
if not contexts:
|
| 108 |
+
return {
|
| 109 |
+
"answer": "I haven't learned this website yet. Please click 'Train' first!",
|
| 110 |
+
"sources": [],
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# 2. Prompting
|
| 114 |
+
context_blob = "\n\n".join(contexts[:5])
|
| 115 |
+
prompt = f"""
|
| 116 |
+
You are a helpful assistant for the website: {url_str}.
|
| 117 |
+
Use ONLY the context provided below to answer the user's question.
|
| 118 |
+
If the answer is not in the context, say "I don't have that information."
|
| 119 |
+
|
| 120 |
+
CONTEXT:
|
| 121 |
+
{context_blob}
|
| 122 |
+
|
| 123 |
+
USER QUESTION:
|
| 124 |
+
{req.message}
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
response = flash.generate_content(prompt)
|
| 129 |
+
return {"answer": response.text, "sources": list(sources)}
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"LLM Error: {e}")
|
| 132 |
+
raise HTTPException(status_code=500, detail="AI Service Error")
|
api/utils.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import re
|
| 3 |
+
import logging
|
| 4 |
+
from urllib.parse import urlparse
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_namespace_id(url: str) -> str:
|
| 10 |
+
"""
|
| 11 |
+
Generates a collision-resistant namespace ID.
|
| 12 |
+
Format: clean_name_MD5hash
|
| 13 |
+
"""
|
| 14 |
+
clean_name = re.sub(r"https?://(www\.)?", "", url)
|
| 15 |
+
clean_name = re.sub(r"[^a-zA-Z0-9]", "_", clean_name)
|
| 16 |
+
clean_name = clean_name.strip("_")[:30]
|
| 17 |
+
|
| 18 |
+
url_hash = hashlib.md5(url.encode("utf-8")).hexdigest()[:6]
|
| 19 |
+
|
| 20 |
+
return f"{clean_name}_{url_hash}"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def is_valid_url(url: str, base_domain: str) -> bool:
|
| 24 |
+
"""
|
| 25 |
+
Enforces strict crawl scope.
|
| 26 |
+
"""
|
| 27 |
+
try:
|
| 28 |
+
parsed = urlparse(url)
|
| 29 |
+
base_parsed = urlparse(base_domain)
|
| 30 |
+
|
| 31 |
+
# 1. Scheme Check
|
| 32 |
+
if parsed.scheme not in ["http", "https"]:
|
| 33 |
+
return False
|
| 34 |
+
|
| 35 |
+
# 2. Strict Domain Check (Ends with pattern to prevent 'google.com.evil.com')
|
| 36 |
+
# We allow subdomains (e.g. portal.batstateu.edu.ph)
|
| 37 |
+
if not parsed.netloc.endswith(base_parsed.netloc):
|
| 38 |
+
return False
|
| 39 |
+
|
| 40 |
+
# 3. File Extension Check
|
| 41 |
+
ignored_exts = [
|
| 42 |
+
".pdf",
|
| 43 |
+
".jpg",
|
| 44 |
+
".png",
|
| 45 |
+
".gif",
|
| 46 |
+
".css",
|
| 47 |
+
".js",
|
| 48 |
+
".docx",
|
| 49 |
+
".xlsx",
|
| 50 |
+
".xml",
|
| 51 |
+
".zip",
|
| 52 |
+
".rar",
|
| 53 |
+
".mp4",
|
| 54 |
+
]
|
| 55 |
+
if any(parsed.path.lower().endswith(ext) for ext in ignored_exts):
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
return True
|
| 59 |
+
except Exception:
|
| 60 |
+
return False
|
api/vectorstore.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
import uuid
|
| 3 |
+
from pinecone import Pinecone, ServerlessSpec
|
| 4 |
+
from sentence_transformers import SentenceTransformer
|
| 5 |
+
from utils import logger
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class VectorStoreManager:
|
| 9 |
+
# Add ': str' to api_key and index_name
|
| 10 |
+
def __init__(self, api_key: str, index_name: str, region: str = "us-east-1"):
|
| 11 |
+
self.pc = Pinecone(api_key=api_key)
|
| 12 |
+
self.index_name = index_name
|
| 13 |
+
self.embed_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
|
| 14 |
+
self.dimension = 384
|
| 15 |
+
|
| 16 |
+
# ... rest of the code is fine
|
| 17 |
+
|
| 18 |
+
# Ensure Index Exists
|
| 19 |
+
if self.index_name not in [i.name for i in self.pc.list_indexes()]:
|
| 20 |
+
logger.info(f"Creating index {self.index_name}...")
|
| 21 |
+
self.pc.create_index(
|
| 22 |
+
name=self.index_name,
|
| 23 |
+
dimension=self.dimension,
|
| 24 |
+
metric="cosine",
|
| 25 |
+
spec=ServerlessSpec(cloud="aws", region=region),
|
| 26 |
+
)
|
| 27 |
+
self.index = self.pc.Index(self.index_name)
|
| 28 |
+
|
| 29 |
+
def batch_upsert(self, data_buffer, namespace, batch_size=100):
|
| 30 |
+
"""
|
| 31 |
+
Upserts vectors in chunks to avoid timeout.
|
| 32 |
+
data_buffer: list of (text, source_url)
|
| 33 |
+
"""
|
| 34 |
+
if not data_buffer:
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
# 1. Batch Embed (Much faster than 1-by-1)
|
| 38 |
+
texts = [item["text"] for item in data_buffer]
|
| 39 |
+
embeddings = self.embed_model.encode(texts)
|
| 40 |
+
|
| 41 |
+
# 2. Prepare Vectors
|
| 42 |
+
vectors = []
|
| 43 |
+
for i, (item, vector) in enumerate(zip(data_buffer, embeddings)):
|
| 44 |
+
vector_id = str(uuid.uuid4())
|
| 45 |
+
metadata = {"text": item["text"], "source": item["source"]}
|
| 46 |
+
vectors.append((vector_id, vector.tolist(), metadata))
|
| 47 |
+
|
| 48 |
+
# 3. Batch Upload to Pinecone
|
| 49 |
+
total_vectors = len(vectors)
|
| 50 |
+
for i in range(0, total_vectors, batch_size):
|
| 51 |
+
batch = vectors[i : i + batch_size]
|
| 52 |
+
try:
|
| 53 |
+
self.index.upsert(vectors=batch, namespace=namespace)
|
| 54 |
+
logger.info(f"Upserted batch {i} to {i + len(batch)} into {namespace}")
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"Upsert failed for batch {i}: {e}")
|
| 57 |
+
|
| 58 |
+
def query_namespace(self, query_text, namespace, top_k=5) -> Any:
|
| 59 |
+
query_vector = self.embed_model.encode(query_text).tolist()
|
| 60 |
+
try:
|
| 61 |
+
results = self.index.query(
|
| 62 |
+
vector=query_vector,
|
| 63 |
+
top_k=top_k,
|
| 64 |
+
include_metadata=True,
|
| 65 |
+
namespace=namespace,
|
| 66 |
+
)
|
| 67 |
+
return results
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"Query failed: {e}")
|
| 70 |
+
return None
|
requirements.txt
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
altair==5.5.0
|
| 2 |
+
annotated-doc==0.0.4
|
| 3 |
+
annotated-types==0.7.0
|
| 4 |
+
anyio==4.11.0
|
| 5 |
+
attrs==25.4.0
|
| 6 |
+
babel==2.17.0
|
| 7 |
+
beautifulsoup4==4.14.2
|
| 8 |
+
blinker==1.9.0
|
| 9 |
+
cachetools==6.2.2
|
| 10 |
+
certifi==2025.11.12
|
| 11 |
+
charset-normalizer==3.4.4
|
| 12 |
+
click==8.3.1
|
| 13 |
+
courlan==1.3.2
|
| 14 |
+
dateparser==1.2.2
|
| 15 |
+
fastapi==0.122.0
|
| 16 |
+
filelock==3.20.0
|
| 17 |
+
fsspec==2025.10.0
|
| 18 |
+
gitdb==4.0.12
|
| 19 |
+
GitPython==3.1.45
|
| 20 |
+
google-ai-generativelanguage==0.6.15
|
| 21 |
+
google-api-core==2.28.1
|
| 22 |
+
google-api-python-client==2.187.0
|
| 23 |
+
google-auth==2.43.0
|
| 24 |
+
google-auth-httplib2==0.2.1
|
| 25 |
+
google-generativeai==0.8.5
|
| 26 |
+
googleapis-common-protos==1.72.0
|
| 27 |
+
grpcio==1.76.0
|
| 28 |
+
grpcio-status==1.71.2
|
| 29 |
+
h11==0.16.0
|
| 30 |
+
hf-xet==1.2.0
|
| 31 |
+
htmldate==1.9.4
|
| 32 |
+
httplib2==0.31.0
|
| 33 |
+
huggingface-hub==0.36.0
|
| 34 |
+
idna==3.11
|
| 35 |
+
Jinja2==3.1.6
|
| 36 |
+
joblib==1.5.2
|
| 37 |
+
jsonschema==4.25.1
|
| 38 |
+
jsonschema-specifications==2025.9.1
|
| 39 |
+
jusText==3.0.2
|
| 40 |
+
lxml==6.0.2
|
| 41 |
+
lxml_html_clean==0.4.3
|
| 42 |
+
MarkupSafe==3.0.3
|
| 43 |
+
mpmath==1.3.0
|
| 44 |
+
narwhals==2.12.0
|
| 45 |
+
networkx==3.6
|
| 46 |
+
numpy==2.3.5
|
| 47 |
+
orjson==3.11.4
|
| 48 |
+
packaging==24.2
|
| 49 |
+
pandas==2.3.3
|
| 50 |
+
pillow==12.0.0
|
| 51 |
+
pinecone==8.0.0
|
| 52 |
+
pinecone-client==6.0.0
|
| 53 |
+
pinecone-plugin-assistant==3.0.1
|
| 54 |
+
pinecone-plugin-interface==0.0.7
|
| 55 |
+
proto-plus==1.26.1
|
| 56 |
+
protobuf==5.29.5
|
| 57 |
+
pyarrow==21.0.0
|
| 58 |
+
pyasn1==0.6.1
|
| 59 |
+
pyasn1_modules==0.4.2
|
| 60 |
+
pydantic==2.12.4
|
| 61 |
+
pydantic_core==2.41.5
|
| 62 |
+
pydeck==0.9.1
|
| 63 |
+
pyparsing==3.2.5
|
| 64 |
+
python-dateutil==2.9.0.post0
|
| 65 |
+
python-dotenv==1.2.1
|
| 66 |
+
pytz==2025.2
|
| 67 |
+
PyYAML==6.0.3
|
| 68 |
+
referencing==0.37.0
|
| 69 |
+
regex==2025.11.3
|
| 70 |
+
requests==2.32.5
|
| 71 |
+
rpds-py==0.29.0
|
| 72 |
+
rsa==4.9.1
|
| 73 |
+
safetensors==0.7.0
|
| 74 |
+
scikit-learn==1.7.2
|
| 75 |
+
scipy==1.16.3
|
| 76 |
+
sentence-transformers==5.1.2
|
| 77 |
+
setuptools==80.9.0
|
| 78 |
+
six==1.17.0
|
| 79 |
+
smmap==5.0.2
|
| 80 |
+
sniffio==1.3.1
|
| 81 |
+
soupsieve==2.8
|
| 82 |
+
starlette==0.50.0
|
| 83 |
+
streamlit==1.51.0
|
| 84 |
+
sympy==1.14.0
|
| 85 |
+
tenacity==9.1.2
|
| 86 |
+
threadpoolctl==3.6.0
|
| 87 |
+
tld==0.13.1
|
| 88 |
+
tokenizers==0.22.1
|
| 89 |
+
toml==0.10.2
|
| 90 |
+
torch==2.9.1
|
| 91 |
+
tornado==6.5.2
|
| 92 |
+
tqdm==4.67.1
|
| 93 |
+
trafilatura==2.0.0
|
| 94 |
+
transformers==4.57.2
|
| 95 |
+
typing-inspection==0.4.2
|
| 96 |
+
typing_extensions==4.15.0
|
| 97 |
+
tzdata==2025.2
|
| 98 |
+
tzlocal==5.3.1
|
| 99 |
+
uritemplate==4.2.0
|
| 100 |
+
urllib3==2.5.0
|
| 101 |
+
uvicorn==0.38.0
|