finrag / backend /app /stream.py
fardeen16's picture
Upload FinRAG source for Render Docker build
e6c104f verified
Raw History Blame Contribute Delete
10.7 kB
"""Translate LangGraph `astream_events` into the SSE event contract.
The first version of this adapter *predicted* the next node after each update.
After `verify` that guess is often wrong — the router and the adapter can
disagree about whether to replan, run another tool, or synthesise. Each wrong
guess emitted a `node_start synthesizer` the UI never closed, so the trace
showed "Synthesizer attempt 8 / running" while the graph was still on tools.
`astream_events` reports the node LangGraph actually entered, so a start is
only emitted for a real visit.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from typing import Any, AsyncIterator, Dict, Optional
from .agent.graph import get_graph, run_config
from .config import get_settings
from .events import (
Audit,
Clarification,
Error,
Heartbeat,
NodeEnd,
NodeName,
NodeStart,
Plan,
PlanStep,
RunEnd,
RunStart,
Token,
ToolEnd,
ToolStart,
sse,
)
GRAPH_TO_UI = {
"gatekeeper": NodeName.gatekeeper,
"planner": NodeName.planner,
"execute_tool": NodeName.tool_executor,
"verify": NodeName.auditor,
"synthesize": NodeName.synthesizer,
}
PING_S = 5.0
def _preview(output: Any, width: int = 240) -> str:
text = output if isinstance(output, str) else str(output)
return " ".join(text.split())[:width]
def _node_name(event: Dict[str, Any]) -> Optional[str]:
"""Return a supervisor node name, or None for nested LLM/tool events."""
name = event.get("name")
node = (event.get("metadata") or {}).get("langgraph_node")
if name in GRAPH_TO_UI and node == name:
return name
return None
async def live_stream(query: str, conversation_id: Optional[str] = None) -> AsyncIterator[str]:
"""Yield SSE frames for one live supervisor run."""
settings = get_settings()
seq = 0
started = time.perf_counter()
last_mark = started
deadline = started + settings.agent_timeout_s
node_timings_ms: Dict[str, int] = {}
attempts: Dict[str, int] = {}
llm_calls = 0
output_tokens = 0
streamed_tokens = False
replan_count = 0
run_id = f"run_{uuid.uuid4().hex[:12]}"
conversation_id = conversation_id or f"conv_{uuid.uuid4().hex[:12]}"
def emit(event) -> str:
nonlocal seq
seq += 1
event.seq = seq
return sse(event)
yield emit(RunStart(seq=0, run_id=run_id, conversation_id=conversation_id, query=query))
iterator = get_graph().astream_events(
{"original_request": query, "replan_count": 0},
run_config(),
version="v2",
).__aiter__()
# wait_for() cancels its awaitable on timeout. Cancelling anext() closes
# the async generator, so a RunPod call that takes >PING_S would abort the
# whole graph. Shield the next event and reuse the same task across pings.
pending: Optional[asyncio.Task] = None
try:
while True:
remaining = deadline - time.perf_counter()
if remaining <= 0:
if pending is not None:
pending.cancel()
yield emit(
Error(
seq=0,
code="timeout",
message=f"Agent exceeded its {settings.agent_timeout_s:.0f}s ceiling.",
retryable=True,
)
)
return
if pending is None:
pending = asyncio.ensure_future(anext(iterator))
try:
event = await asyncio.wait_for(
asyncio.shield(pending), timeout=min(PING_S, remaining)
)
pending = None
except asyncio.TimeoutError:
if time.perf_counter() >= deadline:
pending.cancel()
yield emit(
Error(
seq=0,
code="timeout",
message=f"Agent exceeded its {settings.agent_timeout_s:.0f}s ceiling.",
retryable=True,
)
)
return
yield emit(Heartbeat(seq=0))
continue
except StopAsyncIteration:
pending = None
break
kind = event.get("event")
graph_node = _node_name(event)
if (
kind == "on_chat_model_stream"
and (event.get("metadata") or {}).get("langgraph_node") == "synthesize"
):
chunk = (event.get("data") or {}).get("chunk")
text = getattr(chunk, "content", None) or ""
if isinstance(text, list):
text = "".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in text
)
if text:
streamed_tokens = True
output_tokens += 1
yield emit(Token(seq=0, text=text))
continue
if graph_node is None:
continue
ui_node = GRAPH_TO_UI[graph_node]
if kind == "on_chain_start":
attempts[graph_node] = attempts.get(graph_node, 0) + 1
last_mark = time.perf_counter()
yield emit(
NodeStart(seq=0, node=ui_node, attempt=attempts[graph_node])
)
continue
if kind != "on_chain_end":
continue
now = time.perf_counter()
duration_ms = int((now - last_mark) * 1000)
node_timings_ms[ui_node.value] = (
node_timings_ms.get(ui_node.value, 0) + duration_ms
)
payload = (event.get("data") or {}).get("output") or {}
if not isinstance(payload, dict):
payload = {}
yield emit(
NodeEnd(
seq=0,
node=ui_node,
attempt=attempts.get(graph_node, 1),
duration_ms=duration_ms,
)
)
if graph_node == "gatekeeper":
llm_calls += 1
question = payload.get("clarification_question")
if question:
yield emit(Clarification(seq=0, question=question))
scoped = payload.get("final_response")
if scoped:
streamed_tokens = True
output_tokens = len(str(scoped).split())
yield emit(Token(seq=0, text=str(scoped)))
elif graph_node == "planner":
llm_calls += 1
replan_count = int(payload.get("replan_count") or replan_count)
steps = [
PlanStep(
tool_name=s["tool_name"],
tool_input=s.get("tool_input") or "",
)
for s in payload.get("plan") or []
if isinstance(s, dict) and s.get("tool_name") != "FINISH"
]
yield emit(
Plan(seq=0, attempt=attempts.get(graph_node, 1), steps=steps)
)
elif graph_node == "execute_tool":
steps = payload.get("intermediate_steps") or []
if steps:
last = steps[-1]
output = last.get("tool_output")
failed = isinstance(output, str) and (
output.startswith("Tool raised")
or output.startswith("Unknown tool")
)
yield emit(
ToolStart(
seq=0,
tool_name=last.get("tool_name") or "unknown",
tool_input=last.get("tool_input") or "",
)
)
yield emit(
ToolEnd(
seq=0,
tool_name=last.get("tool_name") or "unknown",
duration_ms=duration_ms,
ok=not failed,
preview=_preview(output),
result_count=len(output) if isinstance(output, list) else None,
error=(str(output)[:200] if failed else None),
)
)
elif graph_node == "verify":
llm_calls += 1
history = payload.get("verification_history") or []
if history:
audit = history[-1]
score = int(audit.get("confidence_score") or 1)
action = (
"replan" if score < settings.audit_pass_score else "accept"
)
yield emit(
Audit(
seq=0,
attempt=attempts.get(graph_node, 1),
confidence_score=score,
is_relevant=bool(audit.get("is_relevant")),
reasoning=str(audit.get("reasoning") or ""),
action=action,
)
)
elif graph_node == "synthesize":
llm_calls += 1
if not streamed_tokens:
answer = payload.get("final_response") or ""
output_tokens = len(str(answer).split())
if answer:
yield emit(Token(seq=0, text=str(answer)))
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - the client needs the error on the stream
yield emit(
Error(seq=0, code=type(exc).__name__, message=str(exc)[:400], retryable=True)
)
return
yield emit(
RunEnd(
seq=0,
run_id=run_id,
duration_ms=int((time.perf_counter() - started) * 1000),
node_timings_ms=node_timings_ms,
llm_calls=llm_calls,
output_tokens=output_tokens,
replans=replan_count,
)
)