| """FastAPI backend β streams investigation progress as Server-Sent Events.""" |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import logging |
| import threading |
| import uuid |
| from typing import Any |
|
|
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| from fastapi import FastAPI |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel, Field |
| from sse_starlette.sse import EventSourceResponse |
|
|
| from agent.constants import DEMO_QUESTIONS |
| from agent.state import InvestigationState |
| from client.backend.deps import get_graph |
| from client.backend.sse import to_sse |
|
|
| logger = logging.getLogger(__name__) |
|
|
| app = FastAPI(title="why-agent API", version="0.1.0") |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["GET", "POST"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| class InvestigateRequest(BaseModel): |
| question: str = Field(min_length=1, description="The question to investigate.") |
|
|
|
|
| @app.get("/api/health") |
| def health() -> dict[str, bool]: |
| return {"ok": True} |
|
|
|
|
| @app.get("/api/demo-questions") |
| def demo_questions() -> dict[str, list[str]]: |
| return {"questions": DEMO_QUESTIONS} |
|
|
|
|
| @app.post("/api/investigate") |
| async def investigate(req: InvestigateRequest) -> EventSourceResponse: |
| """Stream a root-cause investigation as Server-Sent Events. |
| |
| The client POSTs a question and reads the response body as a stream. |
| Events are emitted in order: run_started β phase β evidence β report β done. |
| """ |
| run_id = uuid.uuid4().hex |
| graph = get_graph() |
| init = InvestigationState(user_question=req.question) |
| loop = asyncio.get_running_loop() |
| queue: asyncio.Queue[dict[str, str] | None] = asyncio.Queue(maxsize=256) |
| stop = threading.Event() |
|
|
| def drive() -> None: |
| """Run the sync LangGraph stream in a thread, pushing SSE events into the queue.""" |
| seen_evidence = 0 |
| last_phase: str | None = None |
|
|
| def _push(evt: dict[str, str] | None) -> None: |
| |
| def _put() -> None: |
| try: |
| queue.put_nowait(evt) |
| except asyncio.QueueFull: |
| pass |
|
|
| loop.call_soon_threadsafe(_put) |
|
|
| try: |
| for chunk in graph.stream(init, stream_mode="values"): |
| if stop.is_set(): |
| return |
|
|
| phase = str(chunk.get("phase", "")).split(".")[-1].lower() |
| if phase and phase != last_phase: |
| _push( |
| to_sse( |
| "phase", {"phase": phase, "retry_count": chunk.get("retry_count", 0)} |
| ) |
| ) |
| last_phase = phase |
|
|
| evidence: list[Any] = chunk.get("evidence") or [] |
| for i, e in enumerate(evidence[seen_evidence:], start=seen_evidence): |
| d: dict[str, Any] = e.model_dump() if hasattr(e, "model_dump") else dict(e) |
| d.get("args", {}).pop("_tool_call_id", None) |
| _push(to_sse("evidence", {"index": i, **d})) |
| seen_evidence = len(evidence) |
|
|
| if chunk.get("final_report"): |
| _push(to_sse("report", chunk["final_report"])) |
|
|
| except Exception as exc: |
| logger.exception("Graph stream failed for run %s", run_id) |
| _push(to_sse("error", {"message": str(exc)})) |
| finally: |
| _push(None) |
|
|
| async def gen(): |
| yield to_sse("run_started", {"run_id": run_id, "user_question": req.question}) |
| fut = loop.run_in_executor(None, drive) |
| fut.add_done_callback( |
| lambda f: logger.error("drive() raised: %s", f.exception()) if f.exception() else None |
| ) |
| try: |
| while True: |
| evt = await queue.get() |
| if evt is None: |
| yield to_sse("done", {}) |
| return |
| yield evt |
| except asyncio.CancelledError: |
| raise |
| finally: |
| stop.set() |
|
|
| return EventSourceResponse(gen()) |
|
|