CoolFace
Apppublic

hemant2747/multi-agent-framework

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
main.py265 linesDownload Raw Back to server
1"""FastAPI backend for the dashboard.2 3Endpoints:4  GET  /api/health            -> liveness + current session log file5  GET  /api/categories        -> log categories (for filter buttons)6  POST /api/analyze           -> fetch URL -> MCP ingest+analyze -> answer7  POST /api/clientlog         -> let the Next.js UI push NEXTJS-tagged logs8  GET  /api/logs/stream       -> Server-Sent Events stream of all logs9 10Run:  uvicorn server.main:app --reload --port 8000   (from the repo root)11"""12from __future__ import annotations13 14import asyncio15import json16from contextlib import asynccontextmanager17from pathlib import Path18 19from fastapi import FastAPI20from fastapi.middleware.cors import CORSMiddleware21from fastapi.responses import StreamingResponse22from pydantic import BaseModel23 24from server import fetcher, mcp_bridge25from server.diffutil import diff_stats, line_diff26from server.editstore import store as edit_store27from server.logging_hub import hub28from src.logging_setup import CATEGORIES, get_logger29 30ROOT = Path(__file__).resolve().parent.parent31 32 33@asynccontextmanager34async def lifespan(app: FastAPI):35    # New session log file on every startup, then start tailing it.36    logfile = hub.start_session()37    await hub.start_tail()38    import config39    log = get_logger("FASTAPI")40    log.info("FastAPI backend started")41    # Surface the effective config so a stray override (e.g. OLLAMA_HOST) is obvious.42    from src.llm import client as llm43    log.info(f"config: LLM={llm.describe()} | EMBED_BACKEND={config.EMBED_BACKEND} "44             f"EMBED_MODEL={config.EMBED_MODEL} LLM_TIMEOUT={config.LLM_TIMEOUT}s")45 46    # Warm up the local chat model in the background (cloud needs no warm-up).47    if config.ENABLE_LOCAL_LLM:48        async def _warm():49            await asyncio.to_thread(llm.warmup)50        asyncio.create_task(_warm())51 52    try:53        yield54    finally:55        get_logger("FASTAPI").info("FastAPI backend shutting down")56        await hub.stop()57 58 59import config as _cfg60 61app = FastAPI(title="Multi-Agent Codebase Analyzer API", lifespan=lifespan)62# Allowed frontend origins — configurable for cloud deploy via CORS_ORIGINS.63_origins = [o.strip() for o in _cfg.CORS_ORIGINS.split(",") if o.strip()]64app.add_middleware(65    CORSMiddleware,66    allow_origins=_origins,67    allow_methods=["*"],68    allow_headers=["*"],69)70 71_log = get_logger("FASTAPI")72 73try:74    _EXC_GROUP: tuple = (BaseExceptionGroup,)  # Python 3.11+75except NameError:  # pragma: no cover76    _EXC_GROUP = ()77 78 79def _clean_error(exc: BaseException) -> str:80    """Flatten anyio/Python ExceptionGroups to their underlying message(s) so the81    UI shows the real cause, not 'unhandled errors in a TaskGroup'."""82    msgs: list[str] = []83 84    def walk(e: BaseException) -> None:85        if _EXC_GROUP and isinstance(e, _EXC_GROUP):86            for sub in e.exceptions:  # type: ignore[attr-defined]87                walk(sub)88        else:89            m = str(e) or e.__class__.__name__90            if m not in msgs:91                msgs.append(m)92 93    walk(exc)94    return "; ".join(msgs) if msgs else str(exc)95 96 97class RunRequest(BaseModel):98    source_url: str99    query: str100    mode: str = "ask"  # ask | plan | agent101    version: str | None = None102    github_token: str | None = None  # for cloning private repos (UI sign-in)103 104 105class ClientLog(BaseModel):106    message: str107    category: str = "NEXTJS"108 109 110class EditAction(BaseModel):111    edit_id: str112 113 114@app.get("/api/health")115async def health():116    return {117        "status": "ok",118        "session_log": hub.logfile.name if hub.logfile else None,119    }120 121 122@app.get("/api/categories")123async def categories():124    return {"categories": CATEGORIES}125 126 127@app.post("/api/run")128async def run(req: RunRequest):129    source = req.source_url.strip()130    query = req.query.strip()131    mode = (req.mode or "ask").lower()132    if not query:133        return {"ok": False, "error": "Please enter a question."}134    if mode not in {"ask", "plan", "agent"}:135        return {"ok": False, "error": f"unknown mode '{mode}'"}136 137    # No source → direct chat (general Q&A, no codebase needed).138    if not source:139        _log.info(f"/api/run direct-chat query={query[:60]!r}")140        try:141            from src.agents.direct import direct_chat142            answer = await asyncio.to_thread(direct_chat, query)143            return {"ok": True, "mode": "ask", "source_kind": "chat",144                    "answer": answer, "findings": {}}145        except Exception as e:146            return {"ok": False, "error": _clean_error(e)}147 148    _log.info(f"/api/run mode={mode} source={source!r} query={query[:60]!r}")149    try:150        fetched = await asyncio.to_thread(151            fetcher.resolve_and_fetch, source, req.github_token)152        path, kind = fetched["path"], fetched["kind"]153 154        if mode == "ask":155            result = await mcp_bridge.run_pipeline(path, query, version=req.version)156            analysis = result.get("analysis") or {}157            return {"ok": True, "mode": mode, "source_kind": kind,158                    "version": result.get("version"),159                    "answer": analysis.get("answer", ""),160                    "findings": analysis.get("findings", {}),161                    "agenda_ran": analysis.get("agenda_ran", [])}162 163        if mode == "plan":164            result = await mcp_bridge.run_plan(path, query, version=req.version)165            plan = result.get("plan") or {}166            return {"ok": True, "mode": mode, "source_kind": kind,167                    "version": result.get("version"),168                    "summary": plan.get("summary", ""),169                    "steps": plan.get("steps", [])}170 171        # agent mode172        result = await mcp_bridge.run_agent(path, query, version=req.version)173        proposal = result.get("proposal") or {}174        raw_edits = proposal.get("edits", [])175        registered = edit_store.put_many(raw_edits)176        edits = []177        for e in registered:178            rows = line_diff(e["original"], e["modified"])179            edits.append({180                "id": e["id"], "path": e["rel"], "language": e.get("language", ""),181                "rationale": e.get("rationale", ""),182                "diff": rows, "stats": diff_stats(rows),183            })184        return {"ok": True, "mode": mode, "source_kind": kind,185                "version": result.get("version"),186                "summary": proposal.get("summary", ""), "edits": edits}187    except fetcher.AuthRequiredError as e:188        _log.info(f"/api/run needs GitHub auth: {source}")189        return {"ok": False, "needs_auth": True, "error": str(e)}190    except Exception as e:191        msg = _clean_error(e)192        _log.error(f"/api/run failed: {msg}")193        return {"ok": False, "error": msg}194 195 196# Backwards-compatible alias for ask mode.197@app.post("/api/analyze")198async def analyze(req: RunRequest):199    req.mode = "ask"200    return await run(req)201 202 203@app.post("/api/apply")204async def apply_edit(action: EditAction):205    try:206        res = edit_store.apply(action.edit_id)207        return {"ok": True, "path": res["path"]}208    except Exception as e:209        _log.error(f"/api/apply failed: {e}")210        return {"ok": False, "error": str(e)}211 212 213@app.post("/api/reject")214async def reject_edit(action: EditAction):215    edit_store.discard(action.edit_id)216    return {"ok": True}217 218 219@app.post("/api/clientlog")220async def clientlog(entry: ClientLog):221    cat = entry.category.upper()222    cat = cat if cat in CATEGORIES else "NEXTJS"223    get_logger(cat).info(entry.message)224    return {"ok": True}225 226 227@app.get("/api/logs/stream")228async def logs_stream():229    queue = hub.subscribe()230 231    async def event_gen():232        # Replay recent history first so a fresh client isn't blank.233        for entry in hub.history():234            yield f"data: {json.dumps(entry)}\n\n"235        try:236            while True:237                try:238                    entry = await asyncio.wait_for(queue.get(), timeout=15)239                    yield f"data: {json.dumps(entry)}\n\n"240                except asyncio.TimeoutError:241                    # Heartbeat: keeps the connection alive through proxies (e.g.242                    # Hugging Face Spaces) that drop idle streaming connections,243                    # so the client stays "connected" instead of flipping to red.244                    yield ": keepalive\n\n"245        finally:246            hub.unsubscribe(queue)247 248    return StreamingResponse(249        event_gen(),250        media_type="text/event-stream",251        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},252    )253 254 255# --- Static frontend (single-container deploy) -----------------------------256# If a built Next.js export exists (ui/out), serve it at "/" so the API and the257# dashboard share one origin/URL (e.g. on Hugging Face Spaces). Mounted LAST so258# the /api/* routes above take precedence.259from fastapi.staticfiles import StaticFiles  # noqa: E402260 261_frontend_dir = ROOT / "ui" / "out"262if _frontend_dir.is_dir():263    app.mount("/", StaticFiles(directory=str(_frontend_dir), html=True), name="frontend")264    _log.info(f"serving static frontend from {_frontend_dir}")265