hackrx-llm / final_app.py
akashrajeev's picture
Rename final_app 9-.py to final_app.py
5085e0b verified
Raw History Blame Contribute Delete
13 kB
import asyncio
import hashlib
import logging
import re
import time
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from typing import List, Tuple, Optional
import tempfile
import os
import platform
import faiss
import fitz
import google.generativeai as genai
import nltk
import numpy as np
import requests
from fastapi import FastAPI, HTTPException, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# ------------------- Cross-Platform NLTK Setup -------------------
def setup_nltk():
"""Setup NLTK data directory based on platform and environment."""
if os.getenv('NLTK_DATA'):
nltk_data_dir = os.getenv('NLTK_DATA')
else:
system = platform.system().lower()
if system == "windows":
base_dir = os.getenv('LOCALAPPDATA', tempfile.gettempdir())
nltk_data_dir = os.path.join(base_dir, "nltk_data")
elif system == "darwin": # macOS
home = os.path.expanduser("~")
base_dir = os.path.join(home, "Library", "Caches")
if os.path.exists(base_dir):
nltk_data_dir = os.path.join(base_dir, "nltk_data")
else:
nltk_data_dir = os.path.join(tempfile.gettempdir(), "nltk_data")
else: # Linux and others
nltk_data_dir = os.path.join(tempfile.gettempdir(), "nltk_data")
os.makedirs(nltk_data_dir, exist_ok=True)
try:
nltk.data.find("tokenizers/punkt")
except LookupError:
try:
nltk.download("punkt", download_dir=nltk_data_dir, quiet=True)
except Exception as e:
logging.warning(f"Failed to download punkt to {nltk_data_dir}: {e}")
try:
nltk.download("punkt", quiet=True)
except Exception:
logging.error("Failed to download punkt data. Tokenization may fail.")
if nltk_data_dir not in nltk.data.path:
nltk.data.path.append(nltk_data_dir)
return nltk_data_dir
# Setup NLTK
nltk_data_dir = setup_nltk()
try:
from nltk.tokenize import sent_tokenize
except ImportError:
logging.error("NLTK tokenizer not available. Install NLTK and download punkt data.")
def sent_tokenize(text):
# fallback if nltk tokenizer unavailable
import re
sentences = re.split(r'[.!?]+', text)
return [s.strip() for s in sentences if s.strip()]
# -------------- SPEED-OPTIMIZED Configuration --------------
GEMINI_API_KEY = "AIzaSyCYVp6yfDYGfUTGdbeuibEp5X5-WYfRDmg"
SECRET_TOKEN = "bc7aba495dfdaa88e718dec0d0fba29a2d45eaecac3268453778d027c7419081"
MAX_WORKERS = min(64, (os.cpu_count() or 4) * 4) # Max threads for I/O
EMBED_BATCH = 400 # Batch size for embedding calls
CHUNK_WORDS = 600 # Chunk size in words
OVERLAP_WORDS = 30 # Small overlap to catch boundary context, test effect (was 0)
RETRIEVE_K = 4 # Increased from 3 to 4 for more context chunks
SIMILARITY_THRESHOLD = 0.2 # Increased threshold (from 0.15) to reduce noise
MAX_OUTPUT_TOKENS = 120 # Increased from 90 to 120 for richer answers
CONCURRENCY_LIMIT = min(12, MAX_WORKERS // 2) # Tune per resource availability
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel("gemini-1.5-flash")
executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("hackrx")
logger.info(f"Platform: {platform.system()} {platform.release()}")
logger.info(f"NLTK data directory: {nltk_data_dir}")
logger.info(f"Max workers: {MAX_WORKERS}, Concurrency limit: {CONCURRENCY_LIMIT}")
# ---------- SIMPLE FAST CACHE ----------
class FastCache:
def __init__(self, max_size=1000):
self.store = {}
self.max_size = max_size
def get(self, key: str):
return self.store.get(key)
def set(self, key: str, value):
if len(self.store) >= self.max_size:
# Remove oldest (simple FIFO)
oldest_key = next(iter(self.store))
self.store.pop(oldest_key)
self.store[key] = value
def clear(self):
self.store.clear()
# Global caches
doc_cache = FastCache(50)
emb_cache = FastCache(3000)
ans_cache = FastCache(1000)
# ---------- FastAPI Setup ----------
app = FastAPI(title="HackRx LLM API Final", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
class Req(BaseModel):
documents: str
questions: List[str]
class Resp(BaseModel):
answers: List[str]
async def verify_token(authorization: str = Header()):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "Invalid authorization header")
token = authorization.split()[1]
if token != SECRET_TOKEN:
raise HTTPException(401, "Invalid token")
# ---------- ULTRA-FAST PDF Extraction ----------
async def extract_text(url: str) -> str:
key = hashlib.md5(url.encode()).hexdigest()
cached = doc_cache.get(f"txt_{key}")
if cached:
return cached
def _extract():
try:
r = requests.get(url, timeout=10)
r.raise_for_status()
with fitz.open(stream=r.content, filetype="pdf") as doc:
text = " ".join(page.get_text() for page in doc if page.get_text().strip())
return re.sub(r'\s+', ' ', text).strip()
except Exception as e:
logger.error(f"PDF extraction failed: {e}")
raise HTTPException(500, f"Failed to extract PDF: {str(e)}")
text = await asyncio.get_event_loop().run_in_executor(executor, _extract)
if not text:
raise HTTPException(400, "Extracted PDF text is empty")
doc_cache.set(f"txt_{key}", text)
return text
# ---------- FAST Chunking (with small overlap) ----------
def chunk_text(text: str) -> List[str]:
words = text.split()
chunks = []
step = CHUNK_WORDS - OVERLAP_WORDS
if step <= 0:
step = CHUNK_WORDS # fallback to no overlap if misconfigured
for i in range(0, len(words), step):
chunk = " ".join(words[i:i + CHUNK_WORDS])
if chunk.strip():
chunks.append(chunk)
return chunks
# ---------- ULTRA-FAST Embeddings ----------
@lru_cache(maxsize=16384) # Increased cache size from 8192
def embed_one(text: str, task: str):
key = hashlib.md5((task + text).encode()).hexdigest()[:16]
cached = emb_cache.get(key)
if cached:
return cached
try:
emb = genai.embed_content(model="models/embedding-001", content=text, task_type=task)["embedding"]
except Exception as e:
logger.error(f"Embedding error: {e}")
emb = [0.0] * 768
emb_cache.set(key, emb)
return emb
async def embed_batch(texts: List[str], task: str) -> List[List[float]]:
if not texts:
return []
sem = asyncio.Semaphore(20) # High concurrency
async def embed_single_async(text):
async with sem:
return await asyncio.get_event_loop().run_in_executor(executor, embed_one, text, task)
return await asyncio.gather(*[embed_single_async(t) for t in texts])
# ---------- FAST FAISS Index ----------
def build_index(embeddings: List[List[float]]) -> Tuple[faiss.Index, np.ndarray]:
if not embeddings:
raise ValueError("No embeddings provided")
mat = np.array(embeddings, dtype=np.float32)
faiss.normalize_L2(mat)
idx = faiss.IndexFlatIP(mat.shape[1])
idx.add(mat)
return idx, mat
# ---------- SOFT KEYWORD BOOSTING (OPTIONAL) ----------
def soft_keyword_boost(question: str, candidates: List[Tuple[float, str]]) -> List[Tuple[float, str]]:
"""
Given a list of (similarity, chunk) tuples,
boost similarity by a small factor if chunk contains question keywords.
This is soft boosting, not strict filtering.
"""
keywords = set(re.findall(r"\w+", question.lower()))
boosted = []
for sim, chunk in candidates:
chunk_words = set(re.findall(r"\w+", chunk.lower()))
common = keywords.intersection(chunk_words)
boost_factor = 1.0 + 0.05 * len(common) # 5% boost per keyword overlap, capped elsewhere
boosted.append((sim * boost_factor, chunk))
# Sort descending by boosted similarity score
boosted.sort(key=lambda x: x[0], reverse=True)
return boosted
# ---------- FAST Retrieval with soft keyword boosting ----------
async def retrieve(query: str, chunks: List[str], index: faiss.Index) -> List[str]:
if not query or not chunks:
return []
q_emb = (await embed_batch([query], "retrieval_query"))[0]
q_vec = np.array([q_emb], dtype=np.float32)
faiss.normalize_L2(q_vec)
top_k = min(RETRIEVE_K * 3, len(chunks)) # Search more initially for boosting
D, I = index.search(q_vec, top_k)
candidates = []
for score, idx in zip(D[0], I[0]):
if score > SIMILARITY_THRESHOLD:
candidates.append((score, chunks[idx]))
# Apply optional soft keyword boosting for ranking
boosted = soft_keyword_boost(query, candidates)
# Select top RETRIEVE_K chunks after boosting
selected = [chunk for _, chunk in boosted[:RETRIEVE_K]]
return selected
# ---------- FAST Answer Generation ----------
async def generate(question: str, chunks: List[str]) -> str:
if not chunks:
return "Information not found in the policy."
cache_key = hashlib.md5((question + "".join(chunks)).encode()).hexdigest()[:16]
cached = ans_cache.get(cache_key)
if cached:
return cached
# Compose prompt with selected chunks
context = "\n\n".join(chunks)
prompt = f"""Context: {context}
Question: {question}
Answer briefly and precisely. If not found, say "Information not found in the policy."
Answer:"""
def _gen():
try:
resp = model.generate_content(
prompt,
generation_config=genai.types.GenerationConfig(
temperature=0.1,
max_output_tokens=MAX_OUTPUT_TOKENS,
top_p=0.7, # Lower for accuracy
top_k=20,
),
)
return re.sub(r'\s+', ' ', resp.text.strip()) or "Information not found in the policy."
except Exception as e:
logger.error(f"Generation error: {e}")
return "Information not found in the policy."
answer = await asyncio.get_event_loop().run_in_executor(executor, _gen)
ans_cache.set(cache_key, answer)
return answer
# ---------- MAIN PIPELINE ----------
async def process(doc_url: str, questions: List[str]) -> List[str]:
start = time.time()
text = await extract_text(doc_url)
chunks = chunk_text(text)
logger.info(f"Extracted and chunked in {time.time() - start:.2f}s ({len(chunks)} chunks)")
idx_key = hashlib.md5("".join(chunks).encode()).hexdigest()[:16]
cached_idx = doc_cache.get(f"idx_{idx_key}")
if cached_idx:
index, mat = cached_idx
logger.info("Using cached index")
else:
emb_start = time.time()
embeddings = await embed_batch(chunks, "retrieval_document")
logger.info(f"Embeddings took {time.time() - emb_start:.2f}s")
index, mat = build_index(embeddings)
doc_cache.set(f"idx_{idx_key}", (index, mat))
sem = asyncio.Semaphore(CONCURRENCY_LIMIT)
async def handle_q(q):
async with sem:
ctx = await retrieve(q, chunks, index)
return await generate(q, ctx)
answers = await asyncio.gather(*[handle_q(q) for q in questions])
total_time = time.time() - start
logger.info(f"Total processing time: {total_time:.2f}s for {len(questions)} questions")
return answers
# ---------- Endpoints ----------
@app.post("/hackrx/run", response_model=Resp)
async def run(req: Req, token=Depends(verify_token)):
return {"answers": await process(req.documents, req.questions)}
@app.get("/health")
async def health():
return {
"status": "ok",
"platform": platform.system(),
"python_version": platform.python_version(),
"nltk_data_dir": nltk_data_dir,
"cache_stats": {
"doc_cache": len(doc_cache.store),
"emb_cache": len(emb_cache.store),
"ans_cache": len(ans_cache.store),
}
}
@app.get("/")
async def root():
return {"message": "HackRx LLM API Final is running"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, workers=1)