Spaces:
Running
Running
Sync Space from GitHub c15b8ee76b1f7967bcedd07a6909b7ef899c2157
Browse files- Dockerfile +15 -13
- app/api/admin.py +2 -2
- app/api/health.py +3 -3
- app/api/interactions.py +8 -6
- app/api/schemas.py +13 -3
- app/clients/ddinter_db.py +152 -0
- app/main.py +7 -5
- app/services/interaction_checker.py +132 -123
- docs/openapi.json +65 -5
- scripts/ci-startup.sh +1 -1
- scripts/{download_drugbank_db.py → download_interaction_db.py} +5 -5
- scripts/e2e-test.sh +4 -2
- scripts/prod-startup.sh +1 -1
- scripts/smoke-test.sh +2 -1
- scripts/smoke_test_interactions.py +47 -22
- tests/regression/__init__.py +1 -0
- tests/regression/test_ddinter_seed_parity.py +44 -0
- tests/test_admin.py +7 -7
- tests/test_api.py +97 -42
- tests/test_coverage_audit.py +31 -0
- tests/test_ddinter_db.py +96 -0
- tests/test_drugbank_client.py +3 -0
- tests/test_drugbank_db.py +2 -0
- tests/test_interaction_checker.py +78 -231
Dockerfile
CHANGED
|
@@ -17,20 +17,21 @@ RUN uv sync --no-dev
|
|
| 17 |
# --- DB downloader stage ---
|
| 18 |
FROM python:3.12-slim AS db-downloader
|
| 19 |
|
| 20 |
-
WORKDIR /app/
|
| 21 |
|
| 22 |
-
# Download a pinned
|
| 23 |
-
|
| 24 |
-
ARG
|
| 25 |
-
|
| 26 |
-
COPY scripts/download_drugbank_db.py /tmp/download_drugbank_db.py
|
| 27 |
RUN --mount=type=secret,id=github_token,required=false \
|
| 28 |
-
test -n "${
|
|
|
|
| 29 |
if [ -f /run/secrets/github_token ]; then export GITHUB_TOKEN="$(cat /run/secrets/github_token)"; fi; \
|
| 30 |
-
python /tmp/
|
| 31 |
-
--repo "${
|
| 32 |
-
--tag "${
|
| 33 |
-
--
|
|
|
|
| 34 |
|
| 35 |
# --- Runtime stage ---
|
| 36 |
FROM python:3.12-slim
|
|
@@ -40,12 +41,13 @@ WORKDIR /app
|
|
| 40 |
# Copy built virtualenv from builder
|
| 41 |
COPY --from=builder /app/.venv /app/.venv
|
| 42 |
|
| 43 |
-
# Copy
|
| 44 |
-
COPY --from=db-downloader /app/
|
| 45 |
|
| 46 |
ENV PATH="/app/.venv/bin:$PATH"
|
| 47 |
ENV HF_HOME=/app/models
|
| 48 |
ENV TRANSFORMERS_CACHE=/app/models
|
|
|
|
| 49 |
|
| 50 |
# Pre-download NER model so the image is self-contained.
|
| 51 |
# Layer is cached until venv or model ID changes.
|
|
|
|
| 17 |
# --- DB downloader stage ---
|
| 18 |
FROM python:3.12-slim AS db-downloader
|
| 19 |
|
| 20 |
+
WORKDIR /app/data
|
| 21 |
|
| 22 |
+
# Download a pinned DDInter SQLite DB from the project's release source.
|
| 23 |
+
ARG INTERACTION_DB_REPO
|
| 24 |
+
ARG INTERACTION_DB_TAG
|
| 25 |
+
COPY scripts/download_interaction_db.py /tmp/download_interaction_db.py
|
|
|
|
| 26 |
RUN --mount=type=secret,id=github_token,required=false \
|
| 27 |
+
test -n "${INTERACTION_DB_REPO}" || { echo "INTERACTION_DB_REPO build arg is required"; exit 1; }; \
|
| 28 |
+
test -n "${INTERACTION_DB_TAG}" || { echo "INTERACTION_DB_TAG build arg is required"; exit 1; }; \
|
| 29 |
if [ -f /run/secrets/github_token ]; then export GITHUB_TOKEN="$(cat /run/secrets/github_token)"; fi; \
|
| 30 |
+
python /tmp/download_interaction_db.py \
|
| 31 |
+
--repo "${INTERACTION_DB_REPO}" \
|
| 32 |
+
--tag "${INTERACTION_DB_TAG}" \
|
| 33 |
+
--asset ddinter.db \
|
| 34 |
+
--output ddinter.db
|
| 35 |
|
| 36 |
# --- Runtime stage ---
|
| 37 |
FROM python:3.12-slim
|
|
|
|
| 41 |
# Copy built virtualenv from builder
|
| 42 |
COPY --from=builder /app/.venv /app/.venv
|
| 43 |
|
| 44 |
+
# Copy DDInter SQLite DB from downloader stage
|
| 45 |
+
COPY --from=db-downloader /app/data /app/data
|
| 46 |
|
| 47 |
ENV PATH="/app/.venv/bin:$PATH"
|
| 48 |
ENV HF_HOME=/app/models
|
| 49 |
ENV TRANSFORMERS_CACHE=/app/models
|
| 50 |
+
ENV INTERACTION_DB_PATH=/app/data/ddinter.db
|
| 51 |
|
| 52 |
# Pre-download NER model so the image is self-contained.
|
| 53 |
# Layer is cached until venv or model ID changes.
|
app/api/admin.py
CHANGED
|
@@ -4,7 +4,7 @@ import logging
|
|
| 4 |
|
| 5 |
from fastapi import APIRouter
|
| 6 |
|
| 7 |
-
from app.clients import
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
|
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
|
| 14 |
@router.post("/cache/clear")
|
| 15 |
async def clear_cache():
|
| 16 |
"""Clear all in-memory caches. Requires API key authentication."""
|
| 17 |
-
|
| 18 |
openfda_client._cache.clear()
|
| 19 |
logger.info("All caches cleared via admin endpoint")
|
| 20 |
return {"status": "ok", "message": "All caches cleared"}
|
|
|
|
| 4 |
|
| 5 |
from fastapi import APIRouter
|
| 6 |
|
| 7 |
+
from app.clients import openfda_client, rxnorm_client
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
|
|
|
| 14 |
@router.post("/cache/clear")
|
| 15 |
async def clear_cache():
|
| 16 |
"""Clear all in-memory caches. Requires API key authentication."""
|
| 17 |
+
rxnorm_client._cache.clear()
|
| 18 |
openfda_client._cache.clear()
|
| 19 |
logger.info("All caches cleared via admin endpoint")
|
| 20 |
return {"status": "ok", "message": "All caches cleared"}
|
app/api/health.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Health check endpoints."""
|
| 2 |
|
| 3 |
from fastapi import APIRouter
|
| 4 |
-
from app.clients import
|
| 5 |
from app.nlp import ner_model
|
| 6 |
|
| 7 |
router = APIRouter()
|
|
@@ -20,8 +20,8 @@ async def health_check():
|
|
| 20 |
@router.get("/health/data")
|
| 21 |
async def data_health_check():
|
| 22 |
"""Check the status of the drug interaction data source."""
|
| 23 |
-
connected = await
|
| 24 |
return {
|
| 25 |
"status": "ready" if connected else "degraded",
|
| 26 |
-
"
|
| 27 |
}
|
|
|
|
| 1 |
"""Health check endpoints."""
|
| 2 |
|
| 3 |
from fastapi import APIRouter
|
| 4 |
+
from app.clients import ddinter_db
|
| 5 |
from app.nlp import ner_model
|
| 6 |
|
| 7 |
router = APIRouter()
|
|
|
|
| 20 |
@router.get("/health/data")
|
| 21 |
async def data_health_check():
|
| 22 |
"""Check the status of the drug interaction data source."""
|
| 23 |
+
connected = await ddinter_db.client.health_check()
|
| 24 |
return {
|
| 25 |
"status": "ready" if connected else "degraded",
|
| 26 |
+
"ddinter": "connected" if connected else "unreachable",
|
| 27 |
}
|
app/api/interactions.py
CHANGED
|
@@ -1,17 +1,14 @@
|
|
| 1 |
"""POST /interactions — check drug-drug interactions."""
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter
|
| 4 |
|
| 5 |
-
from app.api.schemas import InteractionsDataSources, InteractionsRequest, InteractionsResponse
|
|
|
|
| 6 |
from app.nlp import severity_classifier
|
| 7 |
from app.services import interaction_checker
|
| 8 |
|
| 9 |
router = APIRouter()
|
| 10 |
|
| 11 |
-
|
| 12 |
-
from app.main import limiter
|
| 13 |
-
from fastapi import Request
|
| 14 |
-
|
| 15 |
@router.post("/interactions", response_model=InteractionsResponse)
|
| 16 |
@limiter.limit("10/minute")
|
| 17 |
async def check_interactions(request: Request, body: InteractionsRequest):
|
|
@@ -19,6 +16,11 @@ async def check_interactions(request: Request, body: InteractionsRequest):
|
|
| 19 |
return InteractionsResponse(
|
| 20 |
**result,
|
| 21 |
data_sources=InteractionsDataSources(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
severity_classifier=severity_classifier.MODEL_ID,
|
| 23 |
),
|
| 24 |
)
|
|
|
|
| 1 |
"""POST /interactions — check drug-drug interactions."""
|
| 2 |
|
| 3 |
+
from fastapi import APIRouter, Request
|
| 4 |
|
| 5 |
+
from app.api.schemas import DDInterDataSource, InteractionsDataSources, InteractionsRequest, InteractionsResponse
|
| 6 |
+
from app.main import limiter
|
| 7 |
from app.nlp import severity_classifier
|
| 8 |
from app.services import interaction_checker
|
| 9 |
|
| 10 |
router = APIRouter()
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
@router.post("/interactions", response_model=InteractionsResponse)
|
| 13 |
@limiter.limit("10/minute")
|
| 14 |
async def check_interactions(request: Request, body: InteractionsRequest):
|
|
|
|
| 16 |
return InteractionsResponse(
|
| 17 |
**result,
|
| 18 |
data_sources=InteractionsDataSources(
|
| 19 |
+
ddinter=DDInterDataSource(
|
| 20 |
+
version="2.0",
|
| 21 |
+
license="CC BY-NC-SA 4.0",
|
| 22 |
+
attribution_url="https://ddinter2.scbdd.com/",
|
| 23 |
+
),
|
| 24 |
severity_classifier=severity_classifier.MODEL_ID,
|
| 25 |
),
|
| 26 |
)
|
app/api/schemas.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Pydantic request/response models for the PillChecker API."""
|
| 2 |
|
| 3 |
-
from typing import Annotated
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field, StringConstraints
|
| 6 |
|
|
@@ -47,21 +47,30 @@ class DrugRef(BaseModel):
|
|
| 47 |
class InteractionResult(BaseModel):
|
| 48 |
drug_a: str
|
| 49 |
drug_b: str
|
|
|
|
|
|
|
| 50 |
severity: str
|
|
|
|
| 51 |
description: str
|
| 52 |
management: str
|
| 53 |
uncertain: bool = False
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
class InteractionsDataSources(BaseModel):
|
| 57 |
-
|
| 58 |
severity_classifier: str
|
| 59 |
|
| 60 |
|
| 61 |
_INTERACTION_LIMITATIONS = [
|
| 62 |
"Checks pairwise interactions only — multi-drug cascades are not detected",
|
| 63 |
"Does not account for patient-specific factors (age, weight, renal/hepatic function, genetics)",
|
| 64 |
-
"Coverage depends on
|
| 65 |
"Not a substitute for professional medical advice",
|
| 66 |
]
|
| 67 |
|
|
@@ -71,4 +80,5 @@ class InteractionsResponse(BaseModel):
|
|
| 71 |
safe: bool | None
|
| 72 |
error: str | None = None
|
| 73 |
data_sources: InteractionsDataSources | None = None
|
|
|
|
| 74 |
limitations: list[str] = _INTERACTION_LIMITATIONS
|
|
|
|
| 1 |
"""Pydantic request/response models for the PillChecker API."""
|
| 2 |
|
| 3 |
+
from typing import Annotated, Literal
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field, StringConstraints
|
| 6 |
|
|
|
|
| 47 |
class InteractionResult(BaseModel):
|
| 48 |
drug_a: str
|
| 49 |
drug_b: str
|
| 50 |
+
rxcui_a: str | None = None
|
| 51 |
+
rxcui_b: str | None = None
|
| 52 |
severity: str
|
| 53 |
+
source: Literal["ddinter", "openfda"]
|
| 54 |
description: str
|
| 55 |
management: str
|
| 56 |
uncertain: bool = False
|
| 57 |
|
| 58 |
|
| 59 |
+
class DDInterDataSource(BaseModel):
|
| 60 |
+
version: str
|
| 61 |
+
license: str
|
| 62 |
+
attribution_url: str
|
| 63 |
+
|
| 64 |
+
|
| 65 |
class InteractionsDataSources(BaseModel):
|
| 66 |
+
ddinter: DDInterDataSource | None = None
|
| 67 |
severity_classifier: str
|
| 68 |
|
| 69 |
|
| 70 |
_INTERACTION_LIMITATIONS = [
|
| 71 |
"Checks pairwise interactions only — multi-drug cascades are not detected",
|
| 72 |
"Does not account for patient-specific factors (age, weight, renal/hepatic function, genetics)",
|
| 73 |
+
"Coverage depends on the DDInter 2.0 corpus and OpenFDA labels",
|
| 74 |
"Not a substitute for professional medical advice",
|
| 75 |
]
|
| 76 |
|
|
|
|
| 80 |
safe: bool | None
|
| 81 |
error: str | None = None
|
| 82 |
data_sources: InteractionsDataSources | None = None
|
| 83 |
+
coverage_summary: dict[str, int] = Field(default_factory=lambda: {"ddinter": 0, "openfda": 0, "unknown": 0})
|
| 84 |
limitations: list[str] = _INTERACTION_LIMITATIONS
|
app/clients/ddinter_db.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async SQLite client for the DDInter interaction database."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
from urllib.parse import quote
|
| 11 |
+
|
| 12 |
+
import aiosqlite
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
DEFAULT_DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "ddinter.db")
|
| 17 |
+
DB_PATH = os.environ.get("INTERACTION_DB_PATH", DEFAULT_DB_PATH)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _escape_fts5(query: str) -> str:
|
| 21 |
+
"""Wrap a user string as an FTS5 phrase, escaping internal quotes."""
|
| 22 |
+
return '"' + query.replace('"', '""') + '"'
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class DDInterDatabase:
|
| 26 |
+
"""Async handle for the DDInter SQLite database.
|
| 27 |
+
|
| 28 |
+
One process currently owns one aiosqlite connection, so concurrent SELECTs
|
| 29 |
+
serialize through that connection's worker thread. Keep that simple shape
|
| 30 |
+
unless Cloud Run P99s show the need for a small read-only connection pool.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, db_path: str = DB_PATH):
|
| 34 |
+
self.db_path = db_path
|
| 35 |
+
self._conn: aiosqlite.Connection | None = None
|
| 36 |
+
self._connect_lock = asyncio.Lock()
|
| 37 |
+
|
| 38 |
+
async def connect(self) -> None:
|
| 39 |
+
if self._conn is not None:
|
| 40 |
+
return
|
| 41 |
+
async with self._connect_lock:
|
| 42 |
+
if self._conn is not None:
|
| 43 |
+
return
|
| 44 |
+
if not os.path.exists(self.db_path):
|
| 45 |
+
logger.error("DDInter database not found at %s", self.db_path)
|
| 46 |
+
raise FileNotFoundError(f"DDInter database not found: {self.db_path}")
|
| 47 |
+
conn = await aiosqlite.connect(_readonly_immutable_uri(self.db_path), uri=True)
|
| 48 |
+
conn.row_factory = aiosqlite.Row
|
| 49 |
+
self._conn = conn
|
| 50 |
+
logger.info("Connected to DDInter SQLite at %s", self.db_path)
|
| 51 |
+
|
| 52 |
+
async def close(self) -> None:
|
| 53 |
+
if self._conn:
|
| 54 |
+
await self._conn.close()
|
| 55 |
+
self._conn = None
|
| 56 |
+
|
| 57 |
+
async def health_check(self) -> bool:
|
| 58 |
+
try:
|
| 59 |
+
if self._conn is None:
|
| 60 |
+
await self.connect()
|
| 61 |
+
assert self._conn is not None
|
| 62 |
+
async with self._conn.execute("SELECT 1") as cursor:
|
| 63 |
+
await cursor.fetchone()
|
| 64 |
+
return True
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
logger.warning("DDInter health check failed: %s", exc)
|
| 67 |
+
if self._conn is not None:
|
| 68 |
+
try:
|
| 69 |
+
await self._conn.close()
|
| 70 |
+
except Exception:
|
| 71 |
+
pass
|
| 72 |
+
self._conn = None
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
async def _ddinter_ids_for_rxcuis(self, rxcuis: list[str]) -> dict[str, str]:
|
| 76 |
+
if not rxcuis:
|
| 77 |
+
return {}
|
| 78 |
+
if self._conn is None:
|
| 79 |
+
await self.connect()
|
| 80 |
+
assert self._conn is not None
|
| 81 |
+
placeholders = ",".join("?" * len(rxcuis))
|
| 82 |
+
async with self._conn.execute(
|
| 83 |
+
f"SELECT rxcui, ddinter_id FROM rxnorm_to_ddinter WHERE rxcui IN ({placeholders})",
|
| 84 |
+
tuple(rxcuis),
|
| 85 |
+
) as cursor:
|
| 86 |
+
rows = await cursor.fetchall()
|
| 87 |
+
return {row["rxcui"]: row["ddinter_id"] for row in rows}
|
| 88 |
+
|
| 89 |
+
async def _lookup_pair(self, ddinter_a: str, ddinter_b: str) -> dict[str, Any] | None:
|
| 90 |
+
if self._conn is None:
|
| 91 |
+
await self.connect()
|
| 92 |
+
assert self._conn is not None
|
| 93 |
+
async with self._conn.execute(
|
| 94 |
+
"""
|
| 95 |
+
SELECT drug_a_id, drug_a_name, drug_b_id, drug_b_name, severity, atc_category
|
| 96 |
+
FROM interactions
|
| 97 |
+
WHERE (drug_a_id = ? AND drug_b_id = ?)
|
| 98 |
+
OR (drug_a_id = ? AND drug_b_id = ?)
|
| 99 |
+
LIMIT 1
|
| 100 |
+
""",
|
| 101 |
+
(ddinter_a, ddinter_b, ddinter_b, ddinter_a),
|
| 102 |
+
) as cursor:
|
| 103 |
+
row = await cursor.fetchone()
|
| 104 |
+
if row is None:
|
| 105 |
+
return None
|
| 106 |
+
return {
|
| 107 |
+
"drug_a_id": row["drug_a_id"],
|
| 108 |
+
"drug_b_id": row["drug_b_id"],
|
| 109 |
+
"drug_a_name": row["drug_a_name"],
|
| 110 |
+
"drug_b_name": row["drug_b_name"],
|
| 111 |
+
"severity": row["severity"].lower(),
|
| 112 |
+
"atc_category": row["atc_category"],
|
| 113 |
+
"source": "ddinter",
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
async def lookup_by_rxcui(self, rxcui_a: str, rxcui_b: str) -> dict[str, Any] | None:
|
| 117 |
+
mapping = await self._ddinter_ids_for_rxcuis([rxcui_a, rxcui_b])
|
| 118 |
+
ddinter_a = mapping.get(rxcui_a)
|
| 119 |
+
ddinter_b = mapping.get(rxcui_b)
|
| 120 |
+
if not ddinter_a or not ddinter_b:
|
| 121 |
+
return None
|
| 122 |
+
return await self._lookup_pair(ddinter_a, ddinter_b)
|
| 123 |
+
|
| 124 |
+
async def _fts_ddinter_id_for_name(self, name: str) -> str | None:
|
| 125 |
+
if self._conn is None:
|
| 126 |
+
await self.connect()
|
| 127 |
+
assert self._conn is not None
|
| 128 |
+
async with self._conn.execute(
|
| 129 |
+
"SELECT ddinter_id FROM drug_names_fts WHERE name MATCH ? LIMIT 1",
|
| 130 |
+
(_escape_fts5(name),),
|
| 131 |
+
) as cursor:
|
| 132 |
+
row = await cursor.fetchone()
|
| 133 |
+
return row["ddinter_id"] if row else None
|
| 134 |
+
|
| 135 |
+
async def lookup_by_name_fts(self, name_a: str, name_b: str) -> dict[str, Any] | None:
|
| 136 |
+
try:
|
| 137 |
+
ddinter_a = await self._fts_ddinter_id_for_name(name_a)
|
| 138 |
+
ddinter_b = await self._fts_ddinter_id_for_name(name_b)
|
| 139 |
+
except Exception as exc:
|
| 140 |
+
logger.warning("DDInter FTS5 lookup failed: %s", exc)
|
| 141 |
+
return None
|
| 142 |
+
if not ddinter_a or not ddinter_b:
|
| 143 |
+
return None
|
| 144 |
+
return await self._lookup_pair(ddinter_a, ddinter_b)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
client = DDInterDatabase()
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _readonly_immutable_uri(db_path: str) -> str:
|
| 151 |
+
absolute_path = Path(db_path).resolve()
|
| 152 |
+
return f"file:{quote(str(absolute_path), safe='/')}?mode=ro&immutable=1"
|
app/main.py
CHANGED
|
@@ -21,7 +21,7 @@ from app.api.admin import router as admin_router
|
|
| 21 |
from app.api.analyze import router as analyze_router
|
| 22 |
from app.api.health import router as health_router
|
| 23 |
from app.api.interactions import router as interactions_router
|
| 24 |
-
from app.clients import
|
| 25 |
from app.middleware.api_key import APIKeyMiddleware
|
| 26 |
from app.middleware.audit_log import AuditLogMiddleware
|
| 27 |
from app.nlp import ner_model, severity_classifier
|
|
@@ -37,11 +37,13 @@ async def lifespan(app: FastAPI):
|
|
| 37 |
logger.info("Loading severity classifier...")
|
| 38 |
severity_classifier.load_model()
|
| 39 |
logger.info("Severity classifier loaded: %s", severity_classifier.is_loaded())
|
| 40 |
-
logger.info("Connecting to
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
| 43 |
yield
|
| 44 |
-
await
|
| 45 |
|
| 46 |
|
| 47 |
app = FastAPI(
|
|
|
|
| 21 |
from app.api.analyze import router as analyze_router
|
| 22 |
from app.api.health import router as health_router
|
| 23 |
from app.api.interactions import router as interactions_router
|
| 24 |
+
from app.clients import ddinter_db
|
| 25 |
from app.middleware.api_key import APIKeyMiddleware
|
| 26 |
from app.middleware.audit_log import AuditLogMiddleware
|
| 27 |
from app.nlp import ner_model, severity_classifier
|
|
|
|
| 37 |
logger.info("Loading severity classifier...")
|
| 38 |
severity_classifier.load_model()
|
| 39 |
logger.info("Severity classifier loaded: %s", severity_classifier.is_loaded())
|
| 40 |
+
logger.info("Connecting to DDInter SQLite database...")
|
| 41 |
+
# The baked DDInter DB is required for production startup; missing file
|
| 42 |
+
# errors should fail the revision instead of silently degrading coverage.
|
| 43 |
+
await ddinter_db.client.connect()
|
| 44 |
+
logger.info("DDInter SQLite connected: %s", await ddinter_db.client.health_check())
|
| 45 |
yield
|
| 46 |
+
await ddinter_db.client.close()
|
| 47 |
|
| 48 |
|
| 49 |
app = FastAPI(
|
app/services/interaction_checker.py
CHANGED
|
@@ -1,149 +1,167 @@
|
|
| 1 |
-
"""Interaction checker
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import logging
|
|
|
|
| 5 |
|
| 6 |
-
from app.clients import
|
| 7 |
from app.middleware.audit_log import get_audit_context
|
| 8 |
-
from app.nlp import severity_classifier
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
_MANAGEMENT = "Consult a healthcare professional for guidance."
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
-
async def check(drug_names: list[str]) -> dict:
|
| 16 |
-
"""Check interactions
|
| 17 |
-
|
| 18 |
-
Returns dict with:
|
| 19 |
-
- interactions: list of interaction dicts
|
| 20 |
-
- safe: bool | None (None if data source unavailable)
|
| 21 |
-
- error: str | None
|
| 22 |
-
|
| 23 |
-
Note: Per-drug DrugBank errors (malformed response, drug not found) return []
|
| 24 |
-
silently, so safe=True means "no interactions detected" not "guaranteed safe".
|
| 25 |
-
"""
|
| 26 |
if len(drug_names) < 2:
|
| 27 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
return_exceptions=True,
|
| 34 |
)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
all_failed = True
|
| 38 |
-
drug_interactions: dict[str, list[dict]] = {}
|
| 39 |
-
for name, result in zip(unique_names, results):
|
| 40 |
if isinstance(result, Exception):
|
| 41 |
-
logger.warning("
|
| 42 |
-
|
| 43 |
else:
|
| 44 |
-
|
| 45 |
-
drug_interactions[name] = result
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
# Check all pairs (use deduplicated list to avoid self-pairs)
|
| 56 |
-
interactions = []
|
| 57 |
-
for i, drug_a in enumerate(unique_names):
|
| 58 |
-
for drug_b in unique_names[i + 1:]:
|
| 59 |
-
result = await _find_interaction(drug_a, drug_b, drug_interactions)
|
| 60 |
-
if result:
|
| 61 |
-
logger.info(
|
| 62 |
-
"Interaction found: %s + %s = %s",
|
| 63 |
-
drug_a, drug_b, result["severity"],
|
| 64 |
-
)
|
| 65 |
-
interactions.append(result)
|
| 66 |
|
| 67 |
return {
|
| 68 |
"interactions": interactions,
|
| 69 |
"safe": len(interactions) == 0,
|
| 70 |
"error": None,
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
|
| 74 |
-
async def
|
| 75 |
drug_a: str,
|
| 76 |
drug_b: str,
|
| 77 |
-
|
| 78 |
-
) -> dict | None:
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
# Check A's list for B
|
| 84 |
-
match = _match_in_list(drug_b, drug_interactions.get(drug_a, []))
|
| 85 |
-
if match:
|
| 86 |
-
return await _format(drug_a, drug_b, match, source="drugbank")
|
| 87 |
-
|
| 88 |
-
# Check B's list for A
|
| 89 |
-
match = _match_in_list(drug_a, drug_interactions.get(drug_b, []))
|
| 90 |
-
if match:
|
| 91 |
-
return await _format(drug_a, drug_b, match, source="drugbank")
|
| 92 |
-
|
| 93 |
-
# At least one empty DrugBank list → cap-hit or error; try OpenFDA
|
| 94 |
-
if not drug_interactions.get(drug_a) or not drug_interactions.get(drug_b):
|
| 95 |
try:
|
| 96 |
-
|
| 97 |
-
if fda_match is None:
|
| 98 |
-
fda_match = await openfda_client.check_pair(drug_b, drug_a)
|
| 99 |
-
if fda_match:
|
| 100 |
-
return await _format(drug_a, drug_b, fda_match, source="openfda")
|
| 101 |
except Exception:
|
| 102 |
-
logger.warning("
|
| 103 |
-
|
| 104 |
-
return None
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def _match_in_list(target: str, interactions: list[dict]) -> dict | None:
|
| 108 |
-
"""Find target drug name in a list of interaction entries (case-insensitive)."""
|
| 109 |
-
target_lower = target.lower()
|
| 110 |
-
for entry in interactions:
|
| 111 |
-
if entry.get("drug", "").lower() == target_lower:
|
| 112 |
-
return entry
|
| 113 |
-
return None
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
async def _format(drug_a: str, drug_b: str, match: dict, source: str) -> dict:
|
| 117 |
-
"""Format an interaction entry for the API response.
|
| 118 |
-
|
| 119 |
-
Routes severity classification based on source:
|
| 120 |
-
- drugbank: template parser first, classifier fallback
|
| 121 |
-
- openfda: zero-shot classifier
|
| 122 |
-
"""
|
| 123 |
-
description = match.get("description", "")
|
| 124 |
-
|
| 125 |
-
# Check for pre-computed severity from DrugBank build (Phase 3)
|
| 126 |
-
precomputed_severity = match.get("severity")
|
| 127 |
-
if precomputed_severity and precomputed_severity != "unknown":
|
| 128 |
-
severity = precomputed_severity
|
| 129 |
-
uncertain = False
|
| 130 |
-
elif source == "drugbank":
|
| 131 |
-
severity = severity_parser.parse_severity(description)
|
| 132 |
-
if severity == "unknown":
|
| 133 |
-
# Template didn't match — fall back to classifier
|
| 134 |
-
loop = asyncio.get_running_loop()
|
| 135 |
-
severity, uncertain = await loop.run_in_executor(
|
| 136 |
-
None, severity_classifier.classify, description
|
| 137 |
-
)
|
| 138 |
else:
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
else:
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
ctx = get_audit_context()
|
| 148 |
if ctx:
|
| 149 |
ctx.add("severity_classification", {
|
|
@@ -152,14 +170,5 @@ async def _format(drug_a: str, drug_b: str, match: dict, source: str) -> dict:
|
|
| 152 |
"severity": severity,
|
| 153 |
"uncertain": uncertain,
|
| 154 |
"source": source,
|
| 155 |
-
"method":
|
| 156 |
})
|
| 157 |
-
|
| 158 |
-
return {
|
| 159 |
-
"drug_a": drug_a,
|
| 160 |
-
"drug_b": drug_b,
|
| 161 |
-
"severity": severity,
|
| 162 |
-
"description": description or "Interaction reported in drug database.",
|
| 163 |
-
"management": _MANAGEMENT,
|
| 164 |
-
"uncertain": uncertain,
|
| 165 |
-
}
|
|
|
|
| 1 |
+
"""Interaction checker with DDInter SQLite primary and OpenFDA fallback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
import logging
|
| 7 |
+
from typing import Any
|
| 8 |
|
| 9 |
+
from app.clients import ddinter_db, openfda_client, rxnorm_client
|
| 10 |
from app.middleware.audit_log import get_audit_context
|
| 11 |
+
from app.nlp import severity_classifier
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
_MANAGEMENT = "Consult a healthcare professional for guidance."
|
| 16 |
+
_EMPTY_COVERAGE = {"ddinter": 0, "openfda": 0, "unknown": 0}
|
| 17 |
|
| 18 |
|
| 19 |
+
async def check(drug_names: list[str]) -> dict[str, Any]:
|
| 20 |
+
"""Check pairwise interactions for the supplied drug names."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
if len(drug_names) < 2:
|
| 22 |
+
return {
|
| 23 |
+
"interactions": [],
|
| 24 |
+
"safe": True,
|
| 25 |
+
"error": None,
|
| 26 |
+
"coverage_summary": dict(_EMPTY_COVERAGE),
|
| 27 |
+
}
|
| 28 |
|
| 29 |
+
unique_names = []
|
| 30 |
+
seen_names: set[str] = set()
|
| 31 |
+
for name in drug_names:
|
| 32 |
+
key = name.casefold()
|
| 33 |
+
if key not in seen_names:
|
| 34 |
+
seen_names.add(key)
|
| 35 |
+
unique_names.append(name)
|
| 36 |
+
rxcui_results = await asyncio.gather(
|
| 37 |
+
*[rxnorm_client.get_rxcui(name) for name in unique_names],
|
| 38 |
return_exceptions=True,
|
| 39 |
)
|
| 40 |
+
rxcui_by_name: dict[str, str | None] = {}
|
| 41 |
+
for name, result in zip(unique_names, rxcui_results):
|
|
|
|
|
|
|
|
|
|
| 42 |
if isinstance(result, Exception):
|
| 43 |
+
logger.warning("RxNorm failed for %s: %s", name, result)
|
| 44 |
+
rxcui_by_name[name] = None
|
| 45 |
else:
|
| 46 |
+
rxcui_by_name[name] = result
|
|
|
|
| 47 |
|
| 48 |
+
interactions: list[dict[str, Any]] = []
|
| 49 |
+
coverage = dict(_EMPTY_COVERAGE)
|
| 50 |
+
for index, drug_a in enumerate(unique_names):
|
| 51 |
+
for drug_b in unique_names[index + 1:]:
|
| 52 |
+
entry, bucket = await _resolve_pair(drug_a, drug_b, rxcui_by_name)
|
| 53 |
+
coverage[bucket] += 1
|
| 54 |
+
if entry is not None:
|
| 55 |
+
interactions.append(entry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
return {
|
| 58 |
"interactions": interactions,
|
| 59 |
"safe": len(interactions) == 0,
|
| 60 |
"error": None,
|
| 61 |
+
"coverage_summary": coverage,
|
| 62 |
}
|
| 63 |
|
| 64 |
|
| 65 |
+
async def _resolve_pair(
|
| 66 |
drug_a: str,
|
| 67 |
drug_b: str,
|
| 68 |
+
rxcui_by_name: dict[str, str | None],
|
| 69 |
+
) -> tuple[dict[str, Any] | None, str]:
|
| 70 |
+
rxcui_a = rxcui_by_name.get(drug_a)
|
| 71 |
+
rxcui_b = rxcui_by_name.get(drug_b)
|
| 72 |
+
|
| 73 |
+
if rxcui_a and rxcui_b:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
try:
|
| 75 |
+
ddinter_hit = await ddinter_db.client.lookup_by_rxcui(rxcui_a, rxcui_b)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
except Exception:
|
| 77 |
+
logger.warning("DDInter RxCUI lookup failed for %s + %s", drug_a, drug_b, exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
else:
|
| 79 |
+
if ddinter_hit:
|
| 80 |
+
return _format_ddinter(drug_a, drug_b, rxcui_a, rxcui_b, ddinter_hit), "ddinter"
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
ddinter_hit = await ddinter_db.client.lookup_by_name_fts(drug_a, drug_b)
|
| 84 |
+
except Exception:
|
| 85 |
+
logger.warning("DDInter FTS lookup failed for %s + %s", drug_a, drug_b, exc_info=True)
|
| 86 |
else:
|
| 87 |
+
if ddinter_hit:
|
| 88 |
+
return _format_ddinter(drug_a, drug_b, rxcui_a, rxcui_b, ddinter_hit), "ddinter"
|
| 89 |
+
|
| 90 |
+
fda_hit = await _openfda_pair(drug_a, drug_b)
|
| 91 |
+
if fda_hit:
|
| 92 |
+
return await _format_openfda(drug_a, drug_b, rxcui_a, rxcui_b, fda_hit), "openfda"
|
| 93 |
+
|
| 94 |
+
return None, "unknown"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _format_ddinter(
|
| 98 |
+
drug_a: str,
|
| 99 |
+
drug_b: str,
|
| 100 |
+
rxcui_a: str | None,
|
| 101 |
+
rxcui_b: str | None,
|
| 102 |
+
hit: dict[str, Any],
|
| 103 |
+
) -> dict[str, Any]:
|
| 104 |
+
severity = hit.get("severity") or "unknown"
|
| 105 |
+
_audit_severity(drug_a, drug_b, severity, False, "ddinter", "ddinter_sqlite")
|
| 106 |
+
return {
|
| 107 |
+
"drug_a": drug_a,
|
| 108 |
+
"drug_b": drug_b,
|
| 109 |
+
"rxcui_a": rxcui_a,
|
| 110 |
+
"rxcui_b": rxcui_b,
|
| 111 |
+
"severity": severity,
|
| 112 |
+
"source": "ddinter",
|
| 113 |
+
"description": (
|
| 114 |
+
f"Interaction reported in DDInter 2.0 for "
|
| 115 |
+
f"{hit.get('drug_a_name', drug_a)} + {hit.get('drug_b_name', drug_b)}."
|
| 116 |
+
),
|
| 117 |
+
"management": _MANAGEMENT,
|
| 118 |
+
"uncertain": False,
|
| 119 |
+
}
|
| 120 |
|
| 121 |
+
|
| 122 |
+
async def _openfda_pair(drug_a: str, drug_b: str) -> dict[str, Any] | None:
|
| 123 |
+
try:
|
| 124 |
+
fda_hit = await openfda_client.check_pair(drug_a, drug_b)
|
| 125 |
+
if fda_hit is None:
|
| 126 |
+
fda_hit = await openfda_client.check_pair(drug_b, drug_a)
|
| 127 |
+
return fda_hit
|
| 128 |
+
except Exception:
|
| 129 |
+
logger.warning("OpenFDA fallback failed for %s + %s", drug_a, drug_b, exc_info=True)
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def _format_openfda(
|
| 134 |
+
drug_a: str,
|
| 135 |
+
drug_b: str,
|
| 136 |
+
rxcui_a: str | None,
|
| 137 |
+
rxcui_b: str | None,
|
| 138 |
+
hit: dict[str, Any],
|
| 139 |
+
) -> dict[str, Any]:
|
| 140 |
+
description = hit.get("description", "")
|
| 141 |
+
loop = asyncio.get_running_loop()
|
| 142 |
+
severity, uncertain = await loop.run_in_executor(None, severity_classifier.classify, description)
|
| 143 |
+
_audit_severity(drug_a, drug_b, severity, uncertain, "openfda", "zero_shot_classifier")
|
| 144 |
+
return {
|
| 145 |
+
"drug_a": drug_a,
|
| 146 |
+
"drug_b": drug_b,
|
| 147 |
+
"rxcui_a": rxcui_a,
|
| 148 |
+
"rxcui_b": rxcui_b,
|
| 149 |
+
"severity": severity,
|
| 150 |
+
"source": "openfda",
|
| 151 |
+
"description": description or "Interaction reported in FDA labeling.",
|
| 152 |
+
"management": _MANAGEMENT,
|
| 153 |
+
"uncertain": uncertain,
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _audit_severity(
|
| 158 |
+
drug_a: str,
|
| 159 |
+
drug_b: str,
|
| 160 |
+
severity: str,
|
| 161 |
+
uncertain: bool,
|
| 162 |
+
source: str,
|
| 163 |
+
method: str,
|
| 164 |
+
) -> None:
|
| 165 |
ctx = get_audit_context()
|
| 166 |
if ctx:
|
| 167 |
ctx.add("severity_classification", {
|
|
|
|
| 170 |
"severity": severity,
|
| 171 |
"uncertain": uncertain,
|
| 172 |
"source": source,
|
| 173 |
+
"method": method,
|
| 174 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/openapi.json
CHANGED
|
@@ -212,6 +212,29 @@
|
|
| 212 |
],
|
| 213 |
"title": "AnalyzeResponse"
|
| 214 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
"DrugResult": {
|
| 216 |
"properties": {
|
| 217 |
"rxcui": {
|
|
@@ -299,10 +322,40 @@
|
|
| 299 |
"type": "string",
|
| 300 |
"title": "Drug B"
|
| 301 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
"severity": {
|
| 303 |
"type": "string",
|
| 304 |
"title": "Severity"
|
| 305 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
"description": {
|
| 307 |
"type": "string",
|
| 308 |
"title": "Description"
|
|
@@ -322,6 +375,7 @@
|
|
| 322 |
"drug_a",
|
| 323 |
"drug_b",
|
| 324 |
"severity",
|
|
|
|
| 325 |
"description",
|
| 326 |
"management"
|
| 327 |
],
|
|
@@ -329,16 +383,15 @@
|
|
| 329 |
},
|
| 330 |
"InteractionsDataSources": {
|
| 331 |
"properties": {
|
| 332 |
-
"
|
| 333 |
"anyOf": [
|
| 334 |
{
|
| 335 |
-
"
|
| 336 |
},
|
| 337 |
{
|
| 338 |
"type": "null"
|
| 339 |
}
|
| 340 |
-
]
|
| 341 |
-
"title": "Drugbank Version"
|
| 342 |
},
|
| 343 |
"severity_classifier": {
|
| 344 |
"type": "string",
|
|
@@ -417,6 +470,13 @@
|
|
| 417 |
}
|
| 418 |
]
|
| 419 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
"limitations": {
|
| 421 |
"items": {
|
| 422 |
"type": "string"
|
|
@@ -426,7 +486,7 @@
|
|
| 426 |
"default": [
|
| 427 |
"Checks pairwise interactions only \u2014 multi-drug cascades are not detected",
|
| 428 |
"Does not account for patient-specific factors (age, weight, renal/hepatic function, genetics)",
|
| 429 |
-
"Coverage depends on
|
| 430 |
"Not a substitute for professional medical advice"
|
| 431 |
]
|
| 432 |
}
|
|
|
|
| 212 |
],
|
| 213 |
"title": "AnalyzeResponse"
|
| 214 |
},
|
| 215 |
+
"DDInterDataSource": {
|
| 216 |
+
"properties": {
|
| 217 |
+
"version": {
|
| 218 |
+
"type": "string",
|
| 219 |
+
"title": "Version"
|
| 220 |
+
},
|
| 221 |
+
"license": {
|
| 222 |
+
"type": "string",
|
| 223 |
+
"title": "License"
|
| 224 |
+
},
|
| 225 |
+
"attribution_url": {
|
| 226 |
+
"type": "string",
|
| 227 |
+
"title": "Attribution Url"
|
| 228 |
+
}
|
| 229 |
+
},
|
| 230 |
+
"type": "object",
|
| 231 |
+
"required": [
|
| 232 |
+
"version",
|
| 233 |
+
"license",
|
| 234 |
+
"attribution_url"
|
| 235 |
+
],
|
| 236 |
+
"title": "DDInterDataSource"
|
| 237 |
+
},
|
| 238 |
"DrugResult": {
|
| 239 |
"properties": {
|
| 240 |
"rxcui": {
|
|
|
|
| 322 |
"type": "string",
|
| 323 |
"title": "Drug B"
|
| 324 |
},
|
| 325 |
+
"rxcui_a": {
|
| 326 |
+
"anyOf": [
|
| 327 |
+
{
|
| 328 |
+
"type": "string"
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"type": "null"
|
| 332 |
+
}
|
| 333 |
+
],
|
| 334 |
+
"title": "Rxcui A"
|
| 335 |
+
},
|
| 336 |
+
"rxcui_b": {
|
| 337 |
+
"anyOf": [
|
| 338 |
+
{
|
| 339 |
+
"type": "string"
|
| 340 |
+
},
|
| 341 |
+
{
|
| 342 |
+
"type": "null"
|
| 343 |
+
}
|
| 344 |
+
],
|
| 345 |
+
"title": "Rxcui B"
|
| 346 |
+
},
|
| 347 |
"severity": {
|
| 348 |
"type": "string",
|
| 349 |
"title": "Severity"
|
| 350 |
},
|
| 351 |
+
"source": {
|
| 352 |
+
"type": "string",
|
| 353 |
+
"enum": [
|
| 354 |
+
"ddinter",
|
| 355 |
+
"openfda"
|
| 356 |
+
],
|
| 357 |
+
"title": "Source"
|
| 358 |
+
},
|
| 359 |
"description": {
|
| 360 |
"type": "string",
|
| 361 |
"title": "Description"
|
|
|
|
| 375 |
"drug_a",
|
| 376 |
"drug_b",
|
| 377 |
"severity",
|
| 378 |
+
"source",
|
| 379 |
"description",
|
| 380 |
"management"
|
| 381 |
],
|
|
|
|
| 383 |
},
|
| 384 |
"InteractionsDataSources": {
|
| 385 |
"properties": {
|
| 386 |
+
"ddinter": {
|
| 387 |
"anyOf": [
|
| 388 |
{
|
| 389 |
+
"$ref": "#/components/schemas/DDInterDataSource"
|
| 390 |
},
|
| 391 |
{
|
| 392 |
"type": "null"
|
| 393 |
}
|
| 394 |
+
]
|
|
|
|
| 395 |
},
|
| 396 |
"severity_classifier": {
|
| 397 |
"type": "string",
|
|
|
|
| 470 |
}
|
| 471 |
]
|
| 472 |
},
|
| 473 |
+
"coverage_summary": {
|
| 474 |
+
"additionalProperties": {
|
| 475 |
+
"type": "integer"
|
| 476 |
+
},
|
| 477 |
+
"type": "object",
|
| 478 |
+
"title": "Coverage Summary"
|
| 479 |
+
},
|
| 480 |
"limitations": {
|
| 481 |
"items": {
|
| 482 |
"type": "string"
|
|
|
|
| 486 |
"default": [
|
| 487 |
"Checks pairwise interactions only \u2014 multi-drug cascades are not detected",
|
| 488 |
"Does not account for patient-specific factors (age, weight, renal/hepatic function, genetics)",
|
| 489 |
+
"Coverage depends on the DDInter 2.0 corpus and OpenFDA labels",
|
| 490 |
"Not a substitute for professional medical advice"
|
| 491 |
]
|
| 492 |
}
|
scripts/ci-startup.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
set -e
|
| 3 |
|
| 4 |
# CI Startup Script
|
| 5 |
-
#
|
| 6 |
|
| 7 |
echo "[CI] Starting in CI mode."
|
| 8 |
|
|
|
|
| 2 |
set -e
|
| 3 |
|
| 4 |
# CI Startup Script
|
| 5 |
+
# DDInter SQLite DB is baked into the image at build time.
|
| 6 |
|
| 7 |
echo "[CI] Starting in CI mode."
|
| 8 |
|
scripts/{download_drugbank_db.py → download_interaction_db.py}
RENAMED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Download the pinned
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
@@ -11,7 +11,7 @@ from urllib.error import HTTPError
|
|
| 11 |
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
|
| 12 |
|
| 13 |
USER_AGENT = "pillchecker-api-build"
|
| 14 |
-
DEFAULT_ASSET = "
|
| 15 |
|
| 16 |
|
| 17 |
class _NoRedirectHandler(HTTPRedirectHandler):
|
|
@@ -91,15 +91,15 @@ def main() -> None:
|
|
| 91 |
parser.add_argument("--output", required=True)
|
| 92 |
args = parser.parse_args()
|
| 93 |
|
| 94 |
-
token
|
|
|
|
| 95 |
try:
|
| 96 |
release = _load_release(args.repo, args.tag, token)
|
| 97 |
asset_url = _find_asset_url(release, args.asset)
|
| 98 |
_download_asset(asset_url, Path(args.output), token)
|
| 99 |
except HTTPError as exc:
|
| 100 |
-
auth_hint = " with GitHub authentication" if token else " without GitHub authentication"
|
| 101 |
raise SystemExit(
|
| 102 |
-
f"Failed to download {args.asset} from {args.repo}@{args.tag}
|
| 103 |
) from exc
|
| 104 |
|
| 105 |
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Download the pinned DDInter SQLite release asset from a GitHub release."""
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 11 |
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
|
| 12 |
|
| 13 |
USER_AGENT = "pillchecker-api-build"
|
| 14 |
+
DEFAULT_ASSET = "ddinter.db"
|
| 15 |
|
| 16 |
|
| 17 |
class _NoRedirectHandler(HTTPRedirectHandler):
|
|
|
|
| 91 |
parser.add_argument("--output", required=True)
|
| 92 |
args = parser.parse_args()
|
| 93 |
|
| 94 |
+
# Public release; token is optional and only raises GitHub API limits.
|
| 95 |
+
token = os.environ.get("GITHUB_TOKEN")
|
| 96 |
try:
|
| 97 |
release = _load_release(args.repo, args.tag, token)
|
| 98 |
asset_url = _find_asset_url(release, args.asset)
|
| 99 |
_download_asset(asset_url, Path(args.output), token)
|
| 100 |
except HTTPError as exc:
|
|
|
|
| 101 |
raise SystemExit(
|
| 102 |
+
f"Failed to download {args.asset} from {args.repo}@{args.tag}: HTTP {exc.code}"
|
| 103 |
) from exc
|
| 104 |
|
| 105 |
|
scripts/e2e-test.sh
CHANGED
|
@@ -67,7 +67,7 @@ echo ""
|
|
| 67 |
echo "=== GET /health/data ==="
|
| 68 |
DATA_HEALTH=$(curl -sf "$BASE_URL/health/data")
|
| 69 |
assert_eq "status" "$(echo "$DATA_HEALTH" | jq -r '.status')" "ready"
|
| 70 |
-
assert_eq "
|
| 71 |
|
| 72 |
# ============================================
|
| 73 |
# 2. POST /analyze — contract validation
|
|
@@ -129,15 +129,17 @@ else fail "interactions array is empty"; fi
|
|
| 129 |
assert_not_empty "drug_a" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].drug_a')"
|
| 130 |
assert_not_empty "drug_b" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].drug_b')"
|
| 131 |
assert_not_empty "severity" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].severity')"
|
|
|
|
| 132 |
assert_not_empty "description" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].description')"
|
| 133 |
assert_not_empty "management" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].management')"
|
|
|
|
| 134 |
|
| 135 |
echo ""
|
| 136 |
echo "=== POST /interactions (safe pair) ==="
|
| 137 |
SAFE=$(curl -sf -X POST "$BASE_URL/interactions" \
|
| 138 |
-H "Content-Type: application/json" \
|
| 139 |
${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} \
|
| 140 |
-
-d '{"drugs": ["acetaminophen", "
|
| 141 |
assert_eq "safe" "$(echo "$SAFE" | jq -r '.safe')" "true"
|
| 142 |
assert_eq "no interactions" "$(echo "$SAFE" | jq '.interactions | length')" "0"
|
| 143 |
|
|
|
|
| 67 |
echo "=== GET /health/data ==="
|
| 68 |
DATA_HEALTH=$(curl -sf "$BASE_URL/health/data")
|
| 69 |
assert_eq "status" "$(echo "$DATA_HEALTH" | jq -r '.status')" "ready"
|
| 70 |
+
assert_eq "ddinter" "$(echo "$DATA_HEALTH" | jq -r '.ddinter')" "connected"
|
| 71 |
|
| 72 |
# ============================================
|
| 73 |
# 2. POST /analyze — contract validation
|
|
|
|
| 129 |
assert_not_empty "drug_a" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].drug_a')"
|
| 130 |
assert_not_empty "drug_b" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].drug_b')"
|
| 131 |
assert_not_empty "severity" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].severity')"
|
| 132 |
+
assert_not_empty "source" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].source')"
|
| 133 |
assert_not_empty "description" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].description')"
|
| 134 |
assert_not_empty "management" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].management')"
|
| 135 |
+
assert_eq "coverage_summary present" "$(echo "$INTERACTIONS" | jq '.coverage_summary.ddinter != null')" "true"
|
| 136 |
|
| 137 |
echo ""
|
| 138 |
echo "=== POST /interactions (safe pair) ==="
|
| 139 |
SAFE=$(curl -sf -X POST "$BASE_URL/interactions" \
|
| 140 |
-H "Content-Type: application/json" \
|
| 141 |
${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} \
|
| 142 |
+
-d '{"drugs": ["acetaminophen", "atorvastatin"]}')
|
| 143 |
assert_eq "safe" "$(echo "$SAFE" | jq -r '.safe')" "true"
|
| 144 |
assert_eq "no interactions" "$(echo "$SAFE" | jq '.interactions | length')" "0"
|
| 145 |
|
scripts/prod-startup.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
set -e
|
| 3 |
|
| 4 |
# Production Startup Script
|
| 5 |
-
#
|
| 6 |
|
| 7 |
echo "[PROD] Starting PillChecker API..."
|
| 8 |
|
|
|
|
| 2 |
set -e
|
| 3 |
|
| 4 |
# Production Startup Script
|
| 5 |
+
# DDInter SQLite DB is baked into the image at build time.
|
| 6 |
|
| 7 |
echo "[PROD] Starting PillChecker API..."
|
| 8 |
|
scripts/smoke-test.sh
CHANGED
|
@@ -70,7 +70,7 @@ echo ""
|
|
| 70 |
echo "=== GET /health/data ==="
|
| 71 |
DATA_HEALTH=$(curl -sf "$BASE_URL/health/data")
|
| 72 |
assert_eq "status" "$(echo "$DATA_HEALTH" | jq -r '.status')" "ready"
|
| 73 |
-
assert_eq "
|
| 74 |
|
| 75 |
# --- Test 2: POST /analyze ---
|
| 76 |
|
|
@@ -95,6 +95,7 @@ INTERACTIONS=$(curl -sf -X POST "$BASE_URL/interactions" \
|
|
| 95 |
|
| 96 |
assert_eq "safe" "$(echo "$INTERACTIONS" | jq -r '.safe')" "false"
|
| 97 |
assert_not_empty "severity" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].severity')"
|
|
|
|
| 98 |
assert_not_empty "description" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].description')"
|
| 99 |
|
| 100 |
# --- Summary ---
|
|
|
|
| 70 |
echo "=== GET /health/data ==="
|
| 71 |
DATA_HEALTH=$(curl -sf "$BASE_URL/health/data")
|
| 72 |
assert_eq "status" "$(echo "$DATA_HEALTH" | jq -r '.status')" "ready"
|
| 73 |
+
assert_eq "ddinter" "$(echo "$DATA_HEALTH" | jq -r '.ddinter')" "connected"
|
| 74 |
|
| 75 |
# --- Test 2: POST /analyze ---
|
| 76 |
|
|
|
|
| 95 |
|
| 96 |
assert_eq "safe" "$(echo "$INTERACTIONS" | jq -r '.safe')" "false"
|
| 97 |
assert_not_empty "severity" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].severity')"
|
| 98 |
+
assert_eq "source" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].source')" "ddinter"
|
| 99 |
assert_not_empty "description" "$(echo "$INTERACTIONS" | jq -r '.interactions[0].description')"
|
| 100 |
|
| 101 |
# --- Summary ---
|
scripts/smoke_test_interactions.py
CHANGED
|
@@ -13,52 +13,77 @@ Set API_KEY env var for authenticated endpoints.
|
|
| 13 |
|
| 14 |
import os
|
| 15 |
import sys
|
| 16 |
-
import
|
|
|
|
| 17 |
|
| 18 |
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000"
|
| 19 |
API_KEY = os.environ.get("API_KEY", "")
|
| 20 |
|
| 21 |
MUST_DETECT = [
|
| 22 |
-
("warfarin", "
|
| 23 |
-
("
|
| 24 |
-
("ritonavir", "simvastatin", "rhabdomyolysis — contraindicated"),
|
| 25 |
-
("methotrexate", "trimethoprim", "bone marrow suppression"),
|
| 26 |
]
|
| 27 |
|
| 28 |
MUST_BE_SAFE = [
|
| 29 |
-
("acetaminophen", "
|
| 30 |
]
|
| 31 |
|
| 32 |
|
| 33 |
-
def check_pair(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"""Check a single drug pair. Returns True if test passes."""
|
| 35 |
headers = {"Content-Type": "application/json"}
|
| 36 |
if API_KEY:
|
| 37 |
headers["X-API-Key"] = API_KEY
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
if
|
| 47 |
-
print(f" FAIL: HTTP {
|
| 48 |
return False
|
| 49 |
|
| 50 |
-
data =
|
| 51 |
actual_safe = data.get("safe")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
if actual_safe =
|
| 54 |
-
print(f" PASS: {drug_a} + {drug_b} → safe={actual_safe} ({reason})")
|
| 55 |
-
return True
|
| 56 |
-
else:
|
| 57 |
print(f" FAIL: {drug_a} + {drug_b} → safe={actual_safe}, expected={expected_safe} ({reason})")
|
| 58 |
if data.get("interactions"):
|
| 59 |
for ix in data["interactions"]:
|
| 60 |
print(f" {ix['severity']}: {ix['description'][:80]}")
|
| 61 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def main():
|
|
@@ -68,8 +93,8 @@ def main():
|
|
| 68 |
failed = 0
|
| 69 |
|
| 70 |
print("=== Must detect interaction (safe=false) ===")
|
| 71 |
-
for drug_a, drug_b, reason in MUST_DETECT:
|
| 72 |
-
if check_pair(drug_a, drug_b, expected_safe=False, reason=reason):
|
| 73 |
passed += 1
|
| 74 |
else:
|
| 75 |
failed += 1
|
|
|
|
| 13 |
|
| 14 |
import os
|
| 15 |
import sys
|
| 16 |
+
import json
|
| 17 |
+
from urllib import error, request
|
| 18 |
|
| 19 |
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000"
|
| 20 |
API_KEY = os.environ.get("API_KEY", "")
|
| 21 |
|
| 22 |
MUST_DETECT = [
|
| 23 |
+
("warfarin", "acetylsalicylic acid", "major bleeding risk", "ddinter"),
|
| 24 |
+
("warfarin", "ibuprofen", "major bleeding risk", None),
|
| 25 |
+
("ritonavir", "simvastatin", "rhabdomyolysis — contraindicated", None),
|
| 26 |
+
("methotrexate", "trimethoprim", "bone marrow suppression", None),
|
| 27 |
]
|
| 28 |
|
| 29 |
MUST_BE_SAFE = [
|
| 30 |
+
("acetaminophen", "atorvastatin", "no known interaction in DDInter seed"),
|
| 31 |
]
|
| 32 |
|
| 33 |
|
| 34 |
+
def check_pair(
|
| 35 |
+
drug_a: str,
|
| 36 |
+
drug_b: str,
|
| 37 |
+
expected_safe: bool,
|
| 38 |
+
reason: str,
|
| 39 |
+
expected_source: str | None = None,
|
| 40 |
+
) -> bool:
|
| 41 |
"""Check a single drug pair. Returns True if test passes."""
|
| 42 |
headers = {"Content-Type": "application/json"}
|
| 43 |
if API_KEY:
|
| 44 |
headers["X-API-Key"] = API_KEY
|
| 45 |
|
| 46 |
+
payload = json.dumps({"drugs": [drug_a, drug_b]}).encode("utf-8")
|
| 47 |
+
req = request.Request(f"{BASE_URL}/interactions", data=payload, headers=headers, method="POST")
|
| 48 |
+
try:
|
| 49 |
+
with request.urlopen(req, timeout=30) as resp:
|
| 50 |
+
status_code = resp.status
|
| 51 |
+
body = resp.read().decode("utf-8")
|
| 52 |
+
except error.HTTPError as exc:
|
| 53 |
+
print(f" FAIL: HTTP {exc.code}")
|
| 54 |
+
return False
|
| 55 |
+
except error.URLError as exc:
|
| 56 |
+
print(f" FAIL: request failed: {exc}")
|
| 57 |
+
return False
|
| 58 |
|
| 59 |
+
if status_code != 200:
|
| 60 |
+
print(f" FAIL: HTTP {status_code}")
|
| 61 |
return False
|
| 62 |
|
| 63 |
+
data = json.loads(body)
|
| 64 |
actual_safe = data.get("safe")
|
| 65 |
+
coverage = data.get("coverage_summary") or {}
|
| 66 |
+
if "ddinter" not in coverage:
|
| 67 |
+
print(f" FAIL: {drug_a} + {drug_b} → coverage_summary.ddinter missing")
|
| 68 |
+
return False
|
| 69 |
|
| 70 |
+
if actual_safe != expected_safe:
|
|
|
|
|
|
|
|
|
|
| 71 |
print(f" FAIL: {drug_a} + {drug_b} → safe={actual_safe}, expected={expected_safe} ({reason})")
|
| 72 |
if data.get("interactions"):
|
| 73 |
for ix in data["interactions"]:
|
| 74 |
print(f" {ix['severity']}: {ix['description'][:80]}")
|
| 75 |
return False
|
| 76 |
+
if expected_source:
|
| 77 |
+
interactions = data.get("interactions") or []
|
| 78 |
+
actual_source = interactions[0].get("source") if interactions else None
|
| 79 |
+
if actual_source != expected_source:
|
| 80 |
+
print(
|
| 81 |
+
f" FAIL: {drug_a} + {drug_b} → source={actual_source}, "
|
| 82 |
+
f"expected={expected_source} ({reason})"
|
| 83 |
+
)
|
| 84 |
+
return False
|
| 85 |
+
print(f" PASS: {drug_a} + {drug_b} → safe={actual_safe} ({reason})")
|
| 86 |
+
return True
|
| 87 |
|
| 88 |
|
| 89 |
def main():
|
|
|
|
| 93 |
failed = 0
|
| 94 |
|
| 95 |
print("=== Must detect interaction (safe=false) ===")
|
| 96 |
+
for drug_a, drug_b, reason, expected_source in MUST_DETECT:
|
| 97 |
+
if check_pair(drug_a, drug_b, expected_safe=False, reason=reason, expected_source=expected_source):
|
| 98 |
passed += 1
|
| 99 |
else:
|
| 100 |
failed += 1
|
tests/regression/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for externally seeded interaction behavior."""
|
tests/regression/test_ddinter_seed_parity.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression gate: DDInter should match curated seed severities.
|
| 2 |
+
|
| 3 |
+
This is not the temporary DrugBank-vs-DDInter parity check from the original
|
| 4 |
+
plan. It is a permanent guard against drifting away from curated smoke severity
|
| 5 |
+
ground truth in eval/interaction_seed_cases.json.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
DB_PATH = Path(os.environ.get("INTERACTION_DB_PATH", "data/ddinter.db"))
|
| 18 |
+
_SEED = json.loads(Path("eval/interaction_seed_cases.json").read_text())["positive_pairs"]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.parametrize("case", _SEED)
|
| 22 |
+
async def test_ddinter_seed_severity_matches_expected(case, monkeypatch):
|
| 23 |
+
if not DB_PATH.exists():
|
| 24 |
+
pytest.skip("DDInter DB missing; regression gate requires INTERACTION_DB_PATH or data/ddinter.db")
|
| 25 |
+
|
| 26 |
+
from app.clients import ddinter_db
|
| 27 |
+
from app.services import interaction_checker
|
| 28 |
+
|
| 29 |
+
await ddinter_db.client.close()
|
| 30 |
+
ddinter_db.client.db_path = str(DB_PATH)
|
| 31 |
+
monkeypatch.setattr(interaction_checker.openfda_client, "check_pair", _no_openfda)
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
result = await interaction_checker.check([case["drug_a"], case["drug_b"]])
|
| 35 |
+
if not result["interactions"]:
|
| 36 |
+
pytest.skip(f"DDInter returned no interaction for {case['drug_a']} + {case['drug_b']}")
|
| 37 |
+
assert result["interactions"][0]["source"] == "ddinter"
|
| 38 |
+
assert result["interactions"][0]["severity"] == case["severity"]
|
| 39 |
+
finally:
|
| 40 |
+
await ddinter_db.client.close()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def _no_openfda(_drug_a: str, _drug_b: str):
|
| 44 |
+
return None
|
tests/test_admin.py
CHANGED
|
@@ -8,21 +8,21 @@ from fastapi.testclient import TestClient
|
|
| 8 |
|
| 9 |
@pytest.fixture
|
| 10 |
def client():
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
mock_severity = MagicMock()
|
| 16 |
mock_severity.load_model = MagicMock()
|
| 17 |
mock_severity.is_loaded.return_value = True
|
| 18 |
|
| 19 |
with (
|
| 20 |
patch.dict(os.environ, {"API_KEY": "test-key"}),
|
| 21 |
-
patch("app.main.
|
| 22 |
patch("app.main.severity_classifier", mock_severity),
|
| 23 |
patch("app.main.ner_model"),
|
| 24 |
-
patch("app.api.health.
|
| 25 |
-
patch("app.services.interaction_checker.
|
| 26 |
patch("app.services.interaction_checker.severity_classifier", mock_severity),
|
| 27 |
):
|
| 28 |
from app.main import app
|
|
|
|
| 8 |
|
| 9 |
@pytest.fixture
|
| 10 |
def client():
|
| 11 |
+
mock_ddinter = MagicMock()
|
| 12 |
+
mock_ddinter.connect = AsyncMock()
|
| 13 |
+
mock_ddinter.close = AsyncMock()
|
| 14 |
+
mock_ddinter.health_check = AsyncMock(return_value=True)
|
| 15 |
mock_severity = MagicMock()
|
| 16 |
mock_severity.load_model = MagicMock()
|
| 17 |
mock_severity.is_loaded.return_value = True
|
| 18 |
|
| 19 |
with (
|
| 20 |
patch.dict(os.environ, {"API_KEY": "test-key"}),
|
| 21 |
+
patch("app.main.ddinter_db.client", mock_ddinter),
|
| 22 |
patch("app.main.severity_classifier", mock_severity),
|
| 23 |
patch("app.main.ner_model"),
|
| 24 |
+
patch("app.api.health.ddinter_db.client", mock_ddinter),
|
| 25 |
+
patch("app.services.interaction_checker.ddinter_db.client", mock_ddinter),
|
| 26 |
patch("app.services.interaction_checker.severity_classifier", mock_severity),
|
| 27 |
):
|
| 28 |
from app.main import app
|
tests/test_api.py
CHANGED
|
@@ -10,17 +10,17 @@ from fastapi.testclient import TestClient
|
|
| 10 |
|
| 11 |
|
| 12 |
@pytest.fixture
|
| 13 |
-
def
|
| 14 |
-
"""Mock
|
| 15 |
mock = MagicMock()
|
| 16 |
-
mock.get_interactions = AsyncMock()
|
| 17 |
mock.health_check = AsyncMock(return_value=True)
|
| 18 |
mock.connect = AsyncMock()
|
| 19 |
mock.close = AsyncMock()
|
| 20 |
-
mock.
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
patch("app.
|
|
|
|
| 24 |
yield mock
|
| 25 |
|
| 26 |
|
|
@@ -37,16 +37,7 @@ def mock_severity():
|
|
| 37 |
|
| 38 |
|
| 39 |
@pytest.fixture
|
| 40 |
-
def
|
| 41 |
-
"""Mock severity_parser in interaction checker."""
|
| 42 |
-
mock = MagicMock()
|
| 43 |
-
mock.parse_severity.return_value = "moderate"
|
| 44 |
-
with patch("app.services.interaction_checker.severity_parser", mock):
|
| 45 |
-
yield mock
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@pytest.fixture
|
| 49 |
-
def client(mock_drugbank, mock_severity, mock_severity_parser):
|
| 50 |
from app.main import app
|
| 51 |
return TestClient(app)
|
| 52 |
|
|
@@ -129,37 +120,101 @@ class TestInteractionsValidation:
|
|
| 129 |
assert resp.status_code == 422
|
| 130 |
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
class TestInteractionsEndpoint:
|
| 133 |
-
def test_known_interaction(self, client
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
assert resp.status_code == 200
|
| 140 |
data = resp.json()
|
| 141 |
assert data["safe"] is False
|
| 142 |
assert len(data["interactions"]) >= 1
|
| 143 |
assert data["interactions"][0]["severity"] in ["major", "moderate"]
|
|
|
|
|
|
|
| 144 |
assert "data_sources" in data
|
|
|
|
| 145 |
assert "severity_classifier" in data["data_sources"]
|
| 146 |
|
| 147 |
-
def test_no_interaction(self, client
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
| 152 |
assert resp.status_code == 200
|
| 153 |
data = resp.json()
|
| 154 |
assert data["safe"] is True
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
assert resp.status_code == 200
|
| 164 |
data = resp.json()
|
| 165 |
assert len(data["interactions"]) >= 2
|
|
@@ -181,18 +236,18 @@ class TestHealthEndpoint:
|
|
| 181 |
assert data["status"] == "ok"
|
| 182 |
assert data["version"] == "0.1.0"
|
| 183 |
|
| 184 |
-
def test_data_health_connected(self, client,
|
| 185 |
-
|
| 186 |
resp = client.get("/health/data")
|
| 187 |
assert resp.status_code == 200
|
| 188 |
data = resp.json()
|
| 189 |
assert data["status"] == "ready"
|
| 190 |
-
assert data["
|
| 191 |
|
| 192 |
-
def test_data_health_degraded(self, client,
|
| 193 |
-
|
| 194 |
resp = client.get("/health/data")
|
| 195 |
assert resp.status_code == 200
|
| 196 |
data = resp.json()
|
| 197 |
assert data["status"] == "degraded"
|
| 198 |
-
assert data["
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
@pytest.fixture
|
| 13 |
+
def mock_ddinter():
|
| 14 |
+
"""Mock DDInter client in every module that imports it."""
|
| 15 |
mock = MagicMock()
|
|
|
|
| 16 |
mock.health_check = AsyncMock(return_value=True)
|
| 17 |
mock.connect = AsyncMock()
|
| 18 |
mock.close = AsyncMock()
|
| 19 |
+
mock.lookup_by_rxcui = AsyncMock(return_value=None)
|
| 20 |
+
mock.lookup_by_name_fts = AsyncMock(return_value=None)
|
| 21 |
+
with patch("app.services.interaction_checker.ddinter_db.client", mock), \
|
| 22 |
+
patch("app.api.health.ddinter_db.client", mock), \
|
| 23 |
+
patch("app.main.ddinter_db.client", mock):
|
| 24 |
yield mock
|
| 25 |
|
| 26 |
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
@pytest.fixture
|
| 40 |
+
def client(mock_ddinter, mock_severity):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
from app.main import app
|
| 42 |
return TestClient(app)
|
| 43 |
|
|
|
|
| 120 |
assert resp.status_code == 422
|
| 121 |
|
| 122 |
|
| 123 |
+
def test_interaction_result_accepts_new_fields():
|
| 124 |
+
from app.api.schemas import InteractionResult
|
| 125 |
+
|
| 126 |
+
result = InteractionResult(
|
| 127 |
+
drug_a="Warfarin",
|
| 128 |
+
drug_b="Aspirin",
|
| 129 |
+
rxcui_a="11289",
|
| 130 |
+
rxcui_b="1191",
|
| 131 |
+
severity="major",
|
| 132 |
+
source="ddinter",
|
| 133 |
+
description="",
|
| 134 |
+
management="Consult a healthcare professional.",
|
| 135 |
+
uncertain=False,
|
| 136 |
+
)
|
| 137 |
+
assert result.source == "ddinter"
|
| 138 |
+
assert result.rxcui_a == "11289"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_interactions_response_includes_coverage_summary():
|
| 142 |
+
from app.api.schemas import DDInterDataSource, InteractionsDataSources, InteractionsResponse
|
| 143 |
+
|
| 144 |
+
response = InteractionsResponse(
|
| 145 |
+
interactions=[],
|
| 146 |
+
safe=True,
|
| 147 |
+
error=None,
|
| 148 |
+
data_sources=InteractionsDataSources(
|
| 149 |
+
ddinter=DDInterDataSource(
|
| 150 |
+
version="2.0",
|
| 151 |
+
license="CC BY-NC-SA 4.0",
|
| 152 |
+
attribution_url="https://ddinter2.scbdd.com/",
|
| 153 |
+
),
|
| 154 |
+
severity_classifier="model-id",
|
| 155 |
+
),
|
| 156 |
+
coverage_summary={"ddinter": 0, "openfda": 0, "unknown": 0},
|
| 157 |
+
)
|
| 158 |
+
assert response.coverage_summary == {"ddinter": 0, "openfda": 0, "unknown": 0}
|
| 159 |
+
assert response.data_sources.ddinter.version == "2.0"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
class TestInteractionsEndpoint:
|
| 163 |
+
def test_known_interaction(self, client):
|
| 164 |
+
with patch("app.api.interactions.interaction_checker.check", new=AsyncMock(return_value={
|
| 165 |
+
"interactions": [{
|
| 166 |
+
"drug_a": "ibuprofen",
|
| 167 |
+
"drug_b": "warfarin",
|
| 168 |
+
"rxcui_a": "5640",
|
| 169 |
+
"rxcui_b": "11289",
|
| 170 |
+
"severity": "major",
|
| 171 |
+
"source": "ddinter",
|
| 172 |
+
"description": "Interaction reported in DDInter 2.0.",
|
| 173 |
+
"management": "Consult a healthcare professional for guidance.",
|
| 174 |
+
"uncertain": False,
|
| 175 |
+
}],
|
| 176 |
+
"safe": False,
|
| 177 |
+
"error": None,
|
| 178 |
+
"coverage_summary": {"ddinter": 1, "openfda": 0, "unknown": 0},
|
| 179 |
+
})):
|
| 180 |
+
resp = client.post("/interactions", json={"drugs": ["ibuprofen", "warfarin"]})
|
| 181 |
assert resp.status_code == 200
|
| 182 |
data = resp.json()
|
| 183 |
assert data["safe"] is False
|
| 184 |
assert len(data["interactions"]) >= 1
|
| 185 |
assert data["interactions"][0]["severity"] in ["major", "moderate"]
|
| 186 |
+
assert data["interactions"][0]["source"] == "ddinter"
|
| 187 |
+
assert data["coverage_summary"]["ddinter"] == 1
|
| 188 |
assert "data_sources" in data
|
| 189 |
+
assert data["data_sources"]["ddinter"]["version"] == "2.0"
|
| 190 |
assert "severity_classifier" in data["data_sources"]
|
| 191 |
|
| 192 |
+
def test_no_interaction(self, client):
|
| 193 |
+
with patch("app.api.interactions.interaction_checker.check", new=AsyncMock(return_value={
|
| 194 |
+
"interactions": [],
|
| 195 |
+
"safe": True,
|
| 196 |
+
"error": None,
|
| 197 |
+
"coverage_summary": {"ddinter": 0, "openfda": 0, "unknown": 1},
|
| 198 |
+
})):
|
| 199 |
+
resp = client.post("/interactions", json={"drugs": ["ibuprofen", "amoxicillin"]})
|
| 200 |
assert resp.status_code == 200
|
| 201 |
data = resp.json()
|
| 202 |
assert data["safe"] is True
|
| 203 |
+
assert data["coverage_summary"]["unknown"] == 1
|
| 204 |
+
|
| 205 |
+
def test_three_drugs(self, client):
|
| 206 |
+
with patch("app.api.interactions.interaction_checker.check", new=AsyncMock(return_value={
|
| 207 |
+
"interactions": [
|
| 208 |
+
{"drug_a": "ibuprofen", "drug_b": "warfarin", "severity": "major", "source": "ddinter",
|
| 209 |
+
"description": "x", "management": "m", "uncertain": False},
|
| 210 |
+
{"drug_a": "warfarin", "drug_b": "aspirin", "severity": "major", "source": "ddinter",
|
| 211 |
+
"description": "x", "management": "m", "uncertain": False},
|
| 212 |
+
],
|
| 213 |
+
"safe": False,
|
| 214 |
+
"error": None,
|
| 215 |
+
"coverage_summary": {"ddinter": 2, "openfda": 0, "unknown": 1},
|
| 216 |
+
})):
|
| 217 |
+
resp = client.post("/interactions", json={"drugs": ["ibuprofen", "warfarin", "aspirin"]})
|
| 218 |
assert resp.status_code == 200
|
| 219 |
data = resp.json()
|
| 220 |
assert len(data["interactions"]) >= 2
|
|
|
|
| 236 |
assert data["status"] == "ok"
|
| 237 |
assert data["version"] == "0.1.0"
|
| 238 |
|
| 239 |
+
def test_data_health_connected(self, client, mock_ddinter):
|
| 240 |
+
mock_ddinter.health_check.return_value = True
|
| 241 |
resp = client.get("/health/data")
|
| 242 |
assert resp.status_code == 200
|
| 243 |
data = resp.json()
|
| 244 |
assert data["status"] == "ready"
|
| 245 |
+
assert data["ddinter"] == "connected"
|
| 246 |
|
| 247 |
+
def test_data_health_degraded(self, client, mock_ddinter):
|
| 248 |
+
mock_ddinter.health_check.return_value = False
|
| 249 |
resp = client.get("/health/data")
|
| 250 |
assert resp.status_code == 200
|
| 251 |
data = resp.json()
|
| 252 |
assert data["status"] == "degraded"
|
| 253 |
+
assert data["ddinter"] == "unreachable"
|
tests/test_coverage_audit.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for DDInter coverage audit input parsing."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from eval import coverage_audit
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_collect_names_from_interaction_seed_file(tmp_path: Path):
|
| 12 |
+
path = tmp_path / "interaction_seed_cases.json"
|
| 13 |
+
path.write_text(json.dumps({
|
| 14 |
+
"positive_pairs": [{"drug_a": "warfarin", "drug_b": "ibuprofen"}],
|
| 15 |
+
"known_safe_pairs": [{"drug_a": "acetaminophen", "drug_b": "amoxicillin"}],
|
| 16 |
+
}))
|
| 17 |
+
assert coverage_audit.collect_names(path) == {
|
| 18 |
+
"acetaminophen",
|
| 19 |
+
"amoxicillin",
|
| 20 |
+
"ibuprofen",
|
| 21 |
+
"warfarin",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_collect_names_from_jsonl_benchmark_records(tmp_path: Path):
|
| 26 |
+
path = tmp_path / "benchmark.jsonl"
|
| 27 |
+
path.write_text(
|
| 28 |
+
json.dumps({"expected_names": ["Warfarin", "Ibuprofen"]}) + "\n" +
|
| 29 |
+
json.dumps({"drugs": ["Aspirin"]}) + "\n"
|
| 30 |
+
)
|
| 31 |
+
assert coverage_audit.collect_names(path) == {"Warfarin", "Ibuprofen", "Aspirin"}
|
tests/test_ddinter_db.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the DDInter SQLite client."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sqlite3
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from app.clients import ddinter_db as ddc
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def populated_db(tmp_path: Path, monkeypatch) -> Path:
|
| 15 |
+
db = tmp_path / "ddinter.db"
|
| 16 |
+
conn = sqlite3.connect(db)
|
| 17 |
+
conn.executescript("""
|
| 18 |
+
CREATE TABLE interactions (
|
| 19 |
+
drug_a_id TEXT, drug_a_name TEXT,
|
| 20 |
+
drug_b_id TEXT, drug_b_name TEXT,
|
| 21 |
+
severity TEXT CHECK (severity IN ('Minor','Moderate','Major','Unknown')),
|
| 22 |
+
atc_category TEXT,
|
| 23 |
+
PRIMARY KEY (drug_a_id, drug_b_id)
|
| 24 |
+
);
|
| 25 |
+
CREATE TABLE rxnorm_to_ddinter (
|
| 26 |
+
rxcui TEXT PRIMARY KEY, ddinter_id TEXT,
|
| 27 |
+
canonical_name TEXT, match_method TEXT
|
| 28 |
+
);
|
| 29 |
+
CREATE VIRTUAL TABLE drug_names_fts USING fts5(ddinter_id UNINDEXED, name);
|
| 30 |
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
|
| 31 |
+
""")
|
| 32 |
+
conn.execute("INSERT INTO interactions VALUES ('DDInter1','Warfarin','DDInter2','Aspirin','Major','B')")
|
| 33 |
+
conn.execute("INSERT INTO rxnorm_to_ddinter VALUES ('11289','DDInter1','Warfarin','exact')")
|
| 34 |
+
conn.execute("INSERT INTO rxnorm_to_ddinter VALUES ('1191','DDInter2','Aspirin','exact')")
|
| 35 |
+
conn.execute("INSERT INTO drug_names_fts (ddinter_id, name) VALUES ('DDInter1','Warfarin')")
|
| 36 |
+
conn.execute("INSERT INTO drug_names_fts (ddinter_id, name) VALUES ('DDInter2','Aspirin')")
|
| 37 |
+
conn.execute("INSERT INTO meta VALUES ('source_release', 'ddinter-test')")
|
| 38 |
+
conn.commit()
|
| 39 |
+
conn.close()
|
| 40 |
+
monkeypatch.setattr(ddc, "DB_PATH", str(db))
|
| 41 |
+
monkeypatch.setattr(ddc.client, "db_path", str(db))
|
| 42 |
+
ddc.client._conn = None
|
| 43 |
+
return db
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_lookup_by_rxcui_returns_lowercase_severity(populated_db):
|
| 47 |
+
result = await ddc.client.lookup_by_rxcui("11289", "1191")
|
| 48 |
+
assert result is not None
|
| 49 |
+
assert result["severity"] == "major"
|
| 50 |
+
assert result["source"] == "ddinter"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def test_lookup_by_rxcui_handles_either_order(populated_db):
|
| 54 |
+
a = await ddc.client.lookup_by_rxcui("11289", "1191")
|
| 55 |
+
b = await ddc.client.lookup_by_rxcui("1191", "11289")
|
| 56 |
+
assert a is not None and b is not None
|
| 57 |
+
assert a["severity"] == b["severity"] == "major"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def test_lookup_by_rxcui_miss_returns_none(populated_db):
|
| 61 |
+
assert await ddc.client.lookup_by_rxcui("11289", "99999") is None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def test_lookup_by_name_fts(populated_db):
|
| 65 |
+
result = await ddc.client.lookup_by_name_fts("Warfarin", "Aspirin")
|
| 66 |
+
assert result is not None
|
| 67 |
+
assert result["severity"] == "major"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def test_lookup_by_name_fts_phrase_escapes_special_chars(populated_db):
|
| 71 |
+
assert await ddc.client.lookup_by_name_fts('War"farin', "Aspirin") is None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def test_health_check_true_when_db_present(populated_db):
|
| 75 |
+
assert await ddc.client.health_check() is True
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def test_connect_opens_database_read_only_without_sidecars(populated_db):
|
| 79 |
+
instance = ddc.DDInterDatabase(db_path=str(populated_db))
|
| 80 |
+
await instance.connect()
|
| 81 |
+
assert instance._conn is not None
|
| 82 |
+
try:
|
| 83 |
+
with pytest.raises(sqlite3.OperationalError):
|
| 84 |
+
await instance._conn.execute("CREATE TABLE should_fail (id TEXT)")
|
| 85 |
+
assert not populated_db.with_suffix(".db-wal").exists()
|
| 86 |
+
assert not populated_db.with_suffix(".db-shm").exists()
|
| 87 |
+
finally:
|
| 88 |
+
await instance.close()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def test_health_check_false_when_db_missing(tmp_path, monkeypatch):
|
| 92 |
+
missing = tmp_path / "missing.db"
|
| 93 |
+
monkeypatch.setattr(ddc, "DB_PATH", str(missing))
|
| 94 |
+
monkeypatch.setattr(ddc.client, "db_path", str(missing))
|
| 95 |
+
ddc.client._conn = None
|
| 96 |
+
assert await ddc.client.health_check() is False
|
tests/test_drugbank_client.py
CHANGED
|
@@ -3,6 +3,9 @@
|
|
| 3 |
import time
|
| 4 |
import pytest
|
| 5 |
from unittest.mock import AsyncMock, patch
|
|
|
|
|
|
|
|
|
|
| 6 |
from app.clients import drugbank_client
|
| 7 |
|
| 8 |
|
|
|
|
| 3 |
import time
|
| 4 |
import pytest
|
| 5 |
from unittest.mock import AsyncMock, patch
|
| 6 |
+
|
| 7 |
+
pytest.skip("Legacy DrugBank client is retained only until Phase C cleanup.", allow_module_level=True)
|
| 8 |
+
|
| 9 |
from app.clients import drugbank_client
|
| 10 |
|
| 11 |
|
tests/test_drugbank_db.py
CHANGED
|
@@ -7,6 +7,8 @@ import tempfile
|
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
|
|
|
|
|
|
|
| 10 |
from app.clients.drugbank_db import DrugBankDatabase, _escape_fts5_query
|
| 11 |
|
| 12 |
|
|
|
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
|
| 10 |
+
pytest.skip("Legacy DrugBank SQLite client is retained only until Phase C cleanup.", allow_module_level=True)
|
| 11 |
+
|
| 12 |
from app.clients.drugbank_db import DrugBankDatabase, _escape_fts5_query
|
| 13 |
|
| 14 |
|
tests/test_interaction_checker.py
CHANGED
|
@@ -1,238 +1,85 @@
|
|
| 1 |
-
"""Tests for
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
from unittest.mock import AsyncMock, patch
|
| 5 |
-
from app.clients.drugbank_client import DrugBankUnavailableError
|
| 6 |
-
from app.nlp import severity_parser
|
| 7 |
-
from app.services import interaction_checker
|
| 8 |
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
def mock_drugbank():
|
| 12 |
-
"""Mock drugbank_client.get_interactions for all tests."""
|
| 13 |
-
with patch("app.services.interaction_checker.drugbank_client") as mock:
|
| 14 |
-
mock.get_interactions = AsyncMock()
|
| 15 |
-
mock.DrugBankUnavailableError = DrugBankUnavailableError
|
| 16 |
-
yield mock
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
-
def mock_severity():
|
| 21 |
-
"""Mock severity_classifier.classify for all tests."""
|
| 22 |
-
with patch("app.services.interaction_checker.severity_classifier") as mock:
|
| 23 |
-
mock.classify.return_value = ("moderate", False)
|
| 24 |
-
yield mock
|
| 25 |
|
| 26 |
|
| 27 |
@pytest.fixture(autouse=True)
|
| 28 |
-
def
|
| 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 |
-
assert result["safe"] is False
|
| 102 |
-
assert result["error"] is None
|
| 103 |
-
# warfarin-aspirin pair should still be found
|
| 104 |
-
assert len(result["interactions"]) >= 1
|
| 105 |
-
pairs = [(i["drug_a"], i["drug_b"]) for i in result["interactions"]]
|
| 106 |
-
assert ("warfarin", "aspirin") in pairs
|
| 107 |
-
|
| 108 |
-
async def test_duplicate_drug_names_no_self_interaction(self, mock_drugbank):
|
| 109 |
-
"""Duplicate drug names must not produce self-interaction pairs."""
|
| 110 |
-
mock_drugbank.get_interactions.side_effect = [
|
| 111 |
-
[{"drug": "Ibuprofen", "description": "bleeding"}], # ibuprofen lists itself
|
| 112 |
-
[{"drug": "Warfarin", "description": "bleeding"}],
|
| 113 |
-
]
|
| 114 |
-
result = await interaction_checker.check(["ibuprofen", "ibuprofen", "warfarin"])
|
| 115 |
-
# Should check only one pair: ibuprofen-warfarin (no self-pair)
|
| 116 |
-
for interaction in result["interactions"]:
|
| 117 |
-
assert interaction["drug_a"] != interaction["drug_b"]
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
@pytest.fixture
|
| 121 |
-
def mock_openfda(mock_drugbank):
|
| 122 |
-
"""Mock openfda_client for interaction checker tests."""
|
| 123 |
-
with patch("app.services.interaction_checker.openfda_client") as mock:
|
| 124 |
-
mock.check_pair = AsyncMock(return_value=None)
|
| 125 |
-
yield mock
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
class TestOpenFDAFallback:
|
| 129 |
-
async def test_openfda_called_when_both_drugbank_lists_empty(self, mock_drugbank, mock_openfda, mock_severity):
|
| 130 |
-
"""Both drugs return [] from DrugBank → OpenFDA is tried."""
|
| 131 |
-
mock_drugbank.get_interactions.return_value = []
|
| 132 |
-
mock_openfda.check_pair.return_value = {
|
| 133 |
-
"drug": "ibuprofen",
|
| 134 |
-
"description": "Ibuprofen increases bleeding risk with warfarin.",
|
| 135 |
-
}
|
| 136 |
-
result = await interaction_checker.check(["warfarin", "ibuprofen"])
|
| 137 |
-
assert result["safe"] is False
|
| 138 |
-
assert len(result["interactions"]) == 1
|
| 139 |
-
assert result["interactions"][0]["drug_a"] == "warfarin"
|
| 140 |
-
assert result["interactions"][0]["drug_b"] == "ibuprofen"
|
| 141 |
-
mock_openfda.check_pair.assert_called()
|
| 142 |
-
|
| 143 |
-
async def test_openfda_called_when_one_drugbank_list_empty(self, mock_drugbank, mock_openfda, mock_severity):
|
| 144 |
-
"""Asymmetric case: drug_a empty, drug_b non-empty but no match → OpenFDA fires."""
|
| 145 |
-
mock_drugbank.get_interactions.side_effect = [
|
| 146 |
-
[], # warfarin → empty (cap hit)
|
| 147 |
-
[{"drug": "aspirin", "description": "bleeding"}], # ibuprofen → non-empty, no warfarin
|
| 148 |
-
]
|
| 149 |
-
mock_openfda.check_pair.return_value = {
|
| 150 |
-
"drug": "ibuprofen",
|
| 151 |
-
"description": "Ibuprofen increases anticoagulant effect.",
|
| 152 |
-
}
|
| 153 |
-
result = await interaction_checker.check(["warfarin", "ibuprofen"])
|
| 154 |
-
assert result["safe"] is False
|
| 155 |
-
mock_openfda.check_pair.assert_called()
|
| 156 |
-
|
| 157 |
-
async def test_openfda_not_called_when_both_drugbank_lists_nonempty(self, mock_drugbank, mock_openfda):
|
| 158 |
-
"""Both drugs have non-empty DrugBank lists → OpenFDA is never called."""
|
| 159 |
-
mock_drugbank.get_interactions.side_effect = [
|
| 160 |
-
[{"drug": "metformin", "description": "some"}],
|
| 161 |
-
[{"drug": "lisinopril", "description": "some"}],
|
| 162 |
-
]
|
| 163 |
-
result = await interaction_checker.check(["warfarin", "ibuprofen"])
|
| 164 |
-
assert result["safe"] is True
|
| 165 |
-
mock_openfda.check_pair.assert_not_called()
|
| 166 |
-
|
| 167 |
-
async def test_openfda_bidirectional_retry(self, mock_drugbank, mock_openfda, mock_severity):
|
| 168 |
-
"""check_pair(a, b) returns None → check_pair(b, a) is tried."""
|
| 169 |
-
mock_drugbank.get_interactions.return_value = []
|
| 170 |
-
# First call (warfarin→ibuprofen) returns None, second (ibuprofen→warfarin) finds match
|
| 171 |
-
mock_openfda.check_pair.side_effect = [
|
| 172 |
-
None,
|
| 173 |
-
{"drug": "warfarin", "description": "Warfarin increases bleeding risk."},
|
| 174 |
-
]
|
| 175 |
-
result = await interaction_checker.check(["warfarin", "ibuprofen"])
|
| 176 |
-
assert result["safe"] is False
|
| 177 |
-
assert mock_openfda.check_pair.call_count == 2
|
| 178 |
-
|
| 179 |
-
async def test_openfda_finds_nothing_returns_safe(self, mock_drugbank, mock_openfda):
|
| 180 |
-
"""Both DrugBank and OpenFDA miss → safe: true."""
|
| 181 |
-
mock_drugbank.get_interactions.return_value = []
|
| 182 |
-
mock_openfda.check_pair.return_value = None
|
| 183 |
-
result = await interaction_checker.check(["warfarin", "ibuprofen"])
|
| 184 |
-
assert result["safe"] is True
|
| 185 |
-
assert result["interactions"] == []
|
| 186 |
-
|
| 187 |
-
async def test_openfda_exception_does_not_propagate(self, mock_drugbank, mock_openfda):
|
| 188 |
-
"""OpenFDA raising an exception must not crash the checker."""
|
| 189 |
-
mock_drugbank.get_interactions.return_value = []
|
| 190 |
-
mock_openfda.check_pair.side_effect = Exception("OpenFDA down")
|
| 191 |
-
result = await interaction_checker.check(["warfarin", "ibuprofen"])
|
| 192 |
-
assert result["safe"] is True
|
| 193 |
-
assert result["error"] is None
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
class TestSourceRouting:
|
| 197 |
-
async def test_drugbank_interaction_uses_template_parser(
|
| 198 |
-
self, mock_drugbank, mock_severity_parser, mock_severity
|
| 199 |
-
):
|
| 200 |
-
"""DrugBank interactions should use severity_parser, not classifier."""
|
| 201 |
-
mock_drugbank.get_interactions.side_effect = [
|
| 202 |
-
[{"drug": "Warfarin", "description": "The risk or severity of bleeding can be increased."}],
|
| 203 |
-
[],
|
| 204 |
-
]
|
| 205 |
-
mock_severity_parser.parse_severity.return_value = "major"
|
| 206 |
-
result = await interaction_checker.check(["ibuprofen", "warfarin"])
|
| 207 |
-
mock_severity_parser.parse_severity.assert_called_once()
|
| 208 |
-
mock_severity.classify.assert_not_called()
|
| 209 |
-
assert result["interactions"][0]["severity"] == "major"
|
| 210 |
-
assert result["interactions"][0]["uncertain"] is False
|
| 211 |
-
|
| 212 |
-
async def test_openfda_interaction_uses_classifier(
|
| 213 |
-
self, mock_drugbank, mock_openfda, mock_severity_parser, mock_severity
|
| 214 |
-
):
|
| 215 |
-
"""OpenFDA fallback interactions should use zero-shot classifier."""
|
| 216 |
-
mock_drugbank.get_interactions.return_value = []
|
| 217 |
-
mock_openfda.check_pair.return_value = {
|
| 218 |
-
"drug": "ibuprofen",
|
| 219 |
-
"description": "Ibuprofen increases bleeding risk with warfarin.",
|
| 220 |
-
}
|
| 221 |
-
mock_severity.classify.return_value = ("major", False)
|
| 222 |
-
result = await interaction_checker.check(["warfarin", "ibuprofen"])
|
| 223 |
-
mock_severity.classify.assert_called_once()
|
| 224 |
-
mock_severity_parser.parse_severity.assert_not_called()
|
| 225 |
-
|
| 226 |
-
async def test_drugbank_unknown_template_falls_back_to_classifier(
|
| 227 |
-
self, mock_drugbank, mock_severity_parser, mock_severity
|
| 228 |
-
):
|
| 229 |
-
"""When template parser returns 'unknown', fall back to classifier."""
|
| 230 |
-
mock_drugbank.get_interactions.side_effect = [
|
| 231 |
-
[{"drug": "Warfarin", "description": "Novel interaction format."}],
|
| 232 |
-
[],
|
| 233 |
-
]
|
| 234 |
-
mock_severity_parser.parse_severity.return_value = "unknown"
|
| 235 |
-
mock_severity.classify.return_value = ("moderate", True)
|
| 236 |
-
result = await interaction_checker.check(["ibuprofen", "warfarin"])
|
| 237 |
-
mock_severity.classify.assert_called_once()
|
| 238 |
-
assert result["interactions"][0]["uncertain"] is True
|
|
|
|
| 1 |
+
"""Tests for interaction_checker DDInter -> OpenFDA -> unknown routing."""
|
| 2 |
|
| 3 |
+
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
from unittest.mock import AsyncMock, patch
|
| 6 |
|
| 7 |
+
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
from app.services import interaction_checker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
@pytest.fixture(autouse=True)
|
| 13 |
+
def patch_clients(monkeypatch):
|
| 14 |
+
rx = AsyncMock(return_value=None)
|
| 15 |
+
monkeypatch.setattr(interaction_checker.rxnorm_client, "get_rxcui", rx)
|
| 16 |
+
|
| 17 |
+
ddc = AsyncMock(return_value=None)
|
| 18 |
+
ddn = AsyncMock(return_value=None)
|
| 19 |
+
monkeypatch.setattr(interaction_checker.ddinter_db.client, "lookup_by_rxcui", ddc)
|
| 20 |
+
monkeypatch.setattr(interaction_checker.ddinter_db.client, "lookup_by_name_fts", ddn)
|
| 21 |
+
|
| 22 |
+
fda = AsyncMock(return_value=None)
|
| 23 |
+
monkeypatch.setattr(interaction_checker.openfda_client, "check_pair", fda)
|
| 24 |
+
return {"rxnorm": rx, "ddinter_rxcui": ddc, "ddinter_fts": ddn, "openfda": fda}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def test_ddinter_hit_via_rxcui(patch_clients):
|
| 28 |
+
patch_clients["rxnorm"].side_effect = ["11289", "1191"]
|
| 29 |
+
patch_clients["ddinter_rxcui"].return_value = {
|
| 30 |
+
"drug_a_id": "DDInter1",
|
| 31 |
+
"drug_b_id": "DDInter2",
|
| 32 |
+
"drug_a_name": "Warfarin",
|
| 33 |
+
"drug_b_name": "Aspirin",
|
| 34 |
+
"severity": "major",
|
| 35 |
+
"source": "ddinter",
|
| 36 |
+
"atc_category": "B",
|
| 37 |
+
}
|
| 38 |
+
result = await interaction_checker.check(["Warfarin", "Aspirin"])
|
| 39 |
+
assert result["coverage_summary"]["ddinter"] == 1
|
| 40 |
+
assert result["interactions"][0]["source"] == "ddinter"
|
| 41 |
+
assert result["interactions"][0]["severity"] == "major"
|
| 42 |
+
assert result["interactions"][0]["rxcui_a"] == "11289"
|
| 43 |
+
patch_clients["openfda"].assert_not_called()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_falls_back_to_fts_when_rxcui_misses(patch_clients):
|
| 47 |
+
patch_clients["rxnorm"].side_effect = [None, None]
|
| 48 |
+
patch_clients["ddinter_fts"].return_value = {
|
| 49 |
+
"drug_a_id": "DDInter1",
|
| 50 |
+
"drug_b_id": "DDInter2",
|
| 51 |
+
"drug_a_name": "Warfarin",
|
| 52 |
+
"drug_b_name": "Aspirin",
|
| 53 |
+
"severity": "major",
|
| 54 |
+
"source": "ddinter",
|
| 55 |
+
"atc_category": "B",
|
| 56 |
+
}
|
| 57 |
+
result = await interaction_checker.check(["Warfarin", "Aspirin"])
|
| 58 |
+
assert result["interactions"][0]["source"] == "ddinter"
|
| 59 |
+
patch_clients["openfda"].assert_not_called()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def test_openfda_fallback_when_ddinter_misses(patch_clients):
|
| 63 |
+
patch_clients["rxnorm"].side_effect = ["11289", "1191"]
|
| 64 |
+
patch_clients["openfda"].return_value = {"drug": "Aspirin", "description": "Some FDA label sentence."}
|
| 65 |
+
with patch.object(interaction_checker.severity_classifier, "classify", return_value=("moderate", False)):
|
| 66 |
+
result = await interaction_checker.check(["Warfarin", "Aspirin"])
|
| 67 |
+
assert result["interactions"][0]["source"] == "openfda"
|
| 68 |
+
assert result["interactions"][0]["severity"] == "moderate"
|
| 69 |
+
assert result["coverage_summary"]["openfda"] == 1
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def test_unknown_when_all_paths_miss(patch_clients):
|
| 73 |
+
patch_clients["rxnorm"].side_effect = ["1", "2"]
|
| 74 |
+
result = await interaction_checker.check(["A", "B"])
|
| 75 |
+
assert result["coverage_summary"] == {"ddinter": 0, "openfda": 0, "unknown": 1}
|
| 76 |
+
assert result["interactions"] == []
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_openfda_low_confidence_keeps_safety_default(patch_clients):
|
| 80 |
+
patch_clients["rxnorm"].side_effect = ["1", "2"]
|
| 81 |
+
patch_clients["openfda"].return_value = {"drug": "B", "description": "vague text"}
|
| 82 |
+
with patch.object(interaction_checker.severity_classifier, "classify", return_value=("major", True)):
|
| 83 |
+
result = await interaction_checker.check(["A", "B"])
|
| 84 |
+
assert result["interactions"][0]["severity"] == "major"
|
| 85 |
+
assert result["interactions"][0]["uncertain"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|