why-agent / client /backend /main.py
MapoTofu9's picture
deploy: HF Spaces
5d30bdc
Raw
History Blame Contribute Delete
4.35 kB
"""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:
# Wrap put_nowait in a closure so QueueFull is caught on the event loop thread
def _put() -> None:
try:
queue.put_nowait(evt)
except asyncio.QueueFull:
pass # client too slow or disconnected β€” drop event
loop.call_soon_threadsafe(_put)
try:
for chunk in graph.stream(init, stream_mode="values"):
if stop.is_set():
return # client disconnected β€” bail early
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) # sentinel β€” signals gen() to send done and exit
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() # signal the thread to exit on the next iteration
return EventSourceResponse(gen())