hemant2747/multi-agent-framework
0
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 