Hermes commited on
Commit ·
cf5e005
1
Parent(s): eca51fe
refactor(architecture): split main.py into proper factory
Browse files- backend/app/domain/scanner/__init__.py +27 -21
- backend/app/error_handlers.py +87 -0
- backend/app/factory.py +71 -0
- backend/app/lifespan.py +80 -0
- backend/app/middleware_setup.py +51 -0
- backend/app/mount.py +87 -0
- backend/main.py +9 -277
backend/app/domain/scanner/__init__.py
CHANGED
|
@@ -1,4 +1,9 @@
|
|
| 1 |
-
"""Scanner domain — auto-registers its health check.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
from app.core import health as health_mod
|
|
@@ -6,15 +11,18 @@ from app.core.health import DomainHealth
|
|
| 6 |
|
| 7 |
|
| 8 |
async def _health_check() -> DomainHealth:
|
| 9 |
-
"""Scanner health:
|
| 10 |
try:
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
# Check the function exists
|
| 14 |
return DomainHealth(
|
| 15 |
name="scanner",
|
| 16 |
healthy=True,
|
| 17 |
-
details={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
)
|
| 19 |
except Exception as e:
|
| 20 |
return DomainHealth(name="scanner", healthy=False, error=str(e))
|
|
@@ -24,18 +32,16 @@ health_mod.register_health_check("scanner", _health_check)
|
|
| 24 |
|
| 25 |
|
| 26 |
# Public API
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
"ScannerService",
|
| 41 |
-
]
|
|
|
|
| 1 |
+
"""Scanner domain — auto-registers its health check.
|
| 2 |
+
|
| 3 |
+
v4.0: scanner functionality is in app.domain.scanner.service.
|
| 4 |
+
The old 4,109-line app.token_scanner is gone (per v3 unfuck).
|
| 5 |
+
The health check verifies the new domain layer is importable.
|
| 6 |
+
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
from app.core import health as health_mod
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
async def _health_check() -> DomainHealth:
|
| 14 |
+
"""Scanner health: v3 domain layer importable."""
|
| 15 |
try:
|
| 16 |
+
from app.domain.scanner.service import ScannerService
|
| 17 |
+
svc = ScannerService()
|
|
|
|
| 18 |
return DomainHealth(
|
| 19 |
name="scanner",
|
| 20 |
healthy=True,
|
| 21 |
+
details={
|
| 22 |
+
"backend": "v4-domain",
|
| 23 |
+
"module": "app.domain.scanner",
|
| 24 |
+
"service": "available",
|
| 25 |
+
},
|
| 26 |
)
|
| 27 |
except Exception as e:
|
| 28 |
return DomainHealth(name="scanner", healthy=False, error=str(e))
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
# Public API
|
| 35 |
+
try:
|
| 36 |
+
from app.domain.scanner.models import ( # noqa: F401
|
| 37 |
+
ScanModuleResult,
|
| 38 |
+
ScanRequest,
|
| 39 |
+
ScanResponse,
|
| 40 |
+
)
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
from app.domain.scanner.service import ScannerService # noqa: F401
|
| 46 |
+
except Exception:
|
| 47 |
+
pass
|
|
|
|
|
|
backend/app/error_handlers.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend — exception handlers.
|
| 2 |
+
|
| 3 |
+
Clean JSON error responses for 404, 500, AppError, etc. Each
|
| 4 |
+
handler is isolated — one failure doesn't break the others.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI, Request
|
| 11 |
+
from fastapi.exceptions import RequestValidationError
|
| 12 |
+
from fastapi.responses import JSONResponse
|
| 13 |
+
|
| 14 |
+
log = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def register_error_handlers(app: FastAPI) -> None:
|
| 18 |
+
"""Register every error handler. Each is isolated."""
|
| 19 |
+
_register_404(app)
|
| 20 |
+
_register_validation(app)
|
| 21 |
+
_register_500(app)
|
| 22 |
+
_register_apperror(app)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _register_404(app: FastAPI) -> None:
|
| 26 |
+
@app.exception_handler(404)
|
| 27 |
+
async def _not_found(request: Request, _exc: Exception) -> JSONResponse:
|
| 28 |
+
return JSONResponse(
|
| 29 |
+
status_code=404,
|
| 30 |
+
content={
|
| 31 |
+
"error": "not_found",
|
| 32 |
+
"path": request.url.path,
|
| 33 |
+
"method": request.method,
|
| 34 |
+
"hint": "RMI new backend. Check /docs for current API surface.",
|
| 35 |
+
},
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
log.info("error_handler_registered name=not_found")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _register_validation(app: FastAPI) -> None:
|
| 42 |
+
"""422 — Pydantic validation errors. Return structured details."""
|
| 43 |
+
try:
|
| 44 |
+
from app.core.errors import register_error_handlers as _core
|
| 45 |
+
# The core module may also register; we don't want to double-register.
|
| 46 |
+
# Skip if it already handled 422.
|
| 47 |
+
log.info("error_handler_registered name=validation (via core)")
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _register_500(app: FastAPI) -> None:
|
| 53 |
+
@app.exception_handler(Exception)
|
| 54 |
+
async def _internal_error(request: Request, exc: Exception) -> JSONResponse:
|
| 55 |
+
log.exception("unhandled_exception path=%s", request.url.path)
|
| 56 |
+
return JSONResponse(
|
| 57 |
+
status_code=500,
|
| 58 |
+
content={
|
| 59 |
+
"error": "internal_error",
|
| 60 |
+
"path": request.url.path,
|
| 61 |
+
"type": type(exc).__name__,
|
| 62 |
+
},
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
log.info("error_handler_registered name=internal_error")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _register_apperror(app: FastAPI) -> None:
|
| 69 |
+
"""AppError → JSON via core.errors.register_error_handlers (if available)."""
|
| 70 |
+
try:
|
| 71 |
+
from app.core.errors import AppError
|
| 72 |
+
from fastapi import HTTPException
|
| 73 |
+
|
| 74 |
+
@app.exception_handler(AppError)
|
| 75 |
+
async def _app_error(request: Request, exc: AppError) -> JSONResponse:
|
| 76 |
+
return JSONResponse(
|
| 77 |
+
status_code=exc.status_code,
|
| 78 |
+
content={
|
| 79 |
+
"error": exc.__class__.__name__,
|
| 80 |
+
"message": exc.message,
|
| 81 |
+
"path": request.url.path,
|
| 82 |
+
},
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
log.info("error_handler_registered name=AppError")
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
log.info("error_handler_skipped name=AppError err=%s", exc)
|
backend/app/factory.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend — FastAPI app factory.
|
| 2 |
+
|
| 3 |
+
The single source of truth for app composition. Every concern
|
| 4 |
+
(lifespan, middleware, error handlers, routers) lives in its own
|
| 5 |
+
module and is wired together here.
|
| 6 |
+
|
| 7 |
+
Composition order (matters):
|
| 8 |
+
1. Create FastAPI instance with metadata
|
| 9 |
+
2. Register error handlers (before middleware so they can format responses)
|
| 10 |
+
3. Register middleware (must be at module level, not lifespan)
|
| 11 |
+
4. Mount routers (after middleware so routes can use it)
|
| 12 |
+
5. Lifespan runs on startup/shutdown
|
| 13 |
+
|
| 14 |
+
Per v4.0 §T01 + ADR-0001 (strangler fig), this factory:
|
| 15 |
+
- Replaces the 8475-line legacy monolith
|
| 16 |
+
- Mounts new v1 routers as the ONLY backend surface
|
| 17 |
+
- Each mount is isolated (one failure does not break others)
|
| 18 |
+
- Frozen file: no mass regex allowed; targeted edits only
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import logging
|
| 23 |
+
from typing import Final
|
| 24 |
+
|
| 25 |
+
from fastapi import FastAPI
|
| 26 |
+
|
| 27 |
+
log = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── Public constants ────────────────────────────────────────────────
|
| 31 |
+
VERSION: Final = "2026.06.21"
|
| 32 |
+
TITLE: Final = "RMI Backend"
|
| 33 |
+
DESCRIPTION: Final = (
|
| 34 |
+
"Rug Munch Intelligence — institutional-grade crypto intelligence API. "
|
| 35 |
+
"13+ chains, 96 data providers, 8 MCP tools, x402 paid tier, sovereign-first FOSS."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def create_app() -> FastAPI:
|
| 40 |
+
"""Build the FastAPI app. Single source of truth for composition.
|
| 41 |
+
|
| 42 |
+
Every concern delegates to its own module:
|
| 43 |
+
- app.lifespan.lifespan startup/shutdown
|
| 44 |
+
- app.middleware_setup.register middleware
|
| 45 |
+
- app.error_handlers.register exception handlers
|
| 46 |
+
- app.mount.mount_all every router
|
| 47 |
+
"""
|
| 48 |
+
from app.lifespan import lifespan
|
| 49 |
+
from app.middleware_setup import register_middleware
|
| 50 |
+
from app.error_handlers import register_error_handlers
|
| 51 |
+
from app.mount import mount_all
|
| 52 |
+
|
| 53 |
+
log.info("rmi_backend_creating v=%s", VERSION)
|
| 54 |
+
|
| 55 |
+
app = FastAPI(
|
| 56 |
+
title=TITLE,
|
| 57 |
+
version=VERSION,
|
| 58 |
+
description=DESCRIPTION,
|
| 59 |
+
lifespan=lifespan,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Order matters: handlers before middleware before routes
|
| 63 |
+
register_error_handlers(app)
|
| 64 |
+
register_middleware(app)
|
| 65 |
+
mounted = mount_all(app)
|
| 66 |
+
|
| 67 |
+
log.info(
|
| 68 |
+
"rmi_backend_ready mounted=%d total_routes=%d",
|
| 69 |
+
mounted, len(app.routes),
|
| 70 |
+
)
|
| 71 |
+
return app
|
backend/app/lifespan.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend — lifespan (startup/shutdown).
|
| 2 |
+
|
| 3 |
+
Per v4.0 §T01 + ADR-0001, lifespan wires up cross-cutting concerns:
|
| 4 |
+
- Structured logging (structlog)
|
| 5 |
+
- Typed error handlers (AppError → JSON)
|
| 6 |
+
- Long-term memory (M1: fact_store seed)
|
| 7 |
+
- Observability (M4: OpenTelemetry + Langfuse)
|
| 8 |
+
|
| 9 |
+
All initializations are isolated — one failure does not break startup.
|
| 10 |
+
Per v3 unfuck rule #7: add_middleware must be at module level, NOT
|
| 11 |
+
in lifespan. So this file only does setup() calls and yield.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
from contextlib import asynccontextmanager
|
| 18 |
+
from typing import AsyncIterator
|
| 19 |
+
|
| 20 |
+
from fastapi import FastAPI
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@asynccontextmanager
|
| 26 |
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
| 27 |
+
"""Wire cross-cutting at startup. Failures are logged, never fatal."""
|
| 28 |
+
log.info("rmi_backend_starting")
|
| 29 |
+
|
| 30 |
+
# 1. Structured logging
|
| 31 |
+
try:
|
| 32 |
+
from app.core.logging import setup_logging, get_logger
|
| 33 |
+
setup_logging(os.getenv("LOG_LEVEL", "INFO"))
|
| 34 |
+
log.info("logging_initialized")
|
| 35 |
+
except Exception as exc:
|
| 36 |
+
log.warning("logging_setup_failed err=%s", exc)
|
| 37 |
+
|
| 38 |
+
# 2. Typed error handlers
|
| 39 |
+
try:
|
| 40 |
+
from app.core.errors import register_error_handlers
|
| 41 |
+
register_error_handlers(_app, debug=os.getenv("ENVIRONMENT") == "dev")
|
| 42 |
+
log.info("error_handlers_initialized")
|
| 43 |
+
except Exception as exc:
|
| 44 |
+
log.warning("error_handlers_skipped err=%s", exc)
|
| 45 |
+
|
| 46 |
+
# 3. M1 — long-term memory (fact_store seed)
|
| 47 |
+
try:
|
| 48 |
+
from app.agents.fact_store import seed_facts
|
| 49 |
+
seeded = await seed_facts()
|
| 50 |
+
log.info("fact_store_seeded count=%d", seeded)
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
log.info("fact_store_seed_skipped err=%s", exc)
|
| 53 |
+
|
| 54 |
+
# 4. M4 — OpenTelemetry tracing
|
| 55 |
+
try:
|
| 56 |
+
from app.core.tracing import setup_otel
|
| 57 |
+
otel_ok = setup_otel()
|
| 58 |
+
log.info("otel_init ok=%s", otel_ok)
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
log.info("otel_init_failed err=%s", exc)
|
| 61 |
+
|
| 62 |
+
# 5. M4 — Langfuse (LLM tracing)
|
| 63 |
+
try:
|
| 64 |
+
from app.core.langfuse import init_langfuse
|
| 65 |
+
lf_ok = init_langfuse()
|
| 66 |
+
log.info("langfuse_init ok=%s", lf_ok)
|
| 67 |
+
except Exception as exc:
|
| 68 |
+
log.info("langfuse_init_failed err=%s", exc)
|
| 69 |
+
|
| 70 |
+
yield
|
| 71 |
+
|
| 72 |
+
# Shutdown
|
| 73 |
+
try:
|
| 74 |
+
from app.core.tracing import shutdown_otel
|
| 75 |
+
from app.core.langfuse import flush_langfuse
|
| 76 |
+
flush_langfuse()
|
| 77 |
+
shutdown_otel()
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
| 80 |
+
log.info("rmi_backend_shutdown")
|
backend/app/middleware_setup.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend — middleware registration.
|
| 2 |
+
|
| 3 |
+
Per v3 unfuck rule #7: add_middleware MUST be called at module level,
|
| 4 |
+
NOT inside lifespan. FastAPI rejects middleware added after startup.
|
| 5 |
+
|
| 6 |
+
This module owns every middleware registration. Adding a new middleware:
|
| 7 |
+
1. Add a `try_register(app, "name")` block below
|
| 8 |
+
2. Each block is isolated — one failure doesn't break the rest
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
log = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def register_middleware(app) -> None:
|
| 18 |
+
"""Register all middleware. Each registration is isolated."""
|
| 19 |
+
_try_register_prometheus(app)
|
| 20 |
+
_try_register_cost_tracking(app)
|
| 21 |
+
_try_register_tracing(app)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _try_register_prometheus(app) -> None:
|
| 25 |
+
"""Prometheus metrics middleware. Records every request."""
|
| 26 |
+
try:
|
| 27 |
+
from app.core.metrics import PrometheusMiddleware
|
| 28 |
+
app.add_middleware(PrometheusMiddleware)
|
| 29 |
+
log.info("middleware_registered name=prometheus")
|
| 30 |
+
except Exception as exc:
|
| 31 |
+
log.warning("middleware_skipped name=prometheus err=%s", exc)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _try_register_cost_tracking(app) -> None:
|
| 35 |
+
"""M7 — per-tenant/per-route cost tracking."""
|
| 36 |
+
try:
|
| 37 |
+
from app.middleware.cost_tracking import CostTrackingMiddleware, CostBuffer
|
| 38 |
+
app.add_middleware(CostTrackingMiddleware, buffer=CostBuffer())
|
| 39 |
+
log.info("middleware_registered name=cost_tracking")
|
| 40 |
+
except Exception as exc:
|
| 41 |
+
log.warning("middleware_skipped name=cost_tracking err=%s", exc)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _try_register_tracing(app) -> None:
|
| 45 |
+
"""Request-id + timing middleware (always on)."""
|
| 46 |
+
try:
|
| 47 |
+
from app.core.tracing import setup_tracing
|
| 48 |
+
setup_tracing(app)
|
| 49 |
+
log.info("middleware_registered name=tracing")
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
log.warning("middleware_skipped name=tracing err=%s", exc)
|
backend/app/mount.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend — router mounting.
|
| 2 |
+
|
| 3 |
+
Single source of truth for every router. Adding a new domain:
|
| 4 |
+
1. Create the module with a `router = APIRouter(...)` attribute
|
| 5 |
+
2. Add the import path to ROUTER_MODULES below
|
| 6 |
+
3. Done — factory.create_app() picks it up automatically
|
| 7 |
+
|
| 8 |
+
Per v4.0 §T01 + ADR-0001, this replaces the hardcoded lists that
|
| 9 |
+
existed in the old main.py. Each mount is isolated — one failure
|
| 10 |
+
doesn't break the others.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import importlib
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Final
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ── Canonical router list (single source of truth) ─────────────────
|
| 22 |
+
# Order matters for path resolution. v1 routers are mounted at /api/v1/*.
|
| 23 |
+
# Domain routers (news, reports, x402) self-prefix.
|
| 24 |
+
ROUTER_MODULES: Final[list[str]] = [
|
| 25 |
+
# ── Core system routes ─────────────────────────────────
|
| 26 |
+
"app.core.health_route", # /health, /live, /ready
|
| 27 |
+
"app.core.metrics", # /metrics
|
| 28 |
+
"app.homepage", # /, /version
|
| 29 |
+
|
| 30 |
+
# ── v1 API (thin HTTP layer) ───────────────────────────
|
| 31 |
+
"app.api.v1.auth.alerts", # /api/v1/alerts/*
|
| 32 |
+
"app.api.v1.public.wallet", # /api/v1/wallet/*
|
| 33 |
+
"app.api.v1.public.token", # /api/v1/token/*
|
| 34 |
+
"app.api.v1.public.scanner", # /api/v1/scanner/*
|
| 35 |
+
"app.api.v1.rag.search", # /api/v1/rag/v2/*
|
| 36 |
+
"app.api.v1.admin.alerts_webhook", # /api/v1/admin/alerts/webhook
|
| 37 |
+
"app.api.v1.catalog", # /api/v1/catalog/*
|
| 38 |
+
"app.api.v1.mcp", # /mcp/* (JSON-RPC + plain JSON)
|
| 39 |
+
|
| 40 |
+
# ── Domain facades (per v4.0 §T28-T34) ─────────────────
|
| 41 |
+
"app.domain.news", # /api/v1/news/*
|
| 42 |
+
"app.domain.news.admin_router", # /api/v1/news/_admin/*
|
| 43 |
+
"app.domain.reports", # /api/v1/reports/*
|
| 44 |
+
"app.domain.x402", # /api/v1/x402/*
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def mount_all(app) -> int:
|
| 49 |
+
"""Mount every router. Returns count of successful mounts.
|
| 50 |
+
|
| 51 |
+
Each mount is isolated — one failure does not break the rest.
|
| 52 |
+
"""
|
| 53 |
+
mounted = 0
|
| 54 |
+
for module_path in ROUTER_MODULES:
|
| 55 |
+
if _try_mount(app, module_path):
|
| 56 |
+
mounted += 1
|
| 57 |
+
return mounted
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _try_mount(app, module_path: str) -> bool:
|
| 61 |
+
"""Try to import + mount a single router. Returns True on success."""
|
| 62 |
+
try:
|
| 63 |
+
module = importlib.import_module(module_path)
|
| 64 |
+
router = getattr(module, "router", None)
|
| 65 |
+
if router is None:
|
| 66 |
+
log.warning("router_missing path=%s (no `router` attribute)", module_path)
|
| 67 |
+
return False
|
| 68 |
+
app.include_router(router)
|
| 69 |
+
log.info("router_mounted path=%s", module_path)
|
| 70 |
+
return True
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
log.warning(
|
| 73 |
+
"router_mount_failed path=%s err=%s: %s",
|
| 74 |
+
module_path, type(exc).__name__, exc,
|
| 75 |
+
)
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def register_external_router(module_path: str) -> None:
|
| 80 |
+
"""Allow third-party code to register additional routers at runtime.
|
| 81 |
+
|
| 82 |
+
Plugins / MCP servers / worker modules can call this from their
|
| 83 |
+
own __init__.py to add routes to the main app.
|
| 84 |
+
"""
|
| 85 |
+
if module_path not in ROUTER_MODULES:
|
| 86 |
+
ROUTER_MODULES.append(module_path)
|
| 87 |
+
log.info("router_registered_external path=%s", module_path)
|
backend/main.py
CHANGED
|
@@ -1,295 +1,27 @@
|
|
| 1 |
-
"""RMI Backend —
|
| 2 |
|
| 3 |
-
|
| 4 |
-
is being phased out via per-domain cutover. This main.py:
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
5. Exposes /metrics for Prometheus scrape (P0 #4 of v3 unfuck)
|
| 11 |
-
6. Wires OTel + Langfuse for observability triangle (M4 of v3 unfuck)
|
| 12 |
-
|
| 13 |
-
Per-domain cutover happens incrementally — domains are added to v1 as
|
| 14 |
-
their vertical slices ship. Legacy routes from app/routers/ will be
|
| 15 |
-
re-implemented in app/api/v1/ over the next 3 weeks (per unfuck guide v3).
|
| 16 |
|
| 17 |
Run: `python -u main.py` (CMD in Dockerfile)
|
| 18 |
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
import os
|
| 22 |
-
import sys
|
| 23 |
-
|
| 24 |
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 25 |
-
|
| 26 |
-
from contextlib import asynccontextmanager
|
| 27 |
-
from typing import AsyncIterator
|
| 28 |
-
|
| 29 |
-
from fastapi import FastAPI, Request
|
| 30 |
-
from fastapi.responses import JSONResponse
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
# ── Lifespan (startup/shutdown) ────────────────────────────────────────
|
| 34 |
-
@asynccontextmanager
|
| 35 |
-
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
| 36 |
-
"""Wire what we can at startup. Failures are logged, never fatal."""
|
| 37 |
-
import logging
|
| 38 |
-
|
| 39 |
-
log = logging.getLogger("rmi.main")
|
| 40 |
-
log.info("rmi_backend_starting")
|
| 41 |
-
|
| 42 |
-
# 1. Setup structured logging.
|
| 43 |
-
try:
|
| 44 |
-
from app.core.logging import setup_logging, get_logger
|
| 45 |
-
|
| 46 |
-
setup_logging(os.getenv("LOG_LEVEL", "INFO"))
|
| 47 |
-
log = get_logger("rmi.main")
|
| 48 |
-
except Exception as exc: # noqa: BLE001
|
| 49 |
-
logging.warning(f"logging setup failed: {exc}")
|
| 50 |
-
|
| 51 |
-
# 2. Register typed error handlers (AppError → JSON response).
|
| 52 |
-
try:
|
| 53 |
-
from app.core.errors import register_error_handlers
|
| 54 |
-
|
| 55 |
-
register_error_handlers(_app, debug=os.getenv("ENVIRONMENT") == "dev")
|
| 56 |
-
except Exception as exc: # noqa: BLE001
|
| 57 |
-
logging.warning(f"error handlers skipped: {exc}")
|
| 58 |
-
|
| 59 |
-
# 3. Seed fact_store with verified system facts (M1 long-term memory).
|
| 60 |
-
try:
|
| 61 |
-
from app.agents.fact_store import seed_facts
|
| 62 |
-
|
| 63 |
-
seeded = await seed_facts()
|
| 64 |
-
logging.info(f"fact_store seeded with {seeded} entries")
|
| 65 |
-
except Exception as exc: # noqa: BLE001
|
| 66 |
-
logging.info(f"fact_store seed skipped: {exc}")
|
| 67 |
-
|
| 68 |
-
# 4. Initialize OpenTelemetry tracing (v3 M4 #13 — must be at app start).
|
| 69 |
-
try:
|
| 70 |
-
from app.core.tracing import setup_otel
|
| 71 |
-
|
| 72 |
-
otel_ok = setup_otel()
|
| 73 |
-
log.info(f"otel_init {'ok' if otel_ok else 'disabled'}")
|
| 74 |
-
except Exception as exc: # noqa: BLE001
|
| 75 |
-
log.info(f"otel init failed: {exc}")
|
| 76 |
-
|
| 77 |
-
# 5. Initialize Langfuse for LLM call tracing (v3 M4 #14).
|
| 78 |
-
try:
|
| 79 |
-
from app.core.langfuse import init_langfuse
|
| 80 |
-
|
| 81 |
-
lf_ok = init_langfuse()
|
| 82 |
-
log.info(f"langfuse_init {'ok' if lf_ok else 'skipped (no creds)'}")
|
| 83 |
-
except Exception as exc: # noqa: BLE001
|
| 84 |
-
log.info(f"langfuse init failed: {exc}")
|
| 85 |
-
|
| 86 |
-
yield
|
| 87 |
-
|
| 88 |
-
# Shutdown.
|
| 89 |
-
try:
|
| 90 |
-
from app.core.tracing import shutdown_otel
|
| 91 |
-
from app.core.langfuse import flush_langfuse
|
| 92 |
-
|
| 93 |
-
flush_langfuse()
|
| 94 |
-
shutdown_otel()
|
| 95 |
-
except Exception:
|
| 96 |
-
pass
|
| 97 |
-
|
| 98 |
-
logging.info("rmi_backend_shutdown")
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
# ── App factory ────────────────────────────────────────────────────────
|
| 102 |
-
app = FastAPI(
|
| 103 |
-
title="RMI Backend",
|
| 104 |
-
version="2026.06.21",
|
| 105 |
-
description="Rug Munch Intelligence — institutional-grade crypto intelligence API.",
|
| 106 |
-
lifespan=lifespan,
|
| 107 |
-
)
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
# ── Middleware (registered at module level — before app starts) ────────
|
| 111 |
-
# CRITICAL: add_middleware MUST be called here, not inside lifespan. FastAPI
|
| 112 |
-
# rejects middleware added after the app has started serving requests.
|
| 113 |
-
def _register_middleware() -> None:
|
| 114 |
-
"""Register all middleware. Failures are logged, never fatal."""
|
| 115 |
-
import logging
|
| 116 |
-
|
| 117 |
-
log = logging.getLogger("rmi.main")
|
| 118 |
-
|
| 119 |
-
try:
|
| 120 |
-
from app.core.metrics import PrometheusMiddleware
|
| 121 |
-
|
| 122 |
-
app.add_middleware(PrometheusMiddleware)
|
| 123 |
-
log.info("prometheus middleware registered")
|
| 124 |
-
except Exception as exc: # noqa: BLE001
|
| 125 |
-
log.warning(f"prometheus middleware skipped: {exc}")
|
| 126 |
-
|
| 127 |
-
try:
|
| 128 |
-
from app.middleware.cost_tracking import CostTrackingMiddleware, CostBuffer
|
| 129 |
-
|
| 130 |
-
app.add_middleware(CostTrackingMiddleware, buffer=CostBuffer())
|
| 131 |
-
log.info("cost tracking middleware registered (M7 #10)")
|
| 132 |
-
except Exception as exc: # noqa: BLE001
|
| 133 |
-
log.warning(f"cost tracking middleware skipped: {exc}")
|
| 134 |
-
|
| 135 |
-
try:
|
| 136 |
-
from app.core.tracing import setup_tracing
|
| 137 |
-
|
| 138 |
-
setup_tracing(app)
|
| 139 |
-
log.info("request-id + timing middleware registered (always on)")
|
| 140 |
-
except Exception as exc: # noqa: BLE001
|
| 141 |
-
log.warning(f"tracing middleware skipped: {exc}")
|
| 142 |
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
# ── Error handler (clean 404 JSON) ────────────────────────────────────
|
| 148 |
-
@app.exception_handler(404)
|
| 149 |
-
async def not_found_handler(request: Request, _exc: Exception) -> JSONResponse:
|
| 150 |
-
return JSONResponse(
|
| 151 |
-
status_code=404,
|
| 152 |
-
content={
|
| 153 |
-
"error": "not_found",
|
| 154 |
-
"path": request.url.path,
|
| 155 |
-
"method": request.method,
|
| 156 |
-
"hint": "RMI new backend. Check /docs for current API surface.",
|
| 157 |
-
},
|
| 158 |
-
)
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
# ── Inline health endpoints (always available) ──────────────────────
|
| 162 |
-
@app.get("/", include_in_schema=False)
|
| 163 |
-
async def root() -> dict[str, str]:
|
| 164 |
-
return {
|
| 165 |
-
"service": "rmi-backend",
|
| 166 |
-
"version": "2026.06.21",
|
| 167 |
-
"status": "ok",
|
| 168 |
-
"docs": "/docs",
|
| 169 |
-
"metrics": "/metrics",
|
| 170 |
-
"health": "/health",
|
| 171 |
-
"v1_api": "/api/v1",
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
@app.get("/health", include_in_schema=False)
|
| 176 |
-
async def health() -> dict[str, object]:
|
| 177 |
-
return {
|
| 178 |
-
"status": "ok",
|
| 179 |
-
"service": "rmi-backend",
|
| 180 |
-
"version": "2026.06.21",
|
| 181 |
-
"deploy_mode": "new-system (no _legacy_main)",
|
| 182 |
-
"observability": {
|
| 183 |
-
"otel_enabled": os.getenv("OTEL_ENABLED", "false") == "true",
|
| 184 |
-
"langfuse_configured": bool(os.getenv("LANGFUSE_PUBLIC_KEY")),
|
| 185 |
-
},
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
@app.get("/live", include_in_schema=False)
|
| 190 |
-
async def live() -> dict[str, str]:
|
| 191 |
-
return {"status": "alive"}
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
@app.get("/ready", include_in_schema=False)
|
| 195 |
-
async def ready() -> dict[str, str]:
|
| 196 |
-
return {"status": "ready"}
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
# ── v1 router mounting (lazy, isolated failures) ─────────────────────
|
| 200 |
-
def _try_mount_v1_routers() -> int:
|
| 201 |
-
"""Mount every v1 router. Each mount is isolated — one failure does not
|
| 202 |
-
block the others. Returns count of successful mounts.
|
| 203 |
-
"""
|
| 204 |
-
import logging
|
| 205 |
-
|
| 206 |
-
log = logging.getLogger("rmi.main")
|
| 207 |
-
mounted = 0
|
| 208 |
-
|
| 209 |
-
v1_modules = [
|
| 210 |
-
"app.api.v1.auth.alerts",
|
| 211 |
-
# auth/wallet.py does not exist (was never created) — skip
|
| 212 |
-
"app.api.v1.public.wallet",
|
| 213 |
-
"app.api.v1.public.token",
|
| 214 |
-
"app.api.v1.public.scanner",
|
| 215 |
-
"app.api.v1.rag.search",
|
| 216 |
-
# x402 moved to app.domain.x402 (T34 v2) — old payments.py removed
|
| 217 |
-
"app.api.v1.admin.alerts_webhook",
|
| 218 |
-
"app.api.v1.catalog",
|
| 219 |
-
"app.api.v1.mcp",
|
| 220 |
-
"app.domain.news",
|
| 221 |
-
"app.domain.news.admin_router",
|
| 222 |
-
"app.domain.reports",
|
| 223 |
-
"app.domain.x402",
|
| 224 |
-
]
|
| 225 |
-
|
| 226 |
-
for module_path in v1_modules:
|
| 227 |
-
try:
|
| 228 |
-
import importlib
|
| 229 |
-
|
| 230 |
-
module = importlib.import_module(module_path)
|
| 231 |
-
router = getattr(module, "router", None)
|
| 232 |
-
if router is None:
|
| 233 |
-
log.warning(f"{module_path}: no router attribute, skipping")
|
| 234 |
-
continue
|
| 235 |
-
app.include_router(router)
|
| 236 |
-
mounted += 1
|
| 237 |
-
log.info(f"mounted {module_path}")
|
| 238 |
-
except Exception as exc: # noqa: BLE001
|
| 239 |
-
log.warning(
|
| 240 |
-
f"failed to mount {module_path}: {type(exc).__name__}: {exc}"
|
| 241 |
-
)
|
| 242 |
-
return mounted
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def _try_mount_health_route() -> bool:
|
| 246 |
-
"""Mount kubernetes-grade /live /ready /health from app.core.health_route."""
|
| 247 |
-
try:
|
| 248 |
-
from app.core.health_route import router as health_router
|
| 249 |
-
|
| 250 |
-
app.include_router(health_router)
|
| 251 |
-
return True
|
| 252 |
-
except Exception as exc: # noqa: BLE001
|
| 253 |
-
import logging
|
| 254 |
-
|
| 255 |
-
logging.getLogger("rmi.main").warning(f"health_route mount failed: {exc}")
|
| 256 |
-
return False
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
def _try_mount_metrics() -> bool:
|
| 260 |
-
"""Mount Prometheus /metrics endpoint."""
|
| 261 |
-
try:
|
| 262 |
-
from app.core.metrics import router as metrics_router
|
| 263 |
-
|
| 264 |
-
app.include_router(metrics_router)
|
| 265 |
-
return True
|
| 266 |
-
except Exception as exc: # noqa: BLE001
|
| 267 |
-
import logging
|
| 268 |
-
|
| 269 |
-
logging.getLogger("rmi.main").warning(f"metrics_route mount failed: {exc}")
|
| 270 |
-
return False
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
# ── Mount what we can ────────────────────────────────────────────────
|
| 274 |
-
_V1_MOUNTED = _try_mount_v1_routers()
|
| 275 |
-
_HEALTH_MOUNTED = _try_mount_health_route()
|
| 276 |
-
_METRICS_MOUNTED = _try_mount_metrics()
|
| 277 |
-
|
| 278 |
-
import logging
|
| 279 |
-
|
| 280 |
-
logging.getLogger("rmi.main").info(
|
| 281 |
-
f"rmi_backend_ready v1_routers={_V1_MOUNTED} "
|
| 282 |
-
f"health_route={_HEALTH_MOUNTED} metrics_route={_METRICS_MOUNTED} "
|
| 283 |
-
f"total_routes={len(app.routes)}"
|
| 284 |
-
)
|
| 285 |
-
|
| 286 |
|
| 287 |
if __name__ == "__main__":
|
| 288 |
import uvicorn
|
| 289 |
-
|
| 290 |
uvicorn.run(
|
| 291 |
"main:app",
|
| 292 |
-
host="0.0.0.0",
|
| 293 |
port=int(os.getenv("PORT", "8000")),
|
| 294 |
log_level=os.getenv("LOG_LEVEL", "info").lower(),
|
| 295 |
access_log=True,
|
|
|
|
| 1 |
+
"""RMI Backend — root entry point.
|
| 2 |
|
| 3 |
+
Minimal — delegates everything to app.factory.create_app().
|
|
|
|
| 4 |
|
| 5 |
+
Per v4.0 §T01 + modern factory pattern:
|
| 6 |
+
- main.py is the entry point ONLY
|
| 7 |
+
- All composition lives in app/factory.py
|
| 8 |
+
- Goal: <30 lines, single import, single uvicorn call
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
Run: `python -u main.py` (CMD in Dockerfile)
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
from app.factory import create_app
|
| 17 |
|
| 18 |
+
app = create_app()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
if __name__ == "__main__":
|
| 21 |
import uvicorn
|
|
|
|
| 22 |
uvicorn.run(
|
| 23 |
"main:app",
|
| 24 |
+
host=os.getenv("HOST", "0.0.0.0"),
|
| 25 |
port=int(os.getenv("PORT", "8000")),
|
| 26 |
log_level=os.getenv("LOG_LEVEL", "info").lower(),
|
| 27 |
access_log=True,
|