File size: 6,817 Bytes
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4dc0836
79b0bef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""
src/api/main.py
────────────────────────────────────────────────────────────────
FastAPI application β€” entry point for the Clinical NLP API.

Start the server
────────────────
  Development (auto-reload on code changes):
    uvicorn src.api.main:app --reload --port 8000

  Production (multiple workers):
    uvicorn src.api.main:app --workers 4 --port 8000

API documentation
─────────────────
  Swagger UI : http://localhost:8000/docs
  ReDoc      : http://localhost:8000/redoc
  OpenAPI    : http://localhost:8000/openapi.json

Environment variables
─────────────────────
  DATABASE_URL  β€” SQLAlchemy DB URL (default: SQLite)
  API_DEBUG     β€” enable /docs and /redoc (default: true)
  API_PORT      β€” server port (used by the run helper)
  LOG_LEVEL     β€” DEBUG / INFO / WARNING (default: INFO)
────────────────────────────────────────────────────────────────
"""

from __future__ import annotations

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from src.api.routes import entities, icd, model, notes
from src.api.schemas import HealthResponse
from src.db.connection import check_connection, create_all_tables
from src.utils.config import APIConfig
from src.utils.logger import get_logger

logger = get_logger(__name__)


# ── Lifespan ──────────────────────────────────────────────────────
# FastAPI's lifespan replaces the older @app.on_event pattern.
# Code before `yield` runs at startup; code after runs at shutdown.

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Run startup and shutdown tasks for the application."""

    # ── Startup ───────────────────────────────────────────────────
    logger.info("=" * 50)
    logger.info("  Clinical NLP API  v%s", APIConfig.version)
    logger.info("=" * 50)

    # Ensure all database tables exist before the first request
    try:
        create_all_tables()
        db_status = "connected" if check_connection() else "unreachable"
    except Exception as exc:
        logger.error("Database setup failed: %s", exc)
        db_status = "unreachable"

    # Attach db_status to app.state so the health endpoint can read it
    app.state.db_status = db_status
    logger.info("Database: %s", db_status)

    # Eagerly load the NER pipeline, ICD-10 mapper, and classifier now,
    # rather than lazily on whichever request happens to be first.
    # Most deployment platforms hold off routing traffic until the
    # health check passes, so this cost is absorbed into startup time
    # rather than a real user's request.
    #
    # Skippable via WARM_UP_MODELS=false -- on a memory-constrained host
    # (e.g. Render's free 512Mi tier) the hybrid NER pipeline + classifier
    # can get OOM-killed by the OS at boot, which no try/except below can
    # catch or recover from since it isn't a Python exception. Skipping
    # warm-up at least lets the API start and serve DB-backed endpoints;
    # NLP endpoints will still need enough memory when actually called.
    if APIConfig.warm_up_models:
        try:
            notes.warm_up()
        except Exception:
            # Non-fatal: whatever didn't warm up here will load lazily on
            # whichever request needs it. exc_info=True so a resource-
            # contention failure (e.g. an import failing under low memory)
            # is actually diagnosable instead of logging an empty message.
            logger.exception("Model warm-up failed β€” falling back to lazy loading")
    else:
        logger.info("WARM_UP_MODELS=false β€” skipping eager model load, will load lazily per-request")

    logger.info("API ready at http://%s:%s", APIConfig.host, APIConfig.port)

    yield   # application is running

    # ── Shutdown ──────────────────────────────────────────────────
    logger.info("Shutting down Clinical NLP API...")


# ── Application ───────────────────────────────────────────────────

app = FastAPI(
    title       = APIConfig.title,
    version     = APIConfig.version,
    description = APIConfig.description,
    docs_url    = "/docs"    if APIConfig.debug else None,
    redoc_url   = "/redoc"   if APIConfig.debug else None,
    lifespan    = lifespan,
)

# CORS β€” allow the Streamlit dashboard (and any other origin in dev)
# to call the API from the browser.
# In production, restrict origins to your actual dashboard domain.
app.add_middleware(
    CORSMiddleware,
    allow_origins     = ["*"],   # tighten in production
    allow_credentials = True,
    allow_methods     = ["*"],
    allow_headers     = ["*"],
)


# ── Routers ───────────────────────────────────────────────────────

app.include_router(notes.router)
app.include_router(entities.router)
app.include_router(icd.router)
app.include_router(model.router)


# ── Health check ──────────────────────────────────────────────────

@app.get(
    "/health",
    response_model = HealthResponse,
    tags           = ["Health"],
    summary        = "API health check",
)
def health() -> HealthResponse:
    """Return the current health status of the API.

    Used by load balancers, monitoring tools, and the Streamlit
    dashboard to confirm the API is running before making requests.

    Returns:
        :class:`HealthResponse` with status, version, DB state, and
        whether semantic ICD-10 matching is available or degraded.
    """
    db_status = getattr(app.state, "db_status", "unknown")
    return HealthResponse(
        status   = "ok",
        version  = APIConfig.version,
        database = db_status,
        icd10_embedding_available = notes.icd10_embedding_available(),
    )


@app.get("/", include_in_schema=False)
def root():
    """Redirect root to the API docs."""
    from fastapi.responses import RedirectResponse
    return RedirectResponse(url="/docs")