Spaces:
Sleeping
Sleeping
Commit ·
a83d8b2
0
Parent(s):
initial deploy
Browse files- .gitignore +0 -0
- Dockerfile +12 -0
- backend/__init__.py +0 -0
- backend/__pycache__/__init__.cpython-314.pyc +0 -0
- backend/__pycache__/ingestion.cpython-314.pyc +0 -0
- backend/__pycache__/main.cpython-314.pyc +0 -0
- backend/__pycache__/parser.cpython-314.pyc +0 -0
- backend/__pycache__/rag.cpython-314.pyc +0 -0
- backend/ingestion.py +21 -0
- backend/main.py +146 -0
- backend/parser.py +54 -0
- backend/rag.py +205 -0
- requirements.txt +12 -0
.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
|