Team Ai
Apppublic

Shashiguduri/github-code-explainer

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
main.py345 linesDownload Raw Back to root
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