File size: 5,325 Bytes
69e310f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""FastAPI application factory.

One container runs the API, the LangGraph orchestrator and the MCP tool server —
which is what makes the free Hugging Face Spaces / Cloud Run deployment a single
image on port 7860.
"""

from __future__ import annotations

import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from app import __version__
from app.api.routes import router
from app.core.middleware import (
    BodyLimitMiddleware,
    RateLimitMiddleware,
    SecurityHeadersMiddleware,
)
from app.core.settings import get_settings
from app.core.tracing import get_tracer
from app.services.repository import get_repository
from app.services.runner import get_run_service

logger = logging.getLogger(__name__)

DESCRIPTION = """
Supervisor-pattern multi-agent research orchestration with MCP tooling and
human-in-the-loop governance.

The LLM never does arithmetic. Tools compute over MCP, a deterministic node
recomputes every figure in the final brief, and a human gate signs off —
hallucinated numbers are impossible by construction, not by prompt-begging.
"""


def configure_logging(level: str) -> None:
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)-8s %(name)s | %(message)s",
    )
    # These libraries are chatty at INFO and drown the run telemetry.
    for noisy in ("httpx", "httpcore", "urllib3", "yfinance", "peewee"):
        logging.getLogger(noisy).setLevel(logging.WARNING)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """Create tables on boot; flush traces and cancel live runs on shutdown."""
    settings = get_settings()
    configure_logging(settings.log_level)
    repository = get_repository()
    await repository.create_all_async()
    service = get_run_service()
    # Must precede reconciliation: whether a paused run is resumable depends on
    # the durability of the checkpointer, which this call establishes.
    await service.ensure_ready()
    # Runs inherited from a previous process hold their watchlist's single active
    # slot forever unless they are closed here.
    await service.reconcile_orphans()
    # Mint the approval token now rather than on the first authenticated request.
    # Locally the console reads it from disk to authenticate, so leaving it lazy
    # deadlocks: the file only appears after an approval that cannot succeed
    # without it.
    settings.require_approval_token()
    logger.info(
        "AlphaBrief %s ready — engine=%s mcp=%s langfuse=%s smtp=%s checkpoints=%s",
        __version__,
        settings.resolved_engine,
        settings.mcp_transport,
        settings.langfuse_enabled,
        settings.smtp_enabled,
        "durable" if service.durable_checkpoints else "in-memory",
    )
    app.state.settings = settings
    try:
        yield
    finally:
        await get_run_service().shutdown()
        get_tracer().shutdown()
        repository.dispose()


def create_app() -> FastAPI:
    """Build the ASGI application."""
    settings = get_settings()
    app = FastAPI(
        title=settings.api_title,
        description=DESCRIPTION,
        version=__version__,
        lifespan=lifespan,
        docs_url="/docs",
        redoc_url=None,
        openapi_url="/openapi.json",
    )

    # Order matters: body limit runs outermost so oversized payloads are rejected
    # before anything else touches them.
    app.add_middleware(SecurityHeadersMiddleware)
    app.add_middleware(RateLimitMiddleware, max_requests=30, window_seconds=60.0)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=settings.cors_origins,
        allow_credentials=False,
        allow_methods=["GET", "POST", "OPTIONS"],
        allow_headers=["Authorization", "Content-Type"],
        max_age=600,
    )
    app.add_middleware(BodyLimitMiddleware, max_bytes=settings.max_request_bytes)

    app.include_router(router)

    @app.exception_handler(RequestValidationError)
    async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
        """Return field-level detail without echoing the raw submitted body."""
        return JSONResponse(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            content={
                "detail": "Request validation failed.",
                "errors": [
                    {
                        "field": ".".join(str(part) for part in err.get("loc", ())),
                        "message": err.get("msg", "invalid value"),
                    }
                    for err in exc.errors()[:20]
                ],
            },
        )

    @app.exception_handler(Exception)
    async def _unhandled(request: Request, _exc: Exception) -> JSONResponse:
        """Never leak internals: log the detail, return an opaque error."""
        logger.exception("unhandled error on %s %s", request.method, request.url.path)
        return JSONResponse(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            content={"detail": "Internal server error."},
        )

    return app


app = create_app()