csabhay commited on
Commit
a83d8b2
·
0 Parent(s):

initial deploy

Browse files
.gitignore ADDED
Binary file (14 Bytes). View file
 
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY backend/ backend/
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
backend/__init__.py ADDED
File without changes
backend/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (204 Bytes). View file
 
backend/__pycache__/ingestion.cpython-314.pyc ADDED
Binary file (1.81 kB). View file
 
backend/__pycache__/main.cpython-314.pyc ADDED
Binary file (9.12 kB). View file
 
backend/__pycache__/parser.cpython-314.pyc ADDED
Binary file (4.32 kB). View file
 
backend/__pycache__/rag.cpython-314.pyc ADDED
Binary file (15.4 kB). View file
 
backend/ingestion.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+ from fastapi import UploadFile
5
+
6
+
7
+ @dataclass
8
+ class FileMetadata:
9
+ filename: str
10
+ file_type: str
11
+ size_bytes: int
12
+
13
+
14
+ async def read_upload(file: UploadFile) -> tuple[bytes, FileMetadata]:
15
+ content = await file.read()
16
+ ext = os.path.splitext(file.filename or "")[-1].lower()
17
+ return content, FileMetadata(
18
+ filename=file.filename or "unknown",
19
+ file_type=ext or "unknown",
20
+ size_bytes=len(content),
21
+ )
backend/main.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from dotenv import load_dotenv
3
+ load_dotenv(Path(__file__).resolve().parent.parent / ".env")
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel
11
+
12
+ from .ingestion import read_upload
13
+ from .parser import parse_file
14
+ from .rag import RAGEngine
15
+
16
+ app = FastAPI(title="Document Ingestion API", version="1.0.0")
17
+
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"],
21
+ allow_methods=["*"],
22
+ allow_headers=["*"],
23
+ )
24
+
25
+ rag_engine = RAGEngine(embed_provider=os.environ.get("EMBED_PROVIDER", "gemini"))
26
+
27
+
28
+ class ChatMessage(BaseModel):
29
+ role: str
30
+ content: str
31
+
32
+
33
+ class QueryRequest(BaseModel):
34
+ query: str
35
+ history: list[ChatMessage] = []
36
+ top_k: int = 3
37
+
38
+
39
+ class IngestTextRequest(BaseModel):
40
+ text: str
41
+ chunk_size: int = 180
42
+ chunk_overlap: int = 40
43
+
44
+
45
+ def _compute_recommendations(text: str, num_chunks: int) -> dict:
46
+ sentences = [s.strip() for s in re.split(r"[.!?\n]+", text) if s.strip()]
47
+ avg_len = sum(len(s) for s in sentences) / max(len(sentences), 1)
48
+ text_len = len(text)
49
+
50
+ if text_len > 5_000_000:
51
+ rec_cs = max(500, min(1000, int(avg_len * 8 / 10) * 10))
52
+ elif text_len > 1_000_000:
53
+ rec_cs = max(300, min(800, int(avg_len * 6 / 10) * 10))
54
+ else:
55
+ rec_cs = max(50, min(1000, int(avg_len * 4 / 10) * 10))
56
+
57
+ rec_co = max(0, min(200, int(rec_cs * 0.2 / 5) * 5))
58
+
59
+ if num_chunks <= 20:
60
+ rec_tk = 3
61
+ elif num_chunks <= 100:
62
+ rec_tk = 5
63
+ elif num_chunks <= 500:
64
+ rec_tk = 7
65
+ else:
66
+ rec_tk = 10
67
+
68
+ return {"chunk_size": rec_cs, "chunk_overlap": rec_co, "top_k": rec_tk}
69
+
70
+
71
+ @app.post("/upload")
72
+ async def upload_file(
73
+ file: UploadFile = File(...),
74
+ chunk_size: int = Form(180),
75
+ chunk_overlap: int = Form(40),
76
+ ):
77
+ content, metadata = await read_upload(file)
78
+
79
+ try:
80
+ text = parse_file(metadata.filename, content)
81
+ except ValueError as e:
82
+ raise HTTPException(status_code=400, detail=str(e))
83
+ except Exception:
84
+ raise HTTPException(status_code=500, detail="Failed to process the file.")
85
+
86
+ rag_engine.chunk_size = chunk_size
87
+ rag_engine.chunk_overlap = chunk_overlap
88
+ num_chunks = rag_engine.start_ingest(text)
89
+
90
+ # Run CPU-bound embedding in a background thread
91
+ loop = asyncio.get_running_loop()
92
+ loop.run_in_executor(None, rag_engine._do_embed)
93
+
94
+ recs = _compute_recommendations(text, num_chunks)
95
+
96
+ return {
97
+ "text": text,
98
+ "metadata": {
99
+ "filename": metadata.filename,
100
+ "file_type": metadata.file_type,
101
+ "size_bytes": metadata.size_bytes,
102
+ "chunk_size": chunk_size,
103
+ "chunk_overlap": chunk_overlap,
104
+ },
105
+ "num_chunks": num_chunks,
106
+ "recommended_chunk_size": recs["chunk_size"],
107
+ "recommended_chunk_overlap": recs["chunk_overlap"],
108
+ "recommended_top_k": recs["top_k"],
109
+ }
110
+
111
+
112
+ @app.post("/ingest_text")
113
+ async def ingest_text(body: IngestTextRequest):
114
+ if not body.text.strip():
115
+ raise HTTPException(status_code=400, detail="Text is empty.")
116
+ rag_engine.chunk_size = body.chunk_size
117
+ rag_engine.chunk_overlap = body.chunk_overlap
118
+ num_chunks = rag_engine.start_ingest(body.text)
119
+
120
+ loop = asyncio.get_running_loop()
121
+ loop.run_in_executor(None, rag_engine._do_embed)
122
+
123
+ return {"num_chunks": num_chunks}
124
+
125
+
126
+ @app.get("/ingest_status")
127
+ async def ingest_status():
128
+ return rag_engine.ingest_progress
129
+
130
+
131
+ @app.get("/config")
132
+ async def config():
133
+ return {"embed_provider": rag_engine.embed_provider}
134
+
135
+
136
+ @app.post("/query")
137
+ async def query_document(body: QueryRequest):
138
+ if not rag_engine.chunks:
139
+ raise HTTPException(status_code=400, detail="No document has been ingested yet. Upload a file first.")
140
+ if not rag_engine.is_ready:
141
+ raise HTTPException(status_code=409, detail="Document is still being embedded. Please wait.")
142
+ if not body.query.strip():
143
+ raise HTTPException(status_code=400, detail="Query cannot be empty.")
144
+ history = [{"role": m.role, "content": m.content} for m in body.history]
145
+ rag_engine.top_k = body.top_k
146
+ return rag_engine.answer(body.query, history=history)
backend/parser.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import io
3
+ import os
4
+
5
+ import fitz
6
+
7
+ SUPPORTED_TYPES = {".txt", ".pdf", ".csv"}
8
+
9
+ _EXTRACTORS: dict[str, callable] = {}
10
+
11
+
12
+ def _decode(content: bytes) -> str:
13
+ try:
14
+ return content.decode("utf-8")
15
+ except UnicodeDecodeError:
16
+ return content.decode("latin-1")
17
+
18
+
19
+ def _extract_txt(content: bytes) -> str:
20
+ return _decode(content)
21
+
22
+
23
+ def _extract_pdf(content: bytes) -> str:
24
+ with fitz.open(stream=content, filetype="pdf") as doc:
25
+ return "\n".join(page.get_text() for page in doc)
26
+
27
+
28
+ def _extract_csv(content: bytes) -> str:
29
+ rows = list(csv.reader(io.StringIO(_decode(content))))
30
+ if not rows:
31
+ return ""
32
+ return "\n".join(" | ".join(cell.strip() for cell in row) for row in rows)
33
+
34
+
35
+ _EXTRACTORS = {
36
+ ".txt": _extract_txt,
37
+ ".pdf": _extract_pdf,
38
+ ".csv": _extract_csv,
39
+ }
40
+
41
+
42
+ def parse_file(filename: str, content: bytes) -> str:
43
+ if not content:
44
+ raise ValueError("Uploaded file is empty.")
45
+
46
+ ext = os.path.splitext(filename)[-1].lower()
47
+ extractor = _EXTRACTORS.get(ext)
48
+ if extractor is None:
49
+ raise ValueError(f"Unsupported file type '{ext}'. Supported: {', '.join(sorted(SUPPORTED_TYPES))}")
50
+
51
+ text = extractor(content)
52
+ if not text.strip():
53
+ raise ValueError("No readable text found in the file.")
54
+ return text
backend/rag.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import threading
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+ import google.generativeai as genai
8
+ import numpy as np
9
+ import requests as _requests
10
+ from huggingface_hub import InferenceClient
11
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
12
+ from sklearn.metrics.pairwise import cosine_similarity
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ FALLBACK_MODELS = [
17
+ "mistralai/Mistral-7B-Instruct-v0.3",
18
+ "Qwen/Qwen2.5-7B-Instruct",
19
+ "meta-llama/Llama-3.1-8B-Instruct",
20
+ ]
21
+
22
+ SYSTEM_PROMPT = (
23
+ "You are a helpful RAG assistant. Use only the provided context when possible. "
24
+ "If context is insufficient, say what is missing. Keep answers concise."
25
+ )
26
+
27
+ EMBED_PROVIDERS = {
28
+ "gemini": {"model": "models/text-embedding-004", "batch_size": 100},
29
+ "hf": {"model": "sentence-transformers/all-MiniLM-L6-v2", "batch_size": 32},
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class RetrievedChunk:
35
+ index: int
36
+ score: float
37
+ text: str
38
+
39
+
40
+ class RAGEngine:
41
+ def __init__(
42
+ self,
43
+ embed_provider: str = "gemini",
44
+ chunk_size: int = 180,
45
+ chunk_overlap: int = 40,
46
+ top_k: int = 3,
47
+ llm_model: str = "mistralai/Mistral-7B-Instruct-v0.3",
48
+ ):
49
+ self.chunk_size = chunk_size
50
+ self.chunk_overlap = chunk_overlap
51
+ self.top_k = top_k
52
+ self.llm_model = llm_model
53
+ self.embed_provider = embed_provider
54
+ self.chunks: list[str] = []
55
+ self.chunk_embeddings: np.ndarray | None = None
56
+ self._ingest_progress: dict = {"state": "idle", "embedded": 0, "total": 0}
57
+ self._embed_lock = threading.Lock()
58
+ self._ingest_generation = 0
59
+ self._configure_gemini()
60
+
61
+ def _configure_gemini(self):
62
+ api_key = os.environ.get("GOOGLE_API_KEY")
63
+ if api_key:
64
+ genai.configure(api_key=api_key)
65
+
66
+ def _embed_texts(self, texts: list[str], task_type: str = "RETRIEVAL_DOCUMENT") -> np.ndarray:
67
+ """Embed a batch of texts using the configured provider."""
68
+ if self.embed_provider == "gemini":
69
+ return self._embed_gemini(texts, task_type)
70
+ return self._embed_hf(texts)
71
+
72
+ def _embed_gemini(self, texts: list[str], task_type: str) -> np.ndarray:
73
+ result = genai.embed_content(
74
+ model=EMBED_PROVIDERS["gemini"]["model"],
75
+ content=texts,
76
+ task_type=task_type,
77
+ )
78
+ return np.array(result["embedding"])
79
+
80
+ def _embed_hf(self, texts: list[str]) -> np.ndarray:
81
+ hf_token = os.environ.get("HF_TOKEN", "")
82
+ model = EMBED_PROVIDERS["hf"]["model"]
83
+ resp = _requests.post(
84
+ f"https://api-inference.huggingface.co/pipeline/feature-extraction/{model}",
85
+ headers={"Authorization": f"Bearer {hf_token}"},
86
+ json={"inputs": texts, "options": {"wait_for_model": True}},
87
+ timeout=120,
88
+ )
89
+ resp.raise_for_status()
90
+ return np.array(resp.json())
91
+
92
+ @property
93
+ def ingest_progress(self) -> dict:
94
+ return dict(self._ingest_progress)
95
+
96
+ @property
97
+ def is_ready(self) -> bool:
98
+ return (
99
+ bool(self.chunks)
100
+ and self.chunk_embeddings is not None
101
+ and self._ingest_progress.get("state") != "running"
102
+ )
103
+
104
+ def ingest(self, text: str) -> int:
105
+ """Synchronous ingest: chunk + embed. Returns chunk count."""
106
+ self._do_split(text)
107
+ self._do_embed()
108
+ return len(self.chunks)
109
+
110
+ def start_ingest(self, text: str) -> int:
111
+ """Fast phase: split text into chunks. Returns chunk count.
112
+ Call _do_embed() in a thread pool to complete ingestion."""
113
+ self._do_split(text)
114
+ return len(self.chunks)
115
+
116
+ def _do_split(self, text: str):
117
+ splitter = RecursiveCharacterTextSplitter(
118
+ chunk_size=self.chunk_size,
119
+ chunk_overlap=self.chunk_overlap,
120
+ )
121
+ self.chunks = splitter.split_text(text)
122
+ self.chunk_embeddings = None
123
+ self._ingest_generation += 1
124
+ total = len(self.chunks)
125
+ self._ingest_progress = {"state": "running", "embedded": 0, "total": total}
126
+ log.info("Chunked into %d pieces (size=%d). Embedding…", total, self.chunk_size)
127
+
128
+ def _do_embed(self):
129
+ gen = self._ingest_generation
130
+ batch_size = EMBED_PROVIDERS.get(self.embed_provider, {}).get("batch_size", 32)
131
+ try:
132
+ with self._embed_lock:
133
+ if gen != self._ingest_generation:
134
+ return
135
+ total = len(self.chunks)
136
+ if total == 0:
137
+ self._ingest_progress = {"state": "done", "embedded": 0, "total": 0}
138
+ return
139
+
140
+ embeddings, t0 = [], time.time()
141
+ for i in range(0, total, batch_size):
142
+ if gen != self._ingest_generation:
143
+ return
144
+ batch = self.chunks[i : i + batch_size]
145
+ embeddings.append(self._embed_texts(batch, task_type="RETRIEVAL_DOCUMENT"))
146
+ done = min(i + batch_size, total)
147
+ self._ingest_progress["embedded"] = done
148
+ elapsed = time.time() - t0
149
+ rate = done / elapsed if elapsed > 0 else 0
150
+ remaining = (total - done) / rate if rate > 0 else 0
151
+ log.info("Embedded %d/%d (%.0f/s, ~%.0fs left)", done, total, rate, remaining)
152
+
153
+ self.chunk_embeddings = np.vstack(embeddings)
154
+ self._ingest_progress["state"] = "done"
155
+ log.info("Embedding done in %.1fs", time.time() - t0)
156
+ except Exception as e:
157
+ log.exception("Embedding failed")
158
+ self._ingest_progress = {"state": "error", "embedded": 0, "total": 0, "error": str(e)}
159
+
160
+ def retrieve(self, query: str) -> list[RetrievedChunk]:
161
+ if not self.chunks or self.chunk_embeddings is None:
162
+ return []
163
+ query_vec = self._embed_texts([query], task_type="RETRIEVAL_QUERY")
164
+ scores = cosine_similarity(query_vec, self.chunk_embeddings)[0]
165
+ top_idx = scores.argsort()[::-1][: self.top_k]
166
+ return [
167
+ RetrievedChunk(index=int(i), score=float(scores[i]), text=self.chunks[i])
168
+ for i in top_idx
169
+ ]
170
+
171
+ def answer(self, query: str, history: list[dict] | None = None) -> dict:
172
+ retrieved = self.retrieve(query)
173
+ chunks_payload = [{"index": c.index, "score": round(c.score, 4), "text": c.text} for c in retrieved]
174
+
175
+ context_block = "\n\n".join(
176
+ f"Chunk {c.index} (score={c.score:.4f}):\n{c.text}" for c in retrieved
177
+ )
178
+
179
+ hf_token = os.environ.get("HF_TOKEN")
180
+ if not hf_token:
181
+ return {"answer": "HF_TOKEN not set.", "chunks": chunks_payload, "model_used": None}
182
+
183
+ messages = self._build_messages(query, context_block, history)
184
+ return self._call_llm(hf_token, messages, chunks_payload)
185
+
186
+ def _build_messages(self, query: str, context: str, history: list[dict] | None) -> list[dict]:
187
+ messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
188
+ if history:
189
+ messages.extend({"role": m["role"], "content": m["content"]} for m in history)
190
+ messages.append({"role": "user", "content": f"Question:\n{query}\n\nContext:\n{context}"})
191
+ return messages
192
+
193
+ def _call_llm(self, token: str, messages: list[dict], chunks_payload: list[dict]) -> dict:
194
+ client = InferenceClient(api_key=token)
195
+ # Deduplicated ordered candidate list
196
+ candidates = list(dict.fromkeys([self.llm_model] + FALLBACK_MODELS))
197
+
198
+ for model in candidates:
199
+ try:
200
+ resp = client.chat_completion(model=model, messages=messages, max_tokens=350, temperature=0.2)
201
+ return {"answer": resp.choices[0].message.content, "chunks": chunks_payload, "model_used": model}
202
+ except Exception:
203
+ continue
204
+
205
+ return {"answer": "All candidate models failed.", "chunks": chunks_payload, "model_used": None}
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ python-multipart
4
+ python-dotenv
5
+ PyMuPDF
6
+ streamlit
7
+ requests
8
+ langchain-text-splitters
9
+ google-generativeai
10
+ scikit-learn
11
+ huggingface-hub
12
+ numpy