| """HTTP wrapper around the LangGraph pipeline (pipeline.py). |
| |
| Drives the full SCAN -> TRIAGE -> ENSEMBLE -> SYNTHESISE -> HUMAN CHECKPOINT loop |
| from the browser: |
| |
| POST /pipeline/start {question, question_type} -> runs to the checkpoint |
| POST /pipeline/resume {thread_id, resume} -> "" accepts, "override 0.XX" |
| |
| Robustness: |
| * the ensemble is computed IN-PROCESS (no HTTP self-call), avoiding |
| localhost/IPv6 and nested-request issues. |
| * any failure is returned as a readable {"status":"error","error":...} with |
| HTTP 200, so the UI shows the message instead of a raw 500. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import traceback |
| import uuid |
| from typing import Literal, Optional |
|
|
| from fastapi import APIRouter |
| from pydantic import BaseModel, Field |
|
|
| import pipeline |
|
|
| router = APIRouter(prefix="/pipeline", tags=["pipeline"]) |
|
|
| _SNAPSHOT_KEYS = [ |
| "question", "question_type", "horizon", "market_context", "context_source", |
| "eligible_market", "eligibility_status", "hits", "scored", "top", "arbitrage", |
| "ensemble", "use_forecast", "ensemble_trend", "brier_estimate", |
| "market_consensus", "final_probability", "rationale", "history_source", |
| "history_status", "decision_note", "forecast_id", |
| ] |
|
|
| _GRAPH = None |
| _PATCHED = False |
|
|
|
|
| def _graph(): |
| global _GRAPH |
| if _GRAPH is None: |
| _GRAPH = pipeline.build_graph() |
| return _GRAPH |
|
|
|
|
| def _use_inprocess_forecast(): |
| """Replace the pipeline's HTTP forecast call with a direct in-process call.""" |
| global _PATCHED |
| if _PATCHED: |
| return |
|
|
| def _inprocess(prices, question_type, horizon): |
| import main |
| req = main.ForecastRequest( |
| prices=list(prices or []), |
| question_type=question_type, |
| horizon=horizon, |
| ) |
| return main.forecast(req) |
|
|
| pipeline.call_forecast = _inprocess |
| _PATCHED = True |
|
|
|
|
| def _snapshot(graph, config) -> dict: |
| try: |
| values = graph.get_state(config).values or {} |
| except Exception: |
| values = {} |
| return {k: values.get(k) for k in _SNAPSHOT_KEYS if k in values} |
|
|
|
|
| def _interrupt_payload(result): |
| if isinstance(result, dict) and result.get("__interrupt__"): |
| try: |
| return result["__interrupt__"][0].value |
| except Exception: |
| return None |
| return None |
|
|
|
|
| def _error(thread_id, graph, config, exc) -> dict: |
| return { |
| "thread_id": thread_id, |
| "status": "error", |
| "state": _snapshot(graph, config) if graph is not None else {}, |
| "checkpoint": None, |
| "error": f"{type(exc).__name__}: {exc}", |
| "trace": traceback.format_exc()[-1500:], |
| } |
|
|
|
|
| class StartRequest(BaseModel): |
| question: str = Field(min_length=3, max_length=500) |
| question_type: Literal["numeric", "event"] = "event" |
| horizon: int = Field(default=5, ge=1, le=64) |
| market_context: Optional[dict] = None |
|
|
|
|
| class ResumeRequest(BaseModel): |
| thread_id: str |
| resume: str = "" |
|
|
|
|
| @router.post("/start") |
| def start(req: StartRequest) -> dict: |
| _use_inprocess_forecast() |
| thread_id = f"web-{uuid.uuid4().hex[:10]}" |
| try: |
| graph = _graph() |
| except Exception as exc: |
| return _error(thread_id, None, None, exc) |
|
|
| config = {"configurable": {"thread_id": thread_id}} |
| initial = { |
| "question": req.question, |
| "question_type": req.question_type, |
| "horizon": req.horizon, |
| } |
| if req.market_context is not None: |
| initial["market_context"] = req.market_context |
| try: |
| result = graph.invoke(initial, config=config) |
| except Exception as exc: |
| return _error(thread_id, graph, config, exc) |
|
|
| payload = _interrupt_payload(result) |
| return { |
| "thread_id": thread_id, |
| "status": "checkpoint" if payload is not None else "done", |
| "state": _snapshot(graph, config), |
| "checkpoint": payload, |
| } |
|
|
|
|
| @router.post("/resume") |
| def resume(req: ResumeRequest) -> dict: |
| from langgraph.types import Command |
|
|
| try: |
| graph = _graph() |
| except Exception as exc: |
| return _error(req.thread_id, None, None, exc) |
|
|
| config = {"configurable": {"thread_id": req.thread_id}} |
| try: |
| existing = graph.get_state(config) |
| if existing is None or not existing.values: |
| return { |
| "thread_id": req.thread_id, "status": "error", "state": {}, |
| "checkpoint": None, "error": "unknown or expired thread_id", |
| } |
| result = graph.invoke(Command(resume=req.resume), config=config) |
| except Exception as exc: |
| return _error(req.thread_id, graph, config, exc) |
|
|
| payload = _interrupt_payload(result) |
| return { |
| "thread_id": req.thread_id, |
| "status": "checkpoint" if payload is not None else "done", |
| "state": _snapshot(graph, config), |
| "checkpoint": payload, |
| } |
|
|