Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- .dockerignore +3 -0
- .zenodo.json +20 -0
- CITATION.cff +18 -0
- Dockerfile +25 -23
- LICENSE +21 -0
- README.md +106 -11
- app/__pycache__/__init__.cpython-312.pyc +0 -0
- app/clients/__pycache__/__init__.cpython-312.pyc +0 -0
- app/clients/__pycache__/rxnorm_client.cpython-312.pyc +0 -0
- app/middleware/__pycache__/__init__.cpython-312.pyc +0 -0
- app/middleware/__pycache__/audit_log.cpython-312.pyc +0 -0
- app/nlp/__pycache__/__init__.cpython-312.pyc +0 -0
- app/nlp/__pycache__/dosage_parser.cpython-312.pyc +0 -0
- app/nlp/__pycache__/gliner_model.cpython-312.pyc +0 -0
- app/nlp/__pycache__/ingredient_labels.cpython-312.pyc +0 -0
- app/nlp/__pycache__/ner_model.cpython-312.pyc +0 -0
- app/nlp/__pycache__/ocr_cleaner.cpython-312.pyc +0 -0
- app/nlp/gliner_model.py +0 -48
- app/nlp/ingredient_labels.py +0 -10
- app/services/__pycache__/__init__.cpython-312.pyc +0 -0
- app/services/__pycache__/drug_analyzer.cpython-312.pyc +0 -0
- app/services/__pycache__/ingredient_adjudicator.cpython-312.pyc +0 -0
- app/services/drug_analyzer.py +3 -72
- app/services/ingredient_adjudicator.py +0 -102
- docker-compose.ci.yml +14 -0
- docker-compose.yml +15 -0
- docs/infrastructure_hardening.md +62 -0
- docs/openapi.json +351 -0
- pyproject.toml +0 -3
- tests/__init__.py +0 -0
- tests/test_admin.py +40 -0
- tests/test_api.py +198 -0
- tests/test_api_key.py +85 -0
- tests/test_audit_log.py +29 -0
- tests/test_dosage_parser.py +138 -0
- tests/test_drug_analyzer.py +382 -0
- tests/test_drugbank_client.py +192 -0
- tests/test_drugbank_db.py +195 -0
- tests/test_interaction_checker.py +238 -0
- tests/test_ocr_cleaner.py +54 -0
- tests/test_openfda_client.py +147 -0
- tests/test_rxnorm_client.py +48 -0
- tests/test_severity_classifier.py +131 -0
- tests/test_severity_parser.py +87 -0
- 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
# Pre-download NER
|
|
|
|
|
|
|
| 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
|
| 54 |
-
COPY
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
-
EXPOSE 7860
|
| 60 |
|
| 61 |
-
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
[](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 |
-
|
| 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":
|
| 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.
|
| 522 |
source = { registry = "https://pypi.org/simple" }
|
| 523 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 524 |
wheels = [
|
| 525 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 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"
|
| 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"
|
| 727 |
-
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl"
|
| 728 |
-
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl"
|
| 729 |
-
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-win_amd64.whl"
|
| 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]]
|