mothy-08 commited on
Commit
aaa4ec9
·
1 Parent(s): 89ec44c

Initial commit

Browse files
Files changed (8) hide show
  1. README.md +39 -0
  2. api/config.py +43 -0
  3. api/crawler.py +90 -0
  4. api/schemas.py +16 -0
  5. api/server.py +132 -0
  6. api/utils.py +60 -0
  7. api/vectorstore.py +70 -0
  8. 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