futurequery-forecast-service / pipeline_api.py
Cemez83's picture
Sync forecast-service with predict_trust_layer support (001bb85)
1f3e349
Raw
History Blame Contribute Delete
4.89 kB
"""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 # function-level import avoids a circular import at load
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,
}