SPerva commited on
Commit
4f2020d
·
verified ·
1 Parent(s): 8525881

Sync from GitHub via hub-sync

Browse files
Files changed (45) hide show
  1. .dockerignore +3 -0
  2. .zenodo.json +20 -0
  3. CITATION.cff +18 -0
  4. Dockerfile +25 -23
  5. LICENSE +21 -0
  6. README.md +106 -11
  7. app/__pycache__/__init__.cpython-312.pyc +0 -0
  8. app/clients/__pycache__/__init__.cpython-312.pyc +0 -0
  9. app/clients/__pycache__/rxnorm_client.cpython-312.pyc +0 -0
  10. app/middleware/__pycache__/__init__.cpython-312.pyc +0 -0
  11. app/middleware/__pycache__/audit_log.cpython-312.pyc +0 -0
  12. app/nlp/__pycache__/__init__.cpython-312.pyc +0 -0
  13. app/nlp/__pycache__/dosage_parser.cpython-312.pyc +0 -0
  14. app/nlp/__pycache__/gliner_model.cpython-312.pyc +0 -0
  15. app/nlp/__pycache__/ingredient_labels.cpython-312.pyc +0 -0
  16. app/nlp/__pycache__/ner_model.cpython-312.pyc +0 -0
  17. app/nlp/__pycache__/ocr_cleaner.cpython-312.pyc +0 -0
  18. app/nlp/gliner_model.py +0 -48
  19. app/nlp/ingredient_labels.py +0 -10
  20. app/services/__pycache__/__init__.cpython-312.pyc +0 -0
  21. app/services/__pycache__/drug_analyzer.cpython-312.pyc +0 -0
  22. app/services/__pycache__/ingredient_adjudicator.cpython-312.pyc +0 -0
  23. app/services/drug_analyzer.py +3 -72
  24. app/services/ingredient_adjudicator.py +0 -102
  25. docker-compose.ci.yml +14 -0
  26. docker-compose.yml +15 -0
  27. docs/infrastructure_hardening.md +62 -0
  28. docs/openapi.json +351 -0
  29. pyproject.toml +0 -3
  30. tests/__init__.py +0 -0
  31. tests/test_admin.py +40 -0
  32. tests/test_api.py +198 -0
  33. tests/test_api_key.py +85 -0
  34. tests/test_audit_log.py +29 -0
  35. tests/test_dosage_parser.py +138 -0
  36. tests/test_drug_analyzer.py +382 -0
  37. tests/test_drugbank_client.py +192 -0
  38. tests/test_drugbank_db.py +195 -0
  39. tests/test_interaction_checker.py +238 -0
  40. tests/test_ocr_cleaner.py +54 -0
  41. tests/test_openfda_client.py +147 -0
  42. tests/test_rxnorm_client.py +48 -0
  43. tests/test_severity_classifier.py +131 -0
  44. tests/test_severity_parser.py +87 -0
  45. uv.lock +8 -90
.dockerignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ drugbank-mcp-server/node_modules/
2
+ drugbank-mcp-server/data/
3
+ drugbank-mcp-server/build/
.zenodo.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "PillChecker is an open-source API and benchmarking suite for identifying pharmaceutical ingredients from OCR text and checking for potential drug-drug interactions. It features a multi-agent NER pipeline using PharmaDetect and GLiNER, and validates results against RxNorm and DrugBank.",
3
+ "creators": [
4
+ {
5
+ "name": "Perekrestova, Svetlana",
6
+ "affiliation": "Independent Researcher"
7
+ }
8
+ ],
9
+ "keywords": [
10
+ "pharmaceutical-ner",
11
+ "drug-interactions",
12
+ "nlp",
13
+ "ocr-cleaning",
14
+ "benchmarking",
15
+ "mcp"
16
+ ],
17
+ "license": "MIT",
18
+ "title": "PillChecker API: Pharmaceutical Entity Extraction and Interaction Checker",
19
+ "access_right": "open"
20
+ }
CITATION.cff ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ authors:
4
+ - family-names: "Perekrestova"
5
+ given-names: "Svetlana"
6
+ orcid: "https://orcid.org/0009-0003-2905-6040"
7
+ title: "PillChecker API: A Multi-Agent System for Pharmaceutical Entity Extraction and Interaction Checking"
8
+ version: 1.2.2
9
+ doi: 10.5281/zenodo.19792062
10
+ date-released: 2026-04-26
11
+ url: "https://github.com/SPerekrestova/pillchecker-api"
12
+ keywords:
13
+ - pharmaceutical-ner
14
+ - drug-interactions
15
+ - ocr-correction
16
+ - nlp
17
+ - benchmarking
18
+ license: MIT
Dockerfile CHANGED
@@ -1,12 +1,11 @@
1
  FROM ghcr.io/astral-sh/uv:0.9-python3.12-bookworm-slim AS builder
2
 
3
- # Set the same path as the final runtime for venv portability
4
- WORKDIR /home/user/app
5
 
6
  # Copy dependency files first for layer caching
7
  COPY pyproject.toml uv.lock .python-version ./
8
 
9
- # Install dependencies only
10
  RUN uv sync --no-install-project --no-dev
11
 
12
  # Copy application code and install the project
@@ -19,6 +18,7 @@ FROM python:3.12-slim AS db-downloader
19
  WORKDIR /app/drugbank-mcp-server/data
20
 
21
  # Use curl to download a pinned version of the pre-built SQLite DB.
 
22
  ARG DRUGBANK_DB_REPO=openpharma-org/drugbank-mcp-server
23
  ARG DRUGBANK_DB_TAG=db-2026-04-01
24
  RUN apt-get update && apt-get install -y curl && \
@@ -28,36 +28,38 @@ RUN apt-get update && apt-get install -y curl && \
28
  # --- Runtime stage ---
29
  FROM python:3.12-slim
30
 
31
- # HF Spaces user is 1000
32
- RUN useradd -m -u 1000 user
33
- USER user
34
- ENV HOME=/home/user \
35
- PATH=/home/user/app/.venv/bin:/home/user/.local/bin:$PATH \
36
- HF_HOME=/home/user/models \
37
- TRANSFORMERS_CACHE=/home/user/models
38
 
39
- WORKDIR $HOME/app
40
-
41
- # Copy built virtualenv from builder (path now matches)
42
- COPY --from=builder --chown=user:user /home/user/app/.venv $HOME/app/.venv
43
 
44
  # Copy DrugBank SQLite DB from downloader stage
45
- COPY --from=db-downloader --chown=user:user /app/drugbank-mcp-server/data $HOME/app/drugbank-mcp-server/data
 
 
 
 
46
 
47
- # Pre-download NER models so the image is self-contained.
 
 
48
  RUN python -c "from transformers import pipeline; \
49
  pipeline('ner', model='OpenMed/OpenMed-NER-PharmaDetect-BioPatient-108M', aggregation_strategy='none'); \
50
  pipeline('zero-shot-classification', model='MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli')"
51
 
52
  # App code comes last — most frequently changing layer
53
- COPY --from=builder --chown=user:user /home/user/app/app $HOME/app/app
54
- COPY --chown=user:user scripts/ $HOME/scripts/
 
 
55
 
56
- RUN chmod +x $HOME/scripts/prod-startup.sh
 
 
57
 
58
- # HF Spaces expects port 7860
59
- EXPOSE 7860
60
 
61
- ENTRYPOINT ["/home/user/scripts/prod-startup.sh"]
62
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
63
 
 
 
 
1
  FROM ghcr.io/astral-sh/uv:0.9-python3.12-bookworm-slim AS builder
2
 
3
+ WORKDIR /app
 
4
 
5
  # Copy dependency files first for layer caching
6
  COPY pyproject.toml uv.lock .python-version ./
7
 
8
+ # Install dependencies only (locked, no project code yet)
9
  RUN uv sync --no-install-project --no-dev
10
 
11
  # Copy application code and install the project
 
18
  WORKDIR /app/drugbank-mcp-server/data
19
 
20
  # Use curl to download a pinned version of the pre-built SQLite DB.
21
+ # Pinning the tag ensures deterministic builds and allows Docker to cache this layer reliably.
22
  ARG DRUGBANK_DB_REPO=openpharma-org/drugbank-mcp-server
23
  ARG DRUGBANK_DB_TAG=db-2026-04-01
24
  RUN apt-get update && apt-get install -y curl && \
 
28
  # --- Runtime stage ---
29
  FROM python:3.12-slim
30
 
31
+ WORKDIR /app
 
 
 
 
 
 
32
 
33
+ # Copy built virtualenv from builder
34
+ COPY --from=builder /app/.venv /app/.venv
 
 
35
 
36
  # Copy DrugBank SQLite DB from downloader stage
37
+ COPY --from=db-downloader /app/drugbank-mcp-server/data /app/drugbank-mcp-server/data
38
+
39
+ ENV PATH="/app/.venv/bin:$PATH"
40
+ ENV HF_HOME=/app/models
41
+ ENV TRANSFORMERS_CACHE=/app/models
42
 
43
+ # Pre-download NER model so the image is self-contained.
44
+ # Layer is cached until venv or model ID changes.
45
+ # In local dev, docker-compose mounts a volume over /app/models.
46
  RUN python -c "from transformers import pipeline; \
47
  pipeline('ner', model='OpenMed/OpenMed-NER-PharmaDetect-BioPatient-108M', aggregation_strategy='none'); \
48
  pipeline('zero-shot-classification', model='MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli')"
49
 
50
  # App code comes last — most frequently changing layer
51
+ COPY --from=builder /app/app /app/app
52
+ COPY scripts/ /app/scripts/
53
+
54
+ RUN chmod +x /app/scripts/prod-startup.sh /app/scripts/ci-startup.sh
55
 
56
+ # Create a non-root user for security
57
+ RUN groupadd -r pillchecker && useradd -r -g pillchecker pillchecker && \
58
+ chown -R pillchecker:pillchecker /app
59
 
60
+ USER pillchecker
 
61
 
62
+ EXPOSE 8000
 
63
 
64
+ ENTRYPOINT ["/app/scripts/prod-startup.sh"]
65
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,11 +1,106 @@
1
- ---
2
- title: Pillchecker Staging
3
- emoji: 📚
4
- colorFrom: green
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: true
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PillChecker API
2
+
3
+ PillChecker helps users find out if two medications are safe to take at the same time. This repository contains the backend API that identifies drugs from OCR text and checks for dangerous interactions using DrugBank pharmaceutical data.
4
+
5
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19792062.svg)](https://doi.org/10.5281/zenodo.19792062)
6
+
7
+ > **MEDICAL DISCLAIMER**
8
+ >
9
+ > This service is provided for **informational and self-educational purposes only**. While the application utilizes data from respected pharmaceutical sources, the information provided should **not** be treated as medical advice, diagnosis, or treatment.
10
+ >
11
+ > The developer of this project **does not have any medical qualifications**. This tool was built as a technical exercise to explore NLP and medical data integration.
12
+ >
13
+ > **Always consult with a qualified healthcare professional** (such as a doctor or pharmacist) before making any decisions regarding your medications or health. The developer assumes **no responsibility or liability** for any errors, omissions, or consequences arising from the use of the information provided by this service.
14
+
15
+ ## Architecture
16
+
17
+ ### Drug Identification
18
+
19
+ Converts unstructured OCR text into standardized drug records using a multi-step strategy:
20
+
21
+ 1. **OCR Cleaning**: The `ocr_cleaner` normalizes common OCR artifacts before NER: digit-letter confusion (`0`/`o`, `1`/`l`), `rn`→`m` in drug names, ligatures, invisible characters, and whitespace.
22
+ 2. **NER**: The **[OpenMed-NER-PharmaDetect-BioPatient-108M](https://huggingface.co/OpenMed/OpenMed-NER-PharmaDetect-BioPatient-108M)** model (108M parameters) extracts chemical entity names from the cleaned text.
23
+ 3. **Fallback**: If NER yields no results, an approximate term search via the **RxNorm REST API** catches brand names (e.g., "Advil" -> ibuprofen).
24
+ 4. **Enrichment**: A regex parser extracts dosages (e.g., "400 mg"), and the RxNorm API maps every identified drug to its **RxCUI** for standardized downstream lookups.
25
+ 5. **Confidence**: Results with NER score below 0.85 or sourced from the RxNorm fallback are flagged with `needs_confirmation = true` to prompt user verification.
26
+
27
+ ### Interaction Checking
28
+
29
+ Drug-drug interactions are resolved against the **DrugBank** pharmaceutical database via a vendored MCP server:
30
+
31
+ 1. **DrugBank MCP server**: A Node.js process (vendored under `drugbank-mcp-server/`) communicates over stdio using the Model Context Protocol. It serves a pre-built SQLite database (~17,400 drugs) with structured pairwise interaction data.
32
+ 2. **Bidirectional lookup**: For each drug pair, the checker queries both directions (A->B and B->A) in parallel using `asyncio.gather()`.
33
+ 3. **Severity classification**: Interaction descriptions are first parsed by a deterministic **template parser** that matches regex patterns in DrugBank text. If the parser cannot determine severity, a **DeBERTa v3** zero-shot classifier is used as fallback. Unknown severity defaults to `major` with `uncertain = true`.
34
+ 4. **Caching**: DrugBank interaction records are cached in-process for 4 hours; RxNorm lookups are cached for 24 hours.
35
+
36
+ ### Transparency
37
+
38
+ Both `/analyze` and `/interactions` responses include:
39
+ - `data_sources`: which models and databases were used for the result
40
+ - `limitations` (interactions only): scope disclaimers about what the system does and does not cover
41
+
42
+ ### Docker Build
43
+
44
+ The image uses a three-stage build to keep layers small and reproducible:
45
+
46
+ - **Stage 1 (Python)**: `uv` installs Python dependencies into an isolated venv.
47
+ - **Stage 2 (Node.js)**: `npm ci` installs Node dependencies; the DrugBank SQLite database is downloaded from GitHub Releases.
48
+ - **Stage 3 (Runtime)**: Combines the venv, Node binary, and built MCP server. NER and severity models are pre-downloaded so the image is fully self-contained.
49
+
50
+ ## API Endpoints
51
+
52
+ | Method | Path | Auth | Description |
53
+ |--------|------|------|-------------|
54
+ | `GET` | `/health` | No | Liveness check |
55
+ | `GET` | `/health/data` | No | Readiness -- confirms DrugBank MCP connection |
56
+ | `POST` | `/analyze` | API key | Extract drugs from OCR text |
57
+ | `POST` | `/interactions` | API key | Check interactions for a list of drug names |
58
+ | `POST` | `/admin/cache/clear` | API key | Clear all in-memory caches |
59
+
60
+ ## Eval Benchmark
61
+
62
+ The benchmark suite and raw results have been migrated to the Hugging Face Hub for better reproducibility and visualization.
63
+
64
+ * **Benchmark Dataset:** [SPerva/pillchecker-ner-benchmark](https://huggingface.co/datasets/SPerva/pillchecker-ner-benchmark)
65
+ * **Result History:** [hf://buckets/SPerva/pillchecker-experiments](https://huggingface.co/buckets/SPerva/pillchecker-experiments)
66
+ * **Methodology:** See the dataset card on Hugging Face for details on the 11,796 synthesized cases.
67
+
68
+ | Pipeline (Clean Text) | Precision | Recall | F1 |
69
+ |------------------------|-----------|--------|----|
70
+ | Bare NER Baseline | 46.9% | 84.4% | 60.3% |
71
+ | Full Pipeline | 71.6% | 81.0% | 76.0% |
72
+ | **GLiNER Union (Best)** | **78.0%** | **93.6%** | **85.1%** |
73
+
74
+ ## Staging & Deployment
75
+
76
+ The API is deployed as a staging environment on Hugging Face Spaces for remote testing:
77
+
78
+ * **Staging Space:** [sperva-pillchecker-staging](https://huggingface.co/spaces/SPerva/pillchecker-staging)
79
+ * **API Docs:** [sperva-pillchecker-staging.hf.space/docs](https://sperva-pillchecker-staging.hf.space/docs)
80
+
81
+
82
+ - **[PillChecker Collection](https://huggingface.co/collections/SPerva/pillchecker-69ee0f67dee76ff7ae9ea30a)** -- Central hub for all models and datasets used in this project.
83
+ - **[OpenMed NER PharmaDetect](https://huggingface.co/OpenMed/OpenMed-NER-PharmaDetect-BioPatient-108M)** -- drug entity recognition model (108M params). License: Apache 2.0
84
+ - **[RxNorm REST API](https://lhncbc.nlm.nih.gov/RxNav/APIs/RxNormAPIs.html)** -- drug name normalization and RxCUI mapping. Provided by NLM (free to use).
85
+ - **[DrugBank](https://www.drugbank.com/)** -- pharmaceutical database providing structured drug-drug interaction data. Accessed via the [openpharma-org/drugbank-mcp-server](https://github.com/openpharma-org/drugbank-mcp-server) open-source MCP server.
86
+ - **[DeBERTa-v3-base-mnli-fever-anli](https://huggingface.co/MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli)** -- zero-shot classifier for interaction severity. License: MIT
87
+ - **[Hugging Face Transformers](https://huggingface.co/docs/transformers)** -- NLP pipeline library. License: Apache 2.0
88
+
89
+ ## Citation
90
+
91
+ If you use this software or the benchmark dataset in your research, please cite it as:
92
+
93
+ ```bibtex
94
+ @software{perekrestova_pillchecker_2026,
95
+ author = {Perekrestova, Svetlana},
96
+ orcid = {0009-0003-2905-6040},
97
+ title = {PillChecker API: Pharmaceutical Entity Extraction and Interaction Checker},
98
+ version = {1.2.2},
99
+ doi = {10.5281/zenodo.19792062},
100
+ url = {https://github.com/SPerekrestova/pillchecker-api},
101
+ date = {2026-04-26},
102
+ publisher = {Zenodo},
103
+ note = {GitHub Repository}
104
+ }
105
+ ```
106
+
app/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (144 Bytes)
 
app/clients/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (152 Bytes)
 
app/clients/__pycache__/rxnorm_client.cpython-312.pyc DELETED
Binary file (6.97 kB)
 
app/middleware/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (155 Bytes)
 
app/middleware/__pycache__/audit_log.cpython-312.pyc DELETED
Binary file (4.16 kB)
 
app/nlp/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (148 Bytes)
 
app/nlp/__pycache__/dosage_parser.cpython-312.pyc DELETED
Binary file (3.36 kB)
 
app/nlp/__pycache__/gliner_model.cpython-312.pyc DELETED
Binary file (2 kB)
 
app/nlp/__pycache__/ingredient_labels.cpython-312.pyc DELETED
Binary file (364 Bytes)
 
app/nlp/__pycache__/ner_model.cpython-312.pyc DELETED
Binary file (2.93 kB)
 
app/nlp/__pycache__/ocr_cleaner.cpython-312.pyc DELETED
Binary file (2.88 kB)
 
app/nlp/gliner_model.py DELETED
@@ -1,48 +0,0 @@
1
- """GLiNER model wrapper.
2
-
3
- Loads the model lazily and exposes a predict() function.
4
- """
5
-
6
- from typing import List, Optional
7
-
8
- from app.nlp.ner_model import Entity
9
- from app.nlp.ingredient_labels import GLINER_LABELS
10
-
11
- _gliner_model = None
12
- MODEL_ID = "urchade/gliner_medium-v2.1"
13
-
14
- def load_model() -> None:
15
- """Load the GLiNER model into memory lazily."""
16
- global _gliner_model
17
- if _gliner_model is None:
18
- try:
19
- from gliner import GLiNER
20
- except ImportError:
21
- raise RuntimeError("GLiNER is not installed. Install with `uv pip install gliner` or `pip install .[gliner]`")
22
-
23
- _gliner_model = GLiNER.from_pretrained(MODEL_ID)
24
-
25
- def is_loaded() -> bool:
26
- """Check if the GLiNER model is loaded."""
27
- return _gliner_model is not None
28
-
29
- def predict(text: str, labels: Optional[List[str]] = None, threshold: float = 0.5) -> List[Entity]:
30
- """Extract entities using GLiNER."""
31
- if _gliner_model is None:
32
- load_model()
33
-
34
- if labels is None:
35
- labels = GLINER_LABELS
36
-
37
- raw_entities = _gliner_model.predict_entities(text, labels, threshold=threshold)
38
-
39
- return [
40
- Entity(
41
- text=ent["text"],
42
- label=ent["label"],
43
- score=round(float(ent["score"]), 4),
44
- start=ent["start"],
45
- end=ent["end"],
46
- )
47
- for ent in raw_entities
48
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/nlp/ingredient_labels.py DELETED
@@ -1,10 +0,0 @@
1
- """GLiNER labels for ingredient evaluation."""
2
-
3
- GLINER_LABELS = [
4
- "active pharmaceutical ingredient",
5
- "salt or counter-ion",
6
- "brand or trade name",
7
- "manufacturer",
8
- "dosage form",
9
- "dosage strength"
10
- ]
 
 
 
 
 
 
 
 
 
 
 
app/services/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (153 Bytes)
 
app/services/__pycache__/drug_analyzer.cpython-312.pyc DELETED
Binary file (10.1 kB)
 
app/services/__pycache__/ingredient_adjudicator.cpython-312.pyc DELETED
Binary file (4.68 kB)
 
app/services/drug_analyzer.py CHANGED
@@ -15,9 +15,6 @@ from app.middleware.audit_log import get_audit_context
15
  from app.nlp import ner_model
16
  from app.nlp.dosage_parser import Dosage, extract_dosages
17
  from app.nlp.ocr_cleaner import clean as ocr_clean
18
- import os
19
-
20
- NER_EXPERIMENT_MODE = os.getenv("NER_EXPERIMENT_MODE", "")
21
 
22
  logger = logging.getLogger(__name__)
23
 
@@ -78,86 +75,21 @@ async def analyze(text: str) -> list[dict]:
78
  and _is_valid_entity_name(e.text)
79
  ]
80
 
81
- gliner_entities = []
82
- if NER_EXPERIMENT_MODE:
83
- from app.nlp import gliner_model
84
- gliner_entities = gliner_model.predict(cleaned_text)
85
- if ctx:
86
- ctx.add("gliner", {
87
- "entities": [{"text": e.text, "label": e.label, "score": e.score} for e in gliner_entities],
88
- })
89
-
90
  if drug_entities:
91
  logger.info("NER found %d drug entities", len(drug_entities))
92
- if NER_EXPERIMENT_MODE == "gliner_sequential":
93
- from app.nlp import gliner_model
94
- # For each PharmaDetect entity, run GLiNER on the snippet to classify it
95
- verified_entities = []
96
- for ent in drug_entities:
97
- snippet_ents = gliner_model.predict(ent.text)
98
- # Check if GLiNER thinks this snippet contains an active ingredient
99
- is_active = any(g.label == "active pharmaceutical ingredient" for g in snippet_ents)
100
- if is_active:
101
- verified_entities.append(ent)
102
-
103
- if verified_entities:
104
- enriched = await _enrich_ner_results(verified_entities, dosages, source="gliner_sequential")
105
- else:
106
- enriched = []
107
- else:
108
- enriched = await _enrich_ner_results(drug_entities, dosages)
109
-
110
- if NER_EXPERIMENT_MODE in ("gliner_filter", "gliner_adjudicated"):
111
- from app.services import ingredient_adjudicator
112
- enriched = ingredient_adjudicator.adjudicate(NER_EXPERIMENT_MODE, enriched, gliner_entities)
113
-
114
- if NER_EXPERIMENT_MODE == "gliner_union":
115
- # Extract active ingredients using GLiNER
116
- gliner_active = [e for e in gliner_entities if e.label == "active pharmaceutical ingredient"]
117
- if gliner_active:
118
- gliner_enriched = await _enrich_ner_results(gliner_active, dosages, source="gliner_union")
119
- # Union and deduplicate
120
- seen_rxcuis = {e["rxcui"] for e in enriched if e.get("rxcui")}
121
- for ge in gliner_enriched:
122
- if ge.get("rxcui") not in seen_rxcuis:
123
- enriched.append(ge)
124
- if ge.get("rxcui"):
125
- seen_rxcuis.add(ge["rxcui"])
126
- enriched.sort(key=lambda r: r["confidence"], reverse=True)
127
-
128
  if enriched:
129
- # Strip _entity before returning
130
- for item in enriched:
131
- item.pop("_entity", None)
132
- item.pop("_rejected", None)
133
- item.pop("_rejection_reason", None)
134
  return enriched
135
-
136
  logger.info("All NER entities filtered out, trying RxNorm fallback")
137
 
138
- # Pass 2: Fallback
139
  logger.info("Falling through to RxNorm fallback")
140
-
141
- if NER_EXPERIMENT_MODE in ("gliner_fallback", "gliner_adjudicated"):
142
- if not gliner_entities and NER_EXPERIMENT_MODE == "gliner_fallback":
143
- from app.nlp import gliner_model
144
- gliner_entities = gliner_model.predict(cleaned_text)
145
-
146
- gliner_active = [e for e in gliner_entities if e.label == "active pharmaceutical ingredient"]
147
- if gliner_active:
148
- gliner_enriched = await _enrich_ner_results(gliner_active, dosages, source="gliner_fallback_rxnorm")
149
- if gliner_enriched:
150
- for item in gliner_enriched:
151
- item.pop("_entity", None)
152
- return gliner_enriched
153
-
154
  return await _rxnorm_fallback(text, dosage_str)
155
 
156
 
157
  async def _enrich_ner_results(
158
  entities: list[ner_model.Entity],
159
  dosages: list[Dosage],
160
- source: str = "ner"
161
  ) -> list[dict]:
162
  """Enrich NER entities with RxNorm data."""
163
  results = []
@@ -180,10 +112,9 @@ async def _enrich_ner_results(
180
  "name": name,
181
  "dosage": _nearest_dosage(entity.end, dosages),
182
  "form": None,
183
- "source": source,
184
  "confidence": entity.score,
185
  "needs_confirmation": entity.score < 0.85,
186
- "_entity": entity,
187
  })
188
 
189
  results.sort(key=lambda r: r["confidence"], reverse=True)
 
15
  from app.nlp import ner_model
16
  from app.nlp.dosage_parser import Dosage, extract_dosages
17
  from app.nlp.ocr_cleaner import clean as ocr_clean
 
 
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
 
75
  and _is_valid_entity_name(e.text)
76
  ]
77
 
 
 
 
 
 
 
 
 
 
78
  if drug_entities:
79
  logger.info("NER found %d drug entities", len(drug_entities))
80
+ enriched = await _enrich_ner_results(drug_entities, dosages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  if enriched:
 
 
 
 
 
82
  return enriched
 
83
  logger.info("All NER entities filtered out, trying RxNorm fallback")
84
 
85
+ # Pass 2: Fallback — try RxNorm approximate matching on text blocks
86
  logger.info("Falling through to RxNorm fallback")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  return await _rxnorm_fallback(text, dosage_str)
88
 
89
 
90
  async def _enrich_ner_results(
91
  entities: list[ner_model.Entity],
92
  dosages: list[Dosage],
 
93
  ) -> list[dict]:
94
  """Enrich NER entities with RxNorm data."""
95
  results = []
 
112
  "name": name,
113
  "dosage": _nearest_dosage(entity.end, dosages),
114
  "form": None,
115
+ "source": "ner",
116
  "confidence": entity.score,
117
  "needs_confirmation": entity.score < 0.85,
 
118
  })
119
 
120
  results.sort(key=lambda r: r["confidence"], reverse=True)
app/services/ingredient_adjudicator.py DELETED
@@ -1,102 +0,0 @@
1
- """Ingredient adjudicator to compare PharmaDetect, GLiNER, and RxNorm results."""
2
-
3
- from typing import List, Dict, Any
4
-
5
- def _spans_overlap(start1: int, end1: int, start2: int, end2: int) -> bool:
6
- """Check if two character spans overlap."""
7
- return max(start1, start2) < min(end1, end2)
8
-
9
- def _get_overlapping_labels(start: int, end: int, gliner_entities: List[Any]) -> List[Any]:
10
- return [g for g in gliner_entities if _spans_overlap(start, end, g.start, g.end)]
11
-
12
- def _has_neighboring_active(start: int, end: int, gliner_entities: List[Any], distance: int = 20) -> bool:
13
- for g in gliner_entities:
14
- if g.label == "active pharmaceutical ingredient":
15
- # Check if it's nearby
16
- if min(abs(start - g.end), abs(end - g.start)) <= distance:
17
- return True
18
- return False
19
-
20
- def adjudicate_filter(
21
- pharm_candidates: List[Dict[str, Any]],
22
- gliner_entities: List[Any]
23
- ) -> List[Dict[str, Any]]:
24
- """gliner_filter mode: precision filter."""
25
- accepted = []
26
-
27
- for cand in pharm_candidates:
28
- ent = cand.get("_entity")
29
- if not ent:
30
- accepted.append(cand)
31
- continue
32
-
33
- overlaps = _get_overlapping_labels(ent.start, ent.end, gliner_entities)
34
- overlap_labels = [g.label for g in overlaps]
35
-
36
- # Keep if GLiNER labels it as active ingredient
37
- if "active pharmaceutical ingredient" in overlap_labels:
38
- accepted.append(cand)
39
- continue
40
-
41
- # If GLiNER labels as brand, manufacturer, dosage form, or salt
42
- negative_labels = {"brand or trade name", "manufacturer", "dosage form", "salt or counter-ion"}
43
- found_neg = set(overlap_labels).intersection(negative_labels)
44
-
45
- if found_neg:
46
- # Reject unless it has a neighboring active ingredient
47
- if not _has_neighboring_active(ent.start, ent.end, gliner_entities):
48
- cand["_rejected"] = True
49
- cand["_rejection_reason"] = f"GLiNER labeled as {', '.join(found_neg)}"
50
- continue
51
-
52
- accepted.append(cand)
53
-
54
- return accepted
55
-
56
- def adjudicate_salt(
57
- pharm_candidates: List[Dict[str, Any]],
58
- gliner_entities: List[Any]
59
- ) -> List[Dict[str, Any]]:
60
- """Salt-aware adjudicator."""
61
- accepted = []
62
- salt_words = {"Sodium", "Hydrochloride", "Calcium", "Phosphate", "Maleate", "Potassium"}
63
-
64
- for cand in pharm_candidates:
65
- ent = cand.get("_entity")
66
- if not ent:
67
- accepted.append(cand)
68
- continue
69
-
70
- overlaps = _get_overlapping_labels(ent.start, ent.end, gliner_entities)
71
- overlap_labels = [g.label for g in overlaps]
72
-
73
- # If it's just a standalone salt word according to text
74
- is_salt_word = ent.text in salt_words
75
- is_gliner_salt = "salt or counter-ion" in overlap_labels
76
-
77
- if (is_salt_word or is_gliner_salt) and "active pharmaceutical ingredient" not in overlap_labels:
78
- if not _has_neighboring_active(ent.start, ent.end, gliner_entities):
79
- cand["_rejected"] = True
80
- cand["_rejection_reason"] = "Standalone salt or counter-ion"
81
- continue
82
-
83
- accepted.append(cand)
84
-
85
- return accepted
86
-
87
- def adjudicate(
88
- mode: str,
89
- pharm_candidates: List[Dict[str, Any]],
90
- gliner_entities: List[Any]
91
- ) -> List[Dict[str, Any]]:
92
- """Run adjudication based on experiment mode."""
93
- if mode == "gliner_filter":
94
- return adjudicate_filter(pharm_candidates, gliner_entities)
95
- elif mode == "gliner_adjudicated":
96
- # gliner_adjudicated combines filter and salt-aware logic
97
- filtered = adjudicate_filter(pharm_candidates, gliner_entities)
98
- # remove rejected
99
- valid = [c for c in filtered if not c.get("_rejected")]
100
- return adjudicate_salt(valid, gliner_entities)
101
-
102
- return pharm_candidates
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docker-compose.ci.yml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CI override — use pre-built image, no volume mounts, no nginx.
2
+ # Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d api
3
+ services:
4
+ api:
5
+ image: pillchecker-api:ci
6
+ entrypoint: ["/app/scripts/ci-startup.sh"]
7
+ command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
8
+ ports:
9
+ - "8000:8000"
10
+ volumes: []
11
+ environment:
12
+ - HF_HOME=/app/models
13
+ restart: "no"
14
+
docker-compose.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ api:
3
+ build: .
4
+ ports:
5
+ - "8000:8000"
6
+ volumes:
7
+ - model-cache:/app/models
8
+ environment:
9
+ - HF_HOME=/app/models
10
+ - HF_TOKEN=${HF_TOKEN:-}
11
+ - API_KEY=${API_KEY}
12
+ restart: unless-stopped
13
+
14
+ volumes:
15
+ model-cache:
docs/infrastructure_hardening.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PillChecker Infrastructure Hardening Recommendations
2
+
3
+ Following an assessment of the current CI/CD and Google Cloud Platform (GCP) setup, the following improvements are recommended to enhance security, reliability, and observability.
4
+
5
+ ## 1. IAM Permissions: Principle of Least Privilege
6
+
7
+ Currently, the `deploy-sa` service account is used for both CI/CD (deployment) and runtime (execution on Cloud Run). It also has project-wide access to all secrets.
8
+
9
+ ### Recommendations:
10
+
11
+ * **Separate Service Accounts**: Split `deploy-sa` into two distinct roles:
12
+ * **Deployer SA**: Used only by GitHub Actions. Permissions: `roles/run.admin`, `roles/artifactregistry.writer`, `roles/iam.serviceAccountUser` (restricted to the Runner SA).
13
+ * **Runner SA**: Used only by the Cloud Run service at runtime. Permissions: `roles/logging.logWriter`, `roles/secretmanager.secretAccessor` (restricted to specific secrets).
14
+ * **Restrict Secret Access**: Instead of granting `roles/secretmanager.secretAccessor` at the project level, grant it only on the specific secrets the application needs (`API_KEY`, `HF_TOKEN`, `DRUGBANK_DB_REPO`).
15
+ * **Remove Default Service Account**: Ensure the Default Compute Service Account is not used and has no permissions, as it often has broad `Editor` access by default.
16
+
17
+ ## 2. Cloud Run Reliability: Health Probes
18
+
19
+ The current Cloud Run configuration uses a basic `tcpSocket` startup probe.
20
+
21
+ ### Recommendations:
22
+
23
+ * **Switch to HTTP Probes**: Use `httpGet` probes to `/health` instead of `tcpSocket`. This ensures the application is not just listening on a port but is actually ready to handle requests.
24
+ * **Add Liveness Probe**: Implement a liveness probe to automatically restart the container if the Python process deadlocks or becomes unresponsive.
25
+ * **Example Configuration**:
26
+ ```yaml
27
+ startupProbe:
28
+ httpGet:
29
+ path: /health
30
+ port: 8000
31
+ failureThreshold: 5
32
+ periodSeconds: 10
33
+ livenessProbe:
34
+ httpGet:
35
+ path: /health
36
+ port: 8000
37
+ periodSeconds: 30
38
+ ```
39
+
40
+ ## 3. Observability: Structured Logging
41
+
42
+ Audit logs are currently generated as JSON strings in `stdout`.
43
+
44
+ ### Recommendations:
45
+
46
+ * **Standardize Structured Logging**: Use a logging library (like `structlog` or `google-cloud-logging`) to ensure all logs, not just audit logs, are emitted as structured JSON.
47
+ * **Cloud Logging Integration**: Ensure `severity` levels (INFO, WARNING, ERROR) are correctly mapped to GCP Cloud Logging levels by including a `"severity"` field in the JSON payload.
48
+ * **Log Retention**: Ensure audit logs are retained for a period sufficient for compliance/auditing (e.g., 365 days), potentially exporting them to BigQuery for long-term analysis.
49
+
50
+ ## 4. Container Optimization (Completed)
51
+
52
+ We have already improved the container security and efficiency by:
53
+ * Switching to a **non-root user** (`pillchecker`) in the `Dockerfile`.
54
+ * Replacing the Node.js-based MCP server with **direct SQLite integration**, which:
55
+ * Reduced the image size (no Node.js runtime or binaries).
56
+ * Eliminated child process management overhead and latency.
57
+ * Removed Node-specific security vulnerabilities from the attack surface.
58
+
59
+ ## 5. Network Security
60
+
61
+ * **Ingress Control**: If the API is only intended for use by a specific frontend or mobile app, consider restricting ingress to `Internal and Cloud Load Balancing` and placing a Cloud Armor policy in front of it.
62
+ * **Egress Control**: If the application only needs to talk to specific external APIs (like NLM or HuggingFace), consider using a VPC Service Control or a NAT Gateway with restricted egress rules.
docs/openapi.json ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "PillChecker API",
5
+ "description": "Medication interaction checker",
6
+ "version": "0.1.0"
7
+ },
8
+ "paths": {
9
+ "/health": {
10
+ "get": {
11
+ "summary": "Health Check",
12
+ "description": "Basic health check to verify the API is running.",
13
+ "operationId": "health_check_health_get",
14
+ "responses": {
15
+ "200": {
16
+ "description": "Successful Response",
17
+ "content": {
18
+ "application/json": {
19
+ "schema": {}
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ },
26
+ "/health/data": {
27
+ "get": {
28
+ "summary": "Data Health Check",
29
+ "description": "Check the status of the medication interaction database.",
30
+ "operationId": "data_health_check_health_data_get",
31
+ "responses": {
32
+ "200": {
33
+ "description": "Successful Response",
34
+ "content": {
35
+ "application/json": {
36
+ "schema": {}
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ },
43
+ "/analyze": {
44
+ "post": {
45
+ "summary": "Analyze",
46
+ "operationId": "analyze_analyze_post",
47
+ "requestBody": {
48
+ "content": {
49
+ "application/json": {
50
+ "schema": {
51
+ "$ref": "#/components/schemas/AnalyzeRequest"
52
+ }
53
+ }
54
+ },
55
+ "required": true
56
+ },
57
+ "responses": {
58
+ "200": {
59
+ "description": "Successful Response",
60
+ "content": {
61
+ "application/json": {
62
+ "schema": {
63
+ "$ref": "#/components/schemas/AnalyzeResponse"
64
+ }
65
+ }
66
+ }
67
+ },
68
+ "422": {
69
+ "description": "Validation Error",
70
+ "content": {
71
+ "application/json": {
72
+ "schema": {
73
+ "$ref": "#/components/schemas/HTTPValidationError"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ },
81
+ "/interactions": {
82
+ "post": {
83
+ "summary": "Check Interactions",
84
+ "operationId": "check_interactions_interactions_post",
85
+ "requestBody": {
86
+ "content": {
87
+ "application/json": {
88
+ "schema": {
89
+ "$ref": "#/components/schemas/InteractionsRequest"
90
+ }
91
+ }
92
+ },
93
+ "required": true
94
+ },
95
+ "responses": {
96
+ "200": {
97
+ "description": "Successful Response",
98
+ "content": {
99
+ "application/json": {
100
+ "schema": {
101
+ "$ref": "#/components/schemas/InteractionsResponse"
102
+ }
103
+ }
104
+ }
105
+ },
106
+ "422": {
107
+ "description": "Validation Error",
108
+ "content": {
109
+ "application/json": {
110
+ "schema": {
111
+ "$ref": "#/components/schemas/HTTPValidationError"
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ },
120
+ "components": {
121
+ "schemas": {
122
+ "AnalyzeRequest": {
123
+ "properties": {
124
+ "text": {
125
+ "type": "string",
126
+ "minLength": 1,
127
+ "title": "Text",
128
+ "examples": [
129
+ "BRUFEN Ibuprofen 400 mg Film-Coated Tablets"
130
+ ]
131
+ }
132
+ },
133
+ "type": "object",
134
+ "required": [
135
+ "text"
136
+ ],
137
+ "title": "AnalyzeRequest"
138
+ },
139
+ "AnalyzeResponse": {
140
+ "properties": {
141
+ "drugs": {
142
+ "items": {
143
+ "$ref": "#/components/schemas/DrugResult"
144
+ },
145
+ "type": "array",
146
+ "title": "Drugs"
147
+ },
148
+ "raw_text": {
149
+ "type": "string",
150
+ "title": "Raw Text"
151
+ }
152
+ },
153
+ "type": "object",
154
+ "required": [
155
+ "drugs",
156
+ "raw_text"
157
+ ],
158
+ "title": "AnalyzeResponse"
159
+ },
160
+ "DrugResult": {
161
+ "properties": {
162
+ "rxcui": {
163
+ "anyOf": [
164
+ {
165
+ "type": "string"
166
+ },
167
+ {
168
+ "type": "null"
169
+ }
170
+ ],
171
+ "title": "Rxcui"
172
+ },
173
+ "name": {
174
+ "type": "string",
175
+ "title": "Name"
176
+ },
177
+ "dosage": {
178
+ "anyOf": [
179
+ {
180
+ "type": "string"
181
+ },
182
+ {
183
+ "type": "null"
184
+ }
185
+ ],
186
+ "title": "Dosage"
187
+ },
188
+ "form": {
189
+ "anyOf": [
190
+ {
191
+ "type": "string"
192
+ },
193
+ {
194
+ "type": "null"
195
+ }
196
+ ],
197
+ "title": "Form"
198
+ },
199
+ "source": {
200
+ "type": "string",
201
+ "title": "Source"
202
+ },
203
+ "confidence": {
204
+ "type": "number",
205
+ "title": "Confidence"
206
+ }
207
+ },
208
+ "type": "object",
209
+ "required": [
210
+ "rxcui",
211
+ "name",
212
+ "dosage",
213
+ "form",
214
+ "source",
215
+ "confidence"
216
+ ],
217
+ "title": "DrugResult"
218
+ },
219
+ "HTTPValidationError": {
220
+ "properties": {
221
+ "detail": {
222
+ "items": {
223
+ "$ref": "#/components/schemas/ValidationError"
224
+ },
225
+ "type": "array",
226
+ "title": "Detail"
227
+ }
228
+ },
229
+ "type": "object",
230
+ "title": "HTTPValidationError"
231
+ },
232
+ "InteractionResult": {
233
+ "properties": {
234
+ "drug_a": {
235
+ "type": "string",
236
+ "title": "Drug A"
237
+ },
238
+ "drug_b": {
239
+ "type": "string",
240
+ "title": "Drug B"
241
+ },
242
+ "severity": {
243
+ "type": "string",
244
+ "title": "Severity"
245
+ },
246
+ "description": {
247
+ "type": "string",
248
+ "title": "Description"
249
+ },
250
+ "management": {
251
+ "type": "string",
252
+ "title": "Management"
253
+ }
254
+ },
255
+ "type": "object",
256
+ "required": [
257
+ "drug_a",
258
+ "drug_b",
259
+ "severity",
260
+ "description",
261
+ "management"
262
+ ],
263
+ "title": "InteractionResult"
264
+ },
265
+ "InteractionsRequest": {
266
+ "properties": {
267
+ "drugs": {
268
+ "items": {
269
+ "type": "string"
270
+ },
271
+ "type": "array",
272
+ "minItems": 2,
273
+ "title": "Drugs",
274
+ "examples": [
275
+ [
276
+ "ibuprofen",
277
+ "warfarin"
278
+ ]
279
+ ]
280
+ }
281
+ },
282
+ "type": "object",
283
+ "required": [
284
+ "drugs"
285
+ ],
286
+ "title": "InteractionsRequest"
287
+ },
288
+ "InteractionsResponse": {
289
+ "properties": {
290
+ "interactions": {
291
+ "items": {
292
+ "$ref": "#/components/schemas/InteractionResult"
293
+ },
294
+ "type": "array",
295
+ "title": "Interactions"
296
+ },
297
+ "safe": {
298
+ "type": "boolean",
299
+ "title": "Safe"
300
+ }
301
+ },
302
+ "type": "object",
303
+ "required": [
304
+ "interactions",
305
+ "safe"
306
+ ],
307
+ "title": "InteractionsResponse"
308
+ },
309
+ "ValidationError": {
310
+ "properties": {
311
+ "loc": {
312
+ "items": {
313
+ "anyOf": [
314
+ {
315
+ "type": "string"
316
+ },
317
+ {
318
+ "type": "integer"
319
+ }
320
+ ]
321
+ },
322
+ "type": "array",
323
+ "title": "Location"
324
+ },
325
+ "msg": {
326
+ "type": "string",
327
+ "title": "Message"
328
+ },
329
+ "type": {
330
+ "type": "string",
331
+ "title": "Error Type"
332
+ },
333
+ "input": {
334
+ "title": "Input"
335
+ },
336
+ "ctx": {
337
+ "type": "object",
338
+ "title": "Context"
339
+ }
340
+ },
341
+ "type": "object",
342
+ "required": [
343
+ "loc",
344
+ "msg",
345
+ "type"
346
+ ],
347
+ "title": "ValidationError"
348
+ }
349
+ }
350
+ }
351
+ }
pyproject.toml CHANGED
@@ -14,9 +14,6 @@ dependencies = [
14
  "aiosqlite>=0.22.1",
15
  ]
16
 
17
- [project.optional-dependencies]
18
- gliner = ["gliner"]
19
-
20
  [[tool.uv.index]]
21
  name = "pytorch-cpu"
22
  url = "https://download.pytorch.org/whl/cpu"
 
14
  "aiosqlite>=0.22.1",
15
  ]
16
 
 
 
 
17
  [[tool.uv.index]]
18
  name = "pytorch-cpu"
19
  url = "https://download.pytorch.org/whl/cpu"
tests/__init__.py ADDED
File without changes
tests/test_admin.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for admin cache management endpoint."""
2
+
3
+ import os
4
+ import pytest
5
+ from unittest.mock import patch, MagicMock, AsyncMock
6
+ from fastapi.testclient import TestClient
7
+
8
+
9
+ @pytest.fixture
10
+ def client():
11
+ mock_drugbank = MagicMock()
12
+ mock_drugbank.connect = AsyncMock()
13
+ mock_drugbank.close = AsyncMock()
14
+ mock_drugbank.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.drugbank_client", mock_drugbank),
22
+ patch("app.main.severity_classifier", mock_severity),
23
+ patch("app.main.ner_model"),
24
+ patch("app.api.health.drugbank_client", mock_drugbank),
25
+ patch("app.services.interaction_checker.drugbank_client", mock_drugbank),
26
+ patch("app.services.interaction_checker.severity_classifier", mock_severity),
27
+ ):
28
+ from app.main import app
29
+ yield TestClient(app)
30
+
31
+
32
+ class TestAdminCacheClear:
33
+ def test_clears_cache_with_valid_key(self, client):
34
+ resp = client.post("/admin/cache/clear", headers={"X-API-Key": "test-key"})
35
+ assert resp.status_code == 200
36
+ assert resp.json()["status"] == "ok"
37
+
38
+ def test_rejects_without_key(self, client):
39
+ resp = client.post("/admin/cache/clear")
40
+ assert resp.status_code == 401
tests/test_api.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API endpoint tests.
2
+
3
+ Tests /interactions and /health endpoints directly.
4
+ /analyze requires the NER model loaded — tested via Docker or manual run.
5
+ """
6
+
7
+ import pytest
8
+ from unittest.mock import AsyncMock, patch, MagicMock
9
+ from fastapi.testclient import TestClient
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_drugbank():
14
+ """Mock drugbank_client in every module that imports it."""
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.DrugBankUnavailableError = Exception
21
+ with patch("app.services.interaction_checker.drugbank_client", mock), \
22
+ patch("app.api.health.drugbank_client", mock), \
23
+ patch("app.main.drugbank_client", mock):
24
+ yield mock
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_severity():
29
+ """Mock severity_classifier in every module that imports it."""
30
+ mock = MagicMock()
31
+ mock.classify.return_value = ("moderate", False)
32
+ mock.load_model = MagicMock()
33
+ mock.is_loaded.return_value = True
34
+ with patch("app.services.interaction_checker.severity_classifier", mock), \
35
+ patch("app.main.severity_classifier", mock):
36
+ yield mock
37
+
38
+
39
+ @pytest.fixture
40
+ def mock_severity_parser():
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
+
53
+
54
+ class TestAnalyzeValidation:
55
+ def test_analyze_rejects_oversized_text(self, client):
56
+ """Text over 5000 chars must be rejected with 422."""
57
+ resp = client.post(
58
+ "/analyze",
59
+ json={"text": "Metformin 500mg " * 500},
60
+ headers={"X-API-Key": "test-key"},
61
+ )
62
+ assert resp.status_code == 422
63
+
64
+ def test_analyze_strips_html_from_raw_text(self, client):
65
+ """HTML tags must be stripped from raw_text to prevent XSS."""
66
+ with patch("app.services.drug_analyzer.analyze", new=AsyncMock(return_value=[])):
67
+ resp = client.post(
68
+ "/analyze",
69
+ json={"text": '<script>alert(1)</script>Metformin 500mg'},
70
+ headers={"X-API-Key": "test-key"},
71
+ )
72
+ assert resp.status_code == 200
73
+ data = resp.json()
74
+ assert "<script>" not in data["raw_text"]
75
+ assert "alert(1)" in data["raw_text"]
76
+
77
+ def test_analyze_non_latin_text_returns_note(self, client):
78
+ """Non-Latin text should return empty drugs with explanatory note."""
79
+ resp = client.post(
80
+ "/analyze",
81
+ json={"text": "阿莫西林胶囊 500mg"},
82
+ headers={"X-API-Key": "test-key"},
83
+ )
84
+ assert resp.status_code == 200
85
+ data = resp.json()
86
+ assert data["drugs"] == []
87
+ assert "note" in data
88
+ assert "Latin" in data["note"]
89
+
90
+ def test_analyze_mixed_script_processes_normally(self, client):
91
+ """Text with mostly Latin chars should process normally even with some non-Latin."""
92
+ with patch("app.api.analyze.drug_analyzer.analyze", new=AsyncMock(return_value=[])):
93
+ resp = client.post(
94
+ "/analyze",
95
+ json={"text": "Metformin 500mg (メトホルミン)"},
96
+ headers={"X-API-Key": "test-key"},
97
+ )
98
+ assert resp.status_code == 200
99
+ data = resp.json()
100
+ assert data.get("note") is None or "Non-Latin" not in data.get("note", "")
101
+
102
+
103
+ class TestInteractionsValidation:
104
+ def test_interactions_rejects_empty_string_drug(self, client):
105
+ """Empty strings in drugs list must be rejected with 422."""
106
+ resp = client.post(
107
+ "/interactions",
108
+ json={"drugs": ["metformin", "", "lisinopril"]},
109
+ headers={"X-API-Key": "test-key"},
110
+ )
111
+ assert resp.status_code == 422
112
+
113
+ def test_interactions_rejects_whitespace_only_drug(self, client):
114
+ """Whitespace-only strings must be rejected after stripping."""
115
+ resp = client.post(
116
+ "/interactions",
117
+ json={"drugs": [" ", "metformin"]},
118
+ headers={"X-API-Key": "test-key"},
119
+ )
120
+ assert resp.status_code == 422
121
+
122
+ def test_interactions_rejects_long_drug_name(self, client):
123
+ """Drug names over 200 chars must be rejected."""
124
+ resp = client.post(
125
+ "/interactions",
126
+ json={"drugs": ["a" * 201, "metformin"]},
127
+ headers={"X-API-Key": "test-key"},
128
+ )
129
+ assert resp.status_code == 422
130
+
131
+
132
+ class TestInteractionsEndpoint:
133
+ def test_known_interaction(self, client, mock_drugbank):
134
+ mock_drugbank.get_interactions.side_effect = [
135
+ [{"drug": "Warfarin", "description": "Increases bleeding risk."}],
136
+ [{"drug": "Ibuprofen", "description": "Increases bleeding risk."}],
137
+ ]
138
+ resp = client.post("/interactions", json={"drugs": ["ibuprofen", "warfarin"]})
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, mock_drugbank):
148
+ mock_drugbank.get_interactions.side_effect = [
149
+ [], [],
150
+ ]
151
+ resp = client.post("/interactions", json={"drugs": ["ibuprofen", "amoxicillin"]})
152
+ assert resp.status_code == 200
153
+ data = resp.json()
154
+ assert data["safe"] is True
155
+
156
+ def test_three_drugs(self, client, mock_drugbank):
157
+ mock_drugbank.get_interactions.side_effect = [
158
+ [{"drug": "Warfarin", "description": "x"}, {"drug": "Aspirin", "description": "x"}],
159
+ [{"drug": "Ibuprofen", "description": "x"}, {"drug": "Aspirin", "description": "x"}],
160
+ [{"drug": "Ibuprofen", "description": "x"}, {"drug": "Warfarin", "description": "x"}],
161
+ ]
162
+ resp = client.post("/interactions", json={"drugs": ["ibuprofen", "warfarin", "aspirin"]})
163
+ assert resp.status_code == 200
164
+ data = resp.json()
165
+ assert len(data["interactions"]) >= 2
166
+
167
+ def test_validation_requires_two_drugs(self, client):
168
+ resp = client.post("/interactions", json={"drugs": ["ibuprofen"]})
169
+ assert resp.status_code == 422
170
+
171
+ def test_validation_requires_drugs_field(self, client):
172
+ resp = client.post("/interactions", json={})
173
+ assert resp.status_code == 422
174
+
175
+
176
+ class TestHealthEndpoint:
177
+ def test_health_returns_ok(self, client):
178
+ resp = client.get("/health")
179
+ assert resp.status_code == 200
180
+ data = resp.json()
181
+ assert data["status"] == "ok"
182
+ assert data["version"] == "0.1.0"
183
+
184
+ def test_data_health_connected(self, client, mock_drugbank):
185
+ mock_drugbank.health_check.return_value = True
186
+ resp = client.get("/health/data")
187
+ assert resp.status_code == 200
188
+ data = resp.json()
189
+ assert data["status"] == "ready"
190
+ assert data["drugbank"] == "connected"
191
+
192
+ def test_data_health_degraded(self, client, mock_drugbank):
193
+ mock_drugbank.health_check.return_value = False
194
+ resp = client.get("/health/data")
195
+ assert resp.status_code == 200
196
+ data = resp.json()
197
+ assert data["status"] == "degraded"
198
+ assert data["drugbank"] == "unreachable"
tests/test_api_key.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for API key middleware."""
2
+
3
+ import os
4
+ import pytest
5
+ from unittest.mock import patch
6
+ from fastapi import FastAPI
7
+ from fastapi.testclient import TestClient
8
+
9
+
10
+ def make_test_app() -> FastAPI:
11
+ """Minimal app with middleware and stub routes (no NER model needed)."""
12
+ from app.middleware.api_key import APIKeyMiddleware
13
+
14
+ test_app = FastAPI()
15
+ test_app.add_middleware(APIKeyMiddleware)
16
+
17
+ @test_app.get("/health")
18
+ def health():
19
+ return {"status": "ok"}
20
+
21
+ @test_app.get("/health/data")
22
+ def health_data():
23
+ return {"status": "ready"}
24
+
25
+ @test_app.post("/analyze")
26
+ def analyze():
27
+ return {"drugs": [], "raw_text": "test"}
28
+
29
+ @test_app.post("/interactions")
30
+ def interactions():
31
+ return {"interactions": [], "safe": True}
32
+
33
+ return test_app
34
+
35
+
36
+ @pytest.fixture
37
+ def client_with_key():
38
+ with patch.dict(os.environ, {"API_KEY": "test-secret-key"}):
39
+ yield TestClient(make_test_app())
40
+
41
+
42
+ @pytest.fixture
43
+ def client_without_key():
44
+ env = {k: v for k, v in os.environ.items() if k != "API_KEY"}
45
+ with patch.dict(os.environ, env, clear=True):
46
+ yield TestClient(make_test_app())
47
+
48
+
49
+ class TestAPIKeyMiddleware:
50
+ def test_health_no_key_required(self, client_with_key):
51
+ r = client_with_key.get("/health")
52
+ assert r.status_code == 200
53
+
54
+ def test_health_data_no_key_required(self, client_with_key):
55
+ r = client_with_key.get("/health/data")
56
+ assert r.status_code == 200
57
+
58
+ def test_analyze_rejected_without_key(self, client_with_key):
59
+ r = client_with_key.post("/analyze", json={"text": "ibuprofen"})
60
+ assert r.status_code == 401
61
+
62
+ def test_analyze_rejected_with_wrong_key(self, client_with_key):
63
+ r = client_with_key.post(
64
+ "/analyze",
65
+ json={"text": "ibuprofen"},
66
+ headers={"X-API-Key": "wrong"},
67
+ )
68
+ assert r.status_code == 401
69
+
70
+ def test_analyze_accepted_with_correct_key(self, client_with_key):
71
+ r = client_with_key.post(
72
+ "/analyze",
73
+ json={"text": "ibuprofen"},
74
+ headers={"X-API-Key": "test-secret-key"},
75
+ )
76
+ assert r.status_code == 200
77
+
78
+ def test_interactions_rejected_without_key(self, client_with_key):
79
+ r = client_with_key.post("/interactions", json={"drugs": ["a", "b"]})
80
+ assert r.status_code == 401
81
+
82
+ def test_no_api_key_env_disables_auth(self, client_without_key):
83
+ r = client_without_key.post("/analyze", json={"text": "ibuprofen"})
84
+ # Auth is disabled when API_KEY env var is not set
85
+ assert r.status_code != 401
tests/test_audit_log.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for audit logging middleware."""
2
+
3
+ from app.middleware.audit_log import AuditContext, get_audit_context, init_audit_context
4
+
5
+
6
+ class TestAuditContext:
7
+ def test_init_creates_context(self):
8
+ ctx = init_audit_context()
9
+ assert isinstance(ctx, AuditContext)
10
+ assert ctx.entries == []
11
+
12
+ def test_append_entry(self):
13
+ ctx = init_audit_context()
14
+ ctx.add("ner", {"entities": ["ibuprofen"]})
15
+ assert len(ctx.entries) == 1
16
+ assert ctx.entries[0]["stage"] == "ner"
17
+
18
+ def test_get_returns_current_context(self):
19
+ ctx = init_audit_context()
20
+ ctx.add("test", {"data": "value"})
21
+ retrieved = get_audit_context()
22
+ assert retrieved is ctx
23
+
24
+ def test_to_dict(self):
25
+ ctx = init_audit_context()
26
+ ctx.add("ner", {"count": 2})
27
+ result = ctx.to_dict()
28
+ assert "entries" in result
29
+ assert "timestamp" in result
tests/test_dosage_parser.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for dosage regex parser — 20+ real packaging examples."""
2
+
3
+ from app.nlp.dosage_parser import Dosage, extract_dosages
4
+
5
+
6
+ class TestSimpleDosages:
7
+ def test_ibuprofen_400mg(self):
8
+ result = extract_dosages("Ibuprofen 400 mg Film-Coated Tablets")
9
+ assert len(result) == 1
10
+ assert result[0].value == 400.0
11
+ assert result[0].unit == "mg"
12
+
13
+ def test_paracetamol_500mg_no_space(self):
14
+ result = extract_dosages("Paracetamol 500mg tablets")
15
+ assert len(result) == 1
16
+ assert result[0].value == 500.0
17
+
18
+ def test_vitamin_d_1000iu(self):
19
+ result = extract_dosages("Vitamin D3 1000 IU capsules")
20
+ assert len(result) == 1
21
+ assert result[0].unit == "IU"
22
+
23
+ def test_levothyroxine_50mcg(self):
24
+ result = extract_dosages("Levothyroxine 50 mcg tablets")
25
+ assert len(result) == 1
26
+ assert result[0].value == 50.0
27
+ assert result[0].unit == "mcg"
28
+
29
+ def test_metformin_850mg(self):
30
+ result = extract_dosages("Metformin HCl 850 mg")
31
+ assert len(result) == 1
32
+ assert result[0].value == 850.0
33
+
34
+ def test_decimal_dosage(self):
35
+ result = extract_dosages("Alprazolam 0.5 mg tablets")
36
+ assert len(result) == 1
37
+ assert result[0].value == 0.5
38
+
39
+ def test_amoxicillin_1g(self):
40
+ result = extract_dosages("Amoxicillin 1 g powder")
41
+ assert len(result) == 1
42
+ assert result[0].unit == "g"
43
+
44
+ def test_microgram_symbol(self):
45
+ result = extract_dosages("Fentanyl 25 µg/hr patch")
46
+ dosage = [d for d in result if d.unit == "µg"]
47
+ assert len(dosage) >= 1
48
+ assert dosage[0].value == 25.0
49
+
50
+
51
+ class TestCompoundDosages:
52
+ def test_suspension_10mg_5ml(self):
53
+ result = extract_dosages("Ibuprofen 10 mg/5 ml oral suspension")
54
+ compound = [d for d in result if d.per_value is not None]
55
+ assert len(compound) >= 1
56
+ assert compound[0].value == 10.0
57
+ assert compound[0].per_value == 5.0
58
+ assert compound[0].per_unit == "ml"
59
+
60
+ def test_concentration_500mg_5ml(self):
61
+ result = extract_dosages("Amoxicillin 500mg/5ml")
62
+ compound = [d for d in result if d.per_value is not None]
63
+ assert len(compound) >= 1
64
+ assert compound[0].value == 500.0
65
+
66
+ def test_per_ml(self):
67
+ result = extract_dosages("Insulin 100 IU/ml")
68
+ compound = [d for d in result if d.per_unit is not None]
69
+ assert len(compound) >= 1
70
+ assert compound[0].unit == "IU"
71
+
72
+ def test_solution_200mg_ml(self):
73
+ result = extract_dosages("Ibuprofen 200mg/ml drops")
74
+ compound = [d for d in result if d.per_unit is not None]
75
+ assert len(compound) >= 1
76
+
77
+
78
+ class TestPerUnitDosages:
79
+ def test_per_tablet(self):
80
+ result = extract_dosages("500 mg/tablet")
81
+ per_unit = [d for d in result if d.per_unit == "tablet"]
82
+ assert len(per_unit) >= 1
83
+ assert per_unit[0].value == 500.0
84
+
85
+ def test_per_capsule(self):
86
+ result = extract_dosages("200 mg/capsule")
87
+ per_unit = [d for d in result if d.per_unit == "capsule"]
88
+ assert len(per_unit) >= 1
89
+
90
+ def test_per_dose(self):
91
+ result = extract_dosages("Salbutamol 100 mcg/dose inhaler")
92
+ per_unit = [d for d in result if d.per_unit == "dose"]
93
+ assert len(per_unit) >= 1
94
+ assert per_unit[0].value == 100.0
95
+
96
+
97
+ class TestPercentage:
98
+ def test_cream_1_percent(self):
99
+ result = extract_dosages("Hydrocortisone 1% cream")
100
+ assert any(d.unit == "%" and d.value == 1.0 for d in result)
101
+
102
+ def test_decimal_percent(self):
103
+ result = extract_dosages("Betamethasone 0.1% ointment")
104
+ assert any(d.unit == "%" and d.value == 0.1 for d in result)
105
+
106
+
107
+ class TestMultipleDosages:
108
+ def test_combination_drug(self):
109
+ # Co-amoxiclav: two active ingredients
110
+ result = extract_dosages("Amoxicillin 500 mg / Clavulanic Acid 125 mg")
111
+ mg_dosages = [d for d in result if d.unit == "mg"]
112
+ assert len(mg_dosages) >= 2
113
+
114
+ def test_real_packaging_brufen(self):
115
+ text = "BRUFEN Ibuprofen 400 mg Film-Coated Tablets"
116
+ result = extract_dosages(text)
117
+ assert len(result) >= 1
118
+ assert result[0].value == 400.0
119
+
120
+
121
+ class TestDosagePosition:
122
+ def test_dosage_includes_start_position(self):
123
+ """Dosage objects must include the character offset where they appear."""
124
+ dosages = extract_dosages("Lisinopril 10mg daily, Metformin 500mg")
125
+ assert len(dosages) == 2
126
+ assert dosages[0].start == 11 # "10mg" starts at char 11
127
+ assert dosages[1].start == 33 # "500mg" starts at char 33
128
+
129
+
130
+ class TestEdgeCases:
131
+ def test_no_dosage(self):
132
+ result = extract_dosages("Take with food and water")
133
+ assert result == []
134
+
135
+ def test_mmol(self):
136
+ result = extract_dosages("Potassium chloride 10 mmol effervescent")
137
+ assert len(result) == 1
138
+ assert result[0].unit == "mmol"
tests/test_drug_analyzer.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for drug_analyzer — mocks RxNorm client and NER model.
2
+
3
+ Covers the fallback path quality filters:
4
+ - Low-score approximate matches must be rejected
5
+ - Empty drug names must be filtered out
6
+ - High-confidence matches must still pass through
7
+ """
8
+
9
+ import pytest
10
+ from unittest.mock import AsyncMock, patch, MagicMock
11
+
12
+ from app.clients.rxnorm_client import DrugInfo
13
+ from app.services import drug_analyzer
14
+ from app.nlp import ner_model
15
+
16
+
17
+ def _no_ner(text):
18
+ """Stub NER predict that always returns no drug entities."""
19
+ return []
20
+
21
+
22
+ # ─── Fixtures ────────────────────────────────────────────────────────────────
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def mock_ner():
27
+ """Patch NER so tests don't require the model to be loaded."""
28
+ with patch("app.services.drug_analyzer.ner_model.predict", side_effect=_no_ner):
29
+ yield
30
+
31
+
32
+ # ─── Score threshold tests ────────────────────────────────────────────────────
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_low_score_candidate_rejected():
37
+ """A candidate with score < threshold must not appear in results."""
38
+ low_score_candidate = DrugInfo(rxcui="2388160", name="Hello Bello", score=3.98)
39
+
40
+ with patch(
41
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
42
+ new=AsyncMock(return_value=[low_score_candidate]),
43
+ ):
44
+ results = await drug_analyzer.analyze("hello world")
45
+
46
+ assert results == [], (
47
+ f"Expected no results for low-score match, got: {results}"
48
+ )
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_rxnorm_fallback_rejects_low_score_match():
53
+ """RxNorm matches with score < 10.0 must be rejected to prevent false positives."""
54
+ weak_candidate = DrugInfo(rxcui="1490058", name="Take Action", score=8.9)
55
+
56
+ with (
57
+ patch("app.services.drug_analyzer.ner_model.predict", return_value=[]),
58
+ patch(
59
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
60
+ new=AsyncMock(return_value=[weak_candidate]),
61
+ ),
62
+ ):
63
+ results = await drug_analyzer.analyze("Take 1 tablet twice daily")
64
+
65
+ assert results == [], f"Expected empty results for low-score match, got: {results}"
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_high_score_candidate_accepted():
70
+ """A candidate with score >= threshold and a valid name must be returned."""
71
+ high_score_candidate = DrugInfo(rxcui="5640", name="Ibuprofen", score=10.55)
72
+
73
+ with (
74
+ patch(
75
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
76
+ new=AsyncMock(return_value=[high_score_candidate]),
77
+ ),
78
+ patch(
79
+ "app.services.drug_analyzer.rxnorm_client.get_drug_details",
80
+ new=AsyncMock(return_value={"name": "ibuprofen"}),
81
+ ),
82
+ ):
83
+ results = await drug_analyzer.analyze("ibuprofen")
84
+
85
+ assert len(results) == 1
86
+ assert results[0]["name"] == "ibuprofen"
87
+ assert results[0]["source"] == "rxnorm_fallback"
88
+ assert results[0]["rxcui"] == "5640"
89
+
90
+
91
+ # ─── Empty name filter tests ──────────────────────────────────────────────────
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_empty_name_candidate_rejected():
96
+ """A candidate with empty name (MMSL source) and empty details must be skipped."""
97
+ nameless_candidate = DrugInfo(rxcui="2388160", name="", score=11.0)
98
+
99
+ with (
100
+ patch(
101
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
102
+ new=AsyncMock(return_value=[nameless_candidate]),
103
+ ),
104
+ patch(
105
+ "app.services.drug_analyzer.rxnorm_client.get_drug_details",
106
+ new=AsyncMock(return_value={}),
107
+ ),
108
+ ):
109
+ results = await drug_analyzer.analyze("some text")
110
+
111
+ assert results == [], (
112
+ f"Expected no results when resolved name is empty, got: {results}"
113
+ )
114
+
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_empty_best_name_resolved_from_details():
118
+ """When candidate name is empty but details has a name, use the details name."""
119
+ nameless_candidate = DrugInfo(rxcui="5640", name="", score=11.0)
120
+
121
+ with (
122
+ patch(
123
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
124
+ new=AsyncMock(return_value=[nameless_candidate]),
125
+ ),
126
+ patch(
127
+ "app.services.drug_analyzer.rxnorm_client.get_drug_details",
128
+ new=AsyncMock(return_value={"name": "ibuprofen"}),
129
+ ),
130
+ ):
131
+ results = await drug_analyzer.analyze("ibuprofen 400mg")
132
+
133
+ assert len(results) == 1
134
+ assert results[0]["name"] == "ibuprofen"
135
+
136
+
137
+ # ─── No candidates ─────────────────────────────────────────��─────────────────
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_no_candidates_returns_empty():
142
+ """When RxNorm returns no candidates for any word, result is empty list."""
143
+ with patch(
144
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
145
+ new=AsyncMock(return_value=[]),
146
+ ):
147
+ results = await drug_analyzer.analyze("xyzzy nonsense zzz")
148
+
149
+ assert results == []
150
+
151
+
152
+ # ─── NER entity filtering tests ───────────────────────────────────────────────
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_ner_entity_without_rxcui_filtered_out():
156
+ """NER entities that don't match any RxNorm drug must be excluded."""
157
+ fake_entity = ner_model.Entity(
158
+ text="Pactavis", label="CHEM", score=0.85, start=0, end=8,
159
+ )
160
+
161
+ with (
162
+ patch(
163
+ "app.services.drug_analyzer.ner_model.predict",
164
+ return_value=[fake_entity],
165
+ ),
166
+ patch(
167
+ "app.services.drug_analyzer.rxnorm_client.get_rxcui",
168
+ new=AsyncMock(return_value=None),
169
+ ),
170
+ # Fallback should also find nothing
171
+ patch(
172
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
173
+ new=AsyncMock(return_value=[]),
174
+ ),
175
+ ):
176
+ results = await drug_analyzer.analyze("Pactavis 6 tablets")
177
+
178
+ assert results == [], (
179
+ f"Expected no results for NER entity without RxCUI, got: {results}"
180
+ )
181
+
182
+
183
+ @pytest.mark.asyncio
184
+ async def test_ner_entity_with_rxcui_returned():
185
+ """NER entities that match a RxNorm drug must be returned."""
186
+ entity = ner_model.Entity(
187
+ text="Paracetamol", label="CHEM", score=0.95, start=0, end=11,
188
+ )
189
+
190
+ with (
191
+ patch(
192
+ "app.services.drug_analyzer.ner_model.predict",
193
+ return_value=[entity],
194
+ ),
195
+ patch(
196
+ "app.services.drug_analyzer.rxnorm_client.get_rxcui",
197
+ new=AsyncMock(return_value="161"),
198
+ ),
199
+ ):
200
+ results = await drug_analyzer.analyze("Paracetamol 500mg")
201
+
202
+ assert len(results) == 1
203
+ assert results[0]["name"] == "Paracetamol"
204
+ assert results[0]["rxcui"] == "161"
205
+ assert results[0]["source"] == "ner"
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_all_ner_filtered_falls_through_to_fallback():
210
+ """When all NER entities lack rxcui, fallback path should be used."""
211
+ fake_entity = ner_model.Entity(
212
+ text="Pactavis", label="CHEM", score=0.85, start=0, end=8,
213
+ )
214
+ fallback_candidate = DrugInfo(rxcui="10689", name="Trimethoprim", score=10.5)
215
+
216
+ with (
217
+ patch(
218
+ "app.services.drug_analyzer.ner_model.predict",
219
+ return_value=[fake_entity],
220
+ ),
221
+ patch(
222
+ "app.services.drug_analyzer.rxnorm_client.get_rxcui",
223
+ new=AsyncMock(return_value=None),
224
+ ),
225
+ patch(
226
+ "app.services.drug_analyzer.rxnorm_client.approximate_term",
227
+ new=AsyncMock(return_value=[fallback_candidate]),
228
+ ),
229
+ patch(
230
+ "app.services.drug_analyzer.rxnorm_client.get_drug_details",
231
+ new=AsyncMock(return_value={"name": "trimethoprim"}),
232
+ ),
233
+ ):
234
+ results = await drug_analyzer.analyze("Pactavis Trimethoprim Tablets")
235
+
236
+ assert len(results) == 1
237
+ assert results[0]["name"] == "trimethoprim"
238
+ assert results[0]["source"] == "rxnorm_fallback"
239
+
240
+
241
+ @pytest.mark.asyncio
242
+ async def test_rxnorm_fallback_sets_needs_confirmation():
243
+ """RxNorm fallback results should have needs_confirmation=True."""
244
+ candidate = DrugInfo(rxcui="5640", name="Ibuprofen", score=10.55)
245
+
246
+ with (
247
+ patch("app.services.drug_analyzer.rxnorm_client.approximate_term",
248
+ new=AsyncMock(return_value=[candidate])),
249
+ patch("app.services.drug_analyzer.rxnorm_client.get_drug_details",
250
+ new=AsyncMock(return_value={"name": "ibuprofen"})),
251
+ ):
252
+ results = await drug_analyzer.analyze("ibuprofen")
253
+
254
+ assert results[0]["needs_confirmation"] is True
255
+ assert results[0]["source"] == "rxnorm_fallback"
256
+
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_high_confidence_ner_no_confirmation():
260
+ """High-confidence NER results should not need confirmation."""
261
+ entity = ner_model.Entity(text="Ibuprofen", label="CHEM", score=0.95, start=0, end=9)
262
+
263
+ with (
264
+ patch("app.services.drug_analyzer.ner_model.predict", return_value=[entity]),
265
+ patch("app.services.drug_analyzer.rxnorm_client.get_rxcui",
266
+ new=AsyncMock(return_value="5640")),
267
+ ):
268
+ results = await drug_analyzer.analyze("Ibuprofen 400mg")
269
+
270
+ assert results[0]["needs_confirmation"] is False
271
+
272
+
273
+ @pytest.mark.asyncio
274
+ async def test_multi_drug_dosages_assigned_by_position():
275
+ """Each drug should get the dosage nearest to it, not the first found."""
276
+ entities = [
277
+ ner_model.Entity(text="Lisinopril", label="CHEM", score=0.93, start=0, end=10),
278
+ ner_model.Entity(text="Metformin", label="CHEM", score=0.92, start=22, end=31),
279
+ ]
280
+
281
+ async def mock_get_rxcui(name):
282
+ return {"Lisinopril": "29046", "Metformin": "6809"}.get(name)
283
+
284
+ with (
285
+ patch("app.services.drug_analyzer.ner_model.predict", return_value=entities),
286
+ patch(
287
+ "app.services.drug_analyzer.rxnorm_client.get_rxcui",
288
+ new=AsyncMock(side_effect=mock_get_rxcui),
289
+ ),
290
+ ):
291
+ results = await drug_analyzer.analyze("Lisinopril 10mg daily, Metformin 500mg")
292
+
293
+ assert len(results) == 2
294
+ lisinopril = next(r for r in results if r["name"] == "Lisinopril")
295
+ metformin = next(r for r in results if r["name"] == "Metformin")
296
+ assert lisinopril["dosage"] == "10mg"
297
+ assert metformin["dosage"] == "500mg"
298
+
299
+
300
+ @pytest.mark.asyncio
301
+ async def test_low_confidence_ner_needs_confirmation():
302
+ """NER results with confidence < 0.85 should need confirmation."""
303
+ entity = ner_model.Entity(text="Paracetamol", label="CHEM", score=0.72, start=0, end=11)
304
+
305
+ with (
306
+ patch("app.services.drug_analyzer.ner_model.predict", return_value=[entity]),
307
+ patch("app.services.drug_analyzer.rxnorm_client.get_rxcui",
308
+ new=AsyncMock(return_value="161")),
309
+ ):
310
+ results = await drug_analyzer.analyze("Paracetamol 500mg")
311
+
312
+ assert results[0]["needs_confirmation"] is True
313
+
314
+
315
+ @pytest.mark.asyncio
316
+ async def test_single_char_ner_entity_filtered():
317
+ """Single-character NER entities (e.g. '-') must be filtered out."""
318
+ entities = [
319
+ ner_model.Entity(text="-", label="CHEM", score=0.95, start=0, end=1),
320
+ ner_model.Entity(text="Metformin", label="CHEM", score=0.93, start=5, end=14),
321
+ ]
322
+
323
+ with (
324
+ patch("app.services.drug_analyzer.ner_model.predict", return_value=entities),
325
+ patch(
326
+ "app.services.drug_analyzer.rxnorm_client.get_rxcui",
327
+ new=AsyncMock(return_value="6809"),
328
+ ),
329
+ ):
330
+ results = await drug_analyzer.analyze("- Metformin 500mg")
331
+
332
+ assert len(results) == 1
333
+ assert results[0]["name"] == "Metformin"
334
+
335
+
336
+ @pytest.mark.asyncio
337
+ async def test_punctuation_only_ner_entity_filtered():
338
+ """Entities that are pure punctuation must be filtered out."""
339
+ entities = [
340
+ ner_model.Entity(text="...", label="CHEM", score=0.90, start=0, end=3),
341
+ ner_model.Entity(text="Lisinopril", label="CHEM", score=0.92, start=5, end=15),
342
+ ]
343
+
344
+ with (
345
+ patch("app.services.drug_analyzer.ner_model.predict", return_value=entities),
346
+ patch(
347
+ "app.services.drug_analyzer.rxnorm_client.get_rxcui",
348
+ new=AsyncMock(return_value="29046"),
349
+ ),
350
+ ):
351
+ results = await drug_analyzer.analyze("... Lisinopril 10mg")
352
+
353
+ assert len(results) == 1
354
+ assert results[0]["name"] == "Lisinopril"
355
+
356
+
357
+ @pytest.mark.asyncio
358
+ async def test_ner_results_sorted_by_confidence_descending():
359
+ """Results must be sorted by confidence, highest first."""
360
+ entities = [
361
+ ner_model.Entity(text="Aspirin", label="CHEM", score=0.70, start=0, end=7),
362
+ ner_model.Entity(text="Ibuprofen", label="CHEM", score=0.95, start=20, end=29),
363
+ ]
364
+
365
+ async def mock_get_rxcui(name):
366
+ return {"Aspirin": "1191", "Ibuprofen": "5640"}.get(name)
367
+
368
+ with (
369
+ patch(
370
+ "app.services.drug_analyzer.ner_model.predict",
371
+ return_value=entities,
372
+ ),
373
+ patch(
374
+ "app.services.drug_analyzer.rxnorm_client.get_rxcui",
375
+ new=AsyncMock(side_effect=mock_get_rxcui),
376
+ ),
377
+ ):
378
+ results = await drug_analyzer.analyze("Aspirin tablets plus Ibuprofen")
379
+
380
+ assert len(results) == 2
381
+ assert results[0]["name"] == "Ibuprofen", "Highest confidence drug should be first"
382
+ assert results[1]["name"] == "Aspirin"
tests/test_drugbank_client.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the DrugBank client (direct SQLite version)."""
2
+
3
+ import time
4
+ import pytest
5
+ from unittest.mock import AsyncMock, patch
6
+ from app.clients import drugbank_client
7
+
8
+
9
+ class TestResolveId:
10
+ """Test the internal name → drugbank_id resolution."""
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def reset_cache(self):
14
+ drugbank_client._cache.clear()
15
+ yield
16
+ drugbank_client._cache.clear()
17
+
18
+ @pytest.fixture
19
+ def mock_db(self):
20
+ with patch("app.clients.drugbank_client.db", new_callable=AsyncMock) as mock:
21
+ yield mock
22
+
23
+ async def test_resolves_name_to_drugbank_id(self, mock_db):
24
+ mock_db.search_by_name.return_value = [{"drugbank_id": "DB01050", "name": "Ibuprofen"}]
25
+ result = await drugbank_client._resolve_drugbank_id("ibuprofen")
26
+ assert result == "DB01050"
27
+ mock_db.search_by_name.assert_called_once_with("ibuprofen", limit=1)
28
+
29
+ async def test_returns_none_when_no_results(self, mock_db):
30
+ mock_db.search_by_name.return_value = []
31
+ result = await drugbank_client._resolve_drugbank_id("notadrug")
32
+ assert result is None
33
+
34
+ async def test_caches_resolved_id(self, mock_db):
35
+ mock_db.search_by_name.return_value = [{"drugbank_id": "DB01050", "name": "Ibuprofen"}]
36
+ await drugbank_client._resolve_drugbank_id("ibuprofen")
37
+ await drugbank_client._resolve_drugbank_id("ibuprofen")
38
+ assert mock_db.search_by_name.call_count == 1
39
+
40
+ async def test_caches_none_for_unknown_drugs(self, mock_db):
41
+ """Unknown drugs (None result) must also be cached."""
42
+ mock_db.search_by_name.return_value = []
43
+ await drugbank_client._resolve_drugbank_id("notadrug")
44
+ await drugbank_client._resolve_drugbank_id("notadrug")
45
+ assert mock_db.search_by_name.call_count == 1
46
+
47
+
48
+ class TestGetInteractions:
49
+ @pytest.fixture(autouse=True)
50
+ def reset_cache(self):
51
+ drugbank_client._cache.clear()
52
+ yield
53
+ drugbank_client._cache.clear()
54
+
55
+ @pytest.fixture
56
+ def mock_db(self):
57
+ with patch("app.clients.drugbank_client.db", new_callable=AsyncMock) as mock:
58
+ yield mock
59
+
60
+ async def test_returns_interactions(self, mock_db):
61
+ """Full flow: resolve name → fetch interactions → return [{drug, description}]."""
62
+ mock_db.search_by_name.return_value = [{"drugbank_id": "DB01050", "name": "Ibuprofen"}]
63
+ mock_db.get_drug_interactions.return_value = [
64
+ {"name": "Warfarin", "description": "Increases bleeding risk.", "severity": "major"}
65
+ ]
66
+
67
+ result = await drugbank_client.get_interactions("ibuprofen")
68
+ assert len(result) == 1
69
+ assert result[0]["drug"] == "Warfarin"
70
+ assert result[0]["description"] == "Increases bleeding risk."
71
+ assert result[0]["severity"] == "major"
72
+
73
+ async def test_returns_empty_when_drug_not_found(self, mock_db):
74
+ mock_db.search_by_name.return_value = []
75
+ result = await drugbank_client.get_interactions("notadrug")
76
+ assert result == []
77
+
78
+ async def test_returns_empty_for_no_interactions(self, mock_db):
79
+ mock_db.search_by_name.return_value = [{"drugbank_id": "DB00001", "name": "SomeDrug"}]
80
+ mock_db.get_drug_interactions.return_value = []
81
+ result = await drugbank_client.get_interactions("somedrug")
82
+ assert result == []
83
+
84
+ async def test_caches_full_interaction_results(self, mock_db):
85
+ mock_db.search_by_name.return_value = [{"drugbank_id": "DB01050", "name": "Ibuprofen"}]
86
+ mock_db.get_drug_interactions.return_value = []
87
+
88
+ await drugbank_client.get_interactions("ibuprofen")
89
+ await drugbank_client.get_interactions("ibuprofen")
90
+ # Only 2 calls total (resolve + interactions), not 4
91
+ assert mock_db.search_by_name.call_count == 1
92
+ assert mock_db.get_drug_interactions.call_count == 1
93
+
94
+ async def test_cache_expires(self, mock_db):
95
+ mock_db.search_by_name.return_value = [{"drugbank_id": "DB01050", "name": "Ibuprofen"}]
96
+ mock_db.get_drug_interactions.return_value = []
97
+
98
+ await drugbank_client.get_interactions("ibuprofen")
99
+ # Expire all cache entries
100
+ for key in drugbank_client._cache:
101
+ drugbank_client._cache[key] = (drugbank_client._cache[key][0], time.time() - 1)
102
+
103
+ await drugbank_client.get_interactions("ibuprofen")
104
+ assert mock_db.search_by_name.call_count == 2
105
+ assert mock_db.get_drug_interactions.call_count == 2
106
+
107
+
108
+ class TestConnect:
109
+ async def test_connect_calls_db_connect(self):
110
+ with patch("app.clients.drugbank_client.db", new_callable=AsyncMock) as mock_db:
111
+ await drugbank_client.connect()
112
+ mock_db.connect.assert_called_once()
113
+
114
+ async def test_connect_handles_failure(self):
115
+ with patch("app.clients.drugbank_client.db", new_callable=AsyncMock) as mock_db:
116
+ mock_db.connect.side_effect = Exception("DB error")
117
+ # Should not raise
118
+ await drugbank_client.connect()
119
+
120
+
121
+ class TestHealthCheck:
122
+ async def test_health_check_delegates_to_db(self):
123
+ with patch("app.clients.drugbank_client.db", new_callable=AsyncMock) as mock_db:
124
+ mock_db.health_check.return_value = True
125
+ assert await drugbank_client.health_check() is True
126
+ mock_db.health_check.assert_called_once()
127
+
128
+ async def test_health_check_returns_false_when_db_unhealthy(self):
129
+ with patch("app.clients.drugbank_client.db", new_callable=AsyncMock) as mock_db:
130
+ mock_db.health_check.return_value = False
131
+ assert await drugbank_client.health_check() is False
132
+
133
+
134
+ class TestErrorPropagation:
135
+ """Database errors must propagate so transient failures are not cached."""
136
+
137
+ @pytest.fixture(autouse=True)
138
+ def reset_cache(self):
139
+ drugbank_client._cache.clear()
140
+ yield
141
+ drugbank_client._cache.clear()
142
+
143
+ @pytest.fixture
144
+ def mock_db(self):
145
+ with patch("app.clients.drugbank_client.db", new_callable=AsyncMock) as mock:
146
+ yield mock
147
+
148
+ async def test_resolve_raises_on_db_error(self, mock_db):
149
+ mock_db.search_by_name.side_effect = RuntimeError("disk I/O error")
150
+ with pytest.raises(drugbank_client.DrugBankUnavailableError):
151
+ await drugbank_client._resolve_drugbank_id("ibuprofen")
152
+
153
+ async def test_resolve_does_not_cache_db_error(self, mock_db):
154
+ """A transient DB error must not be cached as 'not found'."""
155
+ mock_db.search_by_name.side_effect = [
156
+ RuntimeError("disk I/O error"),
157
+ [{"drugbank_id": "DB01050", "name": "Ibuprofen"}],
158
+ ]
159
+ with pytest.raises(drugbank_client.DrugBankUnavailableError):
160
+ await drugbank_client._resolve_drugbank_id("ibuprofen")
161
+ # Next call after DB recovers must hit the DB again and succeed
162
+ result = await drugbank_client._resolve_drugbank_id("ibuprofen")
163
+ assert result == "DB01050"
164
+ assert mock_db.search_by_name.call_count == 2
165
+
166
+ async def test_get_interactions_raises_on_resolve_error(self, mock_db):
167
+ mock_db.search_by_name.side_effect = RuntimeError("disk I/O error")
168
+ with pytest.raises(drugbank_client.DrugBankUnavailableError):
169
+ await drugbank_client.get_interactions("ibuprofen")
170
+
171
+ async def test_get_interactions_raises_on_lookup_error(self, mock_db):
172
+ mock_db.search_by_name.return_value = [
173
+ {"drugbank_id": "DB01050", "name": "Ibuprofen"}
174
+ ]
175
+ mock_db.get_drug_interactions.side_effect = RuntimeError("disk I/O error")
176
+ with pytest.raises(drugbank_client.DrugBankUnavailableError):
177
+ await drugbank_client.get_interactions("ibuprofen")
178
+
179
+ async def test_get_interactions_does_not_cache_db_error(self, mock_db):
180
+ mock_db.search_by_name.return_value = [
181
+ {"drugbank_id": "DB01050", "name": "Ibuprofen"}
182
+ ]
183
+ mock_db.get_drug_interactions.side_effect = [
184
+ RuntimeError("disk I/O error"),
185
+ [{"name": "Warfarin", "description": "x", "severity": "major"}],
186
+ ]
187
+ with pytest.raises(drugbank_client.DrugBankUnavailableError):
188
+ await drugbank_client.get_interactions("ibuprofen")
189
+ # After recovery, the next call must hit the DB again and succeed
190
+ result = await drugbank_client.get_interactions("ibuprofen")
191
+ assert len(result) == 1
192
+ assert result[0]["drug"] == "Warfarin"
tests/test_drugbank_db.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the direct SQLite DrugBank client."""
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import tempfile
7
+
8
+ import pytest
9
+
10
+ from app.clients.drugbank_db import DrugBankDatabase, _escape_fts5_query
11
+
12
+
13
+ @pytest.fixture
14
+ def db_path():
15
+ """Create a temporary SQLite DB that mirrors the drugbank schema."""
16
+ fd, path = tempfile.mkstemp(suffix=".db")
17
+ os.close(fd)
18
+
19
+ conn = sqlite3.connect(path)
20
+ try:
21
+ conn.executescript(
22
+ """
23
+ CREATE TABLE drugs (
24
+ drugbank_id TEXT PRIMARY KEY,
25
+ name TEXT,
26
+ description TEXT,
27
+ groups TEXT,
28
+ cas_number TEXT,
29
+ state TEXT,
30
+ drug_interactions TEXT
31
+ );
32
+ CREATE VIRTUAL TABLE drugs_fts USING fts5(drugbank_id UNINDEXED, name);
33
+ """
34
+ )
35
+ conn.executemany(
36
+ "INSERT INTO drugs (drugbank_id, name, description, groups, cas_number, state, drug_interactions) "
37
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
38
+ [
39
+ (
40
+ "DB01050",
41
+ "Ibuprofen",
42
+ "NSAID",
43
+ json.dumps(["approved"]),
44
+ "15687-27-1",
45
+ "solid",
46
+ json.dumps(
47
+ [
48
+ {
49
+ "name": "Warfarin",
50
+ "description": "Increases bleeding risk.",
51
+ "severity": "major",
52
+ }
53
+ ]
54
+ ),
55
+ ),
56
+ (
57
+ "DB00682",
58
+ "Warfarin",
59
+ "Anticoagulant",
60
+ json.dumps(["approved"]),
61
+ "81-81-2",
62
+ "solid",
63
+ json.dumps(
64
+ [
65
+ {
66
+ "name": "Ibuprofen",
67
+ "description": "Bleeding.",
68
+ "severity": "major",
69
+ }
70
+ ]
71
+ ),
72
+ ),
73
+ (
74
+ "DB0NOINT",
75
+ "Lonelydrug",
76
+ "No interactions recorded",
77
+ None,
78
+ None,
79
+ None,
80
+ None,
81
+ ),
82
+ ],
83
+ )
84
+ conn.executemany(
85
+ "INSERT INTO drugs_fts (drugbank_id, name) VALUES (?, ?)",
86
+ [
87
+ ("DB01050", "Ibuprofen"),
88
+ ("DB00682", "Warfarin"),
89
+ ("DB0NOINT", "Lonelydrug"),
90
+ ],
91
+ )
92
+ conn.commit()
93
+ finally:
94
+ conn.close()
95
+
96
+ yield path
97
+ os.remove(path)
98
+
99
+
100
+ @pytest.fixture
101
+ async def db(db_path):
102
+ instance = DrugBankDatabase(db_path=db_path)
103
+ yield instance
104
+ await instance.close()
105
+
106
+
107
+ class TestConnect:
108
+ async def test_connect_missing_db_raises(self):
109
+ instance = DrugBankDatabase(db_path="/nonexistent/path/drugbank.db")
110
+ with pytest.raises(FileNotFoundError):
111
+ await instance.connect()
112
+
113
+ async def test_connect_is_idempotent(self, db):
114
+ await db.connect()
115
+ conn = db._conn
116
+ await db.connect()
117
+ # Same connection object -- no leak on repeated calls
118
+ assert db._conn is conn
119
+
120
+
121
+ class TestSearchByName:
122
+ async def test_finds_existing_drug(self, db):
123
+ rows = await db.search_by_name("Ibuprofen")
124
+ assert len(rows) == 1
125
+ assert rows[0]["drugbank_id"] == "DB01050"
126
+
127
+ async def test_returns_empty_for_unknown_drug(self, db):
128
+ rows = await db.search_by_name("notarealdrug")
129
+ assert rows == []
130
+
131
+ async def test_handles_fts5_special_characters(self, db):
132
+ """FTS5 has special syntax for `*`, `"`, `:`, `(`, etc.
133
+
134
+ The client must escape these rather than passing them directly.
135
+ """
136
+ # Without escaping, a bare double-quote would be a parse error.
137
+ rows = await db.search_by_name('"aspirin"')
138
+ assert rows == [] # returns empty, not an exception
139
+
140
+ # A hyphen is treated as NOT by default; escaping should leave it literal.
141
+ rows = await db.search_by_name("anti-inflammatory")
142
+ assert rows == []
143
+
144
+
145
+ class TestGetDrugInteractions:
146
+ async def test_returns_interactions(self, db):
147
+ rows = await db.get_drug_interactions("DB01050")
148
+ assert len(rows) == 1
149
+ assert rows[0]["name"] == "Warfarin"
150
+ assert rows[0]["severity"] == "major"
151
+
152
+ async def test_returns_empty_for_missing_drug(self, db):
153
+ rows = await db.get_drug_interactions("DBNOPE999")
154
+ assert rows == []
155
+
156
+ async def test_returns_empty_when_drug_has_null_interactions(self, db):
157
+ rows = await db.get_drug_interactions("DB0NOINT")
158
+ assert rows == []
159
+
160
+
161
+ class TestHealthCheck:
162
+ async def test_healthy(self, db):
163
+ assert await db.health_check() is True
164
+
165
+ async def test_unhealthy_when_db_missing(self):
166
+ instance = DrugBankDatabase(db_path="/nonexistent/path/drugbank.db")
167
+ assert await instance.health_check() is False
168
+
169
+ async def test_unhealthy_when_underlying_connection_broken(self, db):
170
+ await db.connect()
171
+ # Simulate the connection being closed under us; subsequent queries fail.
172
+ await db._conn.close()
173
+ assert await db.health_check() is False
174
+
175
+ async def test_failed_health_check_resets_connection(self, db):
176
+ """A broken connection must be dropped so the next call can reconnect."""
177
+ await db.connect()
178
+ await db._conn.close()
179
+ assert await db.health_check() is False
180
+ # _conn should be reset so subsequent queries re-establish it.
181
+ assert db._conn is None
182
+ # After reset the next query reconnects and succeeds.
183
+ rows = await db.search_by_name("Ibuprofen")
184
+ assert len(rows) == 1
185
+
186
+
187
+ class TestFTS5Escape:
188
+ def test_wraps_in_quotes(self):
189
+ assert _escape_fts5_query("aspirin") == '"aspirin"'
190
+
191
+ def test_escapes_internal_quotes(self):
192
+ assert _escape_fts5_query('say "hi"') == '"say ""hi"""'
193
+
194
+ def test_leaves_hyphens_alone_inside_phrase(self):
195
+ assert _escape_fts5_query("anti-inflammatory") == '"anti-inflammatory"'
tests/test_interaction_checker.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the interaction checker service."""
2
+
3
+ import pytest
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
+ @pytest.fixture(autouse=True)
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
+ @pytest.fixture(autouse=True)
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 mock_severity_parser():
29
+ """Mock severity_parser.parse_severity for all tests."""
30
+ with patch("app.services.interaction_checker.severity_parser") as mock:
31
+ mock.parse_severity.return_value = "moderate"
32
+ yield mock
33
+
34
+
35
+ class TestInteractionChecker:
36
+ async def test_two_interacting_drugs(self, mock_drugbank, mock_severity):
37
+ mock_drugbank.get_interactions.side_effect = [
38
+ [{"drug": "Warfarin", "description": "Increases bleeding risk."}],
39
+ [{"drug": "Ibuprofen", "description": "Increases bleeding risk."}],
40
+ ]
41
+ result = await interaction_checker.check(["ibuprofen", "warfarin"])
42
+ assert result["safe"] is False
43
+ assert len(result["interactions"]) == 1
44
+ assert result["interactions"][0]["drug_a"] == "ibuprofen"
45
+ assert result["interactions"][0]["drug_b"] == "warfarin"
46
+ assert result["interactions"][0]["severity"] == "moderate"
47
+ assert result["interactions"][0]["description"] == "Increases bleeding risk."
48
+ assert result["error"] is None
49
+
50
+ async def test_two_safe_drugs(self, mock_drugbank):
51
+ mock_drugbank.get_interactions.side_effect = [
52
+ [{"drug": "Metformin", "description": "some interaction"}],
53
+ [{"drug": "Lisinopril", "description": "some interaction"}],
54
+ ]
55
+ result = await interaction_checker.check(["ibuprofen", "amoxicillin"])
56
+ assert result["safe"] is True
57
+ assert result["interactions"] == []
58
+
59
+ async def test_three_drugs_multiple_interactions(self, mock_drugbank):
60
+ mock_drugbank.get_interactions.side_effect = [
61
+ [{"drug": "Warfarin", "description": "bleeding"}, {"drug": "Aspirin", "description": "bleeding"}],
62
+ [{"drug": "Ibuprofen", "description": "bleeding"}, {"drug": "Aspirin", "description": "bleeding"}],
63
+ [{"drug": "Ibuprofen", "description": "bleeding"}, {"drug": "Warfarin", "description": "bleeding"}],
64
+ ]
65
+ result = await interaction_checker.check(["ibuprofen", "warfarin", "aspirin"])
66
+ assert result["safe"] is False
67
+ assert len(result["interactions"]) == 3
68
+
69
+ async def test_single_drug(self, mock_drugbank):
70
+ result = await interaction_checker.check(["ibuprofen"])
71
+ assert result["safe"] is True
72
+
73
+ async def test_empty_list(self, mock_drugbank):
74
+ result = await interaction_checker.check([])
75
+ assert result["safe"] is True
76
+
77
+ async def test_drugbank_unavailable(self, mock_drugbank):
78
+ mock_drugbank.get_interactions.side_effect = DrugBankUnavailableError("down")
79
+ result = await interaction_checker.check(["ibuprofen", "warfarin"])
80
+ assert result["safe"] is None
81
+ assert result["error"] == "Drug interaction data temporarily unavailable"
82
+ assert result["interactions"] == []
83
+
84
+ async def test_case_insensitive_matching(self, mock_drugbank):
85
+ mock_drugbank.get_interactions.side_effect = [
86
+ [{"drug": "WARFARIN", "description": "bleeding risk"}],
87
+ [{"drug": "ibuprofen", "description": "bleeding risk"}],
88
+ ]
89
+ result = await interaction_checker.check(["Ibuprofen", "warfarin"])
90
+ assert result["safe"] is False
91
+ assert len(result["interactions"]) == 1
92
+
93
+ async def test_partial_drugbank_failure_still_checks_available_pairs(self, mock_drugbank, mock_severity):
94
+ """If one drug fails but others succeed, check the available pairs."""
95
+ mock_drugbank.get_interactions.side_effect = [
96
+ DrugBankUnavailableError("timeout"), # ibuprofen fails
97
+ [{"drug": "Aspirin", "description": "bleeding"}], # warfarin succeeds
98
+ [{"drug": "Warfarin", "description": "bleeding"}], # aspirin succeeds
99
+ ]
100
+ result = await interaction_checker.check(["ibuprofen", "warfarin", "aspirin"])
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
tests/test_ocr_cleaner.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for OCR text preprocessing."""
2
+
3
+ from app.nlp.ocr_cleaner import clean
4
+
5
+
6
+ class TestCharacterSubstitutions:
7
+ def test_zero_to_o_in_drug_context(self):
8
+ assert "metformin" in clean("metf0rmin").lower()
9
+
10
+ def test_one_to_l(self):
11
+ assert "alprazolam" in clean("a1prazolam").lower()
12
+
13
+ def test_rn_to_m_in_drug_context(self):
14
+ # "ibuprofen" OCR'd as "ibuprofern" — this is tricky,
15
+ # only apply when rn could be m in known patterns
16
+ result = clean("Aceta rninophen")
17
+ assert "Acetaminophen" in result or "acetaminophen" in result.lower()
18
+
19
+
20
+ class TestWhitespaceNormalization:
21
+ def test_multiple_spaces(self):
22
+ assert clean("Ibuprofen 400mg") == "Ibuprofen 400mg"
23
+
24
+ def test_tabs_and_newlines(self):
25
+ assert clean("Ibuprofen\t400\nmg") == "Ibuprofen 400 mg"
26
+
27
+ def test_soft_hyphens(self):
28
+ assert clean("Ibu\u00adprofen") == "Ibuprofen"
29
+
30
+
31
+ class TestNonAsciiArtifacts:
32
+ def test_smart_quotes_replaced(self):
33
+ result = clean("\u201cIbuprofen\u201d")
34
+ assert '"' not in result or result == '"Ibuprofen"'
35
+
36
+ def test_zero_width_chars_stripped(self):
37
+ result = clean("Ibu\u200bprofen")
38
+ assert result == "Ibuprofen"
39
+
40
+ def test_ligatures_expanded(self):
41
+ result = clean("\ufb01lm") # fi ligature
42
+ assert result == "film"
43
+
44
+
45
+ class TestEdgeCases:
46
+ def test_empty_string(self):
47
+ assert clean("") == ""
48
+
49
+ def test_already_clean_text(self):
50
+ assert clean("Ibuprofen 400mg Film-Coated Tablets") == "Ibuprofen 400mg Film-Coated Tablets"
51
+
52
+ def test_preserves_legitimate_digits(self):
53
+ """Digits in dosages must not be converted."""
54
+ assert "400" in clean("Ibuprofen 400mg")
tests/test_openfda_client.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the OpenFDA fallback client."""
2
+
3
+ import pytest
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+ from app.clients import openfda_client
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def reset_cache():
10
+ openfda_client._cache.clear()
11
+ yield
12
+ openfda_client._cache.clear()
13
+
14
+
15
+ WARFARIN_LABEL = {
16
+ "results": [{
17
+ "drug_interactions": [
18
+ "Ibuprofen and other NSAIDs may enhance the anticoagulant effect of warfarin.",
19
+ "Patients should be monitored closely when combining these medications."
20
+ ]
21
+ }]
22
+ }
23
+
24
+ WARFARIN_LABEL_NO_INTERACTIONS = {
25
+ "results": [{}]
26
+ }
27
+
28
+ EMPTY_RESULTS = {"results": []}
29
+
30
+
31
+ def make_response(json_data, status_code=200):
32
+ mock = MagicMock()
33
+ mock.status_code = status_code
34
+ mock.json.return_value = json_data
35
+ mock.raise_for_status = MagicMock()
36
+ if status_code >= 400:
37
+ mock.raise_for_status.side_effect = Exception(f"HTTP {status_code}")
38
+ return mock
39
+
40
+
41
+ class TestCheckPair:
42
+ async def test_returns_match_when_drug_mentioned(self):
43
+ with patch("app.clients.openfda_client._fetch_label_text", new=AsyncMock(
44
+ return_value="Ibuprofen and other NSAIDs may enhance the anticoagulant effect of warfarin. Patients should monitor closely."
45
+ )):
46
+ result = await openfda_client.check_pair("warfarin", "ibuprofen")
47
+ assert result is not None
48
+ assert result["drug"] == "ibuprofen"
49
+ assert "ibuprofen" in result["description"].lower()
50
+ assert len(result["description"]) > 0
51
+
52
+ async def test_returns_none_when_drug_not_mentioned(self):
53
+ with patch("app.clients.openfda_client._fetch_label_text", new=AsyncMock(
54
+ return_value="This drug interacts with aspirin and heparin."
55
+ )):
56
+ result = await openfda_client.check_pair("warfarin", "ibuprofen")
57
+ assert result is None
58
+
59
+ async def test_returns_none_when_label_unavailable(self):
60
+ with patch("app.clients.openfda_client._fetch_label_text", new=AsyncMock(return_value=None)):
61
+ result = await openfda_client.check_pair("unknowndrug", "ibuprofen")
62
+ assert result is None
63
+
64
+ async def test_whole_word_match_only(self):
65
+ """'aspirin' inside 'heparin' must NOT match."""
66
+ with patch("app.clients.openfda_client._fetch_label_text", new=AsyncMock(
67
+ return_value="Use with caution in patients on heparin therapy."
68
+ )):
69
+ result = await openfda_client.check_pair("warfarin", "aspirin")
70
+ assert result is None
71
+
72
+ async def test_description_is_never_empty_on_match(self):
73
+ """check_pair must guarantee non-empty description when it returns a dict."""
74
+ with patch("app.clients.openfda_client._fetch_label_text", new=AsyncMock(
75
+ return_value="ibuprofen" # match with no surrounding sentence context
76
+ )):
77
+ result = await openfda_client.check_pair("warfarin", "ibuprofen")
78
+ assert result is not None
79
+ assert result["description"] # truthy, not empty
80
+
81
+ async def test_case_insensitive_match(self):
82
+ with patch("app.clients.openfda_client._fetch_label_text", new=AsyncMock(
83
+ return_value="IBUPROFEN increases bleeding risk when combined with warfarin."
84
+ )):
85
+ result = await openfda_client.check_pair("warfarin", "ibuprofen")
86
+ assert result is not None
87
+
88
+
89
+ class TestFetchLabelText:
90
+ async def test_fetches_and_joins_drug_interactions_array(self):
91
+ """drug_interactions is a JSON array — must be joined into one string."""
92
+ mock_resp = make_response(WARFARIN_LABEL)
93
+ with patch("httpx.AsyncClient") as mock_client_cls:
94
+ mock_client = AsyncMock()
95
+ mock_client.get.return_value = mock_resp
96
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
97
+ text = await openfda_client._fetch_label_text("warfarin")
98
+ assert "Ibuprofen" in text
99
+ assert "monitored" in text # from second paragraph
100
+
101
+ async def test_returns_empty_string_when_no_drug_interactions_field(self):
102
+ mock_resp = make_response(WARFARIN_LABEL_NO_INTERACTIONS)
103
+ with patch("httpx.AsyncClient") as mock_client_cls:
104
+ mock_client = AsyncMock()
105
+ mock_client.get.return_value = mock_resp
106
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
107
+ text = await openfda_client._fetch_label_text("warfarin")
108
+ assert text == ""
109
+
110
+ async def test_returns_none_on_empty_results(self):
111
+ mock_resp = make_response(EMPTY_RESULTS)
112
+ with patch("httpx.AsyncClient") as mock_client_cls:
113
+ mock_client = AsyncMock()
114
+ mock_client.get.return_value = mock_resp
115
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
116
+ text = await openfda_client._fetch_label_text("unknowndrug")
117
+ assert text is None
118
+
119
+ async def test_returns_none_on_network_error(self):
120
+ with patch("httpx.AsyncClient") as mock_client_cls:
121
+ mock_client = AsyncMock()
122
+ mock_client.get.side_effect = Exception("network error")
123
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
124
+ text = await openfda_client._fetch_label_text("warfarin")
125
+ assert text is None
126
+
127
+ async def test_caches_label_text(self):
128
+ mock_resp = make_response(WARFARIN_LABEL)
129
+ with patch("httpx.AsyncClient") as mock_client_cls:
130
+ mock_client = AsyncMock()
131
+ mock_client.get.return_value = mock_resp
132
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
133
+ await openfda_client._fetch_label_text("warfarin")
134
+ await openfda_client._fetch_label_text("warfarin")
135
+ assert mock_client.get.call_count == 1 # second call hits cache
136
+
137
+ async def test_url_uses_phrase_quoting_for_multiword_name(self):
138
+ mock_resp = make_response(EMPTY_RESULTS)
139
+ with patch("httpx.AsyncClient") as mock_client_cls:
140
+ mock_client = AsyncMock()
141
+ mock_client.get.return_value = mock_resp
142
+ mock_client_cls.return_value.__aenter__.return_value = mock_client
143
+ await openfda_client._fetch_label_text("acetylsalicylic acid")
144
+ call_args = mock_client.get.call_args
145
+ url = call_args[0][0] if call_args[0] else call_args[1].get("url", "")
146
+ # Drug name must be quoted for phrase search
147
+ assert '%22acetylsalicylic' in url or '"acetylsalicylic' in url
tests/test_rxnorm_client.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for RxNorm client — hits live API."""
2
+
3
+ import pytest
4
+
5
+ from app.clients import rxnorm_client
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_get_rxcui_ibuprofen():
10
+ """Ibuprofen should resolve to RxCUI 5640."""
11
+ rxcui = await rxnorm_client.get_rxcui("ibuprofen")
12
+ assert rxcui == "5640"
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_get_rxcui_unknown():
17
+ """Nonsense drug name should return None."""
18
+ rxcui = await rxnorm_client.get_rxcui("xyznotadrug123")
19
+ assert rxcui is None
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_approximate_term_advil():
24
+ """Brand name 'Advil' should find candidates, resolve to Ibuprofen via details."""
25
+ results = await rxnorm_client.approximate_term("Advil")
26
+ assert len(results) > 0
27
+ # The approximate endpoint returns brand-name concepts (e.g. "Advil").
28
+ # To get the generic name, we look up details on the first result's RxCUI.
29
+ assert any("advil" in r.name.lower() for r in results)
30
+ details = await rxnorm_client.get_drug_details(results[0].rxcui)
31
+ # RxCUI 153010 maps to the Advil brand of Ibuprofen
32
+ assert details is not None
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_search_by_name_warfarin():
37
+ """Warfarin should return drug concepts."""
38
+ results = await rxnorm_client.search_by_name("warfarin")
39
+ assert len(results) > 0
40
+ assert any("warfarin" in r.name.lower() for r in results)
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_get_drug_details():
45
+ """RxCUI 5640 (Ibuprofen) should return properties."""
46
+ details = await rxnorm_client.get_drug_details("5640")
47
+ assert details is not None
48
+ assert details.get("name", "").lower() == "ibuprofen"
tests/test_severity_classifier.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the zero-shot severity classifier."""
2
+
3
+ import pytest
4
+ from unittest.mock import patch, MagicMock
5
+ from app.nlp import severity_classifier
6
+
7
+
8
+ class TestClassify:
9
+ """Test classify() with a mocked pipeline so tests run without the model."""
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def mock_pipeline(self):
13
+ """Mock the classifier pipeline for all tests in this class."""
14
+ mock = MagicMock()
15
+ severity_classifier._classifier = mock
16
+ yield mock
17
+ severity_classifier._classifier = None
18
+
19
+ def test_major_severity(self, mock_pipeline):
20
+ mock_pipeline.return_value = {
21
+ "labels": [
22
+ "critical dangerous interaction",
23
+ "moderate interaction requiring monitoring",
24
+ "minor interaction with low risk",
25
+ ],
26
+ "scores": [0.85, 0.10, 0.05],
27
+ }
28
+ severity, uncertain = severity_classifier.classify("contraindicated combination")
29
+ assert severity == "major"
30
+
31
+ def test_moderate_severity(self, mock_pipeline):
32
+ mock_pipeline.return_value = {
33
+ "labels": [
34
+ "moderate interaction requiring monitoring",
35
+ "critical dangerous interaction",
36
+ "minor interaction with low risk",
37
+ ],
38
+ "scores": [0.70, 0.20, 0.10],
39
+ }
40
+ severity, uncertain = severity_classifier.classify("monitor blood pressure")
41
+ assert severity == "moderate"
42
+
43
+ def test_minor_severity(self, mock_pipeline):
44
+ mock_pipeline.return_value = {
45
+ "labels": [
46
+ "minor interaction with low risk",
47
+ "moderate interaction requiring monitoring",
48
+ "critical dangerous interaction",
49
+ ],
50
+ "scores": [0.75, 0.15, 0.10],
51
+ }
52
+ severity, uncertain = severity_classifier.classify("minimal clinical significance")
53
+ assert severity == "minor"
54
+
55
+ def test_returns_tuple_with_uncertain_flag(self, mock_pipeline):
56
+ mock_pipeline.return_value = {
57
+ "labels": [
58
+ "critical dangerous interaction",
59
+ "moderate interaction requiring monitoring",
60
+ "minor interaction with low risk",
61
+ ],
62
+ "scores": [0.85, 0.10, 0.05],
63
+ }
64
+ severity, uncertain = severity_classifier.classify("contraindicated combination")
65
+ assert severity == "major"
66
+ assert uncertain is False
67
+
68
+ def test_low_confidence_returns_major_uncertain(self, mock_pipeline):
69
+ """Below threshold (0.7), classifier should return major+uncertain."""
70
+ mock_pipeline.return_value = {
71
+ "labels": [
72
+ "minor interaction with low risk",
73
+ "moderate interaction requiring monitoring",
74
+ "critical dangerous interaction",
75
+ ],
76
+ "scores": [0.45, 0.35, 0.20],
77
+ }
78
+ severity, uncertain = severity_classifier.classify("some vague description")
79
+ assert severity == "major"
80
+ assert uncertain is True
81
+
82
+ def test_empty_description(self, mock_pipeline):
83
+ severity, uncertain = severity_classifier.classify("")
84
+ assert severity == "unknown"
85
+ assert uncertain is True
86
+ mock_pipeline.assert_not_called()
87
+
88
+ def test_none_description(self, mock_pipeline):
89
+ severity, uncertain = severity_classifier.classify(None)
90
+ assert severity == "unknown"
91
+ assert uncertain is True
92
+ mock_pipeline.assert_not_called()
93
+
94
+ def test_inference_failure_falls_back_to_regex(self, mock_pipeline):
95
+ mock_pipeline.side_effect = RuntimeError("OOM")
96
+ severity, uncertain = severity_classifier.classify("contraindicated")
97
+ assert severity == "major"
98
+ assert uncertain is True
99
+
100
+
101
+ class TestRegexFallback:
102
+ """Test the regex fallback when the model is not loaded."""
103
+
104
+ @pytest.fixture(autouse=True)
105
+ def unload_model(self):
106
+ severity_classifier._classifier = None
107
+ yield
108
+ severity_classifier._classifier = None
109
+
110
+ def test_fallback_major(self):
111
+ result = severity_classifier._regex_fallback("Do not use, contraindicated.")
112
+ assert result == "major"
113
+
114
+ def test_fallback_moderate(self):
115
+ result = severity_classifier._regex_fallback("Use caution, monitor closely.")
116
+ assert result == "moderate"
117
+
118
+ def test_fallback_unknown_for_neutral_text(self):
119
+ """Unrecognized text now defaults to 'major' for safety."""
120
+ result = severity_classifier._regex_fallback("No significant interaction.")
121
+ assert result == "major"
122
+
123
+ def test_classify_uses_fallback_when_unloaded(self):
124
+ severity, uncertain = severity_classifier.classify("contraindicated")
125
+ assert severity == "major"
126
+
127
+
128
+ class TestLoadModel:
129
+ def test_is_loaded_false_initially(self):
130
+ severity_classifier._classifier = None
131
+ assert severity_classifier.is_loaded() is False
tests/test_severity_parser.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the DrugBank template-aware severity parser."""
2
+
3
+ from app.nlp.severity_parser import parse_severity
4
+
5
+
6
+ class TestRiskOrSeverityTemplate:
7
+ """'The risk or severity of X can be increased/decreased' → major."""
8
+
9
+ def test_bleeding_risk(self):
10
+ desc = "The risk or severity of bleeding can be increased when Aspirin is combined with Warfarin."
11
+ assert parse_severity(desc) == "major"
12
+
13
+ def test_adverse_effects(self):
14
+ desc = "The risk or severity of adverse effects can be increased when Drug A is combined with Drug B."
15
+ assert parse_severity(desc) == "major"
16
+
17
+ def test_hemorrhage(self):
18
+ desc = "The risk or severity of bleeding and hemorrhage can be increased when Dasatinib is combined with Warfarin."
19
+ assert parse_severity(desc) == "major"
20
+
21
+ def test_gastrointestinal_bleeding(self):
22
+ desc = "The risk or severity of gastrointestinal bleeding can be increased when Warfarin is combined with Deferasirox."
23
+ assert parse_severity(desc) == "major"
24
+
25
+
26
+ class TestActivityTemplate:
27
+ """'may increase/decrease the X activities' → moderate/minor."""
28
+
29
+ def test_increase_anticoagulant(self):
30
+ desc = "Apixaban may increase the anticoagulant activities of Warfarin."
31
+ assert parse_severity(desc) == "moderate"
32
+
33
+ def test_increase_hypotensive(self):
34
+ desc = "Lisinopril may increase the hypotensive activities of Amlodipine."
35
+ assert parse_severity(desc) == "moderate"
36
+
37
+ def test_decrease_activities(self):
38
+ desc = "Rifampin may decrease the anticoagulant activities of Warfarin."
39
+ assert parse_severity(desc) == "minor"
40
+
41
+
42
+ class TestConcentrationTemplate:
43
+ """'serum concentration can be increased/decreased' → moderate."""
44
+
45
+ def test_concentration_increased(self):
46
+ desc = "The serum concentration of Alfuzosin can be increased when it is combined with Lepirudin."
47
+ assert parse_severity(desc) == "moderate"
48
+
49
+ def test_concentration_decreased(self):
50
+ desc = "The serum concentration of Warfarin can be decreased when it is combined with Rifampin."
51
+ assert parse_severity(desc) == "moderate"
52
+
53
+
54
+ class TestMetabolismTemplate:
55
+ """'metabolism can be increased/decreased' → moderate."""
56
+
57
+ def test_metabolism_increased(self):
58
+ desc = "The metabolism of Lepirudin can be increased when combined with St. John's Wort."
59
+ assert parse_severity(desc) == "moderate"
60
+
61
+ def test_metabolism_decreased(self):
62
+ desc = "The metabolism of Warfarin can be decreased when combined with Fluconazole."
63
+ assert parse_severity(desc) == "moderate"
64
+
65
+
66
+ class TestEfficacyTemplate:
67
+ """'therapeutic efficacy can be decreased' → minor."""
68
+
69
+ def test_efficacy_decreased(self):
70
+ desc = "The therapeutic efficacy of Rotavirus vaccine can be decreased when used in combination with Etanercept."
71
+ assert parse_severity(desc) == "minor"
72
+
73
+
74
+ class TestEdgeCases:
75
+ def test_empty_string(self):
76
+ assert parse_severity("") == "unknown"
77
+
78
+ def test_none(self):
79
+ assert parse_severity(None) == "unknown"
80
+
81
+ def test_unrecognized_template(self):
82
+ desc = "Some completely novel interaction description format."
83
+ assert parse_severity(desc) == "unknown"
84
+
85
+ def test_case_insensitive(self):
86
+ desc = "THE RISK OR SEVERITY OF BLEEDING CAN BE INCREASED WHEN ASPIRIN IS COMBINED WITH WARFARIN."
87
+ assert parse_severity(desc) == "major"
uv.lock CHANGED
@@ -113,14 +113,6 @@ wheels = [
113
  { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
114
  ]
115
 
116
- [[package]]
117
- name = "flatbuffers"
118
- version = "25.12.19"
119
- source = { registry = "https://pypi.org/simple" }
120
- wheels = [
121
- { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
122
- ]
123
-
124
  [[package]]
125
  name = "fsspec"
126
  version = "2026.2.0"
@@ -130,24 +122,6 @@ wheels = [
130
  { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
131
  ]
132
 
133
- [[package]]
134
- name = "gliner"
135
- version = "0.2.26"
136
- source = { registry = "https://pypi.org/simple" }
137
- dependencies = [
138
- { name = "huggingface-hub" },
139
- { name = "onnxruntime" },
140
- { name = "sentencepiece" },
141
- { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
142
- { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" },
143
- { name = "tqdm" },
144
- { name = "transformers" },
145
- ]
146
- sdist = { url = "https://files.pythonhosted.org/packages/49/18/e199cb97147c4a9260c75e4caf51e17be6ff969b0604a029c9c62810cbe0/gliner-0.2.26.tar.gz", hash = "sha256:6783be92b4b81caa878dcc4269ba37800207c37118d8ff9be028b93bddd6813d", size = 181224, upload-time = "2026-03-19T15:07:22.707Z" }
147
- wheels = [
148
- { url = "https://files.pythonhosted.org/packages/7c/6e/d54d3d2867e29b68a22b144f570c8204209647fccc7879cec5218d6ed5fb/gliner-0.2.26-py3-none-any.whl", hash = "sha256:b9baa47641efb90b9d069add0528ed2464d137991ff097f42b0cab37a91ba991", size = 170429, upload-time = "2026-03-19T15:07:19.914Z" },
149
- ]
150
-
151
  [[package]]
152
  name = "h11"
153
  version = "0.16.0"
@@ -336,24 +310,6 @@ wheels = [
336
  { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
337
  ]
338
 
339
- [[package]]
340
- name = "onnxruntime"
341
- version = "1.25.0"
342
- source = { registry = "https://pypi.org/simple" }
343
- dependencies = [
344
- { name = "flatbuffers" },
345
- { name = "numpy" },
346
- { name = "packaging" },
347
- { name = "protobuf" },
348
- ]
349
- wheels = [
350
- { url = "https://files.pythonhosted.org/packages/7a/69/f98c6bda4c34ac382b70c36033a989ceffd1caf5afba47bd2ef26535850f/onnxruntime-1.25.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8ecd3362de3fb496fb3e2d055a95d5acab611cf759a27609c6d99704c9d8f184", size = 17742518, upload-time = "2026-04-22T17:20:34.444Z" },
351
- { url = "https://files.pythonhosted.org/packages/5a/c6/19c5bfbc60396791e975652f982bcff9ff4b27947c8e2bf0064ac5d5727b/onnxruntime-1.25.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c99238d20bfa80ac68c7b03c2c936d389189ae40997f78a30d151570d7e18bf", size = 15841110, upload-time = "2026-04-22T17:19:31.284Z" },
352
- { url = "https://files.pythonhosted.org/packages/a9/1b/d681878f227513917d8620e4ea504af5eb3313fc01f8aea7b19a976c65db/onnxruntime-1.25.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be93baa694ef8e5831fcb7b542da21f502b122918b5b9612d9f02972e043ee01", size = 17996146, upload-time = "2026-04-22T17:19:53.792Z" },
353
- { url = "https://files.pythonhosted.org/packages/55/fe/ec98e416bd75063dea1e493661c7c939e18660ee41d6a63d7221e5657f48/onnxruntime-1.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:9596040c1f7d247bbfab5d4db1e7651c790235e48e460c7d445ec81687d5a182", size = 12872370, upload-time = "2026-04-22T17:20:22.856Z" },
354
- { url = "https://files.pythonhosted.org/packages/f7/86/9a1ac7c8a8eba7967935d4c109fc956d8f9ba61cba61d9368315bb27d0bc/onnxruntime-1.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:463aed7f5e4a3ca5a476db7e9bba9164fa26921ef34c37e59b28c4c61e55f266", size = 12600072, upload-time = "2026-04-22T17:20:11.523Z" },
355
- ]
356
-
357
  [[package]]
358
  name = "packaging"
359
  version = "26.0"
@@ -379,11 +335,6 @@ dependencies = [
379
  { name = "uvicorn", extra = ["standard"] },
380
  ]
381
 
382
- [package.optional-dependencies]
383
- gliner = [
384
- { name = "gliner" },
385
- ]
386
-
387
  [package.dev-dependencies]
388
  dev = [
389
  { name = "pytest" },
@@ -394,7 +345,6 @@ dev = [
394
  requires-dist = [
395
  { name = "aiosqlite", specifier = ">=0.22.1" },
396
  { name = "fastapi", specifier = ">=0.115.0" },
397
- { name = "gliner", marker = "extra == 'gliner'" },
398
  { name = "httpx", specifier = ">=0.28.0" },
399
  { name = "pydantic", specifier = ">=2.10.0" },
400
  { name = "slowapi", specifier = ">=0.1.9" },
@@ -402,7 +352,6 @@ requires-dist = [
402
  { name = "transformers", specifier = ">=4.48.0" },
403
  { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
404
  ]
405
- provides-extras = ["gliner"]
406
 
407
  [package.metadata.requires-dev]
408
  dev = [
@@ -419,21 +368,6 @@ wheels = [
419
  { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
420
  ]
421
 
422
- [[package]]
423
- name = "protobuf"
424
- version = "7.34.1"
425
- source = { registry = "https://pypi.org/simple" }
426
- sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" }
427
- wheels = [
428
- { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" },
429
- { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" },
430
- { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" },
431
- { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" },
432
- { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" },
433
- { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" },
434
- { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" },
435
- ]
436
-
437
  [[package]]
438
  name = "pydantic"
439
  version = "2.12.5"
@@ -518,11 +452,11 @@ wheels = [
518
 
519
  [[package]]
520
  name = "python-dotenv"
521
- version = "1.2.1"
522
  source = { registry = "https://pypi.org/simple" }
523
- sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
524
  wheels = [
525
- { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
526
  ]
527
 
528
  [[package]]
@@ -589,22 +523,6 @@ wheels = [
589
  { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
590
  ]
591
 
592
- [[package]]
593
- name = "sentencepiece"
594
- version = "0.2.1"
595
- source = { registry = "https://pypi.org/simple" }
596
- sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" }
597
- wheels = [
598
- { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" },
599
- { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" },
600
- { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" },
601
- { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" },
602
- { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" },
603
- { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" },
604
- { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" },
605
- { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" },
606
- ]
607
-
608
  [[package]]
609
  name = "setuptools"
610
  version = "81.0.0"
@@ -703,7 +621,7 @@ dependencies = [
703
  { name = "typing-extensions", marker = "sys_platform == 'darwin'" },
704
  ]
705
  wheels = [
706
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43b35116802c85fb88d99f4a396b8bd4472bfca1dd82e69499e5a4f9b8b4e252", upload-time = "2026-03-23T15:16:58Z" },
707
  ]
708
 
709
  [[package]]
@@ -723,10 +641,10 @@ dependencies = [
723
  { name = "typing-extensions", marker = "sys_platform != 'darwin'" },
724
  ]
725
  wheels = [
726
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-linux_s390x.whl", upload-time = "2026-03-23T14:59:01Z" },
727
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:02Z" },
728
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:03Z" },
729
- { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl", upload-time = "2026-03-23T14:59:04Z" },
730
  ]
731
 
732
  [[package]]
 
113
  { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
114
  ]
115
 
 
 
 
 
 
 
 
 
116
  [[package]]
117
  name = "fsspec"
118
  version = "2026.2.0"
 
122
  { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
123
  ]
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  [[package]]
126
  name = "h11"
127
  version = "0.16.0"
 
310
  { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
311
  ]
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  [[package]]
314
  name = "packaging"
315
  version = "26.0"
 
335
  { name = "uvicorn", extra = ["standard"] },
336
  ]
337
 
 
 
 
 
 
338
  [package.dev-dependencies]
339
  dev = [
340
  { name = "pytest" },
 
345
  requires-dist = [
346
  { name = "aiosqlite", specifier = ">=0.22.1" },
347
  { name = "fastapi", specifier = ">=0.115.0" },
 
348
  { name = "httpx", specifier = ">=0.28.0" },
349
  { name = "pydantic", specifier = ">=2.10.0" },
350
  { name = "slowapi", specifier = ">=0.1.9" },
 
352
  { name = "transformers", specifier = ">=4.48.0" },
353
  { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
354
  ]
 
355
 
356
  [package.metadata.requires-dev]
357
  dev = [
 
368
  { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
369
  ]
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  [[package]]
372
  name = "pydantic"
373
  version = "2.12.5"
 
452
 
453
  [[package]]
454
  name = "python-dotenv"
455
+ version = "1.2.2"
456
  source = { registry = "https://pypi.org/simple" }
457
+ sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
458
  wheels = [
459
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
460
  ]
461
 
462
  [[package]]
 
523
  { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
524
  ]
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  [[package]]
527
  name = "setuptools"
528
  version = "81.0.0"
 
621
  { name = "typing-extensions", marker = "sys_platform == 'darwin'" },
622
  ]
623
  wheels = [
624
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43b35116802c85fb88d99f4a396b8bd4472bfca1dd82e69499e5a4f9b8b4e252" },
625
  ]
626
 
627
  [[package]]
 
641
  { name = "typing-extensions", marker = "sys_platform != 'darwin'" },
642
  ]
643
  wheels = [
644
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-linux_s390x.whl" },
645
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" },
646
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" },
647
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl" },
648
  ]
649
 
650
  [[package]]