Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
626 lines
23 KiB
Python
626 lines
23 KiB
Python
"""
|
|
Hold Slayer Gateway — FastAPI Application Entry Point.
|
|
|
|
Your personal AI-powered telephony platform.
|
|
Navigates IVRs, waits on hold, and connects you when a human answers.
|
|
|
|
Usage:
|
|
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
|
|
|
# Or directly:
|
|
python main.py
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
import sys
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import Depends, FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from api import auth as auth_router
|
|
from api import call_flows, call_history, calls, devices, routing, tokens, websocket
|
|
from auth import get_current_owner, init_jwks_client, is_owner, resolve_from_header_or_query
|
|
from config import Settings, get_settings
|
|
from core.gateway import AIPSTNGateway, build_sip_engine
|
|
from db.database import close_db, init_db, session_scope
|
|
from mcp_server.server import create_mcp_server
|
|
from models.call import CallMode
|
|
from services.audio_classifier import AudioClassifier
|
|
from services.call_persistence import persist_call_on_create, persist_call_on_end
|
|
from services.hold_slayer import HoldSlayerService
|
|
from services.notification import NotificationService
|
|
from services.receptionist import ReceptionistService
|
|
from services.recording import RecordingService
|
|
from services.routing import RoutingService
|
|
from services.transcription import TranscriptionService
|
|
from services.tts import TTSService
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
stream=sys.stdout,
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _handle_db_error(exc: Exception) -> None:
|
|
"""Log a clear, human-readable database error and exit cleanly."""
|
|
# Walk the exception chain to find the root asyncpg/psycopg cause
|
|
cause = getattr(exc, "__cause__", None) or getattr(exc, "__context__", None)
|
|
root = cause or exc
|
|
root_type = type(root).__name__
|
|
root_msg = str(root)
|
|
|
|
if "InvalidPasswordError" in root_type or "password authentication failed" in root_msg:
|
|
logger.critical(
|
|
"\n"
|
|
"❌ Database authentication failed — wrong password.\n"
|
|
" The password in DATABASE_URL does not match the PostgreSQL user.\n"
|
|
" Fix DATABASE_URL in your .env file and restart.\n"
|
|
" Default: DATABASE_URL=postgresql+asyncpg://holdslayer:changeme@localhost:5432/holdslayer"
|
|
)
|
|
elif "InvalidCatalogNameError" in root_type or "does not exist" in root_msg:
|
|
logger.critical(
|
|
"\n"
|
|
"❌ Database does not exist.\n"
|
|
" Create it first: createdb holdslayer\n"
|
|
" Or update DATABASE_URL in your .env file."
|
|
)
|
|
elif (
|
|
"Connection refused" in root_msg
|
|
or "could not connect" in root_msg.lower()
|
|
):
|
|
logger.critical(
|
|
"\n"
|
|
"\u274c Cannot reach PostgreSQL \u2014 connection refused.\n"
|
|
" Is PostgreSQL running? Check DATABASE_URL in your .env file."
|
|
)
|
|
elif (
|
|
"nodename nor servname" in root_msg
|
|
or "Name or service not known" in root_msg
|
|
):
|
|
logger.critical(
|
|
"\n"
|
|
f"❌ Cannot resolve the database hostname.\n"
|
|
f" Check the host in DATABASE_URL in your .env file. (detail: {root_msg})"
|
|
)
|
|
else:
|
|
logger.critical(
|
|
f"\n❌ Database initialisation failed: {root_msg}\n"
|
|
f" Check DATABASE_URL in your .env file."
|
|
)
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
def _check_startup_config(settings: Settings) -> None:
|
|
"""Refuse insecure or incomplete configurations before booting anything."""
|
|
if not settings.database_url:
|
|
logger.critical(
|
|
"\n"
|
|
"❌ DATABASE_URL is not set.\n"
|
|
" Add it to your .env file, e.g.:\n"
|
|
" DATABASE_URL=postgresql+asyncpg://holdslayer:<password>@localhost:5432/holdslayer"
|
|
)
|
|
sys.exit(1)
|
|
|
|
loopback = settings.host in ("127.0.0.1", "localhost", "::1")
|
|
if settings.casdoor.enabled:
|
|
c = settings.casdoor
|
|
missing = [
|
|
name
|
|
for name, val in (
|
|
("CASDOOR_ENDPOINT", c.endpoint),
|
|
("CASDOOR_CLIENT_ID", c.client_id),
|
|
("CASDOOR_CLIENT_SECRET", c.client_secret.get_secret_value()),
|
|
("OWNER_NAME", settings.owner_name),
|
|
)
|
|
if not val
|
|
]
|
|
if missing:
|
|
logger.critical(
|
|
"\n"
|
|
"❌ CASDOOR_ENABLED=true but required settings are missing:\n"
|
|
f" {', '.join(missing)}\n"
|
|
" Set them in .env (Casdoor app credentials + the owner's "
|
|
"Casdoor username), or set CASDOOR_ENABLED=false with HOST=127.0.0.1 "
|
|
"for tokenless local development."
|
|
)
|
|
sys.exit(1)
|
|
elif not loopback:
|
|
logger.critical(
|
|
"\n"
|
|
"❌ CASDOOR_ENABLED=false but HOST binds beyond loopback "
|
|
f"({settings.host}).\n"
|
|
" Every surface (REST, WebSocket, MCP make_call) would resolve "
|
|
"to the dev owner — open to the network.\n"
|
|
" Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
|
|
"or set HOST=127.0.0.1 for tokenless local development."
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Startup: Initialize database, SIP engine, and services."""
|
|
settings = get_settings()
|
|
_check_startup_config(settings)
|
|
|
|
# Prefetch Casdoor's JWKS so the first authenticated request doesn't pay
|
|
# the network round-trip (no-op when SSO is disabled).
|
|
init_jwks_client()
|
|
|
|
# The MCP session manager lives in the mounted sub-app's lifespan;
|
|
# without entering it, every /mcp request 500s.
|
|
async with mcp_http_app.lifespan(app):
|
|
# Initialize database
|
|
logger.info("Initializing database...")
|
|
try:
|
|
await init_db()
|
|
except Exception as e:
|
|
_handle_db_error(e)
|
|
|
|
# === Composition root ===
|
|
# Build the gateway and every service here, wiring them by
|
|
# constructor/registration — nothing constructs its own deps.
|
|
gateway = AIPSTNGateway(
|
|
settings=settings,
|
|
on_call_created=persist_call_on_create,
|
|
on_call_ended=persist_call_on_end,
|
|
)
|
|
|
|
classifier = AudioClassifier(settings.classifier)
|
|
transcription = TranscriptionService(settings.speaches)
|
|
tts = TTSService(settings.tts)
|
|
routing_svc = RoutingService(gateway)
|
|
recording_svc = RecordingService()
|
|
receptionist = ReceptionistService(
|
|
gateway,
|
|
tts=tts,
|
|
transcription=transcription,
|
|
recording=recording_svc,
|
|
routing=routing_svc,
|
|
)
|
|
gateway.attach_services(tts=tts)
|
|
|
|
def launch_hold_slayer(call, sip_leg_id, call_flow_id):
|
|
svc = HoldSlayerService(
|
|
gateway=gateway,
|
|
call_manager=gateway.call_manager,
|
|
sip_engine=gateway.sip_engine,
|
|
classifier=classifier,
|
|
transcription=transcription,
|
|
settings=settings,
|
|
tts=tts,
|
|
)
|
|
gateway.spawn(
|
|
svc.run(call, sip_leg_id, call_flow_id),
|
|
name=f"holdslayer_{call.id}",
|
|
)
|
|
|
|
gateway.register_mode_handler(CallMode.HOLD_SLAYER, launch_hold_slayer)
|
|
|
|
try:
|
|
gateway.sip_engine = build_sip_engine(
|
|
settings,
|
|
gateway.media_pipeline,
|
|
on_leg_state_change=gateway._on_sip_leg_state,
|
|
on_device_registered=gateway._on_sip_device_registered,
|
|
on_incoming_call=receptionist.on_inbound_call,
|
|
)
|
|
except Exception as e:
|
|
logger.critical(f"\n❌ SIP engine failed to initialize:\n {e}")
|
|
sys.exit(1)
|
|
|
|
await routing_svc.start()
|
|
await gateway.start()
|
|
app.state.gateway = gateway
|
|
app.state.routing_service = routing_svc
|
|
app.state.transcription_service = transcription
|
|
|
|
notification_svc = NotificationService(gateway.event_bus, settings)
|
|
await notification_svc.start()
|
|
app.state.notification_service = notification_svc
|
|
|
|
await recording_svc.start()
|
|
app.state.recording_service = recording_svc
|
|
|
|
logger.info("=" * 60)
|
|
logger.info("🔥 Hold Slayer Gateway is LIVE")
|
|
# Show a usable URL — 0.0.0.0 is the bind address, not a browser URL
|
|
display_host = "localhost" if settings.host in ("0.0.0.0", "::") else settings.host
|
|
# When launched via `uvicorn main:app --port XXXX`, the CLI --port arg
|
|
# takes precedence over settings.port (which comes from .env).
|
|
display_port = settings.port
|
|
for i, arg in enumerate(sys.argv):
|
|
if arg in ("--port", "-p") and i + 1 < len(sys.argv):
|
|
try:
|
|
display_port = int(sys.argv[i + 1])
|
|
except ValueError:
|
|
pass
|
|
auth_state = (
|
|
f"Casdoor SSO (owner: {settings.owner_name or 'UNSET'})"
|
|
if settings.casdoor.enabled
|
|
else "dev-owner (loopback, no auth)"
|
|
)
|
|
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
|
|
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
|
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
|
logger.info(f" MCP: http://{display_host}:{display_port}/mcp/ (streamable HTTP)")
|
|
logger.info("=" * 60)
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
logger.info("Shutting down Hold Slayer Gateway...")
|
|
await notification_svc.stop()
|
|
await gateway.stop()
|
|
await close_db()
|
|
logger.info("Gateway shut down cleanly. 👋")
|
|
|
|
|
|
def _get_gateway_instance() -> AIPSTNGateway | None:
|
|
"""Lazy gateway resolver for MCP tools (set on app.state by the lifespan)."""
|
|
return getattr(app.state, "gateway", None)
|
|
|
|
|
|
mcp = create_mcp_server(_get_gateway_instance)
|
|
mcp_http_app = mcp.http_app(path="/")
|
|
|
|
|
|
def _public_base_url(scope_or_request) -> str:
|
|
"""Resolve this service's public base URL (scheme + host), no trailing slash.
|
|
|
|
Precedence: explicit PUBLIC_BASE_URL override → X-Forwarded-Proto/Host
|
|
(nginx/HAProxy) → Host header → localhost. Accepts either a FastAPI
|
|
``Request`` or a raw ASGI ``scope`` so the ASGI MCP guard and the FastAPI
|
|
discovery endpoints share one implementation.
|
|
"""
|
|
settings = get_settings()
|
|
if settings.public_base_url:
|
|
return settings.public_base_url.rstrip("/")
|
|
|
|
if hasattr(scope_or_request, "headers"):
|
|
headers = {k.lower(): v for k, v in scope_or_request.headers.items()}
|
|
default_scheme = getattr(scope_or_request.url, "scheme", None) or "http"
|
|
else:
|
|
headers = {
|
|
k.decode("latin-1").lower(): v.decode("latin-1")
|
|
for k, v in scope_or_request.get("headers", [])
|
|
}
|
|
default_scheme = scope_or_request.get("scheme", "http")
|
|
|
|
proto = (headers.get("x-forwarded-proto") or default_scheme).split(",", 1)[0].strip()
|
|
host = (headers.get("x-forwarded-host") or headers.get("host") or "localhost")
|
|
host = host.split(",", 1)[0].strip()
|
|
return f"{proto}://{host}"
|
|
|
|
|
|
def _owner_only_mcp(inner_app):
|
|
"""Wrap the mounted MCP ASGI app to require an owner bearer token.
|
|
|
|
MCP tools reach state via the FastMCP lifespan context, not FastAPI's
|
|
dependency system, so ``Depends`` can't gate ``/mcp``. Instead we read the
|
|
ASGI scope's ``Authorization`` header, resolve it (Casdoor JWT or PAT)
|
|
against a fresh DB session, and short-circuit non-owner requests with
|
|
401/403. In dev mode this resolves to the dev owner, so local development
|
|
keeps working without a token.
|
|
"""
|
|
|
|
async def _send_status(send, scope, status: int, body: bytes) -> None:
|
|
base = _public_base_url(scope)
|
|
resource_metadata_url = f"{base}/.well-known/oauth-protected-resource/mcp"
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": status,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(
|
|
b"www-authenticate",
|
|
f'Bearer realm="hold-slayer-mcp", '
|
|
f'resource_metadata="{resource_metadata_url}"'.encode(),
|
|
),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": body})
|
|
|
|
async def app(scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await inner_app(scope, receive, send)
|
|
return
|
|
|
|
authorization = None
|
|
for name, value in scope.get("headers", []):
|
|
if name == b"authorization":
|
|
authorization = value.decode("latin-1")
|
|
break
|
|
|
|
async with session_scope() as session:
|
|
user = await resolve_from_header_or_query(session, authorization, None)
|
|
if user is None:
|
|
await _send_status(send, scope, 401, b'{"detail":"Not authenticated"}')
|
|
return
|
|
if not is_owner(user):
|
|
await _send_status(send, scope, 403, b'{"detail":"Owner access required"}')
|
|
return
|
|
|
|
await inner_app(scope, receive, send)
|
|
|
|
return app
|
|
|
|
app = FastAPI(
|
|
title="Hold Slayer Gateway",
|
|
description=(
|
|
"🗡️ AI PSTN Gateway — Navigate IVRs, wait on hold, "
|
|
"and connect you when a human answers.\n\n"
|
|
"## Quick Start\n"
|
|
"1. **POST /api/v1/calls/hold-slayer** — Launch the Hold Slayer\n"
|
|
"2. **GET /api/v1/calls/{call_id}** — Check call status\n"
|
|
"3. **WS /ws/events** — Real-time event stream\n"
|
|
"4. **GET /api/v1/call-flows** — Manage stored IVR trees\n"
|
|
),
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# === API Routes ===
|
|
# Every protected surface is gated to the owner (Casdoor JWT or PAT). The
|
|
# unauthenticated OIDC endpoints live on the /auth router (login/callback/…).
|
|
# call_history must register before calls: both live under /api/v1/calls and
|
|
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
|
_auth = [Depends(get_current_owner)]
|
|
app.include_router(auth_router.router)
|
|
app.include_router(tokens.router, dependencies=_auth)
|
|
app.include_router(
|
|
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
|
|
)
|
|
app.include_router(calls.router, prefix="/api/v1/calls", tags=["Calls"], dependencies=_auth)
|
|
app.include_router(
|
|
call_flows.router, prefix="/api/v1/call-flows", tags=["Call Flows"], dependencies=_auth
|
|
)
|
|
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
|
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
|
|
# WebSocket endpoints check the owner themselves (query param or header)
|
|
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
|
|
|
# === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
|
|
# The ASGI guard resolves the bearer to the owner before the inner app runs.
|
|
app.mount("/mcp", _owner_only_mcp(mcp_http_app))
|
|
|
|
# In-memory store of dynamically registered OAuth clients (RFC 7591). MCP
|
|
# clients re-register each session; the real gate is the bearer token.
|
|
_registered_clients: dict[str, dict] = {}
|
|
|
|
|
|
@app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
|
|
@app.get("/.well-known/oauth-protected-resource/mcp", include_in_schema=False)
|
|
async def oauth_protected_resource_metadata(request: Request):
|
|
"""RFC 9728 Protected Resource Metadata — points MCP clients at the AS.
|
|
|
|
``resource`` advertises ``{base}/mcp`` (not the bare origin) because recent
|
|
``mcp-remote`` versions verify it matches the URL they connected to.
|
|
"""
|
|
base = _public_base_url(request)
|
|
return JSONResponse(
|
|
{
|
|
"resource": f"{base}/mcp",
|
|
"authorization_servers": [base],
|
|
"bearer_methods_supported": ["header"],
|
|
"resource_documentation": f"{base}/docs",
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/.well-known/oauth-authorization-server", include_in_schema=False)
|
|
async def oauth_authorization_server_metadata(request: Request):
|
|
"""RFC 8414 Authorization Server Metadata.
|
|
|
|
When Casdoor SSO is on, the real authorization server is Casdoor — advertise
|
|
its endpoints. In dev mode there's no OAuth server; clients supply a PAT
|
|
directly in their MCP configuration.
|
|
"""
|
|
base = _public_base_url(request)
|
|
settings = get_settings()
|
|
if settings.casdoor.enabled:
|
|
casdoor_base = settings.casdoor.endpoint.rstrip("/")
|
|
return JSONResponse(
|
|
{
|
|
"issuer": casdoor_base,
|
|
"authorization_endpoint": f"{casdoor_base}/login/oauth/authorize",
|
|
"token_endpoint": f"{casdoor_base}/api/login/oauth/access_token",
|
|
"jwks_uri": f"{casdoor_base}/.well-known/jwks",
|
|
"registration_endpoint": f"{base}/register",
|
|
"response_types_supported": ["code"],
|
|
"grant_types_supported": ["authorization_code"],
|
|
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
|
"scopes_supported": ["openid", "profile", "email"],
|
|
}
|
|
)
|
|
return JSONResponse(
|
|
{
|
|
"issuer": base,
|
|
"authorization_endpoint": f"{base}/auth/login",
|
|
"token_endpoint": f"{base}/auth/callback",
|
|
"registration_endpoint": f"{base}/register",
|
|
"response_types_supported": ["code"],
|
|
"grant_types_supported": ["authorization_code"],
|
|
}
|
|
)
|
|
|
|
|
|
@app.post("/register", include_in_schema=False)
|
|
async def oauth_dynamic_registration(request: Request):
|
|
"""RFC 7591 Dynamic Client Registration — accept any well-formed request.
|
|
|
|
Registered clients are held in memory (ephemeral); the real security gate
|
|
is the bearer token (PAT or Casdoor JWT) on every /mcp request.
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "invalid_client_metadata",
|
|
"error_description": "Request body must be valid JSON.",
|
|
},
|
|
)
|
|
|
|
redirect_uris = body.get("redirect_uris")
|
|
if not redirect_uris or not isinstance(redirect_uris, list):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": "invalid_redirect_uri",
|
|
"error_description": "redirect_uris is required and must be a non-empty list.",
|
|
},
|
|
)
|
|
|
|
client_id = secrets.token_hex(16)
|
|
now = int(time.time())
|
|
_registered_clients[client_id] = {
|
|
"client_id": client_id,
|
|
"client_id_issued_at": now,
|
|
"redirect_uris": redirect_uris,
|
|
"grant_types": body.get("grant_types", ["authorization_code"]),
|
|
"response_types": body.get("response_types", ["code"]),
|
|
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "none"),
|
|
"client_name": body.get("client_name"),
|
|
"scope": body.get("scope"),
|
|
}
|
|
logger.info("Registered OAuth client %s (name=%s)", client_id, body.get("client_name"))
|
|
return JSONResponse(
|
|
status_code=201,
|
|
content={
|
|
"client_id": client_id,
|
|
"client_id_issued_at": now,
|
|
"redirect_uris": redirect_uris,
|
|
"grant_types": _registered_clients[client_id]["grant_types"],
|
|
"response_types": _registered_clients[client_id]["response_types"],
|
|
"token_endpoint_auth_method": _registered_clients[client_id][
|
|
"token_endpoint_auth_method"
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
|
|
async def api_status():
|
|
"""Gateway status summary for the dashboard header."""
|
|
gateway = getattr(app.state, "gateway", None)
|
|
if gateway:
|
|
status = await gateway.status()
|
|
return {
|
|
"name": "Hold Slayer Gateway",
|
|
"version": "0.1.0",
|
|
"status": "running",
|
|
"uptime": status["uptime"],
|
|
"active_calls": status["active_calls"],
|
|
"trunk": status["trunk"],
|
|
}
|
|
return {
|
|
"name": "Hold Slayer Gateway",
|
|
"version": "0.1.0",
|
|
"status": "starting",
|
|
}
|
|
|
|
|
|
@app.get("/health", tags=["System"])
|
|
async def health():
|
|
"""
|
|
Health check. "healthy" means the gateway can actually do its job:
|
|
real engine, registered trunk, reachable database. A mock engine or
|
|
a failing dependency reports "degraded" with the reason visible.
|
|
"""
|
|
from core.sip_engine import MockSIPEngine
|
|
from db.database import session_scope
|
|
|
|
gateway = getattr(app.state, "gateway", None)
|
|
ready = gateway is not None and await gateway.sip_engine.is_ready()
|
|
trunk_status = await gateway.sip_engine.get_trunk_status() if gateway else {"registered": False}
|
|
engine_mode = (
|
|
"mock" if gateway is None or isinstance(gateway.sip_engine, MockSIPEngine)
|
|
else "sippy"
|
|
)
|
|
|
|
db_ok = False
|
|
db_error = None
|
|
try:
|
|
from sqlalchemy import text
|
|
async with session_scope() as session:
|
|
await session.execute(text("SELECT 1"))
|
|
db_ok = True
|
|
except Exception as e:
|
|
db_error = str(e)[:200]
|
|
|
|
healthy = (
|
|
ready
|
|
and db_ok
|
|
and engine_mode == "sippy"
|
|
and trunk_status.get("registered", False)
|
|
)
|
|
|
|
checks = {
|
|
"gateway": "ready" if gateway else "not initialized",
|
|
"engine": engine_mode,
|
|
"sip_engine": "ready" if ready else "not ready",
|
|
"database": "ok" if db_ok else f"error: {db_error}",
|
|
"sip_trunk": {
|
|
"registered": trunk_status.get("registered", False),
|
|
"host": trunk_status.get("host"),
|
|
"reason": trunk_status.get("reason"),
|
|
},
|
|
}
|
|
if gateway is not None:
|
|
tts = getattr(gateway, "_tts", None)
|
|
checks["tts"] = _availability(tts)
|
|
transcription = getattr(app.state, "transcription_service", None)
|
|
checks["stt"] = _availability(transcription)
|
|
|
|
return {"status": "healthy" if healthy else "degraded", **checks}
|
|
|
|
|
|
def _availability(service) -> str:
|
|
"""Last-known reachability of an HTTP leaf service."""
|
|
if service is None:
|
|
return "not attached"
|
|
available = getattr(service, "available", None)
|
|
if available is None:
|
|
return "unknown (no requests yet)"
|
|
return "ok" if available else "unreachable"
|
|
|
|
|
|
# === Dashboard (built SvelteKit static, served at the root) ===
|
|
# Registered last: a "/" mount matches every path, so the API, WS,
|
|
# health, and MCP routes above must come first.
|
|
import os as _os # noqa: E402
|
|
|
|
_dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build")
|
|
if _os.path.isdir(_dashboard_build):
|
|
app.mount(
|
|
"/",
|
|
StaticFiles(directory=_dashboard_build, html=True),
|
|
name="dashboard",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
settings = get_settings()
|
|
uvicorn.run(
|
|
"main:app",
|
|
host=settings.host,
|
|
port=settings.port,
|
|
reload=settings.debug,
|
|
log_level=settings.log_level,
|
|
)
|