Shashiguduri/github-code-explainer
0
1"""2backend/main.py3---------------4FastAPI application entry point.5 6Endpoints:7 GET /health — liveness check8 POST /analyze-repo — index a GitHub repository9 POST /ask-question — answer a question about an indexed repo10"""11 12import os13import asyncio14import time15import json16import logging17from contextlib import asynccontextmanager18from dotenv import load_dotenv19 20from fastapi import FastAPI, HTTPException21from fastapi.middleware.cors import CORSMiddleware22from fastapi.responses import StreamingResponse23from pydantic import BaseModel24 25# Load .env before importing services (they read env vars at import time)26load_dotenv()27 28from services.repo_loader import load_repository29from services.chunker import chunk_files30from services.embeddings import CodeEmbedder31from services.vector_store import VectorStore32from services.qa_engine import QAEngine33from llm.groq_client import GroqLLMClient34 35# ---------------------------------------------------------------------------36# Logging37# ---------------------------------------------------------------------------38logging.basicConfig(39 level=logging.INFO,40 format="%(asctime)s %(levelname)-8s %(name)s — %(message)s",41)42logger = logging.getLogger(__name__)43 44# ---------------------------------------------------------------------------45# Shared singletons — created once, reused across requests46# ---------------------------------------------------------------------------47# The embedding model is heavy (~130 MB) — load it once at startup.48# QAEngine instances are stored per repo_url after indexing.49_embedder: CodeEmbedder | None = None50_llm: GroqLLMClient | None = None51repo_store: dict[str, QAEngine] = {} # repo_url → QAEngine52 53MAX_CHUNKS = 1500 # safety cap — prevents OOM on free-tier cloud (512 MB RAM)54 55 56@asynccontextmanager57async def lifespan(app: FastAPI):58 """Load heavy singletons when the server starts."""59 global _embedder, _llm60 logger.info("Loading embedding model …")61 _embedder = CodeEmbedder()62 logger.info("Initialising Groq client …")63 _llm = GroqLLMClient()64 logger.info("Backend ready.")65 yield66 logger.info("Shutting down.")67 68 69# ---------------------------------------------------------------------------70# App71# ---------------------------------------------------------------------------72app = FastAPI(73 title="GitHub Code Explainer API",74 description="RAG pipeline: GitHub → LangChain → FAISS → Groq",75 version="1.0.0",76 lifespan=lifespan,77)78 79# CORS — allow the React dev server and any production frontend80# FRONTEND_URL accepts a comma-separated list of origins, or "*" for any origin81_raw_origins = os.getenv("FRONTEND_URL", "*")82if _raw_origins == "*":83 _allowed_origins = ["*"]84else:85 _allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()] + [86 "http://localhost:5173",87 "http://localhost:3000",88 ]89 90app.add_middleware(91 CORSMiddleware,92 allow_origins=_allowed_origins,93 allow_credentials=_allowed_origins != ["*"], # credentials not allowed with wildcard94 allow_methods=["*"],95 allow_headers=["*"],96)97 98 99# ---------------------------------------------------------------------------100# Request / Response models (Pydantic v2)101# ---------------------------------------------------------------------------102class AnalyzeRequest(BaseModel):103 repo_url: str104 105 106class AnalyzeResponse(BaseModel):107 status: str108 repo: str109 files_indexed: int110 chunks_created: int111 message: str112 113 114class AskRequest(BaseModel):115 repo_url: str116 question: str117 118 119class AskResponse(BaseModel):120 answer: str121 sources: list[str]122 chunks_used: int123 model_used: str124 125 126class FileTreeResponse(BaseModel):127 files: list[str]128 129 130 131# ---------------------------------------------------------------------------132# Routes133# ---------------------------------------------------------------------------134 135@app.get("/health", tags=["System"])136def health():137 """Liveness check — also reports how many repos are indexed."""138 return {"status": "ok", "indexed_repos": len(repo_store)}139 140 141@app.get("/file-tree", response_model=FileTreeResponse, tags=["RAG Pipeline"])142def file_tree(repo_url: str):143 """144 Return the sorted list of all file paths indexed for a given repository.145 Used by the frontend sidebar file explorer.146 """147 engine = repo_store.get(repo_url.strip())148 if engine is None:149 raise HTTPException(150 status_code=400,151 detail=f"Repository '{repo_url}' has not been indexed yet.",152 )153 return FileTreeResponse(files=engine.all_sources)154 155 156@app.post("/analyze-repo", response_model=AnalyzeResponse, tags=["RAG Pipeline"])157async def analyze_repo(body: AnalyzeRequest):158 """159 Fetch a GitHub repository, chunk all code files, generate embeddings,160 and build a FAISS index. Stores the QAEngine in memory keyed by repo_url.161 162 Runs CPU-heavy work (embedding + FAISS) in a thread pool so the163 async event loop stays responsive — important on single-worker cloud deployments.164 """165 repo_url = body.repo_url.strip()166 loop = asyncio.get_running_loop()167 168 # --- Step 1: fetch files ---169 t0 = time.perf_counter()170 try:171 files = await loop.run_in_executor(None, load_repository, repo_url)172 except ValueError as exc:173 raise HTTPException(status_code=400, detail=str(exc))174 except Exception as exc:175 logger.exception("Unexpected error loading repo.")176 raise HTTPException(status_code=500, detail=f"Failed to load repository: {exc}")177 logger.info("[TIMER] Download: %.2fs (%d files)", time.perf_counter() - t0, len(files))178 179 # --- Step 2: chunk ---180 t1 = time.perf_counter()181 docs = await loop.run_in_executor(None, chunk_files, files)182 logger.info("[TIMER] Chunking: %.2fs (%d chunks)", time.perf_counter() - t1, len(docs))183 if not docs:184 raise HTTPException(185 status_code=400,186 detail="No indexable content found after chunking. The repository may be empty.",187 )188 189 # Cap chunks to avoid OOM on free-tier cloud (512 MB RAM)190 if len(docs) > MAX_CHUNKS:191 logger.warning(192 "Repo has %d chunks — capping to %d to avoid OOM.", len(docs), MAX_CHUNKS193 )194 docs = docs[:MAX_CHUNKS]195 196 # --- Step 3: embed (CPU-heavy — run in thread pool) ---197 t2 = time.perf_counter()198 try:199 embeddings = await loop.run_in_executor(200 None, lambda: _embedder.embed_documents(docs, show_progress=False)201 )202 except Exception as exc:203 logger.exception("Embedding failed.")204 raise HTTPException(status_code=500, detail=f"Embedding error: {exc}")205 logger.info("[TIMER] Embedding: %.2fs (%d chunks → %.0f chunks/s)",206 time.perf_counter() - t2, len(docs),207 len(docs) / max(time.perf_counter() - t2, 0.01))208 209 # --- Step 4: build FAISS index ---210 t3 = time.perf_counter()211 store = VectorStore()212 store.build(docs, embeddings)213 logger.info("[TIMER] FAISS idx: %.2fs", time.perf_counter() - t3)214 215 # --- Step 5: store QAEngine ---216 engine = QAEngine(217 vector_store=store,218 embedder=_embedder,219 llm_client=_llm,220 )221 repo_store[repo_url] = engine222 223 # Parse owner/repo for display224 from utils.helpers import parse_github_url225 try:226 owner, repo = parse_github_url(repo_url)227 repo_label = f"{owner}/{repo}"228 except ValueError:229 repo_label = repo_url230 231 logger.info(232 "Indexed %s: %d files, %d chunks.", repo_label, len(files), len(docs)233 )234 235 return AnalyzeResponse(236 status="success",237 repo=repo_label,238 files_indexed=len(files),239 chunks_created=len(docs),240 message="Repository indexed successfully. You can now ask questions.",241 )242 243 244@app.post("/ask-question", response_model=AskResponse, tags=["RAG Pipeline"])245def ask_question(body: AskRequest):246 """247 Answer a natural-language question about a previously indexed repository.248 The repo must have been indexed via POST /analyze-repo first.249 """250 repo_url = body.repo_url.strip()251 question = body.question.strip()252 253 if not question:254 raise HTTPException(status_code=400, detail="Question cannot be empty.")255 256 engine = repo_store.get(repo_url)257 if engine is None:258 raise HTTPException(259 status_code=400,260 detail=(261 f"Repository '{repo_url}' has not been indexed yet. "262 "Please call POST /analyze-repo first."263 ),264 )265 266 try:267 result = engine.answer(question)268 except RuntimeError as exc:269 raise HTTPException(status_code=503, detail=f"LLM service error: {exc}")270 except Exception as exc:271 logger.exception("Unexpected error in Q&A.")272 raise HTTPException(status_code=500, detail=f"Internal error: {exc}")273 274 return AskResponse(275 answer=result.answer,276 sources=result.sources,277 chunks_used=result.chunks_used,278 model_used=result.model_used,279 )280 281 282@app.post("/ask-question-stream", tags=["RAG Pipeline"])283async def ask_question_stream(body: AskRequest):284 """285 Stream the LLM answer token-by-token via Server-Sent Events (SSE).286 287 Uses an async generator + asyncio.Queue so each token is flushed to the288 client immediately — the sync Groq generator runs in a thread pool and289 pushes packets to the queue via call_soon_threadsafe.290 291 Events emitted:292 data: {"token": "..."} — one text chunk293 data: {"done": true, "sources": [...]} — final metadata294 data: [DONE] — stream closed295 """296 repo_url = body.repo_url.strip()297 question = body.question.strip()298 299 if not question:300 raise HTTPException(status_code=400, detail="Question cannot be empty.")301 302 engine = repo_store.get(repo_url)303 if engine is None:304 raise HTTPException(305 status_code=400,306 detail=f"Repository '{repo_url}' has not been indexed yet.",307 )308 309 async def event_stream():310 loop = asyncio.get_running_loop()311 queue: asyncio.Queue = asyncio.Queue()312 _DONE = object() # sentinel313 314 def _produce():315 """Runs in a thread — pushes each packet to the async queue."""316 try:317 for packet in engine.stream_answer(question):318 loop.call_soon_threadsafe(queue.put_nowait, packet)319 except Exception as exc:320 loop.call_soon_threadsafe(321 queue.put_nowait, {"error": str(exc)}322 )323 finally:324 loop.call_soon_threadsafe(queue.put_nowait, _DONE)325 326 # Kick off the sync generator in a thread pool327 loop.run_in_executor(None, _produce)328 329 while True:330 item = await queue.get()331 if item is _DONE:332 yield "data: [DONE]\n\n"333 break334 yield f"data: {json.dumps(item)}\n\n"335 336 return StreamingResponse(337 event_stream(),338 media_type="text/event-stream",339 headers={340 "Cache-Control": "no-cache",341 "X-Accel-Buffering": "no",342 "Connection": "keep-alive",343 },344 )345 