Spaces:
Running
Running
Deploy ENCODE with private artifact bucket
Browse files- .dockerignore +0 -14
- COMMIT_CONTENTS.md +80 -0
- Dockerfile +1 -4
- README.md +22 -16
- artifacts/part_a_canonical/algorithm_components.jsonl +0 -0
- artifacts/part_a_canonical/build_report.md +0 -56
- artifacts/part_a_canonical/canonical_phenotypes.jsonl +0 -3
- artifacts/part_a_canonical/cipher_links.jsonl +0 -0
- artifacts/part_a_canonical/code_descriptions.jsonl +0 -0
- artifacts/part_a_canonical/code_descriptions_report.md +0 -32
- artifacts/part_a_canonical/embedding_documents.jsonl +0 -3
- artifacts/part_a_canonical/loinc_terms.jsonl +0 -3
- artifacts/part_a_canonical/med_ingredient_map.jsonl +0 -0
- artifacts/part_a_canonical/rxcui_ingredient_map.jsonl +0 -0
- artifacts/part_a_embeddings/code.npy +0 -3
- artifacts/part_a_embeddings/code_meta.json +0 -0
- artifacts/part_a_embeddings/config.json +0 -10
- artifacts/part_a_embeddings/metadata.npy +0 -3
- artifacts/part_a_embeddings/metadata_ids.json +0 -1
- artifacts/vidul/BGE_FT_VA/config.json +0 -31
- src/backend/__init__.py +1 -0
- src/backend/app.py +200 -0
- src/backend/codesearch.py +121 -0
- src/backend/encode.py +379 -0
- src/backend/graph.py +258 -0
- src/backend/retriever.py +48 -0
- src/backend/rxnav.py +155 -0
- src/frontend/app.js +666 -0
- src/frontend/index.html +76 -0
- src/frontend/styles.css +204 -0
- src/requirements.txt +7 -0
.dockerignore
CHANGED
|
@@ -4,17 +4,3 @@
|
|
| 4 |
!src/
|
| 5 |
!src/**
|
| 6 |
src/frontend/demo/
|
| 7 |
-
!artifacts/
|
| 8 |
-
artifacts/**
|
| 9 |
-
!artifacts/part_a_embeddings/
|
| 10 |
-
!artifacts/part_a_embeddings/**
|
| 11 |
-
!artifacts/part_a_canonical/
|
| 12 |
-
!artifacts/part_a_canonical/canonical_phenotypes.jsonl
|
| 13 |
-
!artifacts/part_a_canonical/code_descriptions.jsonl
|
| 14 |
-
!artifacts/part_a_canonical/algorithm_components.jsonl
|
| 15 |
-
!artifacts/part_a_canonical/cipher_links.jsonl
|
| 16 |
-
!artifacts/part_a_canonical/med_ingredient_map.jsonl
|
| 17 |
-
!artifacts/part_a_canonical/rxcui_ingredient_map.jsonl
|
| 18 |
-
!artifacts/part_a_canonical/loinc_terms.jsonl
|
| 19 |
-
!artifacts/vidul/
|
| 20 |
-
!artifacts/vidul/**
|
|
|
|
| 4 |
!src/
|
| 5 |
!src/**
|
| 6 |
src/frontend/demo/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
COMMIT_CONTENTS.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Initial Commit Contents
|
| 2 |
+
|
| 3 |
+
This file defines the deployed contents of the lightweight ENCODE Space and its
|
| 4 |
+
separate private runtime-artifact bucket.
|
| 5 |
+
|
| 6 |
+
## Application and deployment
|
| 7 |
+
|
| 8 |
+
- `README.md` — project overview and Hugging Face Space metadata.
|
| 9 |
+
- `COMMIT_CONTENTS.md` — this auditable release inventory.
|
| 10 |
+
- `Dockerfile` — CPU-only backend container listening on port 7860.
|
| 11 |
+
- `netlify.toml` and `deploy/` — Netlify build/Drop package that proxies the
|
| 12 |
+
static frontend's API requests to the Hugging Face Space backend.
|
| 13 |
+
- `.dockerignore` — excludes development-only files from container builds.
|
| 14 |
+
- `.gitignore` — excludes raw data, archives, experiments, profiles, and caches.
|
| 15 |
+
- `src/requirements.txt` — pinned Python runtime dependencies.
|
| 16 |
+
|
| 17 |
+
## Backend
|
| 18 |
+
|
| 19 |
+
- `src/backend/app.py` — FastAPI application and HTTP endpoints.
|
| 20 |
+
- `src/backend/codesearch.py` — medical-code retrieval.
|
| 21 |
+
- `src/backend/retriever.py` — phenotype retrieval using Vidul's fine-tuned
|
| 22 |
+
embeddings.
|
| 23 |
+
- `src/backend/encode.py` — annotation and response handling.
|
| 24 |
+
- `src/backend/graph.py` — phenotype detail graph construction.
|
| 25 |
+
- `src/backend/rxnav.py` — conservative, cached RxNAV/RxNorm completion for
|
| 26 |
+
medication mappings that are absent from packaged artifacts.
|
| 27 |
+
- `src/backend/__init__.py` — backend package marker.
|
| 28 |
+
|
| 29 |
+
## Frontend
|
| 30 |
+
|
| 31 |
+
- `src/frontend/index.html` — browser application shell.
|
| 32 |
+
- `src/frontend/app.js` — search, review, and annotation behavior.
|
| 33 |
+
- `src/frontend/styles.css` — application styling.
|
| 34 |
+
|
| 35 |
+
## Private bucket: runtime model and indexes
|
| 36 |
+
|
| 37 |
+
- `artifacts/vidul/BGE_FT_VA/` — Vidul's fine-tuned model weights, tokenizer,
|
| 38 |
+
and configuration.
|
| 39 |
+
- `artifacts/vidul/icd_index/` — diagnosis FAISS index and metadata.
|
| 40 |
+
- `artifacts/vidul/med_index/` — medication FAISS index and metadata.
|
| 41 |
+
- `artifacts/vidul/ndc_index/` — NDC FAISS index and metadata.
|
| 42 |
+
- `artifacts/vidul/labchem_index/` — LOINC/lab FAISS index and metadata.
|
| 43 |
+
- `artifacts/vidul/cpt_index/` — procedure FAISS index and metadata.
|
| 44 |
+
- `artifacts/part_a_embeddings/code.npy`
|
| 45 |
+
- `artifacts/part_a_embeddings/code_meta.json`
|
| 46 |
+
- `artifacts/part_a_embeddings/config.json`
|
| 47 |
+
- `artifacts/part_a_embeddings/metadata.npy`
|
| 48 |
+
- `artifacts/part_a_embeddings/metadata_ids.json`
|
| 49 |
+
|
| 50 |
+
## Private bucket: runtime phenotype records
|
| 51 |
+
|
| 52 |
+
- `artifacts/part_a_canonical/algorithm_components.jsonl`
|
| 53 |
+
- `artifacts/part_a_canonical/canonical_phenotypes.jsonl`
|
| 54 |
+
- `artifacts/part_a_canonical/cipher_links.jsonl`
|
| 55 |
+
- `artifacts/part_a_canonical/code_descriptions.jsonl`
|
| 56 |
+
- `artifacts/part_a_canonical/loinc_terms.jsonl`
|
| 57 |
+
- `artifacts/part_a_canonical/med_ingredient_map.jsonl`
|
| 58 |
+
- `artifacts/part_a_canonical/rxcui_ingredient_map.jsonl`
|
| 59 |
+
|
| 60 |
+
## Verification
|
| 61 |
+
|
| 62 |
+
- `tests/test_retrieval.py` — focused tests for the retained dense-retrieval
|
| 63 |
+
path.
|
| 64 |
+
- `tests/test_rxnav_and_deployment.py` — RxNAV provenance and Netlify Drop
|
| 65 |
+
deployment checks.
|
| 66 |
+
|
| 67 |
+
## Explicitly excluded
|
| 68 |
+
|
| 69 |
+
- Raw VA source data and local data exports.
|
| 70 |
+
- Source ZIPs, including the original application bundle, plus LOINC and RxNorm
|
| 71 |
+
distribution archives.
|
| 72 |
+
- Training, evaluation, reranker, extracted-facet, and profiling outputs.
|
| 73 |
+
- Historical documentation, retired scripts, generated demos, and caches.
|
| 74 |
+
- Local environments, credentials, editor settings, and annotations.
|
| 75 |
+
|
| 76 |
+
The release contains one retrieval path: Vidul's fine-tuned model, prebuilt
|
| 77 |
+
embeddings, and exact dense search. Large runtime assets are stored in
|
| 78 |
+
`hf://buckets/hiasgnpsadgd/encode-artifacts` and mounted read-only at
|
| 79 |
+
`/home/user/app/artifacts`; they are not committed to the Space repository or
|
| 80 |
+
copied into the Docker image.
|
Dockerfile
CHANGED
|
@@ -19,6 +19,7 @@ ENV HOME=/home/user \
|
|
| 19 |
HF_HUB_DISABLE_TELEMETRY=1
|
| 20 |
|
| 21 |
WORKDIR $HOME/app
|
|
|
|
| 22 |
|
| 23 |
COPY --chown=user src/requirements.txt ./requirements.txt
|
| 24 |
RUN python -m pip install --no-cache-dir --upgrade pip && \
|
|
@@ -26,11 +27,7 @@ RUN python -m pip install --no-cache-dir --upgrade pip && \
|
|
| 26 |
--index-url https://download.pytorch.org/whl/cpu "torch==2.13.0" && \
|
| 27 |
python -m pip install --no-cache-dir -r requirements.txt
|
| 28 |
|
| 29 |
-
COPY --chown=user artifacts/vidul ./artifacts/vidul
|
| 30 |
-
|
| 31 |
COPY --chown=user src ./src
|
| 32 |
-
COPY --chown=user artifacts/part_a_embeddings ./artifacts/part_a_embeddings
|
| 33 |
-
COPY --chown=user artifacts/part_a_canonical ./artifacts/part_a_canonical
|
| 34 |
|
| 35 |
EXPOSE 7860
|
| 36 |
STOPSIGNAL SIGTERM
|
|
|
|
| 19 |
HF_HUB_DISABLE_TELEMETRY=1
|
| 20 |
|
| 21 |
WORKDIR $HOME/app
|
| 22 |
+
RUN mkdir -p ./artifacts
|
| 23 |
|
| 24 |
COPY --chown=user src/requirements.txt ./requirements.txt
|
| 25 |
RUN python -m pip install --no-cache-dir --upgrade pip && \
|
|
|
|
| 27 |
--index-url https://download.pytorch.org/whl/cpu "torch==2.13.0" && \
|
| 28 |
python -m pip install --no-cache-dir -r requirements.txt
|
| 29 |
|
|
|
|
|
|
|
| 30 |
COPY --chown=user src ./src
|
|
|
|
|
|
|
| 31 |
|
| 32 |
EXPOSE 7860
|
| 33 |
STOPSIGNAL SIGTERM
|
README.md
CHANGED
|
@@ -13,7 +13,9 @@ short_description: CPU clinical-code and phenotype retrieval with BGE_FT_VA
|
|
| 13 |
# ENCODE
|
| 14 |
|
| 15 |
ENCODE is a CPU-only FastAPI application packaged as a Hugging Face Docker
|
| 16 |
-
Space. The runtime uses Vidul's `BGE_FT_VA` weights and prebuilt dense indexes
|
|
|
|
|
|
|
| 17 |
|
| 18 |
The exact proposed fresh-history file set is listed in
|
| 19 |
[COMMIT_CONTENTS.md](COMMIT_CONTENTS.md).
|
|
@@ -50,22 +52,26 @@ Dockerfile CPU-only Space image
|
|
| 50 |
src/backend/ FastAPI and retrieval engines
|
| 51 |
src/frontend/ Static browser UI
|
| 52 |
tests/ Focused retrieval tests
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 56 |
```
|
| 57 |
|
| 58 |
Raw VA inputs, source ZIP archives, LOINC/RxNorm archives, prior experiment
|
| 59 |
outputs, model-training
|
| 60 |
artifacts, profiling output, generated demos, retired scripts, and historical
|
| 61 |
-
docs are intentionally excluded by `.gitignore`.
|
| 62 |
-
|
|
|
|
| 63 |
|
| 64 |
## Run locally
|
| 65 |
|
| 66 |
```bash
|
| 67 |
docker build -t encode-space .
|
| 68 |
-
docker run --rm --memory=16g --cpus=2
|
|
|
|
|
|
|
| 69 |
```
|
| 70 |
|
| 71 |
Open <http://localhost:7860>. The readiness endpoint is
|
|
@@ -73,19 +79,19 @@ Open <http://localhost:7860>. The readiness endpoint is
|
|
| 73 |
|
| 74 |
## Push to a Hugging Face Space
|
| 75 |
|
| 76 |
-
|
| 77 |
|
| 78 |
```bash
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
```
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
|
| 90 |
## Deploy the Netlify Drop frontend
|
| 91 |
|
|
|
|
| 13 |
# ENCODE
|
| 14 |
|
| 15 |
ENCODE is a CPU-only FastAPI application packaged as a Hugging Face Docker
|
| 16 |
+
Space. The runtime uses Vidul's `BGE_FT_VA` weights and prebuilt dense indexes
|
| 17 |
+
from a private Hugging Face Storage Bucket mounted read-only at
|
| 18 |
+
`/home/user/app/artifacts`.
|
| 19 |
|
| 20 |
The exact proposed fresh-history file set is listed in
|
| 21 |
[COMMIT_CONTENTS.md](COMMIT_CONTENTS.md).
|
|
|
|
| 52 |
src/backend/ FastAPI and retrieval engines
|
| 53 |
src/frontend/ Static browser UI
|
| 54 |
tests/ Focused retrieval tests
|
| 55 |
+
hf://buckets/hiasgnpsadgd/encode-artifacts/
|
| 56 |
+
part_a_embeddings/ Runtime phenotype vectors
|
| 57 |
+
part_a_canonical/ Runtime phenotype/detail records
|
| 58 |
+
vidul/ Vidul model and original FAISS indexes
|
| 59 |
```
|
| 60 |
|
| 61 |
Raw VA inputs, source ZIP archives, LOINC/RxNorm archives, prior experiment
|
| 62 |
outputs, model-training
|
| 63 |
artifacts, profiling output, generated demos, retired scripts, and historical
|
| 64 |
+
docs are intentionally excluded by `.gitignore`. Runtime artifacts are excluded
|
| 65 |
+
from the Docker build and Space repository so storage is billed and managed
|
| 66 |
+
independently from runtime compute.
|
| 67 |
|
| 68 |
## Run locally
|
| 69 |
|
| 70 |
```bash
|
| 71 |
docker build -t encode-space .
|
| 72 |
+
docker run --rm --memory=16g --cpus=2 \
|
| 73 |
+
-v "$PWD/artifacts:/home/user/app/artifacts:ro" \
|
| 74 |
+
-p 7860:7860 encode-space
|
| 75 |
```
|
| 76 |
|
| 77 |
Open <http://localhost:7860>. The readiness endpoint is
|
|
|
|
| 79 |
|
| 80 |
## Push to a Hugging Face Space
|
| 81 |
|
| 82 |
+
Mount the private artifact bucket and upload the lightweight Space files:
|
| 83 |
|
| 84 |
```bash
|
| 85 |
+
hf spaces volumes set hiasgnpsadgd/ENCODE \
|
| 86 |
+
--volume hf://buckets/hiasgnpsadgd/encode-artifacts:/home/user/app/artifacts:ro
|
| 87 |
+
hf upload hiasgnpsadgd/ENCODE . . --type space \
|
| 88 |
+
--include README.md --include COMMIT_CONTENTS.md \
|
| 89 |
+
--include Dockerfile --include .dockerignore --include 'src/**'
|
| 90 |
```
|
| 91 |
|
| 92 |
+
Hugging Face reads the YAML metadata at the top of this file and exposes the
|
| 93 |
+
container on port 7860. The image runs one Uvicorn worker so model and index
|
| 94 |
+
memory are not duplicated.
|
| 95 |
|
| 96 |
## Deploy the Netlify Drop frontend
|
| 97 |
|
artifacts/part_a_canonical/algorithm_components.jsonl
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/part_a_canonical/build_report.md
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
# ENCODE Part A Canonical Data Build Report
|
| 2 |
-
|
| 3 |
-
Generated by `scripts/build_part_a_canonical.py`.
|
| 4 |
-
|
| 5 |
-
## Inputs
|
| 6 |
-
|
| 7 |
-
- `Data source/all_phenotypes.zip`
|
| 8 |
-
- `Data source/VA_CDW_Code_dimensions_tables (1).zip`
|
| 9 |
-
|
| 10 |
-
## Output Contract
|
| 11 |
-
|
| 12 |
-
- Schema version: `encode_part_a_v1`
|
| 13 |
-
- Canonical phenotype records: `canonical_phenotypes.jsonl`
|
| 14 |
-
- Embedding documents: `embedding_documents.jsonl`
|
| 15 |
-
- Embedding document chunk cap: 4000 characters
|
| 16 |
-
|
| 17 |
-
## Profile
|
| 18 |
-
|
| 19 |
-
- Phenotypes: 8013
|
| 20 |
-
- Embedding documents: 36681
|
| 21 |
-
- Missing phenotype descriptions: 1665
|
| 22 |
-
- Missing algorithm descriptions: 0
|
| 23 |
-
- CIPHER enum sidecar parsed cleanly: False
|
| 24 |
-
|
| 25 |
-
## Embedding Documents By View
|
| 26 |
-
|
| 27 |
-
- code_evidence: 28668
|
| 28 |
-
- phenotype_metadata: 8013
|
| 29 |
-
|
| 30 |
-
## Code Entries By Type
|
| 31 |
-
|
| 32 |
-
- Healthcare Common Procedure Coding System (HCPCS): 3
|
| 33 |
-
- ICD-10 Diagnostic Codes: 6468
|
| 34 |
-
- ICD-10 Procedure Codes: 21
|
| 35 |
-
- ICD-9 Diagnostic Codes: 5995
|
| 36 |
-
- ICD-9 Procedure Codes: 25
|
| 37 |
-
- Laboratory Tests: 235
|
| 38 |
-
- Medications: 156
|
| 39 |
-
- Observational Medical Outcomes Partnership (OMOP) Concept: 28
|
| 40 |
-
- Other: 1690
|
| 41 |
-
- SNOMED CT, US Edition: 276
|
| 42 |
-
- Text snippets: 1802
|
| 43 |
-
- VA Clinic Stop Codes: 44
|
| 44 |
-
|
| 45 |
-
## Code Label Status
|
| 46 |
-
|
| 47 |
-
- exact: 395795
|
| 48 |
-
- label_missing: 10186
|
| 49 |
-
- prefix_expanded: 86799
|
| 50 |
-
- unsupported_code_system: 140337
|
| 51 |
-
|
| 52 |
-
## Notes
|
| 53 |
-
|
| 54 |
-
- `prefix_expanded` labels are retrieval evidence only; the original CIPHER code is preserved as provenance.
|
| 55 |
-
- Unsupported medication/lab/SNOMED/OMOP/CUI labels remain explicit instead of being guessed.
|
| 56 |
-
- The malformed enum sidecar should still be replaced for production use.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_canonical/canonical_phenotypes.jsonl
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:5062bcec9b228a7962a9d0652a4ab9047d8b8b4f7965e35be0cc7c85222e9c73
|
| 3 |
-
size 198475995
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_canonical/cipher_links.jsonl
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/part_a_canonical/code_descriptions.jsonl
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/part_a_canonical/code_descriptions_report.md
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
# Code Description Resolution Report (professor feedback 1.1)
|
| 2 |
-
|
| 3 |
-
Generated by `scripts/resolve_code_descriptions.py`. Sidecar: `code_descriptions.jsonl`.
|
| 4 |
-
|
| 5 |
-
- Unsupported code occurrences in corpus: **140,337**
|
| 6 |
-
- Distinct unsupported codes: **77,233**
|
| 7 |
-
- Distinct codes resolved to a description: **4,756** (6%)
|
| 8 |
-
|
| 9 |
-
## Resolved by source
|
| 10 |
-
|
| 11 |
-
- `va_dim_exact`: 2,166
|
| 12 |
-
- `cipher_name`: 2,108
|
| 13 |
-
- `va_labname`: 482
|
| 14 |
-
|
| 15 |
-
## Left unresolved (honest gaps)
|
| 16 |
-
|
| 17 |
-
- `study_specific`: 66,856
|
| 18 |
-
- `needs_vocab:OMOP`: 4,322
|
| 19 |
-
- `needs_vocab:LOINC`: 806
|
| 20 |
-
- `needs_vocab:VA_STOP`: 255
|
| 21 |
-
- `needs_vocab:NDC`: 238
|
| 22 |
-
|
| 23 |
-
## Notes
|
| 24 |
-
|
| 25 |
-
- `va_dim_exact`: code matched a VA ICD/CPT dictionary (often a code CIPHER
|
| 26 |
-
filed under "Other").
|
| 27 |
-
- `cipher_name`: Medication codes are the drug name itself (generic/brand).
|
| 28 |
-
- `va_labname`: Lab `%PATTERN%` SQL-LIKE test-name, wildcards stripped.
|
| 29 |
-
- `needs_vocab:*`: resolvable once that vocabulary is added (LOINC, OMOP/Athena,
|
| 30 |
-
RxNorm/NDC, VA stop-code table) — none require guessing.
|
| 31 |
-
- `study_specific`: author-defined variables (e.g. `PDI`, `hPDI`) with no
|
| 32 |
-
standard-vocabulary description; surfaced as-is, never fabricated.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_canonical/embedding_documents.jsonl
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:97a6a429278e07ce8a9be4eb5b66245d76f5e763a17f89972c9cd31487c01f37
|
| 3 |
-
size 84233128
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_canonical/loinc_terms.jsonl
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:dd0f1d9e4571f1f3fc0fda925870a3e0589cf96a9fcbb7475c9558a4abe17124
|
| 3 |
-
size 17205441
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_canonical/med_ingredient_map.jsonl
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/part_a_canonical/rxcui_ingredient_map.jsonl
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/part_a_embeddings/code.npy
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:6bdc926e8827f2e05e22c73d1b06349f707fad8f85f83d6c9f57939d80d456a6
|
| 3 |
-
size 58712192
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_embeddings/code_meta.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
artifacts/part_a_embeddings/config.json
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"model": "artifacts/models/bge_ft_va",
|
| 3 |
-
"pooling": "cls",
|
| 4 |
-
"query_prefix": "",
|
| 5 |
-
"doc_prefix": "",
|
| 6 |
-
"max_seq_length": 512,
|
| 7 |
-
"dim": 1024,
|
| 8 |
-
"n_metadata": 8013,
|
| 9 |
-
"n_code": 28668
|
| 10 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_embeddings/metadata.npy
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:add2b7bcd22e7566f8348a6d6dce7e9a1de47fb2ccbe898febfccf9a6a44afec
|
| 3 |
-
size 16410752
|
|
|
|
|
|
|
|
|
|
|
|
artifacts/part_a_embeddings/metadata_ids.json
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
[2678, 2017, 2679, 2632, 1975, 1976, 1977, 1978, 1979, 1980, 1981, 1982, 1983, 1984, 1985, 1986, 1987, 1988, 1989, 1990, 1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051, 2052, 2053, 2054, 2055, 2056, 2057, 2058, 2059, 2060, 2061, 2062, 2063, 2064, 2065, 2066, 2067, 2068, 2070, 2073, 2074, 2075, 2076, 2077, 2078, 2079, 2080, 2081, 2082, 2083, 2084, 2085, 2086, 2087, 2088, 2090, 2091, 2092, 2093, 2094, 2095, 2096, 2097, 2098, 2099, 2100, 2101, 2102, 2103, 2104, 2105, 2106, 2107, 2108, 2109, 2110, 2111, 2112, 2113, 2114, 2115, 2116, 2117, 2118, 2119, 2120, 2121, 2122, 2123, 2124, 2125, 2126, 2127, 2128, 2129, 2130, 2131, 2132, 2133, 2134, 2135, 2136, 2137, 2138, 2139, 2140, 2141, 2142, 2143, 2144, 2146, 2148, 2149, 2150, 2151, 2153, 2155, 2156, 2157, 2158, 2160, 2161, 2162, 2163, 2165, 2166, 2167, 2168, 2169, 2170, 2171, 2172, 2173, 2174, 2175, 2176, 2177, 2178, 2179, 2180, 2181, 2182, 2184, 2185, 2186, 2187, 2188, 2189, 2190, 2191, 2192, 2193, 2194, 2195, 2196, 2197, 2198, 2199, 2200, 2201, 2202, 2203, 2204, 2205, 2206, 2207, 2208, 2209, 2210, 2211, 2212, 2213, 2214, 2215, 2216, 2217, 2220, 2221, 2222, 2223, 2224, 2225, 2226, 2227, 2228, 2229, 2230, 2231, 2232, 2233, 2234, 2236, 2237, 2238, 2239, 2240, 2241, 2242, 2243, 2244, 2245, 2246, 2247, 2248, 2249, 2250, 2251, 2252, 2253, 2254, 2255, 2256, 2257, 2258, 2259, 2260, 2262, 2263, 2264, 2265, 2266, 2267, 2269, 2270, 2271, 2272, 2273, 2274, 2275, 2276, 2277, 2278, 2279, 2280, 2281, 2283, 2284, 2285, 2286, 2288, 2289, 2290, 2291, 2292, 2293, 2294, 2295, 2296, 2297, 2298, 2299, 2301, 2302, 2303, 2304, 2305, 2306, 2307, 2308, 2309, 2310, 2311, 2312, 2313, 2314, 2315, 2316, 2317, 2321, 2322, 2323, 2324, 2325, 2326, 2327, 2328, 2329, 2330, 2331, 2332, 2333, 2334, 2335, 2336, 2337, 2338, 2339, 2340, 2341, 2342, 2343, 2344, 2345, 2346, 2347, 2348, 2349, 2350, 2351, 2352, 2353, 2354, 2355, 2356, 2357, 2358, 2359, 2360, 2361, 2362, 2363, 2364, 2366, 2367, 2368, 2369, 2370, 2371, 2372, 2373, 2374, 2375, 2376, 2377, 2378, 2381, 2382, 2383, 2384, 2385, 2386, 2387, 2388, 2389, 2390, 2391, 2392, 2393, 2394, 2395, 2396, 2397, 2399, 2401, 2402, 2404, 2405, 2407, 2408, 2409, 2411, 2412, 2413, 2414, 2415, 2416, 2417, 2418, 2419, 2420, 2422, 2424, 2425, 2427, 2428, 2429, 2430, 2431, 2432, 2433, 2434, 2435, 2436, 2437, 2438, 2439, 2440, 2441, 2442, 2443, 2444, 2445, 2446, 2447, 2448, 2449, 2450, 2451, 2452, 2453, 2454, 2455, 2456, 2457, 2458, 2459, 2460, 2461, 2462, 2464, 2465, 2466, 2467, 2468, 2469, 2471, 2472, 2474, 2475, 2476, 2481, 2482, 2483, 2485, 2486, 2487, 2488, 2489, 2490, 2491, 2492, 2493, 2494, 2495, 2496, 2497, 2498, 2501, 2502, 2503, 2504, 2505, 2506, 2507, 2508, 2509, 2510, 2511, 2512, 2513, 2514, 2515, 2516, 2517, 2518, 2519, 2520, 2522, 2523, 2524, 2525, 2526, 2527, 2528, 2529, 2530, 2531, 2532, 2534, 2535, 2536, 2538, 2539, 2540, 2541, 2542, 2543, 2544, 2545, 2546, 2547, 2548, 2549, 2550, 2551, 2552, 2553, 2554, 2555, 2556, 2557, 2558, 2559, 2560, 2561, 2562, 2563, 2565, 2566, 2567, 2568, 2569, 2570, 2571, 2572, 2573, 2574, 2576, 2577, 2578, 2579, 2580, 2581, 2583, 2585, 2586, 2587, 2588, 2589, 2591, 2592, 2593, 2594, 2595, 2596, 2597, 2599, 2600, 2601, 2603, 2604, 2605, 2606, 2607, 2608, 2609, 2610, 2611, 2612, 2613, 2614, 2615, 2616, 2617, 2618, 2619, 2620, 2621, 2622, 2623, 2624, 2625, 2626, 2627, 2628, 2629, 2630, 2631, 2633, 2634, 2635, 2636, 2637, 2638, 2639, 2640, 2641, 2642, 2644, 2645, 2646, 2647, 2650, 2651, 2652, 2653, 2654, 2655, 2656, 2657, 2658, 2659, 2660, 2661, 2662, 2663, 2664, 2665, 2666, 2667, 2668, 2669, 2670, 2671, 2672, 2673, 2674, 2675, 2676, 2677, 2680, 2681, 2682, 2683, 2684, 2686, 2687, 2688, 2689, 2690, 2691, 2692, 2693, 2694, 2695, 2696, 2697, 2698, 2699, 2700, 2701, 2702, 2703, 2704, 2706, 2707, 2708, 2709, 2710, 2711, 2712, 2713, 2725, 2727, 2731, 2732, 2733, 2734, 2735, 2736, 2737, 2738, 2739, 2740, 2741, 2742, 2743, 2744, 2745, 2746, 2747, 2748, 2749, 2750, 2752, 2753, 2754, 2755, 2756, 2758, 2759, 2760, 2761, 2762, 2763, 2765, 2767, 2768, 2769, 2770, 2771, 2772, 2773, 2774, 2775, 2776, 2778, 2779, 2780, 2781, 2782, 2783, 2784, 2786, 2787, 2788, 2789, 2790, 2791, 2792, 2793, 2794, 2795, 2797, 2798, 2799, 2800, 2801, 2802, 2803, 2804, 2805, 2806, 2807, 2808, 2809, 2810, 2811, 2812, 2813, 2814, 2815, 2816, 2817, 2818, 2819, 2821, 2822, 2823, 2824, 2825, 2826, 2827, 2828, 2829, 2830, 2831, 2832, 2833, 2834, 2835, 2836, 2837, 2838, 2839, 2840, 2841, 2842, 2843, 2844, 2846, 2847, 2848, 2849, 2850, 2851, 2852, 2853, 2854, 2855, 2856, 2857, 2858, 2859, 2860, 2861, 2862, 2863, 2864, 2865, 2866, 2867, 2868, 2869, 2870, 2871, 2872, 2873, 2874, 2875, 2876, 2877, 2878, 2879, 2880, 2881, 2882, 2884, 2885, 2886, 2888, 2889, 2890, 2891, 2892, 2893, 2894, 2896, 2897, 2898, 2899, 2900, 2901, 2902, 2903, 2904, 2905, 2906, 2907, 2911, 2913, 2914, 2915, 2917, 2918, 2919, 2920, 2921, 2922, 2924, 2925, 2926, 2927, 2928, 2930, 2931, 2932, 2933, 2934, 2935, 2936, 2937, 2938, 2939, 2940, 2941, 2942, 2943, 2945, 2946, 2948, 2949, 2950, 2952, 2953, 2954, 2955, 2956, 2958, 2959, 2960, 2961, 2962, 2963, 2964, 2965, 2966, 2967, 2969, 2970, 2971, 2972, 2973, 2974, 2478, 2533, 2069, 2071, 2072, 2145, 2147, 2152, 2154, 2159, 2164, 2183, 2218, 2219, 2235, 2268, 2261, 2282, 2287, 2300, 2318, 2319, 2320, 2365, 2379, 2380, 2398, 2400, 2403, 2406, 2410, 2421, 2423, 2426, 2463, 2470, 2477, 2479, 2480, 2484, 2499, 2500, 2521, 2537, 2564, 2575, 2582, 2584, 2590, 2598, 2602, 2643, 2648, 2649, 2685, 2705, 2714, 2715, 2717, 2716, 2718, 2719, 2720, 2721, 2722, 2723, 2724, 2726, 2728, 2729, 2730, 2751, 2757, 2764, 2766, 2785, 2777, 2796, 2820, 2845, 2883, 2887, 2895, 2908, 2910, 2909, 2912, 2957, 2968, 2916, 2923, 2929, 2944, 2947, 2951, 2473, 2089, 3935, 4117, 4065, 4011, 4035, 3509, 3334, 3962, 3508, 3355, 3209, 4045, 3972, 3987, 3948, 3960, 3999, 4001, 4002, 4003, 4004, 4005, 4006, 4008, 4009, 4010, 4012, 4013, 4014, 4015, 4016, 4017, 4019, 4020, 4021, 4022, 4023, 4025, 4026, 4027, 4028, 4030, 4031, 4032, 4034, 4037, 4038, 4039, 4040, 4041, 4044, 4046, 4047, 4049, 4050, 4051, 4052, 4054, 4055, 4056, 4057, 4058, 4059, 4060, 4062, 4063, 4064, 4066, 4067, 4069, 4070, 4042, 4072, 4073, 4074, 4075, 4077, 4078, 4079, 4080, 4081, 4082, 4083, 4084, 4085, 4086, 4088, 4089, 4090, 4093, 4094, 4095, 4096, 4098, 4099, 4100, 4101, 4102, 4103, 4104, 4105, 4106, 4107, 4108, 4109, 4110, 4111, 4112, 4114, 4115, 4116, 4118, 4119, 4121, 4122, 4123, 4124, 3440, 4126, 4127, 3443, 3442, 3449, 3495, 3526, 3560, 3639, 4024, 4120, 3444, 3446, 3447, 3450, 3031, 3036, 3037, 3052, 3056, 4043, 3069, 3074, 3132, 3589, 3441, 3448, 3615, 3158, 3169, 3174, 3176, 3659, 3661, 3171, 3179, 3167, 3189, 3190, 3198, 3199, 3200, 3210, 3224, 3260, 3178, 3177, 3235, 3239, 3246, 3251, 3255, 3276, 3303, 3308, 3310, 3314, 3340, 3343, 3261, 3401, 3437, 3688, 3700, 3732, 3734, 3737, 3346, 3378, 3390, 3439, 3674, 3675, 3676, 3678, 3736, 3738, 3739, 3740, 3750, 2975, 2976, 2977, 2978, 3755, 2980, 2981, 2982, 2983, 2984, 2985, 2986, 2987, 2988, 2989, 2990, 2991, 2992, 2993, 2994, 2995, 2996, 2997, 2998, 2999, 3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010, 3011, 3012, 3013, 3014, 3015, 3016, 3017, 3018, 3019, 3020, 3021, 3022, 3023, 3024, 3025, 3026, 3027, 3028, 3029, 3757, 3032, 3033, 3034, 3035, 3038, 3039, 3040, 3041, 3042, 3043, 3044, 3045, 3046, 3047, 3048, 3049, 3050, 3053, 3054, 3055, 3057, 3058, 3059, 3060, 3061, 3062, 3063, 3064, 3065, 3066, 3067, 3068, 3070, 3071, 3072, 3073, 3075, 3076, 3077, 3078, 3079, 3080, 3081, 3082, 3083, 3084, 3085, 3086, 3087, 3088, 3089, 3090, 3091, 3092, 3765, 3797, 3093, 3094, 3095, 3096, 3097, 3098, 3099, 3100, 3101, 3102, 3103, 3104, 3105, 3106, 3107, 3108, 3109, 3110, 3111, 3112, 3113, 3114, 3115, 3116, 3117, 3118, 3119, 3120, 3121, 3123, 3124, 3125, 3126, 3127, 3128, 3129, 3130, 3131, 3133, 3134, 3135, 3136, 3137, 3138, 3139, 3140, 3141, 3142, 3143, 3144, 3145, 3146, 3147, 3148, 3149, 3150, 3151, 3152, 3153, 3154, 3155, 3156, 3157, 3159, 3160, 3161, 3162, 3163, 3164, 3165, 3166, 3168, 3170, 3172, 3173, 3175, 3180, 3181, 3182, 3183, 3184, 3185, 3186, 3187, 3188, 3191, 3192, 3193, 3194, 3195, 3196, 3197, 3201, 3202, 3203, 3204, 3205, 3206, 3207, 3208, 3211, 3212, 3213, 3214, 3215, 3216, 3217, 3218, 3219, 3220, 3221, 3222, 3223, 3225, 3226, 3227, 3228, 3229, 3230, 3231, 3232, 3233, 3234, 3236, 3237, 3238, 3240, 3241, 3242, 3243, 3244, 3245, 3247, 3248, 3249, 3250, 3252, 3253, 3254, 3256, 3257, 3258, 3259, 3262, 3263, 3264, 3265, 3266, 3267, 3268, 3269, 3270, 3271, 3272, 3273, 3274, 3275, 3277, 3278, 3279, 3280, 3281, 3282, 3283, 3284, 3285, 3286, 3287, 3288, 3289, 3290, 3291, 3292, 3293, 3294, 3295, 3296, 3297, 3298, 3299, 3300, 3301, 3302, 3304, 3305, 3306, 3307, 3309, 3311, 3312, 3313, 3315, 3316, 3317, 3318, 3319, 3320, 3321, 3322, 3323, 3324, 3325, 3326, 3327, 3328, 3329, 3330, 3331, 3332, 3333, 3335, 3336, 3337, 3338, 3339, 3341, 3342, 3344, 3345, 3347, 3348, 3349, 3350, 3351, 3352, 3353, 3354, 3356, 3357, 3358, 3359, 3360, 3361, 3362, 3363, 3364, 3365, 3366, 3367, 3368, 3369, 3370, 3371, 3372, 3373, 3374, 3375, 3376, 3377, 3379, 3380, 3381, 3382, 3383, 3384, 3385, 3386, 3387, 3388, 3389, 3391, 3392, 3393, 3394, 3395, 3396, 3397, 3398, 3399, 3400, 3402, 3403, 3404, 3405, 3406, 3407, 3408, 3409, 3410, 3411, 3412, 3413, 3414, 3415, 3416, 3417, 3418, 3419, 3420, 3421, 3422, 3423, 3424, 3425, 3426, 3427, 3428, 3429, 3430, 3431, 3432, 3433, 3434, 3435, 3436, 3438, 3445, 3451, 3452, 3453, 3454, 3455, 3456, 3457, 3458, 3459, 3460, 3461, 3462, 3463, 3464, 3465, 3466, 3467, 3468, 3469, 3470, 3471, 3472, 3473, 3474, 3475, 3476, 3477, 3478, 3479, 3480, 3481, 3482, 3483, 3484, 3485, 3486, 3487, 3488, 3489, 3490, 3491, 3492, 3493, 3494, 3496, 3497, 3498, 3499, 3500, 3501, 3502, 3503, 3504, 3505, 3506, 3507, 3813, 3824, 3510, 3511, 3512, 3513, 3514, 3515, 3516, 3517, 3518, 3519, 3520, 3521, 3522, 3523, 3524, 3525, 3527, 3528, 3529, 3530, 3531, 3532, 3533, 3534, 3535, 3536, 3537, 3538, 3539, 3540, 3541, 3542, 3543, 3544, 3545, 3546, 3547, 3548, 3549, 3550, 3551, 3552, 3553, 3554, 3555, 3556, 3557, 3558, 3559, 3561, 3562, 3563, 3828, 3651, 3122, 3613, 3626, 3565, 3566, 3567, 3568, 3569, 3570, 3571, 3572, 3573, 3574, 3575, 3576, 3577, 3578, 3579, 3580, 3581, 3582, 3583, 3584, 3585, 3586, 3587, 3588, 3590, 3591, 3592, 3593, 3594, 3595, 3596, 3597, 3598, 3599, 3600, 3601, 3602, 3603, 3604, 3605, 3606, 3607, 3608, 3609, 3610, 3611, 3612, 3614, 3616, 3617, 3618, 3619, 3620, 3621, 3622, 3623, 3624, 3625, 3627, 3628, 3629, 3630, 3631, 3632, 3633, 3634, 3635, 3636, 3637, 3638, 3640, 3641, 3642, 3643, 3644, 3645, 3646, 3647, 3648, 3649, 3650, 3652, 3653, 3654, 3655, 3656, 3657, 3658, 3660, 3662, 3663, 3664, 3665, 3666, 3667, 3668, 3669, 3670, 3672, 3673, 3677, 3679, 3680, 3681, 3682, 3683, 3684, 3685, 3686, 3687, 3689, 3690, 3691, 3692, 3693, 3694, 3695, 3696, 3697, 3698, 3699, 3701, 3702, 3703, 3704, 3705, 3706, 3707, 3708, 3709, 3710, 3711, 3712, 3713, 3714, 3715, 3716, 3717, 3718, 3719, 3720, 3721, 3722, 3723, 3724, 3725, 3726, 3727, 3728, 3729, 3730, 3731, 3733, 3735, 3741, 3742, 3743, 3744, 3745, 3746, 3747, 3748, 3749, 3751, 3752, 3753, 3754, 3756, 3758, 3759, 3760, 3761, 3763, 3764, 3766, 3767, 3768, 3769, 3770, 3771, 3772, 3773, 3774, 3775, 3776, 3777, 3778, 3779, 3780, 3781, 3782, 3783, 3784, 3785, 3786, 3787, 3788, 3789, 3790, 3791, 3792, 3793, 3794, 3795, 3796, 3798, 3799, 3800, 3801, 3802, 3803, 3804, 3805, 3806, 3807, 3808, 3809, 3810, 3811, 3812, 3814, 3815, 3816, 3817, 3818, 3819, 3820, 3821, 3822, 3823, 3825, 3826, 3827, 3829, 3830, 3831, 3832, 3833, 3834, 3835, 3836, 3837, 3838, 3839, 4091, 4113, 4068, 4029, 4061, 3998, 4071, 3671, 4097, 4048, 2979, 3030, 3993, 3994, 3995, 3762, 3564, 4125, 4053, 4007, 3934, 3051, 4033, 4000, 4235, 4752, 4351, 4377, 4374, 12468, 4193, 4224, 4294, 12469, 8628, 8629, 8630, 8631, 8632, 8633, 8634, 8635, 8636, 12471, 8637, 4150, 4276, 12472, 4269, 4129, 4130, 4131, 4132, 12474, 4135, 4136, 4137, 4139, 4140, 4141, 4143, 4144, 4145, 4147, 4148, 4149, 4151, 4152, 4153, 4154, 4155, 4156, 4157, 4158, 4159, 4160, 4161, 4162, 4163, 4164, 4165, 4166, 4168, 4169, 4170, 4171, 4172, 4173, 4174, 4175, 12477, 4178, 4179, 4180, 4181, 4182, 4183, 4185, 4186, 4187, 4188, 4190, 4191, 4192, 4195, 4196, 4198, 4199, 4200, 4201, 4203, 4204, 4205, 4206, 4208, 4209, 4210, 4211, 4212, 4213, 4214, 4216, 4217, 4218, 4219, 4220, 4221, 4222, 4223, 4225, 4226, 4229, 4230, 4231, 4232, 4233, 4234, 4236, 4237, 4238, 4239, 4240, 4241, 4242, 4243, 4244, 4245, 4246, 4247, 4248, 4249, 4250, 4251, 4252, 4253, 4254, 4255, 4256, 4257, 4258, 4259, 4260, 4261, 4262, 4263, 4264, 4265, 4266, 4268, 4270, 4271, 4272, 4273, 4274, 4275, 4277, 4278, 4279, 4280, 4281, 4282, 4283, 4286, 4287, 4288, 4289, 4290, 4291, 4292, 4295, 4296, 4297, 4298, 4299, 4300, 4301, 4302, 4303, 4304, 4305, 4306, 4307, 4308, 4309, 4310, 4311, 4312, 4313, 4314, 4315, 4316, 4317, 4318, 4319, 4320, 4321, 4322, 4323, 4325, 4326, 4327, 4330, 4331, 4333, 4176, 4334, 4335, 4336, 4337, 4338, 4339, 4340, 4341, 4343, 4344, 4345, 4346, 4347, 4348, 4350, 4352, 4354, 12478, 4356, 4357, 4358, 4359, 4360, 4362, 4363, 4364, 4365, 4366, 4367, 4368, 4369, 4370, 4371, 4372, 4373, 4375, 4376, 4378, 4380, 4381, 4382, 4383, 4384, 4385, 4386, 4387, 4388, 4389, 4390, 4391, 4392, 4393, 4394, 4395, 4396, 4397, 4399, 4400, 4401, 4402, 4403, 4404, 4405, 4406, 4407, 4409, 4410, 4411, 4412, 4413, 4414, 4415, 4416, 4417, 4194, 12479, 4408, 4215, 4328, 4722, 12480, 12481, 4128, 4349, 12482, 4267, 12483, 12484, 12485, 4228, 4167, 12487, 4749, 12488, 12489, 12490, 12491, 12492, 10513, 12493, 10526, 10527, 10533, 10534, 10535, 10536, 10537, 4353, 4750, 10515, 10523, 4207, 4739, 4751, 4284, 10586, 10588, 10589, 10590, 12495, 12496, 12497, 12498, 12501, 12502, 12506, 12507, 12514, 12515, 12516, 12518, 12519, 12522, 12523, 12524, 12525, 12526, 12527, 12529, 12530, 12531, 12532, 12533, 12534, 12535, 12536, 12537, 12538, 12539, 12540, 12541, 12542, 12543, 12544, 12545, 12546, 12547, 12549, 12556, 12557, 12562, 12563, 12565, 12566, 12569, 12570, 12573, 12576, 12577, 12578, 12579, 12580, 12581, 12582, 12583, 12584, 12585, 12590, 12591, 12592, 12594, 12595, 12596, 12597, 12598, 12599, 12600, 12602, 12603, 12604, 12605, 12609, 12611, 12617, 12618, 12621, 12622, 12623, 12624, 12625, 12626, 12627, 12630, 12631, 12632, 12633, 12634, 12635, 12636, 12637, 12638, 12639, 12640, 12641, 12642, 12643, 12644, 12645, 12646, 12648, 12650, 12651, 12652, 12653, 12654, 12659, 12660, 12662, 12663, 12667, 12668, 12669, 12670, 12671, 12672, 12673, 12674, 12675, 12676, 12677, 12678, 12679, 12680, 12681, 12682, 12686, 12687, 12688, 12689, 12690, 12691, 12693, 12694, 12695, 12697, 12698, 12699, 12703, 12704, 12705, 12706, 12707, 12708, 12709, 12710, 12712, 12713, 12714, 12716, 12718, 12722, 12724, 12725, 12726, 12727, 12728, 12729, 12730, 12731, 12733, 12734, 12735, 12736, 12737, 12738, 12739, 12740, 12741, 12742, 12744, 12745, 12747, 12749, 12750, 12752, 12753, 12755, 12757, 12761, 12762, 12764, 12766, 12767, 12770, 12772, 12773, 12774, 12777, 12780, 12783, 12784, 12785, 12788, 12791, 12793, 12795, 12796, 12797, 12798, 12804, 12805, 12809, 12811, 12813, 12817, 12818, 12819, 12821, 12822, 12823, 12824, 12825, 12827, 12828, 12829, 12830, 12831, 12832, 12833, 12834, 12837, 12838, 12839, 12840, 12841, 12842, 12843, 12844, 12845, 12846, 12847, 12848, 12849, 12850, 12851, 12852, 12854, 12857, 12858, 12859, 12861, 12863, 12864, 12865, 12866, 12867, 12868, 12872, 12874, 12875, 12876, 12877, 12878, 12879, 12880, 12881, 12882, 12883, 12885, 12887, 12891, 12892, 4738, 12894, 12528, 4177, 4737, 12895, 4332, 12896, 12897, 12898, 4146, 4293, 4285, 4723, 4725, 12901, 10522, 10519, 10524, 10521, 10516, 10520, 10518, 10517, 12936, 12906, 12911, 12912, 4202, 4324, 4138, 12913, 12914, 12915, 12918, 12919, 12920, 12922, 12923, 12924, 12925, 12926, 12927, 12930, 12931, 4189, 4197, 12932, 12933, 13010, 13014, 13017, 4355, 4329, 13018, 13019, 13020, 13021, 13022, 13023, 13024, 13025, 13026, 13027, 13028, 13029, 13030, 13032, 13033, 13034, 13035, 13037, 13039, 13043, 13044, 13045, 13046, 13047, 13048, 13049, 13050, 13053, 13055, 13056, 13057, 13058, 13059, 13060, 13062, 13065, 13066, 13067, 13070, 13071, 4721, 4133, 13072, 13073, 13074, 13075, 13076, 13078, 13079, 13080, 13081, 13082, 13083, 13084, 13085, 13086, 13087, 13088, 13089, 13092, 13094, 13095, 13096, 13097, 13098, 13099, 13100, 13101, 13103, 13104, 13105, 13106, 13107, 13108, 13109, 13110, 13111, 13112, 13114, 13115, 13116, 13117, 13118, 13119, 13121, 13122, 13123, 13124, 13125, 13126, 13127, 13128, 13051, 13129, 13130, 13131, 13132, 13134, 13135, 13136, 13137, 13138, 13139, 12470, 12473, 12475, 12476, 12486, 12494, 12499, 12500, 12503, 12504, 12505, 12508, 12509, 12510, 12511, 12512, 12513, 12517, 12520, 12521, 12548, 12550, 12551, 12552, 12553, 12554, 12555, 12558, 12559, 12560, 12561, 12564, 12567, 12568, 12571, 12572, 12574, 12575, 12586, 12587, 12588, 12589, 12593, 12601, 12606, 12607, 12608, 12610, 12612, 12613, 12614, 12615, 12616, 12619, 12620, 12628, 12629, 12647, 12649, 12655, 12656, 12657, 12658, 12661, 12664, 12665, 12666, 12683, 12684, 12685, 12692, 12696, 12700, 12701, 12702, 12711, 12715, 12717, 12719, 12720, 12721, 12723, 12732, 12743, 12746, 12748, 12751, 12754, 12756, 12758, 12759, 12760, 12763, 12765, 12768, 12769, 12771, 12775, 12776, 12778, 12779, 12781, 12782, 12786, 12787, 12789, 12790, 12792, 12794, 12799, 12800, 12801, 12802, 12803, 12806, 12807, 12808, 12810, 12812, 12814, 12815, 12816, 12820, 12826, 12835, 12836, 12853, 12855, 12856, 12860, 12862, 12869, 12870, 12871, 12873, 12884, 12886, 12888, 12889, 12890, 12893, 12899, 12900, 12902, 12903, 12904, 12905, 12907, 12908, 12909, 12910, 12916, 12917, 12921, 12928, 12929, 12934, 12935, 12937, 12938, 12939, 12940, 12941, 12942, 12943, 12944, 12945, 12946, 12947, 12948, 12949, 12950, 12951, 12952, 12953, 12954, 12955, 12956, 12957, 12958, 12959, 12960, 12961, 12962, 12963, 12964, 12965, 12966, 12967, 12968, 12969, 12970, 12971, 12972, 12973, 12974, 12975, 12976, 12977, 12978, 12979, 12980, 12981, 12982, 12983, 12984, 12985, 12986, 12987, 12988, 12989, 12990, 12991, 12992, 12993, 12994, 12995, 12996, 12997, 12998, 12999, 13000, 13001, 13002, 13003, 13004, 13005, 13006, 13007, 13008, 13009, 13011, 13012, 13013, 13015, 13016, 13031, 13036, 13038, 13040, 13041, 13042, 13052, 13054, 13061, 13063, 13064, 13068, 13069, 13077, 13090, 13091, 13093, 13102, 13113, 13120, 13133, 4142, 4184, 4361, 13610, 13822, 13611, 13612, 13615, 13617, 13618, 13823, 13825, 13619, 13826, 13620, 13827, 13621, 13622, 13828, 13829, 13623, 13624, 13625, 13626, 13627, 13831, 13629, 13832, 13833, 13631, 13632, 13834, 13633, 13634, 13635, 13835, 13636, 13637, 13638, 13639, 13836, 13640, 13641, 13642, 13837, 13838, 13643, 13644, 13645, 13839, 13646, 13647, 13840, 13841, 13649, 13843, 13651, 13652, 13653, 13654, 13655, 13844, 13657, 13658, 13659, 13845, 13660, 13661, 13662, 13663, 13846, 13664, 13847, 13665, 13666, 13670, 13672, 13673, 13674, 13848, 13675, 13676, 13849, 13850, 13852, 13854, 13855, 13677, 13856, 13678, 13857, 13679, 13680, 13858, 13681, 13859, 13682, 13860, 13685, 13688, 13861, 13689, 13690, 13691, 13692, 13693, 13694, 13696, 13862, 13697, 13698, 13699, 13863, 13700, 13701, 13704, 13864, 13865, 13866, 13867, 13706, 13868, 13869, 13707, 13708, 13709, 13870, 13710, 13711, 13712, 13715, 13871, 13872, 13873, 13874, 13875, 13716, 13885, 13888, 13892, 13898, 13899, 13903, 13905, 13717, 13906, 13907, 13908, 13915, 13916, 13918, 13919, 13929, 13931, 13932, 13718, 13719, 13936, 13720, 13721, 13942, 13722, 13723, 13724, 13725, 13952, 13726, 13727, 13728, 13960, 13729, 13730, 13731, 13733, 13734, 13735, 13961, 13736, 13737, 13739, 13740, 13741, 13742, 13962, 13963, 13970, 13971, 13974, 13975, 13981, 13985, 13989, 13744, 13745, 13746, 13747, 13748, 13749, 13750, 13752, 13990, 13754, 13756, 13757, 13758, 13759, 13760, 13992, 13767, 13768, 13769, 14001, 13772, 13774, 13775, 14007, 14010, 13779, 13781, 13783, 14024, 14025, 14027, 14028, 14033, 14034, 13788, 13789, 13792, 13795, 13796, 13797, 13798, 13799, 13800, 14038, 13801, 13802, 13803, 13805, 13808, 13809, 13810, 13811, 13813, 13814, 13815, 13816, 13817, 13818, 13824, 13830, 13842, 13851, 14082, 13853, 14084, 14089, 14090, 14092, 13876, 14039, 13877, 13878, 13879, 13880, 13881, 13882, 13883, 13884, 13886, 13887, 13889, 13890, 13891, 13893, 13894, 13895, 13896, 14041, 13897, 13900, 13901, 13902, 13904, 13909, 13910, 14050, 13911, 14054, 13912, 13913, 14056, 13914, 14058, 14061, 14062, 13917, 13920, 13921, 13922, 13923, 14079, 13924, 13925, 13926, 14080, 13927, 13928, 13930, 13933, 14093, 13934, 13935, 13937, 13938, 13939, 13940, 13941, 13943, 13944, 13945, 13946, 13947, 13948, 14100, 14101, 14103, 14105, 14106, 14108, 14109, 14110, 14111, 14112, 14114, 14115, 14116, 14119, 14120, 14123, 14124, 14125, 13949, 14126, 14127, 14128, 14130, 14131, 14133, 14137, 14138, 14139, 13950, 13951, 13953, 13147, 13148, 13151, 13152, 13153, 13154, 13155, 13156, 13157, 13158, 13159, 13160, 13161, 13162, 13163, 13164, 13165, 13168, 13169, 13170, 13171, 13172, 13173, 13174, 13175, 13176, 13178, 13179, 13180, 13954, 13955, 13956, 13957, 13958, 13959, 13964, 13965, 13966, 13967, 13968, 13969, 13972, 13973, 13976, 13977, 13978, 13979, 13980, 13982, 13983, 13984, 13986, 13987, 13988, 13991, 13993, 13994, 13995, 13996, 13997, 13998, 13999, 14000, 13181, 13182, 13184, 13185, 13186, 13187, 14002, 14003, 14004, 14005, 14006, 14008, 14009, 14011, 14012, 14013, 14014, 14015, 14016, 14017, 14018, 14019, 14020, 14021, 14022, 14023, 14026, 14029, 14030, 14031, 14032, 14035, 14036, 14037, 14040, 14042, 14043, 14044, 14045, 14046, 14047, 14048, 14049, 14051, 14052, 14053, 14055, 14057, 14059, 14060, 14063, 14064, 14065, 14066, 14067, 14068, 14069, 14070, 14071, 14072, 14073, 14074, 14075, 14076, 14077, 14078, 14081, 14083, 14085, 14086, 14087, 14088, 14091, 14094, 14095, 14096, 14097, 14098, 14099, 14102, 14104, 14107, 14113, 14117, 14118, 14121, 14122, 14129, 14132, 14134, 14135, 14136, 13140, 13141, 13142, 13143, 13144, 13145, 13146, 13188, 13189, 13190, 13192, 13193, 13194, 13195, 13196, 13197, 13198, 13199, 13202, 13204, 13206, 13207, 13210, 13211, 13212, 13213, 13215, 13216, 13217, 13218, 13219, 13221, 13223, 13224, 13228, 13229, 13230, 13231, 13232, 13234, 13235, 13236, 13237, 13238, 13239, 13240, 13241, 13242, 13243, 13244, 13245, 13246, 13247, 13249, 13250, 13251, 13252, 13253, 13254, 13255, 13256, 13257, 13258, 13259, 13260, 13261, 13263, 13264, 13266, 13267, 13268, 13269, 13270, 13271, 13272, 13273, 13274, 13275, 13277, 13278, 13279, 13280, 13281, 13282, 13284, 13285, 13286, 13287, 13288, 13289, 13290, 13291, 13292, 13293, 13294, 13295, 13296, 13297, 13298, 13299, 13300, 13301, 13302, 13303, 13304, 13305, 13306, 13307, 13308, 13309, 13310, 13311, 13312, 13313, 13314, 13316, 13317, 13318, 13319, 13320, 13321, 13322, 13323, 13324, 13325, 13326, 13327, 13329, 13330, 13331, 13332, 13334, 13335, 13336, 13337, 13338, 13341, 13342, 13343, 13344, 13347, 13348, 13349, 13351, 13352, 13353, 13354, 13357, 13367, 13368, 13369, 13371, 13372, 13373, 13374, 13376, 13378, 13379, 13380, 13381, 13382, 13383, 13384, 13385, 13386, 13387, 13388, 13389, 13390, 13391, 13393, 13394, 13395, 13396, 13397, 13398, 13399, 13400, 13401, 13402, 13403, 13404, 13405, 13406, 13407, 13408, 13409, 13410, 13411, 13412, 13413, 13414, 13415, 13416, 13417, 13420, 13421, 13422, 13423, 13424, 13425, 13426, 13427, 13428, 13429, 13430, 13431, 13432, 13433, 13434, 13435, 13437, 13439, 13440, 13441, 13442, 13443, 13444, 13445, 13446, 13447, 13448, 13449, 13450, 13451, 13452, 13454, 13455, 13457, 13458, 13459, 13461, 13462, 13463, 13464, 13465, 13466, 13469, 13470, 13471, 13472, 13473, 13474, 13475, 13478, 13479, 13480, 13481, 13482, 13483, 13484, 13485, 13487, 13488, 13489, 13490, 13491, 13492, 13493, 13494, 13495, 13496, 13497, 13498, 13499, 13500, 13503, 13504, 13505, 13506, 13507, 13509, 13511, 13512, 13514, 13515, 13518, 13522, 13523, 13524, 13527, 13528, 13529, 13530, 13531, 13532, 13534, 13535, 13536, 13539, 13540, 13541, 13542, 13543, 13544, 13546, 13547, 13548, 13549, 13550, 13551, 13552, 13553, 13554, 13556, 13557, 13558, 13559, 13560, 13561, 13562, 13563, 13564, 13565, 13567, 13568, 13569, 13570, 13571, 13572, 13573, 13574, 13575, 13576, 13579, 13580, 13581, 13582, 13583, 13584, 13585, 13586, 13587, 13588, 13589, 13590, 13591, 13592, 13593, 13594, 13595, 13596, 13597, 13598, 13599, 13600, 13601, 13602, 13604, 13605, 13606, 13607, 13609, 13149, 13150, 13166, 13167, 13177, 13183, 13191, 13200, 13201, 13203, 13205, 13208, 13209, 13214, 13220, 13222, 13225, 13226, 13227, 13233, 13248, 13262, 13265, 13276, 13283, 13315, 13328, 13333, 13339, 13340, 13345, 13346, 13350, 13355, 13356, 13358, 13359, 13360, 13361, 13362, 13363, 13364, 13365, 13366, 13370, 13375, 13377, 13392, 13418, 13419, 13436, 13438, 13453, 13456, 13460, 13467, 13468, 13476, 13477, 13486, 13501, 13502, 13508, 13510, 13513, 13516, 13517, 13519, 13520, 13521, 13525, 13526, 13533, 13537, 13538, 13545, 13555, 13566, 13577, 13578, 13603, 13608, 13613, 13614, 13616, 13628, 13630, 13648, 13650, 13656, 13667, 13668, 13669, 13671, 13683, 13684, 13686, 13687, 13695, 13702, 13703, 13705, 13713, 13714, 13732, 13738, 13743, 13751, 13753, 13755, 13761, 13762, 13763, 13764, 13765, 13766, 13770, 13771, 13773, 13776, 13777, 13778, 13780, 13782, 13784, 13785, 13786, 13787, 13790, 13791, 13793, 13794, 13804, 13806, 13807, 13812, 13819, 13820, 13821, 14346, 14347, 14360, 14359, 14370, 14371, 14373, 14376, 14377, 14378, 14379, 14380, 14381, 14382, 14383, 14384, 14385, 14386, 14387, 14388, 14389, 14390, 14391, 14392, 14393, 14394, 14395, 14396, 14397, 14398, 14399, 14400, 14401, 14402, 14610, 14611, 14612, 14613, 14614, 14615, 14616, 14617, 14368, 14618, 14619, 14620, 14621, 14622, 14623, 14624, 14625, 14626, 14627, 14628, 14629, 14149, 14150, 14151, 14152, 14159, 14172, 14174, 14176, 14178, 14180, 14184, 14186, 14188, 14189, 14196, 14197, 14348, 14349, 14140, 14142, 14350, 14351, 14352, 14143, 14353, 14354, 14355, 14356, 14357, 14363, 14364, 14365, 14366, 14367, 14372, 14144, 14145, 14146, 14147, 14198, 14199, 14217, 14223, 14226, 14227, 14229, 14234, 14241, 14243, 14244, 14248, 14251, 14253, 14254, 14255, 14256, 14267, 14268, 14270, 14271, 14272, 14275, 14281, 14284, 14285, 14286, 14290, 14292, 14294, 14295, 14297, 14299, 14300, 14301, 14303, 14304, 14305, 14312, 14313, 14314, 14315, 14317, 14319, 14320, 14403, 14404, 14405, 14406, 14407, 14408, 14409, 14410, 14411, 14412, 14413, 14414, 14415, 14416, 14417, 14418, 14419, 14420, 14421, 14422, 14423, 14424, 14425, 14426, 14427, 14428, 14429, 14430, 14431, 14432, 14433, 14434, 14435, 14436, 14437, 14438, 14439, 14440, 14441, 14442, 14443, 14444, 14445, 14446, 14447, 14448, 14449, 14450, 14451, 14452, 14453, 14454, 14455, 14456, 14457, 14458, 14459, 14460, 14461, 14462, 14463, 14464, 14465, 14466, 14467, 14468, 14469, 14470, 14471, 14472, 14473, 14474, 14475, 14476, 14477, 14478, 14479, 14480, 14481, 14482, 14483, 14484, 14485, 14486, 14487, 14488, 14489, 14490, 14491, 14492, 14493, 14494, 14495, 14496, 14497, 14498, 14499, 14500, 14501, 14502, 14503, 14504, 14505, 14506, 14507, 14508, 14509, 14510, 14511, 14512, 14513, 14514, 14515, 14516, 14517, 14518, 14519, 14520, 14521, 14522, 14523, 14524, 14525, 14526, 14527, 14528, 14529, 14530, 14531, 14532, 14533, 14534, 14535, 14536, 14537, 14538, 14539, 14540, 14541, 14542, 14543, 14544, 14545, 14546, 14547, 14548, 14549, 14550, 14551, 14552, 14553, 14554, 14555, 14556, 14557, 14558, 14559, 14560, 14561, 14562, 14563, 14564, 14565, 14566, 14567, 14568, 14569, 14570, 14571, 14572, 14573, 14574, 14575, 14576, 14577, 14578, 14579, 14580, 14581, 14582, 14583, 14584, 14585, 14586, 14587, 14588, 14589, 14590, 14591, 14592, 14593, 14594, 14595, 14596, 14597, 14598, 14599, 14600, 14601, 14602, 14603, 14604, 14605, 14606, 14607, 14608, 14609, 14630, 14631, 14632, 14633, 14634, 14635, 14636, 14637, 14638, 14639, 14640, 14641, 14642, 14643, 14644, 14645, 14646, 15029, 15030, 15031, 15032, 15033, 15034, 15035, 15036, 14647, 14648, 14649, 14650, 14651, 14652, 14653, 14654, 14655, 14656, 14657, 14658, 14659, 14660, 14661, 14662, 14663, 14664, 14665, 14666, 14667, 14668, 14669, 14670, 14671, 14672, 14673, 14674, 14675, 14676, 14677, 14678, 14679, 14680, 14681, 14682, 14683, 14684, 14685, 14686, 14687, 14688, 14689, 14690, 14691, 14692, 14693, 14694, 14695, 14696, 14697, 14698, 14699, 14700, 14701, 14702, 14703, 14704, 14705, 14706, 14707, 14708, 14709, 14710, 14711, 14712, 14713, 14714, 14715, 14716, 14717, 14718, 14719, 14720, 14721, 14722, 14723, 14724, 14725, 14726, 14727, 14728, 14729, 14730, 14731, 14732, 14733, 14734, 15037, 15038, 15039, 15040, 15041, 15042, 15043, 15044, 15045, 15046, 15047, 15048, 15049, 15050, 15051, 15052, 15053, 15054, 15055, 15056, 15057, 14735, 14736, 14737, 14738, 14739, 14740, 14741, 14742, 14743, 14744, 14745, 14746, 14747, 14748, 14749, 14750, 14751, 14752, 14753, 14754, 14755, 14756, 14757, 14758, 14759, 14760, 14761, 14762, 14763, 14764, 14765, 14766, 14767, 14768, 14769, 14770, 14771, 14772, 14773, 14774, 14775, 14776, 14777, 14778, 14779, 14780, 14781, 14782, 14783, 14784, 14785, 14786, 14787, 14788, 14789, 14790, 14791, 14792, 14793, 14794, 14795, 14796, 14797, 14798, 14799, 14800, 14801, 14802, 14803, 14804, 14805, 14806, 14807, 14808, 14809, 14810, 14811, 14812, 14813, 14814, 14815, 14816, 14817, 14818, 14819, 14820, 14821, 14822, 14823, 14824, 14825, 14826, 14827, 14828, 14829, 14830, 14831, 14832, 14833, 14834, 14835, 14836, 14837, 14838, 14839, 14840, 14841, 14842, 14843, 14844, 14845, 14846, 14847, 14848, 14849, 14850, 14851, 14852, 14853, 14854, 14855, 14856, 14857, 14858, 14859, 14860, 14861, 14862, 14863, 14864, 14865, 14866, 14867, 14868, 14869, 14870, 14871, 14872, 14873, 14874, 14875, 14876, 14877, 14878, 14879, 14880, 14881, 14882, 14883, 14884, 14885, 14886, 14887, 14888, 14889, 14890, 14891, 14892, 14893, 14322, 14323, 14324, 14325, 14329, 14330, 14331, 14332, 14333, 14334, 14335, 14342, 14894, 14895, 14896, 14897, 14898, 14899, 14900, 14901, 14902, 14903, 14904, 14905, 14906, 14907, 14908, 14909, 14910, 14911, 14912, 14913, 14914, 14915, 14916, 14917, 14918, 14919, 14920, 14369, 14921, 14922, 14923, 14924, 14925, 14926, 14927, 14928, 14929, 14930, 14931, 14932, 14933, 14934, 14935, 14936, 14937, 14938, 14939, 14940, 14941, 14942, 14943, 14944, 14945, 14946, 14947, 14948, 14949, 14950, 14951, 14952, 14953, 14954, 14955, 14956, 14957, 14958, 14959, 14960, 14961, 14962, 14293, 14296, 14298, 14302, 14306, 14307, 14308, 14963, 15058, 15059, 15060, 15061, 15062, 15063, 15064, 14964, 14965, 14966, 14967, 14968, 14969, 14970, 14971, 14972, 14973, 14974, 14975, 14309, 14310, 14311, 14316, 14318, 14321, 14326, 14141, 14148, 14153, 14154, 14155, 14156, 14157, 14158, 14160, 14976, 14977, 14978, 14979, 14980, 14981, 14982, 14983, 14984, 14985, 14986, 14987, 14988, 14161, 14162, 14163, 14164, 14165, 14166, 14167, 14168, 14169, 14170, 14171, 14173, 14175, 14177, 14179, 14181, 14989, 14990, 14991, 14992, 14993, 14994, 14995, 14996, 14997, 14998, 14999, 15000, 15001, 15002, 15003, 15004, 15005, 15006, 15007, 15008, 15009, 15010, 15011, 15012, 14182, 14183, 14185, 14187, 14190, 14191, 14192, 14193, 14194, 14195, 14200, 14201, 14202, 14203, 14204, 14205, 14206, 14207, 14208, 14209, 14210, 14211, 14212, 14213, 14214, 14215, 14216, 14218, 14219, 14220, 14221, 14222, 14224, 14225, 14228, 14230, 15013, 15014, 15015, 15016, 15017, 15018, 15019, 15020, 15021, 15022, 15023, 15024, 15025, 15026, 15027, 15028, 15065, 15066, 15067, 15068, 15069, 15070, 15071, 15072, 15073, 15074, 15075, 15076, 15077, 15078, 15079, 15080, 15081, 15082, 15083, 15084, 15085, 15086, 15087, 15088, 15089, 15090, 15091, 15092, 15093, 15094, 15095, 15096, 15097, 15098, 15099, 15100, 15101, 15102, 15103, 15104, 15105, 15106, 15107, 15108, 15109, 15110, 15111, 15112, 15113, 15114, 15115, 15116, 15117, 15118, 15119, 15120, 15121, 15122, 15123, 15124, 15125, 15126, 15127, 15128, 15129, 15130, 15131, 15132, 15133, 15134, 15135, 15136, 15137, 15138, 15139, 15140, 15141, 15142, 15143, 15144, 15145, 15146, 15147, 14231, 14232, 14233, 14235, 14236, 14237, 14238, 14239, 14240, 14242, 14245, 14246, 14247, 14249, 14250, 14252, 14257, 14258, 14259, 14260, 14261, 14262, 14327, 14328, 14336, 14337, 14338, 14339, 14340, 14341, 14263, 14264, 14265, 14266, 14269, 14273, 14274, 14276, 14277, 14278, 14279, 14280, 14282, 14283, 14287, 14288, 14289, 14291, 16246, 15410, 15411, 15412, 15413, 15414, 15415, 15416, 15417, 15418, 15419, 15420, 15421, 15422, 15423, 15424, 15425, 15426, 15427, 15428, 15429, 15430, 15431, 15432, 15433, 15434, 15435, 15436, 15437, 15438, 15439, 15440, 15441, 15442, 15443, 15444, 15445, 15446, 15447, 15448, 15449, 15450, 15451, 15452, 15453, 15454, 15455, 15456, 15457, 15458, 15459, 15460, 15461, 15462, 15463, 15464, 15465, 15466, 15467, 15468, 15469, 15470, 15471, 15472, 15473, 15474, 15475, 15476, 15477, 15478, 15479, 15480, 15481, 15482, 15483, 15484, 15485, 15486, 15487, 15488, 15489, 15490, 15491, 15492, 15493, 15494, 15495, 15496, 16805, 16806, 15497, 15498, 15499, 15500, 15501, 15502, 15503, 15504, 15505, 15506, 15507, 15508, 15509, 15510, 15511, 15512, 15513, 15514, 15515, 15516, 15517, 15518, 15519, 15520, 15521, 15522, 15523, 15524, 15525, 15526, 15527, 15528, 15529, 15530, 15531, 15532, 15533, 15534, 15535, 15536, 15537, 15538, 15539, 15540, 15541, 15542, 15543, 15544, 15545, 15546, 15547, 15548, 15549, 15550, 15551, 15552, 15553, 15554, 15555, 15556, 15557, 15558, 15559, 15560, 15561, 15562, 15563, 15564, 15565, 15566, 15567, 15568, 15569, 15570, 15571, 15572, 15573, 15574, 15575, 15576, 15577, 15578, 15579, 15580, 15581, 15582, 15583, 15584, 15585, 15586, 15587, 15588, 15589, 15590, 15591, 15592, 15593, 15594, 15595, 16013, 15596, 16057, 16058, 15879, 16918, 15597, 15598, 16937, 15905, 15904, 15599, 16312, 15600, 16306, 16944, 16946, 16947, 16968, 16969, 15601, 15602, 15603, 15604, 15605, 15606, 15607, 15608, 15609, 15610, 15611, 15612, 15613, 15614, 15615, 15616, 15617, 15618, 15619, 15620, 15621, 15622, 15623, 15624, 16938, 16942, 16943, 16945, 16952, 16953, 16954, 16955, 15625, 15626, 15627, 15628, 15872, 15873, 16133, 15876, 15877, 15880, 15881, 15882, 15883, 15887, 15884, 15886, 15885, 15888, 15900, 15906, 16970, 15909, 15912, 15913, 16971, 16972, 15917, 15918, 15919, 15920, 15921, 15922, 15923, 15924, 15925, 15926, 15927, 15928, 15929, 15930, 15931, 15932, 15933, 15934, 15935, 15936, 15938, 15939, 15940, 15941, 15943, 15944, 15945, 15946, 15947, 15948, 15955, 15951, 15914, 15954, 15963, 15967, 15968, 15981, 15982, 15987, 15988, 15957, 15958, 15959, 15960, 15961, 15969, 15970, 15978, 15992, 15995, 15997, 16002, 16003, 16005, 16006, 16007, 16008, 16009, 16010, 16011, 16012, 16043, 16059, 16060, 16061, 16078, 16077, 16102, 16103, 16110, 16111, 16116, 15962, 16126, 16127, 16128, 16130, 16132, 16140, 16141, 16149, 15977, 16146, 15991, 16056, 16182, 16150, 16152, 16153, 16154, 16158, 15986, 16121, 16189, 16190, 16191, 16192, 16204, 16248, 16249, 16254, 16255, 16256, 16207, 16258, 16260, 16261, 16262, 16264, 16294, 16295, 15875, 16305, 15990, 16309, 16310, 16311, 15629, 16314, 16142, 16184, 16321, 16137, 16335, 16336, 16346, 16347, 16350, 16351, 16162, 15973, 16134, 15999, 16253, 16250, 16138, 16183, 16135, 16151, 16145, 15996, 16285, 16466, 16467, 16468, 16469, 16470, 16471, 16472, 16473, 16474, 16475, 16476, 16477, 15956, 16267, 16341, 16342, 16343, 16348, 16055, 16131, 16136, 15975, 16322, 15976, 16798, 16799, 15630, 16004, 16287, 16284, 16283, 16292, 16334, 15979, 15998, 16288, 16293, 16286, 16282, 16280, 16281, 16291, 16279, 16290, 16278, 16277, 16273, 16275, 16274, 16276, 16272, 16206, 16289, 16271, 16268, 16270, 16269, 16074, 16076, 16936, 16104, 16109, 16148, 15994, 15631, 15632, 15633, 15634, 15635, 15636, 15637, 15638, 15639, 15640, 15850, 15851, 15852, 15853, 15854, 15855, 15641, 15642, 15643, 15644, 15645, 15646, 15647, 15648, 15649, 15650, 15651, 15652, 15653, 15654, 15655, 15656, 15657, 15658, 15659, 15660, 15661, 15662, 15663, 15664, 15665, 15666, 15667, 15668, 15669, 15670, 15671, 15672, 15673, 15674, 15675, 15676, 15974, 15677, 15678, 15679, 15680, 15681, 15682, 15683, 15684, 15685, 15686, 15687, 15688, 15689, 15690, 15691, 15692, 15693, 15694, 15695, 15696, 15697, 15698, 15699, 15700, 15701, 15702, 15703, 15704, 15705, 15706, 15707, 15708, 15709, 15710, 15856, 15857, 15858, 15859, 15860, 15861, 15862, 15863, 15711, 15712, 15713, 15714, 15715, 15716, 15717, 15718, 15719, 15720, 15721, 15722, 15723, 15724, 15725, 15726, 15727, 15728, 15729, 15730, 15731, 15732, 15733, 15734, 15735, 15736, 15737, 15738, 15739, 15740, 15741, 15742, 15743, 15744, 15745, 15746, 15747, 15748, 15749, 15750, 15751, 15752, 15753, 15754, 15755, 15756, 15757, 15758, 15759, 15760, 15761, 15762, 15763, 15764, 15765, 15766, 15767, 15768, 15769, 15770, 15771, 15772, 15773, 15774, 15775, 15776, 15777, 15778, 15779, 15780, 15781, 15782, 15783, 15784, 15785, 15786, 15787, 15788, 15789, 15790, 15791, 15792, 15793, 15794, 15795, 15796, 15797, 15798, 15799, 15800, 15953, 15801, 15802, 15803, 15804, 15805, 15806, 15807, 15808, 15809, 15148, 15149, 15150, 15151, 15152, 15153, 15154, 15155, 15156, 15157, 15158, 15159, 15160, 15161, 15162, 15163, 15164, 15165, 15166, 15167, 15168, 15169, 15170, 15171, 15172, 15173, 15174, 15175, 15176, 15177, 15178, 15179, 15180, 15181, 15182, 15183, 15184, 15185, 15186, 15187, 15188, 15189, 15190, 15191, 15192, 15193, 15194, 15195, 15196, 15197, 15198, 15199, 15200, 15201, 15202, 15203, 15204, 15205, 15206, 15207, 15208, 15209, 15210, 15211, 15212, 15213, 15214, 15215, 15216, 15217, 15218, 15219, 15220, 15221, 15222, 15223, 15224, 15225, 15226, 15227, 15228, 15229, 15230, 15231, 15232, 15233, 15234, 15235, 15236, 15237, 15238, 15239, 15240, 15241, 15242, 15243, 15244, 15245, 15246, 15247, 15248, 15249, 15250, 15251, 15252, 15253, 15254, 15255, 15256, 15257, 15258, 15259, 15260, 15261, 15262, 15263, 15264, 15265, 15266, 15267, 15268, 15269, 15270, 15271, 15272, 15273, 15274, 15275, 15276, 15277, 15278, 15279, 15280, 15281, 15282, 15283, 15284, 15285, 15286, 15287, 15288, 15289, 15290, 15291, 15292, 15293, 15294, 15295, 15296, 15297, 15298, 15299, 15300, 15301, 15302, 15303, 15304, 15305, 15306, 15307, 15308, 15309, 15310, 15311, 15312, 15313, 15314, 15315, 15316, 15317, 15318, 15810, 15811, 15812, 15813, 15814, 15815, 15816, 15817, 15818, 15819, 15820, 15821, 15822, 15823, 15824, 15825, 15826, 15827, 15828, 15829, 15830, 15831, 15864, 15865, 15866, 15867, 15868, 15869, 15870, 15871, 16975, 16976, 16977, 16978, 16979, 16980, 15319, 15320, 15321, 15322, 15323, 15324, 15325, 15326, 15327, 15328, 15329, 15330, 15331, 15332, 15333, 15334, 15335, 15336, 15337, 15338, 15339, 15340, 15341, 15342, 15343, 15344, 15345, 15346, 15347, 15348, 15349, 15350, 15351, 15352, 15353, 15354, 15355, 15356, 15357, 15358, 15359, 15360, 15361, 15362, 15363, 15364, 15365, 15366, 15367, 15368, 15369, 15370, 15371, 15372, 15373, 15374, 15375, 15376, 15377, 15378, 15379, 15380, 15381, 15382, 15383, 15384, 15385, 15386, 15387, 15388, 15389, 15390, 15391, 15392, 15393, 15394, 15395, 15396, 15397, 15398, 15399, 15400, 15401, 15402, 15403, 15404, 15405, 15406, 15407, 15408, 15409, 15832, 15833, 15834, 15835, 15836, 15837, 15838, 15839, 15840, 15841, 15842, 15843, 15844, 15845, 15846, 15847, 15848, 15849, 16981, 16982, 16983, 16984, 16985, 16986, 16987, 16988, 16989, 16990, 16991, 16992, 16993, 16994, 16995, 17371, 17372, 17373, 17374, 17375, 17376, 17377, 17378, 17379, 17380, 17381, 17382, 17383, 17384, 17385, 17386, 17387, 17388, 17389, 17390, 17391, 17392, 17393, 17394, 17395, 17396, 17397, 17398, 17399, 17977, 17400, 17401, 17402, 17403, 17404, 17405, 17406, 17407, 17408, 17409, 17410, 17411, 17412, 17413, 17414, 17415, 17416, 17417, 17418, 17419, 17420, 17421, 17422, 17423, 17424, 17425, 17426, 17427, 17428, 17429, 17430, 17431, 17432, 17433, 17434, 17435, 17436, 17437, 17438, 17439, 17440, 17441, 17442, 17882, 17883, 17443, 17444, 17445, 17446, 17447, 17448, 17449, 17450, 17451, 17452, 17453, 17454, 17455, 17456, 17457, 17458, 17459, 17460, 17461, 17462, 17463, 17473, 17474, 17475, 17476, 17477, 17478, 17464, 17465, 17466, 17590, 18221, 18219, 18218, 18210, 18207, 18217, 18203, 18202, 18215, 18211, 18208, 18205, 17467, 17468, 17469, 17470, 17471, 18214, 17607, 18213, 18212, 18201, 17472, 17514, 17515, 17516, 17517, 17518, 17519, 17520, 17521, 17522, 17523, 17524, 17525, 17526, 17527, 17528, 17479, 17480, 17481, 17482, 17483, 17484, 17485, 17486, 17487, 17488, 17489, 17490, 17491, 17492, 17493, 17494, 17495, 17496, 17497, 17498, 17499, 17500, 17501, 17502, 17503, 17504, 17505, 17506, 17507, 17508, 17509, 17510, 17511, 17512, 17513, 18438, 18432, 18433, 17529, 17530, 17531, 17532, 17533, 17534, 17535, 17536, 17537, 17538, 17539, 17540, 17541, 17542, 17543, 17544, 17545, 17546, 17547, 17548, 17549, 17550, 17551, 17552, 17553, 17554, 17555, 17556, 17557, 17558, 17559, 17560, 17561, 17562, 17563, 17564, 17565, 17566, 17567, 17568, 17569, 17570, 17571, 17572, 17573, 17574, 17575, 17576, 17577, 17578, 17579, 17580, 17581, 17582, 17583, 17584, 17585, 17586, 17587, 18484, 18435, 29575, 29576, 29581, 29582, 29583, 29584, 29585, 29586, 29587, 29105, 18439, 29522, 29588, 29589, 17133, 29120, 29187, 29635, 29639, 29640, 29641, 29642, 29648, 29649, 29650, 29651, 29652, 29653, 29654, 29655, 29656, 29657, 29658, 29659, 29636, 29637, 29669, 29190, 29194, 29195, 29197, 29545, 29643, 29644, 29645, 29646, 29647, 18206, 29191, 29192, 29193, 29196, 29198, 29199, 29200, 17865, 18463, 18464, 17877, 18604, 18647, 18649, 18650, 18651, 18838, 18877, 18878, 18879, 18880, 18881, 18886, 18882, 18883, 18891, 18892, 18893, 18894, 18895, 18896, 18897, 18898, 18899, 18900, 18901, 18902, 18903, 18904, 18905, 18906, 18907, 18908, 18909, 18910, 18911, 18912, 18913, 18914, 18915, 18916, 18917, 18918, 18919, 18920, 18921, 18922, 18923, 18924, 18925, 18926, 18927, 18930, 18931, 18932, 18933, 18934, 18935, 18936, 18937, 18938, 18939, 18940, 18941, 18942, 18943, 18944, 18945, 18946, 18947, 18948, 18949, 18950, 18951, 18952, 18953, 18954, 18955, 18956, 18957, 18958, 18959, 18960, 18961, 18969, 18970, 18973, 18974, 18975, 18976, 18977, 18978, 18979, 18980, 18981, 18982, 18983, 18984, 18985, 18986, 18987, 18988, 18989, 18990, 18991, 18992, 18993, 18994, 18995, 18996, 18997, 18998, 19003, 19005, 19014, 19018, 19019, 29201, 29202, 29638, 29203, 29204, 29205, 29206, 29519, 29207, 29546, 29547, 29548, 29208, 29211, 29212, 29213, 29214, 29550, 29554, 29555, 18466, 29209, 29210, 29238, 29215, 29216, 29217, 18472, 18473, 29218, 29553, 18465, 29219, 29221, 29220, 29227, 29228, 29229, 29232, 29233, 29234, 29236, 29237, 18471, 29222, 29556, 29558, 29559, 29230, 29231, 29235, 29239, 29240, 29168, 29557, 29243, 29560, 29561, 18444, 18445, 18446, 18443, 18442, 18448, 18436, 18428, 18485, 29459, 18447, 18440, 18486, 18434, 16996, 16997, 16998, 16999, 17000, 17001, 17002, 17003, 17004, 17005, 17006, 17007, 17008, 17009, 17010, 17011, 17012, 17013, 17014, 17015, 17016, 17017, 17018, 17019, 17020, 17021, 17022, 17023, 17024, 17025, 17026, 17027, 17028, 17029, 17030, 17031, 17032, 17033, 17034, 17035, 17036, 17037, 17038, 17039, 17040, 17041, 17042, 17043, 17044, 17045, 17046, 17047, 17048, 17049, 17050, 17051, 17052, 17053, 17054, 17055, 17056, 17057, 17058, 17059, 17060, 17061, 17062, 17063, 17064, 17065, 17066, 17067, 17068, 17069, 17070, 17071, 17072, 17073, 17074, 17075, 17076, 17077, 17078, 17079, 17080, 17081, 17082, 17083, 17084, 17085, 17086, 17087, 17088, 17089, 17090, 17091, 17092, 17093, 17094, 17095, 17096, 17097, 17098, 17099, 17100, 17101, 17102, 17103, 17104, 17105, 17106, 17107, 17108, 17109, 17110, 17111, 17112, 17113, 17114, 17115, 17116, 17117, 17118, 17119, 17120, 17121, 17122, 17123, 17124, 17125, 17126, 17127, 17128, 17129, 17130, 17131, 17132, 17134, 17135, 17136, 17137, 17138, 17139, 17140, 17141, 17142, 17143, 17144, 17145, 17146, 17147, 17148, 17149, 17150, 17151, 17152, 17153, 17154, 17155, 17156, 17157, 17158, 17159, 17160, 17161, 17162, 17163, 17164, 17165, 17166, 17167, 17168, 17169, 17170, 17171, 17172, 17173, 17174, 17175, 17176, 17177, 17178, 17179, 17180, 17181, 17182, 17183, 17184, 17185, 17186, 17187, 17188, 17189, 17190, 17191, 17192, 17193, 17194, 17195, 17196, 17197, 17198, 17199, 17200, 17201, 17202, 17203, 17204, 17205, 17206, 17207, 17208, 17209, 17210, 17211, 17212, 17213, 17214, 17215, 17216, 17217, 17218, 17219, 17220, 17221, 17222, 17223, 17224, 17225, 17226, 17227, 17228, 17229, 17230, 17231, 17232, 17233, 17234, 17235, 17236, 17237, 17238, 17239, 17240, 17241, 17242, 17243, 17244, 17245, 17246, 17247, 17248, 17249, 17250, 17251, 17252, 17253, 17254, 17255, 17256, 17257, 17258, 17259, 17260, 17261, 17262, 17263, 17264, 17265, 17266, 17267, 17268, 17269, 17270, 17271, 17272, 17273, 17274, 17275, 17276, 17277, 17278, 17279, 17280, 17281, 17282, 17283, 17284, 17285, 17286, 17287, 17288, 17289, 17290, 17291, 17292, 17293, 17294, 17295, 17296, 17297, 17298, 17299, 17300, 17301, 17302, 17303, 17304, 17305, 17306, 17307, 17308, 17309, 17310, 17311, 17312, 17313, 17314, 17315, 17316, 17317, 17318, 17319, 17320, 17321, 17322, 17323, 17324, 17325, 17326, 17327, 17328, 17329, 17330, 17331, 17332, 17333, 17334, 17335, 17336, 17337, 17338, 17339, 17340, 17341, 17342, 17343, 17344, 17345, 17346, 17347, 17348, 17349, 17350, 17351, 17352, 17353, 17354, 17355, 17356, 17357, 17358, 17359, 17360, 17361, 17362, 17363, 17364, 17365, 17366, 17367, 17368, 17369, 17370, 18441, 18449, 18451, 18429, 18450, 18430, 18431, 18437, 29544, 29562, 29563, 29564, 29565, 29580, 29750, 29751, 29752, 29753, 29754, 29755, 29756, 29757, 29758, 29759, 29761, 29762, 29763, 29764, 29765, 29766, 29767, 29768, 29769, 29770, 29771, 29772, 29773, 29774, 29775, 29776, 29777, 29779, 29780, 29781, 29782, 29783, 29784, 29785, 29786, 29787, 29789, 29760, 29792, 29793, 29794, 29795, 29796, 29797, 29798, 29799, 29800, 29801, 29802, 30050, 30051, 30052, 30053, 30054, 30055, 30056, 30057, 30058, 30059, 30060, 30061, 30062, 30063, 30064, 30065, 30066, 30067, 30068, 30069, 30070, 30071, 30072, 30080, 30081, 30085, 30073, 30074, 30075, 30076, 30077, 30078, 30079, 30082, 30083, 30084, 30086, 30087, 30088, 30089, 30090, 30091, 30092, 30093, 30094, 29169, 29566, 29509, 29494, 29502, 29513, 29511, 29507, 29508, 29518, 29505, 29514, 29504, 29492, 29506, 29567, 29568, 29577, 29521, 29495, 29516, 29517, 29512, 29515, 29510, 29503, 29493, 18334, 30095, 30096, 30097, 30098, 30099, 30100, 30107, 30118, 30129, 30119, 30123, 30124, 30128, 30130, 30131, 30101, 30102, 30105, 30108, 30111, 30112, 30113, 30114, 30115, 30120, 30121, 30122, 30125, 30126, 30127, 30133, 30103, 30104, 30106, 30109, 30110, 30116, 30134, 30135, 30136, 30137, 30138, 30139, 30140, 30141, 30142, 30143, 30144, 30145, 30146, 30149, 30150, 30151, 30152, 30153, 30154, 30155, 30156, 30157, 30158, 30159, 30160, 30161, 30162, 30163, 30164, 30165, 30166, 30167, 30168, 30169, 30170, 30171, 30172, 30173, 30174, 30175, 30176, 30177, 30178, 30179, 30180, 30182, 30187, 30185, 30183, 30213, 30214, 30223, 30224, 30231, 30260, 30262, 30263, 30264, 30265, 30266, 30296, 30302, 30303, 30310, 30311, 30312, 30566, 30570, 30571, 30576, 30181, 30184, 30246, 30247, 30248, 30253, 30255, 30268, 30270, 30276, 30279, 30280, 30288, 30289, 30291, 30295, 30297, 30298, 30299, 30300, 30304, 30305, 30309, 30598, 30599, 30600, 30189, 30190, 30192, 30193, 30194, 30195, 30186, 30188, 30191, 30211, 30212, 30215, 30216, 30217, 30218, 30219, 30220, 30221, 30228, 30229, 30230, 30232, 30233, 30234, 30235, 30236, 30240, 30241, 30242, 30579, 30580, 30222, 30225, 30226, 30227, 30237, 30238, 30239, 30243, 30244, 30259, 30269, 30271, 30272, 30284, 30285, 30286, 30581, 30582, 30583, 30584, 30587, 30588, 30132, 30595, 30596, 30597, 30601, 30602, 30250, 30254, 30256, 30257, 30258, 30261, 30267, 30273, 30274, 30275, 30277, 30278, 30281, 30282, 30283, 30287, 30290, 30292, 30293, 30567, 30593, 30594, 30301, 30306, 30307, 30308, 30565, 30568, 30569, 30572, 30573, 30574, 30575, 30577, 30578, 30604, 30605, 30606, 30607, 30608, 30609, 30610, 30611, 30612, 30613, 30614, 30615, 30616, 30617, 30618, 30619, 30620, 30621, 30622, 30623, 30624, 30625, 30626, 30627, 30628, 30629, 30630, 30631, 30632, 30633, 30634, 30635, 30636, 30638, 30639, 30640, 30641, 30642, 30643, 30644, 30645, 30646, 30647, 30648, 30649, 30650, 30651, 30652, 30653, 30654, 30655, 30656, 30657, 30658, 30659, 30660, 30661, 30662, 30663, 30664, 30665, 30666, 30667, 30668, 30669, 30670, 30671, 30672, 30673, 30674, 30675, 30676, 30678, 30689, 30693, 30694, 30695, 30696, 30697, 30698, 30702, 30703, 30704, 30706, 30707, 30715, 30718, 30719, 30720, 30722, 30723, 30726, 30730, 30738, 30739, 30746, 30750, 30752, 30753, 30754, 30755, 30757, 30758, 30764, 30768, 30769, 30778, 30780, 30781, 30789, 30804, 30805, 30806, 31128, 31129, 31169, 31174, 31182, 31183, 31184, 31185, 31192, 31193, 31194, 31195, 31199, 31200, 31204, 31205, 31216, 31220, 31221, 31224, 31225, 31226, 31227, 31228, 31229, 31230, 31238, 31245, 30677, 30679, 30681, 30682, 30683, 30684, 30685, 30687, 30690, 30691, 30692, 30700, 30680, 30686, 30688, 30699, 30701, 31080, 31081, 31084, 31085, 31089, 31093, 31094, 31095, 31096, 31098, 31101, 31104, 31105, 31106, 31107, 31108, 31115, 31116, 31117, 31118, 31119, 31120, 31122, 31124, 31140, 31141, 31142, 31143, 31144, 31145, 31146, 31147, 31157, 30705, 30708, 30709, 30710, 30711, 30712, 30713, 30714, 30716, 30717, 30721, 30724, 30725, 30727, 30728, 30729, 30731, 30732, 30733, 30734, 30735, 30736, 30737, 30740, 30741, 30742, 30743, 30744, 30745, 30747, 30748, 30751, 30756, 30759, 30760, 30761, 30762, 30763, 30765, 30766, 30767, 30770, 30771, 30772, 30773, 30774, 30775, 30776, 30777, 30779, 30782, 30783, 30784, 30785, 30786, 30787, 30788, 30790, 30791, 30792, 30793, 30794, 30795, 30796, 30797, 30798, 30802, 30803, 30807, 30808, 31079, 31082, 31083, 31086, 31209, 31223, 31087, 31088, 31090, 31091, 31092, 31097, 31099, 31100, 31102, 31103, 31109, 31110, 31111, 31112, 31113, 31114, 31121, 31123, 31125, 31126, 31127, 30749, 31133, 31134, 31135, 31136, 31137, 31138, 31139, 31148, 31149, 31150, 31151, 31152, 31153, 31154, 31155, 31156, 31158, 31159, 31160, 31161, 31162, 31163, 31164, 31165, 31166, 31167, 31171, 31172, 31173, 31175, 31176, 31177, 31178, 31179, 31180, 31181, 31186, 31187, 31188, 31189, 31190, 31191, 31196, 31197, 31198, 31201, 31202, 31203, 31206, 31207, 31208, 31222, 31210, 31211, 31212, 31213, 31214, 31215, 31217, 31218, 31219, 31243, 31244, 31231, 31232, 31233, 31234, 31235, 31236, 31237, 31239, 31240, 31241, 31242, 31246, 31248, 31251, 31247, 31249, 31250, 31252, 31253, 31254, 31255, 31256, 31257, 31258, 31259, 31260, 31261, 31262, 31263, 31264, 31271, 31272, 31273, 31274, 31290, 31291, 31293, 31311, 31312, 31314, 31315, 31317, 31265, 31266, 31267, 31268, 31269, 31270, 31275, 31276, 31277, 31278, 31279, 31280, 31281, 31282, 31283, 31284, 31285, 31286, 31292, 31287, 31288, 31289, 31295, 31296, 31297, 31300, 31301, 31589, 31590, 31591, 31294, 31298, 31299, 31302, 31303, 31304, 31305, 31306, 31307, 31308, 31309, 31310, 31313, 31318, 31319, 31320, 31321, 31322, 31323, 31324, 31576, 31577, 31578, 31579, 31586, 31587, 31588, 31592, 31593, 31629, 31630, 31631, 31632, 31633, 31634, 31635, 31636, 31637, 31638, 31639, 31640, 31641, 31642, 31643, 31644, 31645, 31646, 31649, 31647, 31648, 31656, 31659, 31660, 31662, 31666, 31667, 31669, 31670, 31671, 31678, 31679, 31680, 31681, 31685, 31686, 31687, 31688, 31689, 31690, 31691, 31692, 31693, 31694, 31695, 31696, 31699, 31701, 31702, 31707, 31708, 31717, 31722, 31723, 31724, 31725, 31726, 31728, 31729, 31650, 31657, 31658, 31668, 31682, 31683, 31684, 31697, 31698, 31700, 31710, 31711, 31712, 31713, 31651, 31652, 31653, 31654, 31655, 31703, 31704, 31705, 31706, 31709, 31718, 31719, 31720, 31721, 31727, 31730, 31810, 31811, 31813, 31814, 31815, 31816, 31817, 31818, 31819, 31820, 31821, 31822, 31823, 31824, 31825, 31826, 31827, 31828, 31829, 31830, 31831, 31832, 31833, 31834, 31835, 31836, 31837, 31838, 31839, 31840, 31841, 31842, 31843, 31844, 31845, 31846, 31847, 31848, 31849, 31850, 31851, 31852, 31853, 31854, 31856, 31870, 31875, 31876, 31880, 31882, 31916, 31919, 31921, 31855, 31860, 31865, 31867, 31868, 31869, 31877, 31878, 31879, 31891, 31892, 31894, 31897, 31898, 31901, 31902, 31907, 31911, 31857, 31812, 31866, 31871, 31872, 31873, 31874, 31881, 31883, 31884, 31885, 31886, 31887, 31888, 31889, 31890, 31893, 31895, 31896, 31899, 31900, 31903, 31904, 31905, 31906, 31908, 31909, 31910, 31912, 31913, 31914, 31915, 31918, 31920, 31922, 31923, 31924, 31925, 31926, 31927, 31928, 31929, 31931, 31932, 31933, 31934, 31935, 31936, 31937, 31938, 31939, 31940, 31941, 31942, 31943, 31944, 31945, 31946, 31947, 31948, 31949, 31950, 31951, 31952, 31953, 31954, 31955, 31956, 31957, 31958, 31959, 31960, 31961, 31962, 31963, 31964, 31965, 31966, 31967, 31968, 31969, 31970, 31971, 31972, 31973, 31974, 31975, 31976, 31977, 31978, 31983, 31986, 31987, 31989, 31995, 32001, 32002, 32006, 32014, 32015, 31979, 31984, 31985, 31988, 31994, 32004, 32009, 32010, 32011, 32012, 31980, 31981, 31982, 31990, 31991, 31992, 31993, 31996, 31997, 31998, 31999, 32000, 32003, 32008, 32013, 32007, 32005, 31917, 32078, 32079, 32081, 32082, 32083, 32084, 32085, 32086, 32087, 32091, 32095, 32096, 32097, 32098, 32100, 32099, 32104, 32105, 32106, 32107, 32108, 32111, 32112, 32113, 32114, 32115, 31170, 32016, 32017, 32018, 32019, 30637, 32116, 32117, 32118, 32119, 32121, 32122, 32123, 32124, 32120, 32134, 32135, 32136, 32137]
|
|
|
|
|
|
artifacts/vidul/BGE_FT_VA/config.json
DELETED
|
@@ -1,31 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"architectures": [
|
| 3 |
-
"BertModel"
|
| 4 |
-
],
|
| 5 |
-
"attention_probs_dropout_prob": 0.1,
|
| 6 |
-
"classifier_dropout": null,
|
| 7 |
-
"dtype": "float16",
|
| 8 |
-
"gradient_checkpointing": false,
|
| 9 |
-
"hidden_act": "gelu",
|
| 10 |
-
"hidden_dropout_prob": 0.1,
|
| 11 |
-
"hidden_size": 1024,
|
| 12 |
-
"id2label": {
|
| 13 |
-
"0": "LABEL_0"
|
| 14 |
-
},
|
| 15 |
-
"initializer_range": 0.02,
|
| 16 |
-
"intermediate_size": 4096,
|
| 17 |
-
"label2id": {
|
| 18 |
-
"LABEL_0": 0
|
| 19 |
-
},
|
| 20 |
-
"layer_norm_eps": 1e-12,
|
| 21 |
-
"max_position_embeddings": 512,
|
| 22 |
-
"model_type": "bert",
|
| 23 |
-
"num_attention_heads": 16,
|
| 24 |
-
"num_hidden_layers": 24,
|
| 25 |
-
"pad_token_id": 0,
|
| 26 |
-
"position_embedding_type": "absolute",
|
| 27 |
-
"transformers_version": "4.56.1",
|
| 28 |
-
"type_vocab_size": 2,
|
| 29 |
-
"use_cache": true,
|
| 30 |
-
"vocab_size": 30522
|
| 31 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""ENCODE Part A backend package."""
|
src/backend/app.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app for CPU-only, fine-tuned dense retrieval."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import csv
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import Body, FastAPI, HTTPException
|
| 13 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 14 |
+
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
|
| 16 |
+
_NO_CACHE = "no-cache, must-revalidate"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class NoCacheStaticFiles(StaticFiles):
|
| 20 |
+
"""Serve the vanilla frontend with revalidation so a rebuilt app.js/styles.css
|
| 21 |
+
is never served stale from the browser cache (bit us during development)."""
|
| 22 |
+
|
| 23 |
+
async def get_response(self, path, scope):
|
| 24 |
+
resp = await super().get_response(path, scope)
|
| 25 |
+
resp.headers["Cache-Control"] = _NO_CACHE
|
| 26 |
+
return resp
|
| 27 |
+
|
| 28 |
+
from .codesearch import CodeSearchEngine, make_embedder
|
| 29 |
+
from .encode import EncodeEngine
|
| 30 |
+
from .graph import KnowledgeGraph
|
| 31 |
+
from .retriever import DenseEmbedder
|
| 32 |
+
|
| 33 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 34 |
+
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
| 35 |
+
ANNOT = Path(os.environ.get("ENCODE_ANNOT_DIR", ROOT / "artifacts" / "annotations"))
|
| 36 |
+
|
| 37 |
+
app = FastAPI(title="ENCODE — Embedding Empowered Code Search")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@lru_cache(maxsize=1)
|
| 41 |
+
def _embedder() -> DenseEmbedder:
|
| 42 |
+
return make_embedder()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@lru_cache(maxsize=1)
|
| 46 |
+
def codes() -> CodeSearchEngine:
|
| 47 |
+
return CodeSearchEngine(_embedder())
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@lru_cache(maxsize=1)
|
| 51 |
+
def engine() -> EncodeEngine:
|
| 52 |
+
# Phenotype embeddings use the same BGE_FT_VA weights with their own recorded
|
| 53 |
+
# pooling/prefix convention. No alternate model or second stage is loaded.
|
| 54 |
+
return EncodeEngine()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@lru_cache(maxsize=1)
|
| 58 |
+
def graph() -> KnowledgeGraph:
|
| 59 |
+
return KnowledgeGraph(diagnosis_records=codes().records("diagnosis"),
|
| 60 |
+
procedure_records=codes().records("procedure"))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.on_event("startup")
|
| 64 |
+
def _warm() -> None:
|
| 65 |
+
codes() # load the fine-tuned model; FAISS indexes stay lazy by category
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.get("/healthz")
|
| 69 |
+
def health() -> dict:
|
| 70 |
+
return {"status": "ok", "device": _embedder().device}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# -- code search (primary) -------------------------------------------------
|
| 74 |
+
|
| 75 |
+
@app.get("/api/code/categories")
|
| 76 |
+
def code_categories() -> dict:
|
| 77 |
+
return {"categories": codes().categories()}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.get("/api/code/search")
|
| 81 |
+
def code_search(category: str, q: str, k: int = 50) -> dict:
|
| 82 |
+
if not q.strip():
|
| 83 |
+
raise HTTPException(400, "Empty query")
|
| 84 |
+
try:
|
| 85 |
+
return codes().search(category, q, k=min(max(k, 1), 100))
|
| 86 |
+
except KeyError:
|
| 87 |
+
raise HTTPException(404, f"Unknown category '{category}'")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/api/code/export")
|
| 91 |
+
def code_export(category: str, q: str, k: int = 50):
|
| 92 |
+
try:
|
| 93 |
+
data = codes().search(category, q, k=min(max(k, 1), 100))
|
| 94 |
+
except KeyError:
|
| 95 |
+
raise HTTPException(404, f"Unknown category '{category}'")
|
| 96 |
+
buf = io.StringIO()
|
| 97 |
+
w = csv.writer(buf)
|
| 98 |
+
w.writerow(["rank", "code_type", "code", "description", "relevance"])
|
| 99 |
+
for r in data["results"]:
|
| 100 |
+
w.writerow([r["rank"], r["code_type"], r["code"], r["description"], r["relevance"]])
|
| 101 |
+
buf.seek(0)
|
| 102 |
+
fname = f"encode_{category}_{q.strip().replace(' ', '_')[:30]}.csv"
|
| 103 |
+
return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv",
|
| 104 |
+
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.post("/api/code/annotations")
|
| 108 |
+
def code_annotations(payload: dict = Body(...)) -> dict:
|
| 109 |
+
records = payload.get("annotations", [])
|
| 110 |
+
if not records:
|
| 111 |
+
raise HTTPException(400, "No annotations")
|
| 112 |
+
ANNOT.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
row = {"annotator": (payload.get("annotator") or "anonymous").strip(),
|
| 114 |
+
"category": payload.get("category"), "query": payload.get("query"),
|
| 115 |
+
"annotations": records}
|
| 116 |
+
with (ANNOT / "code_annotations.jsonl").open("a") as fh:
|
| 117 |
+
fh.write(json.dumps(row) + "\n")
|
| 118 |
+
return {"saved": len(records), "annotator": row["annotator"]}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# -- knowledge graph (click a code -> parent/child ontology) ---------------
|
| 122 |
+
|
| 123 |
+
@app.get("/api/graph")
|
| 124 |
+
def code_graph(code: str, code_type: str | None = None, drug_name: str | None = None) -> dict:
|
| 125 |
+
if not code.strip():
|
| 126 |
+
raise HTTPException(400, "Empty code")
|
| 127 |
+
return graph().neighbors(code, code_type, drug_name)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# -- phenotype discovery ---------------------------------------------------
|
| 131 |
+
|
| 132 |
+
def _cats(categories: str | None) -> set[str] | None:
|
| 133 |
+
return {c for c in categories.split(",") if c} if categories else None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@app.get("/api/categories")
|
| 137 |
+
def categories() -> dict:
|
| 138 |
+
return {"categories": engine().categories()}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@app.get("/api/search")
|
| 142 |
+
def search(q: str, k: int = 10, categories: str | None = None, validated_only: bool = False) -> dict:
|
| 143 |
+
if not q.strip():
|
| 144 |
+
raise HTTPException(400, "Empty query")
|
| 145 |
+
return engine().search(q, k=min(max(k, 1), 50),
|
| 146 |
+
categories=_cats(categories), validated_only=validated_only)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@app.get("/api/export")
|
| 150 |
+
def export(q: str, k: int = 10, categories: str | None = None, validated_only: bool = False):
|
| 151 |
+
data = engine().search(q, k=min(max(k, 1), 50),
|
| 152 |
+
categories=_cats(categories), validated_only=validated_only)
|
| 153 |
+
buf = io.StringIO()
|
| 154 |
+
w = csv.writer(buf)
|
| 155 |
+
w.writerow(["rank", "phenotype_id", "title", "category", "validated", "relevance", "code_systems"])
|
| 156 |
+
for i, r in enumerate(data["results"], 1):
|
| 157 |
+
w.writerow([i, r["phenotype_id"], r["title"], r["category"], r["validated"],
|
| 158 |
+
r["scores"]["relevance"], "; ".join(r["code_systems"])])
|
| 159 |
+
buf.seek(0)
|
| 160 |
+
fname = f"encode_phenotype_{q.strip().replace(' ', '_')[:30]}.csv"
|
| 161 |
+
return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv",
|
| 162 |
+
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@app.get("/api/phenotype/{pid}")
|
| 166 |
+
def phenotype(pid: int) -> dict:
|
| 167 |
+
detail = engine().phenotype(pid)
|
| 168 |
+
if detail is None:
|
| 169 |
+
raise HTTPException(404, "Phenotype not found")
|
| 170 |
+
return detail
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@app.get("/api/phenotype/{pid}/graph")
|
| 174 |
+
def phenotype_graph(pid: int, focus: str | None = None) -> dict:
|
| 175 |
+
g = engine().phenotype_code_graph(pid, focus=focus)
|
| 176 |
+
if g is None:
|
| 177 |
+
raise HTTPException(404, "Phenotype not found")
|
| 178 |
+
return g
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@app.post("/api/annotations")
|
| 182 |
+
def annotations(payload: dict = Body(...)) -> dict:
|
| 183 |
+
"""Persist phenotype-level relevance labels (the Part A evaluation gold set)."""
|
| 184 |
+
records = payload.get("annotations", [])
|
| 185 |
+
if not records:
|
| 186 |
+
raise HTTPException(400, "No annotations")
|
| 187 |
+
ANNOT.mkdir(parents=True, exist_ok=True)
|
| 188 |
+
row = {"annotator": (payload.get("annotator") or "anonymous").strip(),
|
| 189 |
+
"query": payload.get("query"), "annotations": records}
|
| 190 |
+
with (ANNOT / "query_phenotype_gold.jsonl").open("a") as fh:
|
| 191 |
+
fh.write(json.dumps(row) + "\n")
|
| 192 |
+
return {"saved": len(records), "annotator": row["annotator"]}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.get("/")
|
| 196 |
+
def index() -> FileResponse:
|
| 197 |
+
return FileResponse(FRONTEND / "index.html", headers={"Cache-Control": _NO_CACHE})
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
app.mount("/", NoCacheStaticFiles(directory=FRONTEND), name="static")
|
src/backend/codesearch.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vidul's fine-tuned dense retrieval for medical-code search.
|
| 2 |
+
|
| 3 |
+
The runtime uses the original BGE_FT_VA model and FAISS indexes with no score
|
| 4 |
+
blend or fallback model. Indexes are loaded lazily by category so a CPU Space
|
| 5 |
+
does not pay the memory cost for categories nobody has queried.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import csv
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import faiss
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from .retriever import DenseEmbedder
|
| 19 |
+
|
| 20 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 21 |
+
BASE = Path(os.environ.get("ENCODE_VIDUL_ROOT", ROOT / "artifacts" / "vidul"))
|
| 22 |
+
MODEL_DIR = BASE / "BGE_FT_VA"
|
| 23 |
+
|
| 24 |
+
CATEGORY_INDEXES = {
|
| 25 |
+
"diagnosis": [("icd_index", None)],
|
| 26 |
+
"medication": [("med_index", "Local Drug SID"),
|
| 27 |
+
("ndc_index", "NDC Package Code")],
|
| 28 |
+
"lab": [("labchem_index", "LabChemTestSID")],
|
| 29 |
+
"procedure": [("cpt_index", "CPT")],
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def make_embedder() -> DenseEmbedder:
|
| 34 |
+
"""Reproduce Vidul's SentenceTransformer query convention on CPU."""
|
| 35 |
+
if not MODEL_DIR.exists():
|
| 36 |
+
raise RuntimeError(f"Missing Vidul fine-tuned model: {MODEL_DIR}")
|
| 37 |
+
return DenseEmbedder(str(MODEL_DIR), pooling="mean", query_prefix="passage: ")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class CodeSearchEngine:
|
| 41 |
+
def __init__(self, embedder: DenseEmbedder):
|
| 42 |
+
self.embedder = embedder
|
| 43 |
+
self._loaded: dict[str, list[dict]] = {}
|
| 44 |
+
missing = [name for specs in CATEGORY_INDEXES.values() for name, _ in specs
|
| 45 |
+
if not self._complete_index(BASE / name)]
|
| 46 |
+
if missing:
|
| 47 |
+
raise RuntimeError(
|
| 48 |
+
f"Missing Vidul FAISS index assets under {BASE}: {', '.join(missing)}")
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def _complete_index(directory: Path) -> bool:
|
| 52 |
+
return all((directory / name).exists()
|
| 53 |
+
for name in ("index.faiss", "meta.csv", "config.json"))
|
| 54 |
+
|
| 55 |
+
def categories(self) -> list[str]:
|
| 56 |
+
return list(CATEGORY_INDEXES)
|
| 57 |
+
|
| 58 |
+
def _load_category(self, category: str) -> list[dict]:
|
| 59 |
+
if category not in CATEGORY_INDEXES:
|
| 60 |
+
raise KeyError(category)
|
| 61 |
+
if category in self._loaded:
|
| 62 |
+
return self._loaded[category]
|
| 63 |
+
|
| 64 |
+
loaded = []
|
| 65 |
+
for directory_name, fixed_code_type in CATEGORY_INDEXES[category]:
|
| 66 |
+
directory = BASE / directory_name
|
| 67 |
+
config = json.loads((directory / "config.json").read_text())
|
| 68 |
+
if config.get("model") != "BGE_FT_VA" or config.get("dim") != 1024:
|
| 69 |
+
raise RuntimeError(f"Unexpected retrieval config: {directory / 'config.json'}")
|
| 70 |
+
index = faiss.read_index(str(directory / "index.faiss"))
|
| 71 |
+
with (directory / "meta.csv").open(newline="", encoding="utf-8") as handle:
|
| 72 |
+
records = list(csv.DictReader(handle))
|
| 73 |
+
if index.ntotal != len(records):
|
| 74 |
+
raise RuntimeError(
|
| 75 |
+
f"Index/metadata mismatch in {directory}: {index.ntotal} != {len(records)}")
|
| 76 |
+
loaded.append({"index": index, "records": records,
|
| 77 |
+
"code_type": fixed_code_type})
|
| 78 |
+
self._loaded[category] = loaded
|
| 79 |
+
return loaded
|
| 80 |
+
|
| 81 |
+
def records(self, category: str) -> list[dict]:
|
| 82 |
+
"""Metadata rows in the shape consumed by the optional graph view."""
|
| 83 |
+
rows = []
|
| 84 |
+
for source in self._load_category(category):
|
| 85 |
+
for record in source["records"]:
|
| 86 |
+
rows.append({"code": record["code"],
|
| 87 |
+
"description": record["description"],
|
| 88 |
+
"code_type": source["code_type"] or record.get("version", "")})
|
| 89 |
+
return rows
|
| 90 |
+
|
| 91 |
+
def search(self, category: str, query: str, k: int = 50) -> dict:
|
| 92 |
+
sources = self._load_category(category)
|
| 93 |
+
query_vector = self.embedder.encode([query.strip()]).astype(np.float32)
|
| 94 |
+
candidates = []
|
| 95 |
+
for source in sources:
|
| 96 |
+
scores, indices = source["index"].search(query_vector, k)
|
| 97 |
+
for score, index_position in zip(scores[0], indices[0]):
|
| 98 |
+
if index_position < 0:
|
| 99 |
+
continue
|
| 100 |
+
record = source["records"][int(index_position)]
|
| 101 |
+
code_type = source["code_type"] or record.get("version", "Diagnosis")
|
| 102 |
+
candidates.append({
|
| 103 |
+
"code": record["code"],
|
| 104 |
+
"code_type": code_type,
|
| 105 |
+
"description": record["description"],
|
| 106 |
+
"_score": float(np.clip(score, -1.0, 1.0)),
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
candidates.sort(key=lambda row: row["_score"], reverse=True)
|
| 110 |
+
results = candidates[:k]
|
| 111 |
+
for rank, result in enumerate(results, 1):
|
| 112 |
+
result["relevance"] = round(result.pop("_score"), 4)
|
| 113 |
+
result["rank"] = rank
|
| 114 |
+
return {
|
| 115 |
+
"query": query,
|
| 116 |
+
"category": category,
|
| 117 |
+
"total": sum(source["index"].ntotal for source in sources),
|
| 118 |
+
"count": len(results),
|
| 119 |
+
"model": "BGE_FT_VA",
|
| 120 |
+
"results": results,
|
| 121 |
+
}
|
src/backend/encode.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Natural-language request to ranked CIPHER phenotypes.
|
| 2 |
+
|
| 3 |
+
Dense, two-surface retrieval over prebuilt Vidul BGE_FT_VA embeddings:
|
| 4 |
+
|
| 5 |
+
- phenotype_metadata surface -> phenotype-level semantic similarity
|
| 6 |
+
- code_evidence surface -> code-level supporting evidence (max over chunks)
|
| 7 |
+
|
| 8 |
+
A phenotype is a document, not a bag of codes. Each candidate carries both channel
|
| 9 |
+
scores separately, a grounded summary drawn strictly from source metadata, the
|
| 10 |
+
resolved code set and source-grounded metadata. Ranking is fine-tuned dense
|
| 11 |
+
retrieval only.
|
| 12 |
+
|
| 13 |
+
No fallback: the embeddings index and the model must exist, or startup fails.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
from .retriever import DenseEmbedder
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 28 |
+
CANON = ROOT / "artifacts" / "part_a_canonical"
|
| 29 |
+
# The config beside the vectors records the query encoding convention.
|
| 30 |
+
EMB = Path(os.environ.get("ENCODE_PARTA_EMB_DIR", ROOT / "artifacts" / "part_a_embeddings"))
|
| 31 |
+
# Optional, source-grounded detail sidecars.
|
| 32 |
+
CODE_DESC = CANON / "code_descriptions.jsonl"
|
| 33 |
+
ALGO_COMPONENTS = CANON / "algorithm_components.jsonl"
|
| 34 |
+
|
| 35 |
+
# Algorithm-component fields to surface, in display order (professor 1.4:
|
| 36 |
+
# "refer to CIPHER algorithm components"). Only fields present for a phenotype
|
| 37 |
+
# are shown; the rest are omitted (never fabricated).
|
| 38 |
+
_ALGO_FIELDS = [
|
| 39 |
+
("methods_used", "Methods"),
|
| 40 |
+
("related_diseases", "Related diseases"),
|
| 41 |
+
("data_period", "Data period"),
|
| 42 |
+
("validations", "Validation"),
|
| 43 |
+
("adjudication_method", "Adjudication"),
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
# CIPHER public phenotype library. Detail page:
|
| 47 |
+
# /web/cipher/phenotype-viewer?uqid={uqid}&name={slug}
|
| 48 |
+
# where slug = the full name with every non-alphanumeric char replaced by '_'.
|
| 49 |
+
# uqid comes from the cipher_links sidecar (present for ~79% of phenotypes);
|
| 50 |
+
# the rest fall back to the library root. Domain overridable for other deployments.
|
| 51 |
+
CIPHER_BASE = os.environ.get("ENCODE_CIPHER_BASE", "https://phenomics.va.ornl.gov").rstrip("/")
|
| 52 |
+
CIPHER_LINKS = CANON / "cipher_links.jsonl"
|
| 53 |
+
|
| 54 |
+
# Friendly stand-ins so a still-unresolved code never shows the raw
|
| 55 |
+
# 'unsupported_code_system' token (professor feedback 1.1). (label, source)
|
| 56 |
+
_UNRESOLVED_NOTE = {
|
| 57 |
+
466: ("Medication code", "needs_vocab:RxNorm/NDC"),
|
| 58 |
+
467: ("LOINC lab code", "needs_vocab:LOINC"),
|
| 59 |
+
471: ("OMOP concept ID", "needs_vocab:OMOP"),
|
| 60 |
+
465: ("VA clinic stop code", "needs_vocab:VA_STOP"),
|
| 61 |
+
468: ("Study-defined variable", "study_specific"),
|
| 62 |
+
519: ("Study-defined variable", "study_specific"),
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
class EncodeEngine:
|
| 66 |
+
def __init__(self, embedder: DenseEmbedder | None = None, emb_dir: Path | str | None = None):
|
| 67 |
+
emb = Path(emb_dir) if emb_dir else EMB
|
| 68 |
+
self.emb_dir = emb
|
| 69 |
+
if not (emb / "config.json").exists():
|
| 70 |
+
raise RuntimeError(f"Missing runtime phenotype embeddings: {emb}")
|
| 71 |
+
self.config = json.loads((emb / "config.json").read_text())
|
| 72 |
+
|
| 73 |
+
self.phenotypes = {}
|
| 74 |
+
for line in (CANON / "canonical_phenotypes.jsonl").open():
|
| 75 |
+
r = json.loads(line)
|
| 76 |
+
self.phenotypes[r["phenotype_id"]] = r
|
| 77 |
+
|
| 78 |
+
# Metadata surface: one row per phenotype, aligned to meta_ids.
|
| 79 |
+
self.meta_ids: list[int] = json.loads((emb / "metadata_ids.json").read_text())
|
| 80 |
+
self.meta_mat = np.load(emb / "metadata.npy").astype(np.float32)
|
| 81 |
+
self._row = {pid: i for i, pid in enumerate(self.meta_ids)}
|
| 82 |
+
|
| 83 |
+
# Code surface: many rows per phenotype; map each to its phenotype row.
|
| 84 |
+
code_meta = json.loads((emb / "code_meta.json").read_text())
|
| 85 |
+
self.code_mat = np.load(emb / "code.npy").astype(np.float32)
|
| 86 |
+
self._code_row = np.array([self._row.get(m["phenotype_id"], -1) for m in code_meta])
|
| 87 |
+
|
| 88 |
+
# Per-phenotype filters aligned to meta_ids.
|
| 89 |
+
self._cat = np.array([self.phenotypes[p].get("category") or "(none)" for p in self.meta_ids])
|
| 90 |
+
self._validated = np.array([bool(self.phenotypes[p].get("validated")) for p in self.meta_ids])
|
| 91 |
+
|
| 92 |
+
# Part B: resolved code descriptions + algorithm components (optional sidecars).
|
| 93 |
+
self.code_desc = {}
|
| 94 |
+
if CODE_DESC.exists():
|
| 95 |
+
for line in CODE_DESC.open():
|
| 96 |
+
r = json.loads(line)
|
| 97 |
+
self.code_desc[f"{r['code_type_id']}|{r['sub_type_id']}|{(r['code'] or '').upper()}"] = r
|
| 98 |
+
self.cipher_uqid = {}
|
| 99 |
+
if CIPHER_LINKS.exists():
|
| 100 |
+
for line in CIPHER_LINKS.open():
|
| 101 |
+
r = json.loads(line)
|
| 102 |
+
self.cipher_uqid[r["phenotype_id"]] = r["uqid"]
|
| 103 |
+
|
| 104 |
+
# Extracted CIPHER algorithm components (methods / related diseases /
|
| 105 |
+
# validation / adjudication / data period); one record per phenotype.
|
| 106 |
+
self.algo_components = {}
|
| 107 |
+
if ALGO_COMPONENTS.exists():
|
| 108 |
+
for line in ALGO_COMPONENTS.open():
|
| 109 |
+
r = json.loads(line)
|
| 110 |
+
self.algo_components[r["phenotype_id"]] = r
|
| 111 |
+
|
| 112 |
+
# Query embedder must match how the documents were encoded (model +
|
| 113 |
+
# pooling + prefix + max-seq all travel in config.json).
|
| 114 |
+
model_name = os.environ.get("ENCODE_PARTA_MODEL", self.config["model"])
|
| 115 |
+
self.model_name = model_name
|
| 116 |
+
self.embedder = embedder or DenseEmbedder(
|
| 117 |
+
model_name,
|
| 118 |
+
pooling=self.config.get("pooling", "default"),
|
| 119 |
+
query_prefix=self.config.get("query_prefix", ""),
|
| 120 |
+
max_seq_length=self.config.get("max_seq_length"))
|
| 121 |
+
|
| 122 |
+
def categories(self) -> list[str]:
|
| 123 |
+
return sorted(set(self._cat.tolist()))
|
| 124 |
+
|
| 125 |
+
# -- retrieval ---------------------------------------------------------
|
| 126 |
+
def search(self, query: str, k: int = 10, categories: set[str] | None = None,
|
| 127 |
+
validated_only: bool = False) -> dict:
|
| 128 |
+
q = self.embedder.encode([query])[0]
|
| 129 |
+
meta_score = self.meta_mat @ q # (n_pheno,)
|
| 130 |
+
|
| 131 |
+
# Max code-evidence similarity aggregated to phenotype level.
|
| 132 |
+
code_sims = self.code_mat @ q # (n_code,)
|
| 133 |
+
code_score = np.zeros(len(self.meta_ids), dtype=np.float32)
|
| 134 |
+
valid = self._code_row >= 0
|
| 135 |
+
np.maximum.at(code_score, self._code_row[valid], code_sims[valid])
|
| 136 |
+
|
| 137 |
+
retrieval = 0.5 * meta_score + 0.5 * code_score # bi-encoder candidate score
|
| 138 |
+
|
| 139 |
+
mask = np.ones(len(self.meta_ids), dtype=bool)
|
| 140 |
+
if categories:
|
| 141 |
+
mask &= np.isin(self._cat, list(categories))
|
| 142 |
+
if validated_only:
|
| 143 |
+
mask &= self._validated
|
| 144 |
+
|
| 145 |
+
candidates = np.flatnonzero(mask)
|
| 146 |
+
results = []
|
| 147 |
+
if len(candidates):
|
| 148 |
+
candidate_scores = retrieval[candidates]
|
| 149 |
+
for j in np.argsort(candidate_scores)[::-1][:k]:
|
| 150 |
+
i = candidates[j]
|
| 151 |
+
results.append(self._result(self.meta_ids[i], float(meta_score[i]),
|
| 152 |
+
float(code_score[i]),
|
| 153 |
+
float(candidate_scores[j])))
|
| 154 |
+
return {
|
| 155 |
+
"query": query,
|
| 156 |
+
"model": self.model_name,
|
| 157 |
+
"count": len(results),
|
| 158 |
+
"results": results,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def _result(self, pid, m, c, relevance) -> dict:
|
| 162 |
+
p = self.phenotypes[pid]
|
| 163 |
+
evidence = [
|
| 164 |
+
{"code_system": g.get("code_type_label"),
|
| 165 |
+
"code_count": g.get("code_count") or len(g.get("codes", [])),
|
| 166 |
+
"samples": [x.get("code") for x in g.get("codes", [])[:6]]}
|
| 167 |
+
for g in p.get("associated_code_groups", [])
|
| 168 |
+
]
|
| 169 |
+
return {
|
| 170 |
+
"phenotype_id": pid,
|
| 171 |
+
"title": p.get("title"),
|
| 172 |
+
"category": p.get("category"),
|
| 173 |
+
"validated": p.get("validated"),
|
| 174 |
+
"summary": self._summary(p),
|
| 175 |
+
"keywords": p.get("keywords") or [],
|
| 176 |
+
"scores": {"relevance": round(relevance, 4),
|
| 177 |
+
"metadata": round(m, 4), "code_evidence": round(c, 4)},
|
| 178 |
+
"code_systems": [e["code_system"] for e in evidence],
|
| 179 |
+
"code_evidence": evidence,
|
| 180 |
+
"warnings": [],
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
@staticmethod
|
| 184 |
+
def _summary(p: dict) -> str:
|
| 185 |
+
"""Grounded summary drawn strictly from source metadata (no generation)."""
|
| 186 |
+
desc = (p.get("description") or p.get("algorithm_description") or "").strip()
|
| 187 |
+
if len(desc) > 360:
|
| 188 |
+
desc = desc[:360].rsplit(" ", 1)[0] + "…"
|
| 189 |
+
return desc
|
| 190 |
+
|
| 191 |
+
def _algo_component_rows(self, pid: int) -> list[dict]:
|
| 192 |
+
"""Source-grounded algorithm components as ordered (label, value) rows;
|
| 193 |
+
omit absent fields (never fabricate). Lists are joined for display."""
|
| 194 |
+
rec = self.algo_components.get(pid)
|
| 195 |
+
if not rec:
|
| 196 |
+
return []
|
| 197 |
+
rows = []
|
| 198 |
+
for key, label in _ALGO_FIELDS:
|
| 199 |
+
val = rec.get(key)
|
| 200 |
+
if not val:
|
| 201 |
+
continue
|
| 202 |
+
if isinstance(val, list):
|
| 203 |
+
text = ", ".join(map(str, val))
|
| 204 |
+
elif isinstance(val, dict):
|
| 205 |
+
text = (f"{val.get('start', '?')} to {val.get('end', '?')}"
|
| 206 |
+
if ("start" in val or "end" in val)
|
| 207 |
+
else ", ".join(f"{k}: {v}" for k, v in val.items()))
|
| 208 |
+
else:
|
| 209 |
+
text = str(val)
|
| 210 |
+
rows.append({"label": label, "value": text})
|
| 211 |
+
return rows
|
| 212 |
+
|
| 213 |
+
def _cipher_url(self, pid: int, title: str | None) -> str:
|
| 214 |
+
uqid = self.cipher_uqid.get(pid)
|
| 215 |
+
if uqid:
|
| 216 |
+
slug = re.sub(r"[^A-Za-z0-9]", "_", title or "")
|
| 217 |
+
return f"{CIPHER_BASE}/web/cipher/phenotype-viewer?uqid={uqid}&name={slug}"
|
| 218 |
+
return CIPHER_BASE + "/"
|
| 219 |
+
|
| 220 |
+
def _code_display(self, code_type_id, sub_type_id, entry: dict) -> tuple[str | None, str]:
|
| 221 |
+
"""Resolve a code to (description, source), replacing 'unsupported_code_system'
|
| 222 |
+
with a real description where one exists and an honest note otherwise."""
|
| 223 |
+
status = entry.get("label_status")
|
| 224 |
+
labels = entry.get("labels") or []
|
| 225 |
+
if status in ("exact", "prefix_expanded") and labels:
|
| 226 |
+
return labels[0], status
|
| 227 |
+
if status == "unsupported_code_system":
|
| 228 |
+
hit = self.code_desc.get(
|
| 229 |
+
f"{code_type_id}|{sub_type_id}|{(entry.get('code') or '').upper()}")
|
| 230 |
+
if hit:
|
| 231 |
+
return hit["description"], hit["source"]
|
| 232 |
+
return _UNRESOLVED_NOTE.get(code_type_id, ("No standard description", "unresolved"))
|
| 233 |
+
return (labels[0] if labels else None), status
|
| 234 |
+
|
| 235 |
+
# -- detail ------------------------------------------------------------
|
| 236 |
+
def _code_graphable(self, ct: int | None, code: str | None) -> bool:
|
| 237 |
+
"""Does this specific code have a knowledge-graph? ICD diagnosis (460/461)
|
| 238 |
+
resolves to an ICD family; medication terms (466) can use a packaged map
|
| 239 |
+
or a conservative RxNAV lookup when the user opens the graph."""
|
| 240 |
+
if ct in (460, 461):
|
| 241 |
+
return True
|
| 242 |
+
if ct == 466:
|
| 243 |
+
return bool((code or "").strip())
|
| 244 |
+
return False
|
| 245 |
+
|
| 246 |
+
def _code_group_detail(self, g: dict) -> dict:
|
| 247 |
+
ct, sub = g.get("code_type_id"), g.get("sub_type_id")
|
| 248 |
+
codes, resolved = [], 0
|
| 249 |
+
for x in g.get("codes", [])[:200]:
|
| 250 |
+
desc, source = self._code_display(ct, sub, x)
|
| 251 |
+
if desc and source not in ("study_specific", "unresolved", "label_missing") \
|
| 252 |
+
and not source.startswith("needs_vocab"):
|
| 253 |
+
resolved += 1
|
| 254 |
+
codes.append({"code": x.get("code"), "description": desc,
|
| 255 |
+
"description_source": source, "label_status": x.get("label_status"),
|
| 256 |
+
"graphable": self._code_graphable(ct, x.get("code"))})
|
| 257 |
+
return {"code_system": g.get("code_type_label"), "sub_type": g.get("sub_type_label"),
|
| 258 |
+
"code_count": g.get("code_count") or len(g.get("codes", [])),
|
| 259 |
+
"resolved_count": resolved, "codes": codes,
|
| 260 |
+
"graphable": any(c["graphable"] for c in codes)}
|
| 261 |
+
|
| 262 |
+
def phenotype(self, pid: int) -> dict | None:
|
| 263 |
+
p = self.phenotypes.get(pid)
|
| 264 |
+
if not p:
|
| 265 |
+
return None
|
| 266 |
+
return {
|
| 267 |
+
"phenotype_id": pid,
|
| 268 |
+
"algorithm_id": p.get("algorithm_id"),
|
| 269 |
+
"title": p.get("title"),
|
| 270 |
+
"category": p.get("category"),
|
| 271 |
+
"validated": p.get("validated"),
|
| 272 |
+
"validation_description": p.get("validation_description"),
|
| 273 |
+
"description": p.get("description"),
|
| 274 |
+
"algorithm_description": p.get("algorithm_description"),
|
| 275 |
+
"keywords": p.get("keywords") or [],
|
| 276 |
+
"authors": p.get("authors") or [],
|
| 277 |
+
"publications": p.get("publications") or [],
|
| 278 |
+
"population_description": p.get("population_description"),
|
| 279 |
+
"last_modified": p.get("last_modified"),
|
| 280 |
+
"cipher_url": self._cipher_url(pid, p.get("title")),
|
| 281 |
+
"algorithm_components": self._algo_component_rows(pid),
|
| 282 |
+
"code_groups": [self._code_group_detail(g)
|
| 283 |
+
for g in p.get("associated_code_groups", [])],
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
# -- phenotype code hierarchy (phecode -> ICD main -> ICD sub) ----------
|
| 287 |
+
def phenotype_code_graph(self, pid: int, focus: str | None = None) -> dict | None:
|
| 288 |
+
"""A node+edge tree of the phenotype's diagnosis codes: the phenotype (as
|
| 289 |
+
the phecode-level concept) -> each ICD main category (3-char stem) -> the
|
| 290 |
+
specific ICD sub-codes under it (460 ICD-9, 461 ICD-10).
|
| 291 |
+
|
| 292 |
+
To stay readable it stays bounded: when a code is clicked (`focus`) only
|
| 293 |
+
that code's main branch is expanded (sub-codes windowed around the focus);
|
| 294 |
+
the other mains collapse to a labelled count so you still see the breadth.
|
| 295 |
+
Overflow within a branch is shown as a single "+N more" stub (an edge to a
|
| 296 |
+
count, not every node). `path` = the root→main→code chain to highlight."""
|
| 297 |
+
p = self.phenotypes.get(pid)
|
| 298 |
+
if not p:
|
| 299 |
+
return None
|
| 300 |
+
FOCUS_SUBS, MAIN_SUBS, MAX_MAINS = 14, 6, 20
|
| 301 |
+
focus = (focus or "").strip() or None
|
| 302 |
+
focus_main = focus.split(".")[0] if focus else None
|
| 303 |
+
root = "pheno"
|
| 304 |
+
title = p.get("title") or f"Phenotype {pid}"
|
| 305 |
+
nodes = [{"id": root, "label": title[:60], "sub": "CIPHER phenotype (phecode)",
|
| 306 |
+
"tier": 0, "current": focus is None, "path": True}]
|
| 307 |
+
edges, path = [], [root]
|
| 308 |
+
note = None
|
| 309 |
+
|
| 310 |
+
for g in p.get("associated_code_groups", []):
|
| 311 |
+
ct = g.get("code_type_id")
|
| 312 |
+
if ct not in (460, 461):
|
| 313 |
+
continue
|
| 314 |
+
system = "ICD-9" if ct == 460 else "ICD-10"
|
| 315 |
+
sub_type = g.get("sub_type_id")
|
| 316 |
+
by_main: dict[str, list[dict]] = {}
|
| 317 |
+
for x in g.get("codes", []):
|
| 318 |
+
code = (x.get("code") or "").strip()
|
| 319 |
+
if code:
|
| 320 |
+
by_main.setdefault(code.split(".")[0], []).append(x)
|
| 321 |
+
# focus can be a leaf code (clicked in the drawer) or a main category
|
| 322 |
+
# (clicked to expand it) — both expand that main; a leaf also highlights.
|
| 323 |
+
group_has_focus = focus is not None and focus_main in by_main
|
| 324 |
+
focus_is_leaf = group_has_focus and any(x.get("code") == focus for x in by_main[focus_main])
|
| 325 |
+
|
| 326 |
+
mains = sorted(by_main)
|
| 327 |
+
sel_mains = mains[:MAX_MAINS]
|
| 328 |
+
if group_has_focus and focus_main not in sel_mains:
|
| 329 |
+
sel_mains = sel_mains[:MAX_MAINS - 1] + [focus_main]
|
| 330 |
+
if len(mains) > len(sel_mains):
|
| 331 |
+
note = "Some categories are truncated."
|
| 332 |
+
|
| 333 |
+
for cat in sel_mains:
|
| 334 |
+
main_id = f"{system}:{cat}"
|
| 335 |
+
subs = by_main[cat]
|
| 336 |
+
is_focus_main = group_has_focus and cat == focus_main
|
| 337 |
+
# When focused, only the focused branch expands; others collapse.
|
| 338 |
+
collapse = focus is not None and not is_focus_main
|
| 339 |
+
nodes.append({"id": main_id, "label": cat, "tier": 1, "current": False,
|
| 340 |
+
"sub": (f"{system} · {len(subs)} code" + ("s" if len(subs) != 1 else "")
|
| 341 |
+
if collapse else f"{system} category"),
|
| 342 |
+
"path": is_focus_main, "nav": {"code": cat, "code_type": system}})
|
| 343 |
+
edges.append([root, main_id])
|
| 344 |
+
if is_focus_main:
|
| 345 |
+
path.append(main_id)
|
| 346 |
+
if collapse:
|
| 347 |
+
continue
|
| 348 |
+
|
| 349 |
+
cap = FOCUS_SUBS if is_focus_main else MAIN_SUBS
|
| 350 |
+
if is_focus_main and focus_is_leaf and len(subs) > cap: # window around the focus
|
| 351 |
+
idx = next(i for i, x in enumerate(subs) if x.get("code") == focus)
|
| 352 |
+
start = max(0, min(idx - cap // 2, len(subs) - cap))
|
| 353 |
+
sel = subs[start:start + cap]
|
| 354 |
+
else:
|
| 355 |
+
sel = subs[:cap]
|
| 356 |
+
for x in sel:
|
| 357 |
+
desc, _ = self._code_display(ct, sub_type, x)
|
| 358 |
+
code = x["code"]
|
| 359 |
+
sid = f"{system}:{code}"
|
| 360 |
+
is_cur = code == focus
|
| 361 |
+
nodes.append({"id": sid, "label": code, "sub": desc or "", "tier": 2,
|
| 362 |
+
"current": is_cur, "path": is_cur,
|
| 363 |
+
"nav": {"code": code, "code_type": system}})
|
| 364 |
+
edges.append([main_id, sid])
|
| 365 |
+
if is_cur:
|
| 366 |
+
path.append(sid)
|
| 367 |
+
if len(subs) > len(sel): # overflow stub, not every node
|
| 368 |
+
more_id = f"more:{main_id}"
|
| 369 |
+
nodes.append({"id": more_id, "label": f"+{len(subs) - len(sel)} more",
|
| 370 |
+
"sub": "", "tier": 2, "more": True})
|
| 371 |
+
edges.append([main_id, more_id])
|
| 372 |
+
|
| 373 |
+
if len(nodes) == 1:
|
| 374 |
+
return {"available": False, "kind": "phenotype", "code": str(pid),
|
| 375 |
+
"reason": "This phenotype has no ICD diagnosis codes to chart."}
|
| 376 |
+
return {"available": True, "kind": "phenotype", "code": str(pid), "title": title,
|
| 377 |
+
"subtitle": "Phecode → ICD main categories → ICD sub-codes"
|
| 378 |
+
+ (" (click a category to expand it)" if focus else ""),
|
| 379 |
+
"nodes": nodes, "edges": edges, "path": path, "note": note}
|
src/backend/graph.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Knowledge graph: click a code -> its parent/child ontology as nodes + edges.
|
| 2 |
+
|
| 3 |
+
Every builder returns one uniform shape the frontend renders as a node-edge tree:
|
| 4 |
+
|
| 5 |
+
{available, kind, title, subtitle,
|
| 6 |
+
nodes: [{id, label, sub, tier, current}], edges: [[parentId, childId], ...],
|
| 7 |
+
note}
|
| 8 |
+
|
| 9 |
+
Source-grounded edge families, built only from data in the repo (VA-faced: CIPHER
|
| 10 |
+
+ VA CDW + RxNorm + LOINC — no OMOP/UMLS):
|
| 11 |
+
|
| 12 |
+
- Diagnosis (ICD-9/10) & ICD-9-Proc: dotted-code family (category stem -> the
|
| 13 |
+
codes sharing it), descriptions from the VA dictionary that backs code search.
|
| 14 |
+
- Procedures (ICD-10-PCS): 7-char positional codes -> the 3-char "table" family.
|
| 15 |
+
- Medication: RxNorm ingredient(s) -> the drug -> sibling products, from the
|
| 16 |
+
med_ingredient_map (drug names) and rxcui_ingredient_map (numeric RxNorm codes).
|
| 17 |
+
- Lab (LOINC): analyte COMPONENT -> the LOINC codes measuring it (loinc_terms).
|
| 18 |
+
|
| 19 |
+
A code with no known relations returns available:false with an honest reason —
|
| 20 |
+
never an invented tree. CPT (range-based, no clean prefix hierarchy) and VA-local
|
| 21 |
+
lab/med SIDs are honest gaps.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
from collections import defaultdict
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
from .rxnav import RxNavClient
|
| 31 |
+
|
| 32 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 33 |
+
CANON = ROOT / "artifacts" / "part_a_canonical"
|
| 34 |
+
MED_MAP = CANON / "med_ingredient_map.jsonl"
|
| 35 |
+
RXCUI_MAP = CANON / "rxcui_ingredient_map.jsonl"
|
| 36 |
+
LOINC_TERMS = CANON / "loinc_terms.jsonl"
|
| 37 |
+
DIAG_RECORDS = ROOT / "artifacts" / "code_search" / "diagnosis" / "records.jsonl"
|
| 38 |
+
PROC_RECORDS = ROOT / "artifacts" / "code_search" / "procedure" / "records.jsonl"
|
| 39 |
+
|
| 40 |
+
MEMBER_CAP = 26 # sibling codes shown per family (windowed around the clicked code; rest -> "+N more")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _dotted_cat(code: str) -> str:
|
| 44 |
+
"""Category stem for a dotted code (E11.9 -> E11, 345.01 -> 345)."""
|
| 45 |
+
return code.split(".")[0].strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _pcs_cat(code: str) -> str:
|
| 49 |
+
"""ICD-10-PCS 'table' = first 3 positional chars (0SGK44Z -> 0SG)."""
|
| 50 |
+
return code[:3]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _load_jsonl(path: Path):
|
| 54 |
+
if path.exists():
|
| 55 |
+
with path.open(encoding="utf-8") as handle:
|
| 56 |
+
for line in handle:
|
| 57 |
+
if line.strip():
|
| 58 |
+
yield json.loads(line)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class KnowledgeGraph:
|
| 62 |
+
def __init__(self, diagnosis_records=None, procedure_records=None,
|
| 63 |
+
rxnav_client: RxNavClient | None = None):
|
| 64 |
+
# --- medication (RxNorm) -----------------------------------------
|
| 65 |
+
self.med = {str(r["med_code"]).upper(): r for r in _load_jsonl(MED_MAP)}
|
| 66 |
+
self.rxcui = {str(r["rxcui"]): r for r in _load_jsonl(RXCUI_MAP)}
|
| 67 |
+
self.rxnav = rxnav_client or RxNavClient()
|
| 68 |
+
|
| 69 |
+
# --- lab (LOINC): term table + analyte(component) -> codes -------
|
| 70 |
+
self.loinc: dict[str, dict] = {}
|
| 71 |
+
self.loinc_by_comp: dict[str, list[str]] = defaultdict(list)
|
| 72 |
+
for r in _load_jsonl(LOINC_TERMS):
|
| 73 |
+
self.loinc[r["loinc"]] = r
|
| 74 |
+
self.loinc_by_comp[r["component"].lower()].append(r["loinc"])
|
| 75 |
+
|
| 76 |
+
# --- ICD diagnosis (dotted) --------------------------------------
|
| 77 |
+
self.icd, self.icd_cat = self._index_codes(
|
| 78 |
+
diagnosis_records if diagnosis_records is not None else _load_jsonl(DIAG_RECORDS),
|
| 79 |
+
keep="ICD", cat_fn=_dotted_cat)
|
| 80 |
+
|
| 81 |
+
# --- procedures: ICD-10-PCS (3-char) + ICD-9-Proc (dotted) -------
|
| 82 |
+
self.pcs, self.pcs_cat = {}, defaultdict(list)
|
| 83 |
+
self.icd9p, self.icd9p_cat = {}, defaultdict(list)
|
| 84 |
+
for r in (procedure_records if procedure_records is not None else _load_jsonl(PROC_RECORDS)):
|
| 85 |
+
ct = str(r.get("code_type", ""))
|
| 86 |
+
desc = r.get("description", "")
|
| 87 |
+
for code in str(r.get("code", "")).split(","):
|
| 88 |
+
code = code.strip()
|
| 89 |
+
if not code:
|
| 90 |
+
continue
|
| 91 |
+
if "PCS" in ct and code not in self.pcs:
|
| 92 |
+
self.pcs[code] = {"code_type": "ICD-10-PCS", "description": desc}
|
| 93 |
+
self.pcs_cat[_pcs_cat(code)].append(code)
|
| 94 |
+
elif "ICD-9" in ct and "." in code and code not in self.icd9p:
|
| 95 |
+
self.icd9p[code] = {"code_type": "ICD-9-Proc", "description": desc}
|
| 96 |
+
self.icd9p_cat[_dotted_cat(code)].append(code)
|
| 97 |
+
|
| 98 |
+
@staticmethod
|
| 99 |
+
def _index_codes(records, keep: str, cat_fn):
|
| 100 |
+
table: dict[str, dict] = {}
|
| 101 |
+
buckets: dict[str, list[str]] = defaultdict(list)
|
| 102 |
+
for r in records:
|
| 103 |
+
ct = str(r.get("code_type", ""))
|
| 104 |
+
if keep not in ct:
|
| 105 |
+
continue
|
| 106 |
+
desc = r.get("description", "")
|
| 107 |
+
for code in str(r.get("code", "")).split(","): # split merged rows
|
| 108 |
+
code = code.strip()
|
| 109 |
+
if code and code not in table:
|
| 110 |
+
table[code] = {"code_type": ct, "description": desc}
|
| 111 |
+
buckets[cat_fn(code)].append(code)
|
| 112 |
+
return table, buckets
|
| 113 |
+
|
| 114 |
+
# -- routing -----------------------------------------------------------
|
| 115 |
+
def neighbors(self, code: str, code_type: str | None = None,
|
| 116 |
+
drug_name: str | None = None) -> dict:
|
| 117 |
+
code = (code or "").strip()
|
| 118 |
+
if not code:
|
| 119 |
+
return self._none(code, "No code given.")
|
| 120 |
+
ct = (code_type or "").upper()
|
| 121 |
+
|
| 122 |
+
if "PCS" in ct or (not ct and code in self.pcs):
|
| 123 |
+
return self._family(code.split(",")[0].strip(), self.pcs, self.pcs_cat, _pcs_cat,
|
| 124 |
+
"procedure", "ICD-10-PCS procedure table — codes sharing this 3-character root.")
|
| 125 |
+
if ("ICD" in ct and "PROC" in ct) or ("ICD-9" in ct and code in self.icd9p):
|
| 126 |
+
return self._family(code.split(",")[0].strip(), self.icd9p, self.icd9p_cat, _dotted_cat,
|
| 127 |
+
"procedure", "ICD-9 procedure family — codes in this category.")
|
| 128 |
+
if "ICD" in ct or (not ct and (code in self.icd or _dotted_cat(code) in self.icd_cat)):
|
| 129 |
+
return self._family(code.split(",")[0].strip(), self.icd, self.icd_cat, _dotted_cat,
|
| 130 |
+
"icd", "Diagnosis code family — the codes in this ICD category.")
|
| 131 |
+
if "LOINC" in ct or code.split(",")[0].strip() in self.loinc:
|
| 132 |
+
return self._loinc(code.split(",")[0].strip())
|
| 133 |
+
if code.upper() in self.med or code in self.rxcui:
|
| 134 |
+
return self._med(code)
|
| 135 |
+
if self.rxnav.supports(code, code_type):
|
| 136 |
+
mapping = self.rxnav.resolve(code, code_type, drug_name)
|
| 137 |
+
if mapping:
|
| 138 |
+
return self._med(code, mapping)
|
| 139 |
+
return self._none(code, "No unambiguous RxNAV/RxNorm ingredient mapping is available for "
|
| 140 |
+
f"{drug_name or code}.", "medication")
|
| 141 |
+
return self._none(code, "No parent/child relations available for this code in the "
|
| 142 |
+
"CIPHER + VA + RxNorm + LOINC data. (CPT and VA-local lab/med "
|
| 143 |
+
"SIDs have no simple hierarchy here.)")
|
| 144 |
+
|
| 145 |
+
# -- dotted / positional code family (nodes + edges) -------------------
|
| 146 |
+
def _family(self, code, table, buckets, cat_fn, kind, subtitle) -> dict:
|
| 147 |
+
cat = cat_fn(code)
|
| 148 |
+
members = sorted(buckets.get(cat, []))
|
| 149 |
+
if not members:
|
| 150 |
+
return self._none(code, f"No {kind} family found for {code}.", kind)
|
| 151 |
+
sel = self._window(members, code, MEMBER_CAP) # siblings around the clicked code
|
| 152 |
+
root_desc = (table.get(cat) or {}).get("description")
|
| 153 |
+
nodes = [{"id": cat, "label": cat, "tier": 0, "current": cat == code, "path": True,
|
| 154 |
+
"sub": root_desc or f"{table.get(code, {}).get('code_type', 'ICD')} category {cat}"}]
|
| 155 |
+
edges, path = [], [cat]
|
| 156 |
+
for c in sel:
|
| 157 |
+
if c == cat:
|
| 158 |
+
continue
|
| 159 |
+
is_cur = c == code
|
| 160 |
+
nodes.append({"id": c, "label": c, "sub": table[c]["description"], "tier": 1,
|
| 161 |
+
"current": is_cur, "path": is_cur,
|
| 162 |
+
"nav": {"code": c, "code_type": table[c]["code_type"]}})
|
| 163 |
+
edges.append([cat, c])
|
| 164 |
+
if is_cur:
|
| 165 |
+
path.append(c)
|
| 166 |
+
if len(members) > len(sel):
|
| 167 |
+
nodes.append({"id": f"more:{cat}", "label": f"+{len(members) - len(sel)} more",
|
| 168 |
+
"sub": "", "tier": 1, "more": True})
|
| 169 |
+
edges.append([cat, f"more:{cat}"])
|
| 170 |
+
return {"available": True, "kind": kind, "code": code, "title": code, "subtitle": subtitle,
|
| 171 |
+
"nodes": nodes, "edges": edges, "path": path, "note": None}
|
| 172 |
+
|
| 173 |
+
@staticmethod
|
| 174 |
+
def _window(items: list[str], focus: str, cap: int) -> list[str]:
|
| 175 |
+
"""Up to `cap` items centred on `focus` (so the clicked code keeps context)."""
|
| 176 |
+
if len(items) <= cap:
|
| 177 |
+
return items
|
| 178 |
+
if focus in items:
|
| 179 |
+
idx = items.index(focus)
|
| 180 |
+
start = max(0, min(idx - cap // 2, len(items) - cap))
|
| 181 |
+
return items[start:start + cap]
|
| 182 |
+
return items[:cap]
|
| 183 |
+
|
| 184 |
+
# -- LOINC lab: analyte(component) -> the codes measuring it -----------
|
| 185 |
+
def _loinc(self, code) -> dict:
|
| 186 |
+
term = self.loinc.get(code)
|
| 187 |
+
if not term:
|
| 188 |
+
return self._none(code, f"{code} is not in the LOINC table.", "lab")
|
| 189 |
+
comp = term["component"]
|
| 190 |
+
members = sorted(self.loinc_by_comp.get(comp.lower(), [code]))
|
| 191 |
+
sel = self._window(members, code, MEMBER_CAP)
|
| 192 |
+
comp_id = "comp:" + comp
|
| 193 |
+
nodes = [{"id": comp_id, "label": comp, "tier": 0, "current": False, "path": True,
|
| 194 |
+
"sub": ("LOINC component · " + term.get("class", "")).strip(" ·")}]
|
| 195 |
+
edges, path = [], [comp_id]
|
| 196 |
+
for c in sel:
|
| 197 |
+
t = self.loinc.get(c, {})
|
| 198 |
+
sub = " · ".join(x for x in [t.get("system"), t.get("name")] if x)[:90]
|
| 199 |
+
is_cur = c == code
|
| 200 |
+
nodes.append({"id": c, "label": c, "sub": sub, "tier": 1, "current": is_cur,
|
| 201 |
+
"path": is_cur, "nav": {"code": c, "code_type": "LOINC"}})
|
| 202 |
+
edges.append([comp_id, c])
|
| 203 |
+
if is_cur:
|
| 204 |
+
path.append(c)
|
| 205 |
+
if len(members) > len(sel):
|
| 206 |
+
nodes.append({"id": "more:" + comp_id, "label": f"+{len(members) - len(sel)} more",
|
| 207 |
+
"sub": "", "tier": 1, "more": True})
|
| 208 |
+
edges.append([comp_id, "more:" + comp_id])
|
| 209 |
+
return {"available": True, "kind": "lab", "code": code, "title": code,
|
| 210 |
+
"subtitle": f"Lab codes measuring {comp} (LOINC analyte).",
|
| 211 |
+
"nodes": nodes, "edges": edges, "path": path, "note": None}
|
| 212 |
+
|
| 213 |
+
# -- medication: ingredient(s) -> drug -> sibling products ------------
|
| 214 |
+
def _med(self, code, mapping=None) -> dict:
|
| 215 |
+
e = mapping or self.med.get(code.upper()) or self.rxcui.get(code)
|
| 216 |
+
if not e:
|
| 217 |
+
return self._none(code, f"No RxNorm ingredient mapping for {code}.", "medication")
|
| 218 |
+
label = e.get("name") or code
|
| 219 |
+
ings = e.get("ingredients", [])
|
| 220 |
+
if not ings:
|
| 221 |
+
return self._none(code, f"No RxNorm ingredient is mapped for {code}.", "medication")
|
| 222 |
+
nodes, edges, seen = [], [], set()
|
| 223 |
+
drug_id = "drug:" + str(code)
|
| 224 |
+
path = [drug_id]
|
| 225 |
+
for ing in ings:
|
| 226 |
+
iid = "ing:" + str(ing.get("rxcui") or ing["name"])
|
| 227 |
+
nodes.append({"id": iid, "label": ing["name"], "sub": "RxNorm ingredient",
|
| 228 |
+
"tier": 0, "current": False, "path": True})
|
| 229 |
+
edges.append([iid, drug_id])
|
| 230 |
+
path.append(iid)
|
| 231 |
+
nodes.append({"id": drug_id, "label": label, "sub": "this medication",
|
| 232 |
+
"tier": 1, "current": True, "path": True})
|
| 233 |
+
n_prod = 0
|
| 234 |
+
for ing in ings:
|
| 235 |
+
iid = "ing:" + str(ing.get("rxcui") or ing["name"])
|
| 236 |
+
for prod in (e.get("child_drugs", {}) or {}).get(ing["name"], []):
|
| 237 |
+
if prod.upper() == label.upper() or prod in seen:
|
| 238 |
+
continue
|
| 239 |
+
seen.add(prod)
|
| 240 |
+
pid = "prod:" + prod
|
| 241 |
+
nodes.append({"id": pid, "label": prod, "sub": "", "tier": 2, "current": False})
|
| 242 |
+
edges.append([iid, pid])
|
| 243 |
+
n_prod += 1
|
| 244 |
+
source = e.get("mapping_source", "Packaged RxNorm mapping")
|
| 245 |
+
total_products = e.get("related_product_count", n_prod)
|
| 246 |
+
shown_products = e.get("related_products_shown", n_prod)
|
| 247 |
+
note = f"{total_products} related products."
|
| 248 |
+
if total_products > shown_products:
|
| 249 |
+
note += f" Showing the first {shown_products}."
|
| 250 |
+
return {"available": True, "kind": "medication", "code": code,
|
| 251 |
+
"title": label, "subtitle": "RxNorm ingredient(s) → this drug → other products built from them.",
|
| 252 |
+
"nodes": nodes, "edges": edges, "path": path,
|
| 253 |
+
"note": (note if n_prod else None), "source": source,
|
| 254 |
+
"mapped_rxcuis": e.get("matched_rxcuis", [])}
|
| 255 |
+
|
| 256 |
+
@staticmethod
|
| 257 |
+
def _none(code, reason, kind=None) -> dict:
|
| 258 |
+
return {"available": False, "code": code, "kind": kind, "reason": reason}
|
src/backend/retriever.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU embedding wrapper for the Vidul BGE_FT_VA model.
|
| 2 |
+
|
| 3 |
+
`DenseEmbedder` wraps the model used to encode queries at runtime (the documents
|
| 4 |
+
are encoded offline). The query must use the same pooling, prefix, and sequence
|
| 5 |
+
length convention as the stored vectors.
|
| 6 |
+
|
| 7 |
+
`pooling='cls'|'mean'` forces pooling for raw BERT dirs (e.g. bge_ft_va, which
|
| 8 |
+
ships without a pooling module); `query_prefix` is prepended before encoding.
|
| 9 |
+
|
| 10 |
+
Runtime inference is CPU-only.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
from sentence_transformers import SentenceTransformer, models
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_st_model(model_name: str, pooling: str = "default",
|
| 20 |
+
max_seq_length: int | None = None) -> SentenceTransformer:
|
| 21 |
+
"""Load a SentenceTransformer with an explicit pooling head when asked.
|
| 22 |
+
'default' uses whatever the model dir declares (a raw BERT dir -> mean)."""
|
| 23 |
+
if pooling in (None, "", "default"):
|
| 24 |
+
model = SentenceTransformer(model_name, device="cpu")
|
| 25 |
+
else:
|
| 26 |
+
word = models.Transformer(model_name)
|
| 27 |
+
pool = models.Pooling(word.get_embedding_dimension(), pooling_mode=pooling)
|
| 28 |
+
model = SentenceTransformer(modules=[word, pool], device="cpu")
|
| 29 |
+
if max_seq_length:
|
| 30 |
+
model.max_seq_length = max_seq_length
|
| 31 |
+
return model
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class DenseEmbedder:
|
| 35 |
+
def __init__(self, model_name: str, *, pooling: str = "default", query_prefix: str = "",
|
| 36 |
+
max_seq_length: int | None = None):
|
| 37 |
+
self.model_name = model_name
|
| 38 |
+
self.device = "cpu"
|
| 39 |
+
self.query_prefix = query_prefix or ""
|
| 40 |
+
self.model = load_st_model(model_name, pooling, max_seq_length)
|
| 41 |
+
|
| 42 |
+
def encode(self, texts: list[str]) -> np.ndarray:
|
| 43 |
+
# encode() is the runtime query path, so the query prefix applies here
|
| 44 |
+
# (documents get their prefix in the offline build script).
|
| 45 |
+
if self.query_prefix:
|
| 46 |
+
texts = [self.query_prefix + t for t in texts]
|
| 47 |
+
return self.model.encode(texts, normalize_embeddings=True,
|
| 48 |
+
convert_to_numpy=True).astype(np.float32)
|
src/backend/rxnav.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small, conservative RxNAV client for completing medication graph mappings.
|
| 2 |
+
|
| 3 |
+
RxNAV is queried only after ENCODE's packaged mappings do not resolve a
|
| 4 |
+
medication. Name lookups use RxNAV's exact-or-normalized mode; approximate
|
| 5 |
+
matches are deliberately excluded so the graph never turns a fuzzy text match
|
| 6 |
+
into an asserted medication relationship.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
from functools import lru_cache
|
| 14 |
+
from typing import Callable
|
| 15 |
+
from urllib.error import HTTPError, URLError
|
| 16 |
+
from urllib.parse import urlencode
|
| 17 |
+
from urllib.request import Request, urlopen
|
| 18 |
+
|
| 19 |
+
RXNAV_BASE = "https://rxnav.nlm.nih.gov"
|
| 20 |
+
_NDC = re.compile(r"^\d{10,11}$")
|
| 21 |
+
_NAME_STRIP = re.compile(r"[%\"']")
|
| 22 |
+
_MAX_RELATED_PRODUCTS = 26
|
| 23 |
+
|
| 24 |
+
FetchJson = Callable[[str, dict[str, str]], dict | None]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class RxNavClient:
|
| 28 |
+
"""Resolve NDCs or unambiguous medication names into RxNorm ingredients.
|
| 29 |
+
|
| 30 |
+
Network failures and unknown/ambiguous responses return ``None``. The
|
| 31 |
+
caller can therefore state that no mapping is available without fabricating
|
| 32 |
+
a relationship. Public lookups are cached for the life of the API process.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, fetch_json: FetchJson | None = None, base_url: str = RXNAV_BASE,
|
| 36 |
+
timeout: float = 4.0):
|
| 37 |
+
self.fetch_json = fetch_json
|
| 38 |
+
self.base_url = base_url.rstrip("/")
|
| 39 |
+
self.timeout = timeout
|
| 40 |
+
|
| 41 |
+
def supports(self, code: str, code_type: str | None) -> bool:
|
| 42 |
+
kind = (code_type or "").upper()
|
| 43 |
+
return "NDC" in kind or "MED" in kind or "DRUG" in kind or "RXNORM" in kind
|
| 44 |
+
|
| 45 |
+
def resolve(self, code: str, code_type: str | None, drug_name: str | None = None) -> dict | None:
|
| 46 |
+
kind = (code_type or "").upper()
|
| 47 |
+
if "NDC" in kind:
|
| 48 |
+
return self.resolve_ndc(code)
|
| 49 |
+
if "RXNORM" in kind and code.strip().isdigit():
|
| 50 |
+
return self.resolve_rxcui(code.strip())
|
| 51 |
+
if "MED" in kind or "DRUG" in kind:
|
| 52 |
+
return self.resolve_name(drug_name or code)
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
@lru_cache(maxsize=4096)
|
| 56 |
+
def resolve_ndc(self, code: str) -> dict | None:
|
| 57 |
+
for ndc in self._ndc_candidates(code):
|
| 58 |
+
data = self._request("/REST/ndcstatus.json", {
|
| 59 |
+
"ndc": ndc,
|
| 60 |
+
"history": "1",
|
| 61 |
+
"altpkg": "1",
|
| 62 |
+
})
|
| 63 |
+
status = (data or {}).get("ndcStatus") or {}
|
| 64 |
+
rxcui = str(status.get("rxcui") or "").strip()
|
| 65 |
+
if not rxcui.isdigit():
|
| 66 |
+
continue
|
| 67 |
+
mapping = self.resolve_rxcui(rxcui)
|
| 68 |
+
if mapping:
|
| 69 |
+
return {**mapping, "matched_ndc": ndc,
|
| 70 |
+
"query_name": status.get("conceptName") or mapping["name"]}
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
@lru_cache(maxsize=4096)
|
| 74 |
+
def resolve_name(self, name: str) -> dict | None:
|
| 75 |
+
cleaned = _NAME_STRIP.sub("", (name or "")).strip()
|
| 76 |
+
if not cleaned or len(cleaned) > 240:
|
| 77 |
+
return None
|
| 78 |
+
data = self._request("/REST/Prescribe/rxcui.json", {
|
| 79 |
+
"name": cleaned,
|
| 80 |
+
"search": "2", # exact; then RxNAV's normalized match when needed
|
| 81 |
+
})
|
| 82 |
+
ids = [str(value) for value in ((data or {}).get("idGroup") or {}).get("rxnormId") or []
|
| 83 |
+
if str(value).isdigit()]
|
| 84 |
+
# RxNAV can return several exact/normalized concepts. There is no score on
|
| 85 |
+
# this endpoint, so choosing one would be an ungrounded guess.
|
| 86 |
+
if len(ids) != 1:
|
| 87 |
+
return None
|
| 88 |
+
mapping = self.resolve_rxcui(ids[0])
|
| 89 |
+
return {**mapping, "query_name": cleaned} if mapping else None
|
| 90 |
+
|
| 91 |
+
@lru_cache(maxsize=4096)
|
| 92 |
+
def resolve_rxcui(self, rxcui: str) -> dict | None:
|
| 93 |
+
if not rxcui.isdigit():
|
| 94 |
+
return None
|
| 95 |
+
properties = self._request(f"/REST/rxcui/{rxcui}/properties.json", {})
|
| 96 |
+
props = (properties or {}).get("properties") or {}
|
| 97 |
+
name = str(props.get("name") or rxcui)
|
| 98 |
+
ingredients = self._related(rxcui, "IN")
|
| 99 |
+
if str(props.get("tty") or "").upper() == "IN" and not ingredients:
|
| 100 |
+
ingredients = [{"rxcui": rxcui, "name": name}]
|
| 101 |
+
if not ingredients:
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
child_drugs: dict[str, list[str]] = {}
|
| 105 |
+
product_count = 0
|
| 106 |
+
for ingredient in ingredients:
|
| 107 |
+
products = self._related(ingredient["rxcui"], "SCD SBD")
|
| 108 |
+
names = sorted({p["name"] for p in products if p["name"].lower() != name.lower()},
|
| 109 |
+
key=str.casefold)
|
| 110 |
+
product_count += len(names)
|
| 111 |
+
child_drugs[ingredient["name"]] = names[:_MAX_RELATED_PRODUCTS]
|
| 112 |
+
|
| 113 |
+
shown_count = sum(len(products) for products in child_drugs.values())
|
| 114 |
+
return {
|
| 115 |
+
"name": name,
|
| 116 |
+
"matched_rxcuis": [rxcui],
|
| 117 |
+
"ingredients": ingredients,
|
| 118 |
+
"child_drugs": child_drugs,
|
| 119 |
+
"mapping_source": "RxNAV / RxNorm live lookup",
|
| 120 |
+
"related_product_count": product_count,
|
| 121 |
+
"related_products_shown": shown_count,
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
def _related(self, rxcui: str, tty: str) -> list[dict[str, str]]:
|
| 125 |
+
data = self._request(f"/REST/Prescribe/rxcui/{rxcui}/related.json", {"tty": tty})
|
| 126 |
+
values: list[dict[str, str]] = []
|
| 127 |
+
for group in ((data or {}).get("relatedGroup") or {}).get("conceptGroup") or []:
|
| 128 |
+
for concept in group.get("conceptProperties") or []:
|
| 129 |
+
related_id = str(concept.get("rxcui") or "")
|
| 130 |
+
related_name = str(concept.get("name") or "")
|
| 131 |
+
if related_id.isdigit() and related_name:
|
| 132 |
+
values.append({"rxcui": related_id, "name": related_name})
|
| 133 |
+
return values
|
| 134 |
+
|
| 135 |
+
@staticmethod
|
| 136 |
+
def _ndc_candidates(code: str) -> list[str]:
|
| 137 |
+
candidates = []
|
| 138 |
+
for value in (code or "").split(","):
|
| 139 |
+
digits = re.sub(r"\D", "", value)
|
| 140 |
+
if _NDC.fullmatch(digits) and digits not in candidates:
|
| 141 |
+
candidates.append(digits)
|
| 142 |
+
return candidates
|
| 143 |
+
|
| 144 |
+
def _request(self, path: str, params: dict[str, str]) -> dict | None:
|
| 145 |
+
if self.fetch_json:
|
| 146 |
+
return self.fetch_json(path, params)
|
| 147 |
+
url = f"{self.base_url}{path}"
|
| 148 |
+
if params:
|
| 149 |
+
url += "?" + urlencode(params)
|
| 150 |
+
request = Request(url, headers={"Accept": "application/json", "User-Agent": "ENCODE/1.0"})
|
| 151 |
+
try:
|
| 152 |
+
with urlopen(request, timeout=self.timeout) as response:
|
| 153 |
+
return json.load(response)
|
| 154 |
+
except (HTTPError, URLError, OSError, ValueError):
|
| 155 |
+
return None
|
src/frontend/app.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const $ = (s) => document.querySelector(s);
|
| 2 |
+
const el = (t, c, txt) => { const n = document.createElement(t); if (c) n.className = c; if (txt != null) n.textContent = txt; return n; };
|
| 3 |
+
|
| 4 |
+
const CATS = {
|
| 5 |
+
diagnosis: { label: "Diagnosis", placeholder: "e.g. Type 2 diabetes mellitus", examples: ["Type 2 diabetes mellitus", "Acute myocardial infarction", "Major depressive disorder"] },
|
| 6 |
+
medication: { label: "Medication", placeholder: "e.g. atorvastatin 40 mg", examples: ["Atorvastatin", "Metformin", "Lisinopril"] },
|
| 7 |
+
lab: { label: "Lab", placeholder: "e.g. hemoglobin a1c", examples: ["Hemoglobin A1c", "Serum creatinine", "LDL cholesterol"] },
|
| 8 |
+
procedure: { label: "Procedure", placeholder: "e.g. colonoscopy", examples: ["Colonoscopy", "Coronary artery bypass", "Chest x-ray"] },
|
| 9 |
+
phenotype: { label: "Phenotype", placeholder: "e.g. adults hospitalized with heart failure after 2020", examples: ["Adults hospitalized with heart failure", "New-onset atrial fibrillation", "Chronic kidney disease stage 3+"] },
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const state = { category: "phenotype", query: "", results: [], annotations: {}, phenoCats: new Set(), codeTypeFilter: new Set() };
|
| 13 |
+
|
| 14 |
+
async function apiGet(path) {
|
| 15 |
+
const r = await fetch(path);
|
| 16 |
+
if (!r.ok) throw new Error(((await r.json().catch(() => ({}))).detail) || r.statusText);
|
| 17 |
+
return r.json();
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Column tooltips (VA usability: explain what each column means, no jargon).
|
| 21 |
+
const COL_HELP = {
|
| 22 |
+
"Add": "Add this code to your collected list — it persists across searches and categories.",
|
| 23 |
+
"Relevant": "Directly answers the query — a strong match.",
|
| 24 |
+
"Related": "Loosely related — a partial or indirect match.",
|
| 25 |
+
"Unsure": "Uncertain whether this result matches.",
|
| 26 |
+
"Rank": "Result order by relevance (1 = closest match).",
|
| 27 |
+
"Code Type": "The coding system this code belongs to (ICD-9/10, RxNorm, NDC, LOINC, CPT…).",
|
| 28 |
+
"Code": "The code identifier.",
|
| 29 |
+
"Description": "The code's human-readable description.",
|
| 30 |
+
"Relevance": "Semantic similarity (0–1): 0.8+ strong · 0.6–0.8 moderate · below 0.6 weak.",
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const isPheno = () => state.category === "phenotype";
|
| 34 |
+
|
| 35 |
+
// -- code basket: collect codes/phenotypes across searches & categories -----
|
| 36 |
+
// (the VA "curate a working code list" workflow; persists in localStorage).
|
| 37 |
+
const BASKET_KEY = "encode_basket_v1";
|
| 38 |
+
let basket = (() => { try { return JSON.parse(localStorage.getItem(BASKET_KEY)) || {}; } catch (_) { return {}; } })();
|
| 39 |
+
|
| 40 |
+
const basketKey = (it) => `${it.kind}:${it.category}:${it.code}`;
|
| 41 |
+
const inBasket = (it) => basketKey(it) in basket;
|
| 42 |
+
function saveBasket() { localStorage.setItem(BASKET_KEY, JSON.stringify(basket)); updateBasketCount(); }
|
| 43 |
+
function toggleBasket(it) {
|
| 44 |
+
const k = basketKey(it);
|
| 45 |
+
if (k in basket) delete basket[k];
|
| 46 |
+
else basket[k] = { ...it, query: state.query, added: new Date().toISOString() };
|
| 47 |
+
saveBasket();
|
| 48 |
+
}
|
| 49 |
+
function updateBasketCount() { const b = $("#basket-count"); if (b) b.textContent = String(Object.keys(basket).length); }
|
| 50 |
+
function collectBtn(it, compact) {
|
| 51 |
+
const b = el("button", "collect-btn" + (compact ? " collect-icon" : ""));
|
| 52 |
+
b.type = "button";
|
| 53 |
+
const mark = el("span", "collect-mark");
|
| 54 |
+
const label = compact ? null : el("span", "collect-label");
|
| 55 |
+
mark.setAttribute("aria-hidden", "true");
|
| 56 |
+
b.appendChild(mark);
|
| 57 |
+
if (label) b.appendChild(label);
|
| 58 |
+
const sync = () => {
|
| 59 |
+
const on = inBasket(it);
|
| 60 |
+
b.classList.toggle("on", on);
|
| 61 |
+
b.setAttribute("aria-pressed", String(on));
|
| 62 |
+
b.setAttribute("aria-label", on ? "Remove from collection" : "Add to collection");
|
| 63 |
+
mark.textContent = on ? "✓" : "+";
|
| 64 |
+
if (label) label.textContent = on ? "Collected" : "Collect";
|
| 65 |
+
b.title = on ? "Remove from collected codes" : "Add to collected codes";
|
| 66 |
+
};
|
| 67 |
+
b.onclick = (e) => { e.stopPropagation(); toggleBasket(it); sync(); };
|
| 68 |
+
sync();
|
| 69 |
+
return b;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// -- category / mode switching --------------------------------------------
|
| 73 |
+
function applyCategory() {
|
| 74 |
+
const meta = CATS[state.category];
|
| 75 |
+
$("#search-for").textContent = isPheno()
|
| 76 |
+
? "Discover a phenotype from a natural-language request"
|
| 77 |
+
: `Search for ${meta.label}`;
|
| 78 |
+
$("#query").placeholder = meta.placeholder;
|
| 79 |
+
$("#pheno-filters").classList.toggle("hidden", !isPheno());
|
| 80 |
+
clearResults();
|
| 81 |
+
renderEmpty();
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function clearResults() {
|
| 85 |
+
state.results = []; state.annotations = {};
|
| 86 |
+
$("#results").innerHTML = "";
|
| 87 |
+
$("#status").textContent = "";
|
| 88 |
+
$("#submit-row").classList.add("hidden");
|
| 89 |
+
$("#export").classList.add("hidden");
|
| 90 |
+
$("#submit-msg").textContent = "";
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function renderEmpty() {
|
| 94 |
+
const box = $("#empty");
|
| 95 |
+
if (state.results.length || state.query) { box.innerHTML = ""; return; }
|
| 96 |
+
box.innerHTML = "";
|
| 97 |
+
box.appendChild(el("h2", "empty-title", isPheno() ? "Discover phenotypes" : "Search medical codes"));
|
| 98 |
+
box.appendChild(el("p", "empty-sub", isPheno()
|
| 99 |
+
? "Describe a cohort in plain language to find matching CIPHER phenotypes."
|
| 100 |
+
: "Enter a clinical concept above to find matching diagnosis, medication, lab, or procedure codes."));
|
| 101 |
+
box.appendChild(el("p", "empty-try", "Examples:"));
|
| 102 |
+
box.appendChild(exampleChips());
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// -- search ---------------------------------------------------------------
|
| 106 |
+
async function runSearch() {
|
| 107 |
+
const q = $("#query").value.trim();
|
| 108 |
+
if (!q) return;
|
| 109 |
+
state.query = q; state.annotations = {};
|
| 110 |
+
$("#empty").innerHTML = "";
|
| 111 |
+
$("#status").textContent = "Searching…";
|
| 112 |
+
$("#results").innerHTML = "";
|
| 113 |
+
$("#submit-row").classList.add("hidden");
|
| 114 |
+
$("#export").classList.add("hidden");
|
| 115 |
+
$("#submit-msg").textContent = "";
|
| 116 |
+
const t0 = performance.now();
|
| 117 |
+
try {
|
| 118 |
+
const url = isPheno()
|
| 119 |
+
? `/api/search?q=${encodeURIComponent(q)}&k=${$("#k").value}${phenoFilterParams()}`
|
| 120 |
+
: `/api/code/search?category=${state.category}&q=${encodeURIComponent(q)}&k=${$("#k").value}`;
|
| 121 |
+
const data = await apiGet(url);
|
| 122 |
+
(isPheno() ? renderPhenotypes : renderCodes)(data, (performance.now() - t0) / 1000);
|
| 123 |
+
} catch (e) { $("#status").textContent = `Error: ${e.message}`; }
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function exampleChips() {
|
| 127 |
+
const wrap = el("div", "example-chips");
|
| 128 |
+
(CATS[state.category].examples || []).forEach((ex) => {
|
| 129 |
+
const b = el("button", "example-chip", ex);
|
| 130 |
+
b.onclick = () => { $("#query").value = ex; runSearch(); };
|
| 131 |
+
wrap.appendChild(b);
|
| 132 |
+
});
|
| 133 |
+
return wrap;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function phenoFilterParams() {
|
| 137 |
+
let p = "";
|
| 138 |
+
if (state.phenoCats.size) p += `&categories=${[...state.phenoCats].join(",")}`;
|
| 139 |
+
if ($("#validated").checked) p += `&validated_only=true`;
|
| 140 |
+
return p;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function afterResults(count) {
|
| 144 |
+
if (!count) { $("#submit-row").classList.add("hidden"); $("#export").classList.add("hidden"); return; }
|
| 145 |
+
$("#submit-row").classList.remove("hidden");
|
| 146 |
+
$("#export").classList.remove("hidden");
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// -- code table renderer --------------------------------------------------
|
| 150 |
+
function renderCodes(data, secs) {
|
| 151 |
+
state.results = data.results;
|
| 152 |
+
state.codeTypeFilter = new Set(); // active code-type filters (empty = show all)
|
| 153 |
+
$("#status").innerHTML =
|
| 154 |
+
`${data.count.toLocaleString()} of ${data.total.toLocaleString()} results for <b>"${data.query}"</b> (${secs.toFixed(1)}s)`;
|
| 155 |
+
const box = $("#results");
|
| 156 |
+
box.innerHTML = "";
|
| 157 |
+
if (!data.results.length) { $("#status").textContent = `No results for "${data.query}".`; afterResults(0); return; }
|
| 158 |
+
|
| 159 |
+
box.appendChild(codeFilterBar(data.results));
|
| 160 |
+
const host = el("div"); host.id = "code-table-host";
|
| 161 |
+
box.appendChild(host);
|
| 162 |
+
drawCodeTable();
|
| 163 |
+
afterResults(data.results.length);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// Rows visible under the active code-type filter (narrows the retrieved set
|
| 167 |
+
// client-side; ranks stay the original semantic ranks so gaps are meaningful).
|
| 168 |
+
function activeCodeRows() {
|
| 169 |
+
const f = state.codeTypeFilter;
|
| 170 |
+
return f.size ? state.results.filter((r) => f.has(r.code_type)) : state.results;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Which code-search rows open a knowledge graph. Diagnosis = ICD (full); procedure
|
| 174 |
+
// = ICD-10-PCS / ICD-9-Proc (CPT is range-based, no clean hierarchy); lab = LOINC
|
| 175 |
+
// (VA-Lab SIDs have none). Medication rows are resolved through the packaged
|
| 176 |
+
// maps first, then a conservative RxNAV lookup when a graph is requested.
|
| 177 |
+
function codeSearchGraphable(cat, ct) {
|
| 178 |
+
ct = ct || "";
|
| 179 |
+
if (cat === "diagnosis") return true;
|
| 180 |
+
if (cat === "procedure") return ct.includes("PCS") || ct.includes("ICD-9-Proc");
|
| 181 |
+
if (cat === "lab") return ct.includes("LOINC");
|
| 182 |
+
if (cat === "medication") return true;
|
| 183 |
+
return false;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// Active-filter chips: one per distinct code type in the result set (only when
|
| 187 |
+
// there's more than one to choose between). Toggling narrows the table live.
|
| 188 |
+
function codeFilterBar(rows) {
|
| 189 |
+
const counts = {};
|
| 190 |
+
rows.forEach((r) => { counts[r.code_type] = (counts[r.code_type] || 0) + 1; });
|
| 191 |
+
const types = Object.keys(counts).sort((a, b) => counts[b] - counts[a] || a.localeCompare(b));
|
| 192 |
+
const bar = el("div", "filter-bar");
|
| 193 |
+
if (types.length <= 1) return bar; // single code type — nothing to filter
|
| 194 |
+
bar.appendChild(el("span", "filter-bar-label", "Code type"));
|
| 195 |
+
types.forEach((t) => {
|
| 196 |
+
const chip = el("button", "fchip", `${t} · ${counts[t]}`);
|
| 197 |
+
chip.onclick = () => {
|
| 198 |
+
state.codeTypeFilter.has(t) ? state.codeTypeFilter.delete(t) : state.codeTypeFilter.add(t);
|
| 199 |
+
chip.classList.toggle("on", state.codeTypeFilter.has(t));
|
| 200 |
+
drawCodeTable(); syncFilterBar(bar);
|
| 201 |
+
};
|
| 202 |
+
bar.appendChild(chip);
|
| 203 |
+
});
|
| 204 |
+
const clear = el("button", "fchip-clear hidden", "Clear filters");
|
| 205 |
+
clear.onclick = () => {
|
| 206 |
+
state.codeTypeFilter.clear();
|
| 207 |
+
bar.querySelectorAll(".fchip.on").forEach((c) => c.classList.remove("on"));
|
| 208 |
+
drawCodeTable(); syncFilterBar(bar);
|
| 209 |
+
};
|
| 210 |
+
bar.appendChild(clear);
|
| 211 |
+
bar.appendChild(el("span", "filter-count"));
|
| 212 |
+
return bar;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function syncFilterBar(bar) {
|
| 216 |
+
const active = state.codeTypeFilter.size;
|
| 217 |
+
bar.querySelector(".fchip-clear").classList.toggle("hidden", !active);
|
| 218 |
+
bar.querySelector(".filter-count").textContent =
|
| 219 |
+
active ? `showing ${activeCodeRows().length} of ${state.results.length}` : "";
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function drawCodeTable() {
|
| 223 |
+
const host = $("#code-table-host");
|
| 224 |
+
host.innerHTML = "";
|
| 225 |
+
const rows = activeCodeRows();
|
| 226 |
+
const table = el("table", "code-table");
|
| 227 |
+
const thead = el("thead");
|
| 228 |
+
const hr = el("tr");
|
| 229 |
+
["Add", "Relevant", "Related", "Unsure", "Rank", "Code Type", "Code", "Description", "Relevance"]
|
| 230 |
+
.forEach((h) => { const th = el("th", null, h); if (COL_HELP[h]) th.title = COL_HELP[h]; hr.appendChild(th); });
|
| 231 |
+
thead.appendChild(hr);
|
| 232 |
+
table.appendChild(thead);
|
| 233 |
+
|
| 234 |
+
const tb = el("tbody");
|
| 235 |
+
rows.forEach((r) => {
|
| 236 |
+
const tr = el("tr");
|
| 237 |
+
const collectTd = el("td", "ann-cell");
|
| 238 |
+
collectTd.appendChild(collectBtn({ kind: "code", category: state.category, code: r.code, code_type: r.code_type, description: r.description }, true));
|
| 239 |
+
tr.appendChild(collectTd);
|
| 240 |
+
["relevant", "related", "unsure"].forEach((kind) => {
|
| 241 |
+
const td = el("td", "ann-cell");
|
| 242 |
+
const cb = el("input"); cb.type = "checkbox"; cb.checked = !!(state.annotations[r.rank] || {})[kind];
|
| 243 |
+
cb.onchange = () => { const a = state.annotations[r.rank] || (state.annotations[r.rank] = { row: r }); a[kind] = cb.checked; };
|
| 244 |
+
td.appendChild(cb); tr.appendChild(td);
|
| 245 |
+
});
|
| 246 |
+
tr.appendChild(el("td", "col-rank", String(r.rank)));
|
| 247 |
+
tr.appendChild(el("td", "col-type", r.code_type));
|
| 248 |
+
const codeTd = el("td", "col-code");
|
| 249 |
+
if (codeSearchGraphable(state.category, r.code_type)) { // has a parent/child hierarchy
|
| 250 |
+
const c = el("button", "code-link code-cell-btn", r.code);
|
| 251 |
+
c.title = state.category === "medication" ? "View RxNorm ingredient mapping" : "View parent / child codes";
|
| 252 |
+
c.onclick = () => openGraph(r.code, r.code_type, r.description);
|
| 253 |
+
codeTd.appendChild(c);
|
| 254 |
+
} else { codeTd.textContent = r.code; }
|
| 255 |
+
tr.appendChild(codeTd);
|
| 256 |
+
tr.appendChild(el("td", "col-desc", r.description));
|
| 257 |
+
tr.appendChild(el("td", "col-rel", r.relevance.toFixed(4)));
|
| 258 |
+
tb.appendChild(tr);
|
| 259 |
+
});
|
| 260 |
+
table.appendChild(tb);
|
| 261 |
+
const wrap = el("div", "table-wrap");
|
| 262 |
+
wrap.appendChild(table);
|
| 263 |
+
host.appendChild(wrap);
|
| 264 |
+
if (!rows.length) host.appendChild(el("p", "filter-empty", "No results of that code type in the current set — adjust the filters above."));
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// -- phenotype card renderer (single relevance + rank; no pipeline internals)
|
| 268 |
+
function renderPhenotypes(data, secs) {
|
| 269 |
+
state.results = data.results;
|
| 270 |
+
$("#status").innerHTML =
|
| 271 |
+
`${data.count} phenotype candidate(s) for <b>"${data.query}"</b> (${secs.toFixed(1)}s)`;
|
| 272 |
+
const box = $("#results");
|
| 273 |
+
box.innerHTML = "";
|
| 274 |
+
if (!data.results.length) { $("#status").textContent = `No candidates for "${data.query}".`; afterResults(0); return; }
|
| 275 |
+
data.results.forEach((res, i) => box.appendChild(phenoCard(res, i + 1)));
|
| 276 |
+
afterResults(data.results.length);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function phenoCard(res, rank) {
|
| 280 |
+
const c = el("article", "card");
|
| 281 |
+
const head = el("div", "card-head");
|
| 282 |
+
const left = el("div", "card-head-left");
|
| 283 |
+
left.appendChild(el("span", "rank-badge", `#${rank}`));
|
| 284 |
+
left.appendChild(el("h3", null, res.title || `Phenotype ${res.phenotype_id}`));
|
| 285 |
+
if (res.category) left.appendChild(el("span", "tag", res.category));
|
| 286 |
+
if (res.validated) left.appendChild(el("span", "tag tag-ok", "validated"));
|
| 287 |
+
head.appendChild(left);
|
| 288 |
+
|
| 289 |
+
const right = el("div", "card-head-right");
|
| 290 |
+
right.appendChild(el("span", "rel-label", "Relevance"));
|
| 291 |
+
right.appendChild(el("span", "rel-value", res.scores.relevance.toFixed(4)));
|
| 292 |
+
right.appendChild(annBox(res.phenotype_id, res));
|
| 293 |
+
head.appendChild(right);
|
| 294 |
+
c.appendChild(head);
|
| 295 |
+
|
| 296 |
+
if (res.summary) c.appendChild(el("p", "summary", res.summary));
|
| 297 |
+
|
| 298 |
+
if (res.code_evidence.length) {
|
| 299 |
+
const chips = el("div", "chips");
|
| 300 |
+
res.code_evidence.forEach((e) => chips.appendChild(el("span", "chip-static", `${e.code_system} · ${e.code_count}`)));
|
| 301 |
+
c.appendChild(chips);
|
| 302 |
+
}
|
| 303 |
+
res.warnings.forEach((w) => c.appendChild(el("div", "warning", `⚠ ${w}`)));
|
| 304 |
+
|
| 305 |
+
const foot = el("div", "card-foot");
|
| 306 |
+
const detail = el("button", "link", "View codes & detail →");
|
| 307 |
+
detail.onclick = () => openDrawer(res.phenotype_id);
|
| 308 |
+
const actions = el("div", "card-foot-actions");
|
| 309 |
+
actions.appendChild(detail);
|
| 310 |
+
actions.appendChild(collectBtn({ kind: "phenotype", category: "phenotype", code: String(res.phenotype_id), code_type: "CIPHER phenotype", description: res.title }, false));
|
| 311 |
+
foot.appendChild(actions);
|
| 312 |
+
foot.appendChild(el("span", "pid", `CIPHER phenotype #${res.phenotype_id}`));
|
| 313 |
+
c.appendChild(foot);
|
| 314 |
+
return c;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function annBox(pid, res) {
|
| 318 |
+
const box = el("div", "ann-box");
|
| 319 |
+
["relevant", "related", "unsure"].forEach((kind) => {
|
| 320 |
+
const lbl = el("label", "ann-opt");
|
| 321 |
+
const cb = el("input"); cb.type = "checkbox";
|
| 322 |
+
cb.onchange = () => { const a = state.annotations[pid] || (state.annotations[pid] = { row: res }); a[kind] = cb.checked; };
|
| 323 |
+
lbl.appendChild(cb);
|
| 324 |
+
lbl.appendChild(el("span", null, kind[0].toUpperCase() + kind.slice(1)));
|
| 325 |
+
box.appendChild(lbl);
|
| 326 |
+
});
|
| 327 |
+
return box;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
// -- annotations submit ---------------------------------------------------
|
| 331 |
+
async function submitAnnotations() {
|
| 332 |
+
const marked = Object.entries(state.annotations)
|
| 333 |
+
.filter(([, a]) => a.relevant || a.related || a.unsure);
|
| 334 |
+
if (!marked.length) { $("#submit-msg").textContent = "Mark at least one result first."; return; }
|
| 335 |
+
const annotator = $("#annotator").value;
|
| 336 |
+
let res;
|
| 337 |
+
if (isPheno()) {
|
| 338 |
+
const annotations = marked.map(([pid, a]) => ({
|
| 339 |
+
phenotype_id: Number(pid), title: (a.row || {}).title,
|
| 340 |
+
relevant: !!a.relevant, related: !!a.related, unsure: !!a.unsure,
|
| 341 |
+
}));
|
| 342 |
+
res = await postJSON("/api/annotations", { annotator, query: state.query, annotations });
|
| 343 |
+
} else {
|
| 344 |
+
const annotations = marked.map(([rank, a]) => ({
|
| 345 |
+
rank: Number(rank), code: a.row.code, code_type: a.row.code_type, description: a.row.description,
|
| 346 |
+
relevant: !!a.relevant, related: !!a.related, unsure: !!a.unsure,
|
| 347 |
+
}));
|
| 348 |
+
res = await postJSON("/api/code/annotations", { annotator, category: state.category, query: state.query, annotations });
|
| 349 |
+
}
|
| 350 |
+
$("#submit-msg").textContent = `✓ Saved ${res.saved} label(s) as ${res.annotator}.`;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
async function postJSON(url, body) {
|
| 354 |
+
return (await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function exportCsv() {
|
| 358 |
+
if (!state.results.length) return;
|
| 359 |
+
const esc = (s) => `"${String(s == null ? "" : s).replace(/"/g, '""')}"`;
|
| 360 |
+
const [header, rows] = isPheno()
|
| 361 |
+
? [["rank", "phenotype_id", "title", "category", "validated", "relevance", "code_systems"],
|
| 362 |
+
state.results.map((r, i) => [i + 1, r.phenotype_id, r.title, r.category, r.validated, r.scores.relevance, (r.code_systems || []).join("; ")])]
|
| 363 |
+
: [["rank", "code_type", "code", "description", "relevance"],
|
| 364 |
+
state.results.map((r) => [r.rank, r.code_type, r.code, r.description, r.relevance])];
|
| 365 |
+
const csv = [header.join(","), ...rows.map((row) => row.map(esc).join(","))].join("\n");
|
| 366 |
+
const blob = new Blob([csv], { type: "text/csv" });
|
| 367 |
+
const a = document.createElement("a");
|
| 368 |
+
a.href = URL.createObjectURL(blob);
|
| 369 |
+
a.download = `encode_${state.category}_${(state.query || "export").replace(/\s+/g, "_").slice(0, 30)}.csv`;
|
| 370 |
+
a.click(); URL.revokeObjectURL(a.href);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// -- phenotype detail drawer (+ About) ------------------------------------
|
| 374 |
+
// Description sources that mean "resolved to a real description" vs an honest gap.
|
| 375 |
+
const RESOLVED_SRC = (s) => s && !["study_specific", "unresolved", "label_missing"].includes(s) && !s.startsWith("needs_vocab");
|
| 376 |
+
|
| 377 |
+
async function openDrawer(pid) {
|
| 378 |
+
const body = $("#drawer-body");
|
| 379 |
+
body.innerHTML = "Loading…";
|
| 380 |
+
showDrawer();
|
| 381 |
+
const p = await apiGet(`/api/phenotype/${pid}`);
|
| 382 |
+
body.innerHTML = "";
|
| 383 |
+
body.appendChild(el("h2", null, p.title));
|
| 384 |
+
const meta = el("div", "drawer-meta");
|
| 385 |
+
if (p.category) meta.appendChild(el("span", "tag", p.category));
|
| 386 |
+
if (p.validated) meta.appendChild(el("span", "tag tag-ok", "validated"));
|
| 387 |
+
meta.appendChild(el("span", "pid", `#${p.phenotype_id}`));
|
| 388 |
+
body.appendChild(meta);
|
| 389 |
+
if (p.description) { body.appendChild(el("h4", null, "Description")); body.appendChild(el("p", "summary", p.description)); }
|
| 390 |
+
if (p.population_description) { body.appendChild(el("h4", null, "Population")); body.appendChild(el("p", "summary", p.population_description)); }
|
| 391 |
+
|
| 392 |
+
if (p.publications.length) {
|
| 393 |
+
body.appendChild(el("h4", null, "Publications"));
|
| 394 |
+
const ul = el("ul"); p.publications.forEach((x) => ul.appendChild(el("li", null, x))); body.appendChild(ul);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// CIPHER algorithm components (professor 1.4): methods, related diseases,
|
| 398 |
+
// validation, adjudication, data period — source-grounded, absent fields omitted.
|
| 399 |
+
if ((p.algorithm_components || []).length) {
|
| 400 |
+
body.appendChild(el("h4", null, "Algorithm components"));
|
| 401 |
+
const box = el("div", "algo-components");
|
| 402 |
+
p.algorithm_components.forEach((row) => {
|
| 403 |
+
const r = el("div", "algo-row");
|
| 404 |
+
r.appendChild(el("span", "algo-key", row.label));
|
| 405 |
+
r.appendChild(el("span", "algo-val", row.value));
|
| 406 |
+
box.appendChild(r);
|
| 407 |
+
});
|
| 408 |
+
body.appendChild(box);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
const cgHead = el("div", "cg-head");
|
| 412 |
+
cgHead.appendChild(el("h4", null, `Associated code groups (${p.code_groups.length})`));
|
| 413 |
+
// Phecode → ICD main → ICD sub tree for this phenotype (professor 2).
|
| 414 |
+
if (p.code_groups.some((g) => (g.code_system || "").includes("ICD"))) {
|
| 415 |
+
const gb = el("button", "graph-btn", "⤳ View code hierarchy");
|
| 416 |
+
gb.title = "Phecode → ICD main categories → ICD sub-codes";
|
| 417 |
+
gb.onclick = () => openPhenotypeGraph(p.phenotype_id, p.title);
|
| 418 |
+
cgHead.appendChild(gb);
|
| 419 |
+
}
|
| 420 |
+
body.appendChild(cgHead);
|
| 421 |
+
p.code_groups.forEach((g) => {
|
| 422 |
+
const det = el("details", "codegroup");
|
| 423 |
+
const cap = g.resolved_count ? ` · ${g.resolved_count}/${g.codes.length} described` : "";
|
| 424 |
+
det.appendChild(el("summary", null, `${g.code_system}${g.sub_type ? " / " + g.sub_type : ""} — ${g.code_count} codes${cap}`));
|
| 425 |
+
const table = el("div", "codes");
|
| 426 |
+
g.codes.forEach((x) => {
|
| 427 |
+
const row = el("div", "code-row");
|
| 428 |
+
// ICD codes -> this phenotype's phecode → ICD main → ICD sub tree (clicked
|
| 429 |
+
// code highlighted); medication codes -> RxNorm ingredient graph (prof 2/3).
|
| 430 |
+
if (x.graphable) {
|
| 431 |
+
const c = el("button", "code code-link", x.code);
|
| 432 |
+
const isIcd = (g.code_system || "").includes("ICD");
|
| 433 |
+
c.title = isIcd ? "Show in phecode → ICD hierarchy" : "View ingredient graph";
|
| 434 |
+
c.onclick = isIcd
|
| 435 |
+
? () => openPhenotypeGraph(p.phenotype_id, p.title, x.code)
|
| 436 |
+
: () => openGraph(x.code, g.code_system);
|
| 437 |
+
row.appendChild(c);
|
| 438 |
+
} else {
|
| 439 |
+
row.appendChild(el("span", "code", x.code));
|
| 440 |
+
}
|
| 441 |
+
const desc = el("span", "code-label", x.description || `(${x.label_status})`);
|
| 442 |
+
if (!RESOLVED_SRC(x.description_source)) desc.classList.add("code-label-gap");
|
| 443 |
+
row.appendChild(desc);
|
| 444 |
+
table.appendChild(row);
|
| 445 |
+
});
|
| 446 |
+
det.appendChild(table);
|
| 447 |
+
body.appendChild(det);
|
| 448 |
+
});
|
| 449 |
+
|
| 450 |
+
// More information -> CIPHER original website (professor 1.5).
|
| 451 |
+
if (p.cipher_url) {
|
| 452 |
+
const more = el("div", "drawer-more");
|
| 453 |
+
more.appendChild(el("h4", null, "More information"));
|
| 454 |
+
const a = el("a", "cipher-link", "View this phenotype on CIPHER ↗");
|
| 455 |
+
a.href = p.cipher_url; a.target = "_blank"; a.rel = "noopener";
|
| 456 |
+
more.appendChild(a);
|
| 457 |
+
body.appendChild(more);
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
function showAbout() {
|
| 462 |
+
const body = $("#drawer-body");
|
| 463 |
+
body.innerHTML = "";
|
| 464 |
+
body.appendChild(el("h2", null, "About ENCODE"));
|
| 465 |
+
body.appendChild(el("p", "summary",
|
| 466 |
+
"ENCODE turns a plain-language clinical concept into ranked medical codes. Pick a category — Diagnosis, Medication, Lab, or Procedure — type a concept, and results are ordered by semantic relevance to your query."));
|
| 467 |
+
body.appendChild(el("p", "summary",
|
| 468 |
+
"Relevance is a single similarity score between your query and each code description. Switch to Phenotype to discover CIPHER phenotype definitions instead of individual codes."));
|
| 469 |
+
showDrawer();
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// -- collected-codes basket drawer ----------------------------------------
|
| 473 |
+
function openBasket() {
|
| 474 |
+
const body = $("#drawer-body");
|
| 475 |
+
body.innerHTML = "";
|
| 476 |
+
const items = Object.values(basket);
|
| 477 |
+
body.appendChild(el("h2", null, `Collected codes (${items.length})`));
|
| 478 |
+
if (!items.length) {
|
| 479 |
+
body.appendChild(el("p", "summary",
|
| 480 |
+
"Collect codes and phenotypes with “+ Collect”. Your list persists across searches and categories, and exports as a CSV."));
|
| 481 |
+
showDrawer(); return;
|
| 482 |
+
}
|
| 483 |
+
const actions = el("div", "basket-actions");
|
| 484 |
+
const exp = el("button", "cipher-link", "Export CSV ↓"); exp.onclick = exportBasket;
|
| 485 |
+
const clr = el("button", "link", "Clear all");
|
| 486 |
+
clr.onclick = () => { if (confirm("Clear the whole collected list?")) { basket = {}; saveBasket(); openBasket(); } };
|
| 487 |
+
actions.appendChild(exp); actions.appendChild(clr);
|
| 488 |
+
body.appendChild(actions);
|
| 489 |
+
|
| 490 |
+
const list = el("div", "basket-list");
|
| 491 |
+
items.forEach((it) => {
|
| 492 |
+
const row = el("div", "basket-row");
|
| 493 |
+
const main = el("div", "basket-main");
|
| 494 |
+
main.appendChild(el("span", "chip-static", it.category));
|
| 495 |
+
main.appendChild(el("span", "code", it.code));
|
| 496 |
+
main.appendChild(el("span", "basket-desc", it.description || ""));
|
| 497 |
+
row.appendChild(main);
|
| 498 |
+
const rm = el("button", "basket-rm", "×"); rm.title = "Remove";
|
| 499 |
+
rm.onclick = () => { delete basket[basketKey(it)]; saveBasket(); openBasket(); };
|
| 500 |
+
row.appendChild(rm);
|
| 501 |
+
list.appendChild(row);
|
| 502 |
+
});
|
| 503 |
+
body.appendChild(list);
|
| 504 |
+
showDrawer();
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
function exportBasket() {
|
| 508 |
+
const items = Object.values(basket);
|
| 509 |
+
if (!items.length) return;
|
| 510 |
+
const esc = (s) => `"${String(s == null ? "" : s).replace(/"/g, '""')}"`;
|
| 511 |
+
const rows = [
|
| 512 |
+
"# ENCODE collected codes",
|
| 513 |
+
`# Exported: ${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
|
| 514 |
+
`# Items: ${items.length}`,
|
| 515 |
+
["kind", "category", "code_type", "code", "description", "source_query"].join(","),
|
| 516 |
+
...items.map((it) => [it.kind, it.category, it.code_type, it.code, it.description, it.query].map(esc).join(",")),
|
| 517 |
+
];
|
| 518 |
+
const blob = new Blob([rows.join("\n")], { type: "text/csv" });
|
| 519 |
+
const url = URL.createObjectURL(blob);
|
| 520 |
+
const a = document.createElement("a");
|
| 521 |
+
a.href = url; a.download = `encode_collected_${new Date().toISOString().slice(0, 10)}.csv`;
|
| 522 |
+
a.click(); URL.revokeObjectURL(url);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
function showDrawer() { $("#drawer").classList.remove("hidden"); $("#overlay").classList.remove("hidden"); }
|
| 526 |
+
function closeDrawer() { $("#drawer").classList.add("hidden"); $("#overlay").classList.add("hidden"); }
|
| 527 |
+
|
| 528 |
+
// -- knowledge graph: click a code -> parent/child ontology (SVG node+edge) --
|
| 529 |
+
async function openGraph(code, codeType, drugName) {
|
| 530 |
+
const body = $("#graph-body");
|
| 531 |
+
body.innerHTML = "Loading…"; showGraph();
|
| 532 |
+
let g;
|
| 533 |
+
try {
|
| 534 |
+
g = await apiGet(`/api/graph?code=${encodeURIComponent(code)}${codeType ? "&code_type=" + encodeURIComponent(codeType) : ""}${drugName ? "&drug_name=" + encodeURIComponent(drugName) : ""}`);
|
| 535 |
+
} catch (e) { body.innerHTML = ""; body.appendChild(el("p", "summary", "Could not load the code graph.")); return; }
|
| 536 |
+
renderGraph(body, g, code);
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
async function openPhenotypeGraph(pid, title, focus) {
|
| 540 |
+
const body = $("#graph-body");
|
| 541 |
+
body.innerHTML = "Loading…"; showGraph();
|
| 542 |
+
let g;
|
| 543 |
+
try {
|
| 544 |
+
g = await apiGet(`/api/phenotype/${pid}/graph${focus ? "?focus=" + encodeURIComponent(focus) : ""}`);
|
| 545 |
+
} catch (e) { body.innerHTML = ""; body.appendChild(el("p", "summary", "Could not load the phenotype graph.")); return; }
|
| 546 |
+
renderGraph(body, g, title || `Phenotype ${pid}`);
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
function renderGraph(body, g, fallbackTitle) {
|
| 550 |
+
body.innerHTML = "";
|
| 551 |
+
body.appendChild(el("h2", null, g.title || fallbackTitle));
|
| 552 |
+
if (!g.available) {
|
| 553 |
+
body.appendChild(el("p", "summary", g.reason || "No parent/child relations available for this code."));
|
| 554 |
+
return;
|
| 555 |
+
}
|
| 556 |
+
if (g.subtitle) body.appendChild(el("p", "graph-sub", g.subtitle));
|
| 557 |
+
if (g.source) body.appendChild(el("p", "graph-note", `Mapping source: ${g.source}`));
|
| 558 |
+
body.appendChild(buildGraphSvg(g));
|
| 559 |
+
if (g.note) body.appendChild(el("p", "graph-note", g.note));
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
const SVGNS = "http://www.w3.org/2000/svg";
|
| 563 |
+
const svgEl = (t, a) => { const n = document.createElementNS(SVGNS, t); for (const k in a) n.setAttribute(k, a[k]); return n; };
|
| 564 |
+
const truncate = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : (s || ""));
|
| 565 |
+
|
| 566 |
+
// Tidy left-to-right tree: x = tier column, y = DFS leaf-stacking (parent centred
|
| 567 |
+
// on its children). Real nodes + edges; nodes with a `nav` hint re-centre on click.
|
| 568 |
+
function buildGraphSvg(g) {
|
| 569 |
+
const NODEW = 214, NODEH = 40, COLW = 274, ROWH = 52, PADX = 16, PADY = 16;
|
| 570 |
+
const pathSet = new Set(g.path || []);
|
| 571 |
+
const byId = {}; g.nodes.forEach((n) => (byId[n.id] = n));
|
| 572 |
+
const kids = {}; g.nodes.forEach((n) => (kids[n.id] = []));
|
| 573 |
+
const hasParent = {};
|
| 574 |
+
g.edges.forEach(([a, b]) => { if (kids[a]) kids[a].push(b); hasParent[b] = true; });
|
| 575 |
+
const roots = g.nodes.filter((n) => !hasParent[n.id]).map((n) => n.id);
|
| 576 |
+
const pos = {}; let leaf = 0;
|
| 577 |
+
const place = (id, seen) => {
|
| 578 |
+
if (seen.has(id)) return (pos[id] || { y: 0 }).y;
|
| 579 |
+
seen.add(id);
|
| 580 |
+
const cs = kids[id] || [];
|
| 581 |
+
const x = (byId[id].tier || 0) * COLW + PADX;
|
| 582 |
+
if (!cs.length) { const y = leaf * ROWH + PADY; leaf++; pos[id] = { x, y }; return y; }
|
| 583 |
+
const ys = cs.map((c) => place(c, seen));
|
| 584 |
+
const y = (ys[0] + ys[ys.length - 1]) / 2; pos[id] = { x, y }; return y;
|
| 585 |
+
};
|
| 586 |
+
const seen = new Set();
|
| 587 |
+
roots.forEach((r) => place(r, seen));
|
| 588 |
+
g.nodes.forEach((n) => { if (!pos[n.id]) { pos[n.id] = { x: (n.tier || 0) * COLW + PADX, y: leaf * ROWH + PADY }; leaf++; } });
|
| 589 |
+
const maxTier = Math.max(0, ...g.nodes.map((n) => n.tier || 0));
|
| 590 |
+
const W = (maxTier + 1) * COLW + PADX, H = Math.max(leaf * ROWH + PADY, 72);
|
| 591 |
+
const svg = svgEl("svg", { width: W, height: H, class: "graph-svg", viewBox: `0 0 ${W} ${H}` });
|
| 592 |
+
|
| 593 |
+
g.edges.forEach(([a, b]) => {
|
| 594 |
+
const pa = pos[a], pb = pos[b]; if (!pa || !pb) return;
|
| 595 |
+
const onPath = pathSet.has(a) && pathSet.has(b);
|
| 596 |
+
const x1 = pa.x + NODEW, y1 = pa.y + NODEH / 2, x2 = pb.x, y2 = pb.y + NODEH / 2, mx = (x1 + x2) / 2;
|
| 597 |
+
svg.appendChild(svgEl("path", { d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`,
|
| 598 |
+
class: "graph-edge" + (onPath ? " graph-edge-path" : "") }));
|
| 599 |
+
});
|
| 600 |
+
g.nodes.forEach((n) => {
|
| 601 |
+
const p = pos[n.id]; if (!p) return;
|
| 602 |
+
let cls = "graph-rect";
|
| 603 |
+
if (n.more) cls += " graph-rect-more";
|
| 604 |
+
else if (n.current) cls += " graph-rect-current";
|
| 605 |
+
else if (n.path) cls += " graph-rect-path";
|
| 606 |
+
else if (n.tier === 0) cls += " graph-rect-root";
|
| 607 |
+
const grp = svgEl("g", { transform: `translate(${p.x},${p.y})` });
|
| 608 |
+
grp.appendChild(svgEl("rect", { width: NODEW, height: NODEH, rx: 8, class: cls }));
|
| 609 |
+
const labCls = "graph-node-label" + (n.current ? " graph-node-cur" : "") + (n.more ? " graph-node-more" : "");
|
| 610 |
+
const lab = svgEl("text", { x: 12, y: 17, class: labCls }); lab.textContent = truncate(n.label, 30); grp.appendChild(lab);
|
| 611 |
+
if (n.sub) { const s = svgEl("text", { x: 12, y: 31, class: "graph-node-sub" }); s.textContent = truncate(n.sub, 36); grp.appendChild(s); }
|
| 612 |
+
const tt = svgEl("title"); tt.textContent = n.label + (n.sub ? " — " + n.sub : ""); grp.appendChild(tt);
|
| 613 |
+
if (n.nav && !n.current && !n.more) {
|
| 614 |
+
grp.setAttribute("class", "graph-node-clickable");
|
| 615 |
+
grp.onclick = (g.kind === "phenotype" && (n.tier || 0) === 1)
|
| 616 |
+
? () => openPhenotypeGraph(g.code, g.title, n.nav.code) // re-expand this category branch
|
| 617 |
+
: () => openGraph(n.nav.code, n.nav.code_type); // drill to the code's full family
|
| 618 |
+
}
|
| 619 |
+
svg.appendChild(grp);
|
| 620 |
+
});
|
| 621 |
+
const wrap = el("div", "graph-scroll"); wrap.appendChild(svg);
|
| 622 |
+
return wrap;
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
function showGraph() { $("#graph-drawer").classList.remove("hidden"); $("#graph-overlay").classList.remove("hidden"); }
|
| 626 |
+
function closeGraph() { $("#graph-drawer").classList.add("hidden"); $("#graph-overlay").classList.add("hidden"); }
|
| 627 |
+
|
| 628 |
+
// -- phenotype sub-category filters ---------------------------------------
|
| 629 |
+
async function loadPhenoCategories() {
|
| 630 |
+
try {
|
| 631 |
+
const { categories } = await apiGet("/api/categories");
|
| 632 |
+
const box = $("#categories");
|
| 633 |
+
box.innerHTML = "";
|
| 634 |
+
categories.forEach((c) => {
|
| 635 |
+
const row = el("label", "filter-opt");
|
| 636 |
+
const cb = el("input"); cb.type = "checkbox";
|
| 637 |
+
cb.onchange = () => { cb.checked ? state.phenoCats.add(c) : state.phenoCats.delete(c); if (state.query && isPheno()) runSearch(); };
|
| 638 |
+
row.appendChild(cb); row.appendChild(el("span", null, c));
|
| 639 |
+
box.appendChild(row);
|
| 640 |
+
});
|
| 641 |
+
} catch (_) { /* phenotype index optional */ }
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
// -- wiring ---------------------------------------------------------------
|
| 645 |
+
$("#category").onchange = (e) => { state.category = e.target.value; state.query = ""; $("#query").value = ""; applyCategory(); };
|
| 646 |
+
$("#search").onclick = runSearch;
|
| 647 |
+
$("#query").addEventListener("keydown", (e) => { if (e.key === "Enter") runSearch(); });
|
| 648 |
+
$("#submit").onclick = submitAnnotations;
|
| 649 |
+
$("#export").onclick = exportCsv;
|
| 650 |
+
$("#about-btn").onclick = showAbout;
|
| 651 |
+
$("#basket-btn").onclick = openBasket;
|
| 652 |
+
$("#drawer-close").onclick = closeDrawer;
|
| 653 |
+
$("#overlay").onclick = closeDrawer;
|
| 654 |
+
$("#graph-close").onclick = closeGraph;
|
| 655 |
+
$("#graph-overlay").onclick = closeGraph;
|
| 656 |
+
$("#k").oninput = (e) => { $("#k-value").textContent = e.target.value; };
|
| 657 |
+
$("#k").onchange = () => { if (state.query) runSearch(); };
|
| 658 |
+
$("#validated").onchange = () => { if (state.query && isPheno()) runSearch(); };
|
| 659 |
+
|
| 660 |
+
async function init() {
|
| 661 |
+
loadPhenoCategories();
|
| 662 |
+
applyCategory();
|
| 663 |
+
updateBasketCount();
|
| 664 |
+
$("#query").focus();
|
| 665 |
+
}
|
| 666 |
+
init();
|
src/frontend/index.html
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>ENCODE — Embedding Empowered Code Search</title>
|
| 7 |
+
<link rel="stylesheet" href="/styles.css?v=demo1" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<header class="topbar">
|
| 11 |
+
<span class="brand">ENCODE</span>
|
| 12 |
+
<span class="topbar-title">Embedding Empowered Code Search</span>
|
| 13 |
+
<button id="basket-btn" class="basket-btn">Collection <span id="basket-count" class="basket-count">0</span></button>
|
| 14 |
+
<button id="about-btn" class="about-btn">About</button>
|
| 15 |
+
</header>
|
| 16 |
+
|
| 17 |
+
<div class="layout">
|
| 18 |
+
<aside class="sidebar">
|
| 19 |
+
<label class="side-label" for="category">Code Search Category</label>
|
| 20 |
+
<select id="category" class="category-select">
|
| 21 |
+
<option value="phenotype">Phenotype</option>
|
| 22 |
+
<option value="diagnosis">Diagnosis</option>
|
| 23 |
+
<option value="medication">Medication</option>
|
| 24 |
+
<option value="lab">Lab</option>
|
| 25 |
+
<option value="procedure">Procedure</option>
|
| 26 |
+
</select>
|
| 27 |
+
|
| 28 |
+
<label class="side-label">Number of Results</label>
|
| 29 |
+
<output id="k-value" class="k-value">50</output>
|
| 30 |
+
<input id="k" type="range" min="10" max="100" step="10" value="50" />
|
| 31 |
+
|
| 32 |
+
<div id="pheno-filters" class="hidden">
|
| 33 |
+
<label class="side-label">Phenotype Category</label>
|
| 34 |
+
<div id="categories" class="filters"></div>
|
| 35 |
+
<label class="side-label">Validation</label>
|
| 36 |
+
<label class="filter-opt"><input id="validated" type="checkbox" /><span>Validated only</span></label>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<button id="export" class="export hidden">Export CSV</button>
|
| 40 |
+
</aside>
|
| 41 |
+
|
| 42 |
+
<main class="content">
|
| 43 |
+
<p id="search-for" class="search-for">Search for Diagnosis</p>
|
| 44 |
+
<div class="search-row">
|
| 45 |
+
<input id="query" class="query" type="text" autocomplete="off"
|
| 46 |
+
placeholder="e.g. Type 2 diabetes mellitus" />
|
| 47 |
+
<button id="search" class="search-btn">Search</button>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<p id="status" class="status"></p>
|
| 51 |
+
<section id="results" class="results"></section>
|
| 52 |
+
<div id="empty" class="empty"></div>
|
| 53 |
+
|
| 54 |
+
<div id="submit-row" class="submit-row hidden">
|
| 55 |
+
<input id="annotator" class="annotator" type="text" placeholder="Your name" />
|
| 56 |
+
<button id="submit" class="submit-btn">Submit Annotations</button>
|
| 57 |
+
<span id="submit-msg" class="submit-msg"></span>
|
| 58 |
+
</div>
|
| 59 |
+
</main>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<aside id="drawer" class="drawer hidden">
|
| 63 |
+
<button id="drawer-close" class="drawer-close" aria-label="Close">×</button>
|
| 64 |
+
<div id="drawer-body"></div>
|
| 65 |
+
</aside>
|
| 66 |
+
<div id="overlay" class="overlay hidden"></div>
|
| 67 |
+
|
| 68 |
+
<aside id="graph-drawer" class="drawer graph-drawer hidden">
|
| 69 |
+
<button id="graph-close" class="drawer-close" aria-label="Close">×</button>
|
| 70 |
+
<div id="graph-body"></div>
|
| 71 |
+
</aside>
|
| 72 |
+
<div id="graph-overlay" class="overlay graph-overlay hidden"></div>
|
| 73 |
+
|
| 74 |
+
<script src="/app.js?v=cpu1"></script>
|
| 75 |
+
</body>
|
| 76 |
+
</html>
|
src/frontend/styles.css
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--dark: #141c25; --blue: #3b8bbf; --blue-d: #357aa8; --line: #e2e6ea;
|
| 3 |
+
--text: #2c3744; --muted: #8a97a4; --ok: #2e7d52; --warn: #8a5a00;
|
| 4 |
+
}
|
| 5 |
+
* { box-sizing: border-box; }
|
| 6 |
+
body { margin: 0; color: var(--text); background: #f7f9fb;
|
| 7 |
+
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
| 8 |
+
|
| 9 |
+
.topbar { display: flex; align-items: center; background: var(--dark); color: #fff; height: 56px; padding: 0 18px; }
|
| 10 |
+
.brand { font-weight: 800; letter-spacing: 1px; font-size: 19px; }
|
| 11 |
+
.topbar-title { flex: 1; text-align: center; font-weight: 700; font-size: 18px; }
|
| 12 |
+
.about-btn { background: var(--blue); color: #fff; border: 0; border-radius: 6px; padding: 7px 16px; font-size: 14px; cursor: pointer; }
|
| 13 |
+
.about-btn:hover { background: var(--blue-d); }
|
| 14 |
+
.basket-btn { display: inline-flex; align-items: center; gap: 7px; margin-right: 10px; background: transparent; color: #dbe5ec; border: 1px solid #43515f; border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 13px; cursor: pointer; }
|
| 15 |
+
.basket-btn:hover { color: #fff; background: #1d2a36; border-color: #667786; }
|
| 16 |
+
.basket-btn:focus-visible, .about-btn:focus-visible { outline: 2px solid #8bc7ed; outline-offset: 2px; }
|
| 17 |
+
.basket-count { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; color: #fff; background: var(--blue); border-radius: 10px; padding: 0 6px; font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
| 18 |
+
|
| 19 |
+
.category-select { width: 100%; padding: 10px 12px; border-radius: 7px; border: 1px solid #33424f; background: #1d2731; color: #fff; font-size: 15px; cursor: pointer; }
|
| 20 |
+
.category-select:focus { outline: none; border-color: var(--blue); }
|
| 21 |
+
|
| 22 |
+
.layout { display: flex; min-height: calc(100vh - 56px); }
|
| 23 |
+
.sidebar { width: 270px; flex-shrink: 0; background: var(--dark); color: #cdd6df; padding: 22px 20px; }
|
| 24 |
+
.side-label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; margin: 22px 0 8px; color: #fff; }
|
| 25 |
+
.side-label:first-child { margin-top: 0; }
|
| 26 |
+
.k-value { display: block; font-size: 15px; color: #fff; margin: 2px 0 4px; }
|
| 27 |
+
.sidebar input[type=range] { width: 100%; accent-color: var(--blue); }
|
| 28 |
+
.filters { margin: 4px 0; }
|
| 29 |
+
.filter-opt { display: flex; align-items: center; gap: 9px; padding: 4px 0; font-size: 14px; cursor: pointer; }
|
| 30 |
+
.filter-opt input { accent-color: var(--blue); width: 15px; height: 15px; }
|
| 31 |
+
.export { width: 100%; margin-top: 30px; background: var(--blue); color: #fff; border: 0; border-radius: 6px; padding: 11px; font-size: 14px; cursor: pointer; }
|
| 32 |
+
.export:hover { background: var(--blue-d); }
|
| 33 |
+
|
| 34 |
+
.content { flex: 1; min-width: 0; max-width: 1080px; margin: 0 auto; padding: 30px 24px 70px; width: 100%; }
|
| 35 |
+
.search-for { text-align: center; font-size: 12px; letter-spacing: 1px; color: var(--muted); text-transform: uppercase; margin: 6px 0 14px; }
|
| 36 |
+
.search-row { display: flex; gap: 8px; }
|
| 37 |
+
.query { flex: 1; padding: 13px 16px; border: 1px solid var(--line); border-radius: 9px; background: #fff; font-size: 16px; }
|
| 38 |
+
.query:focus { outline: none; border-color: var(--blue); }
|
| 39 |
+
.search-btn { background: var(--blue); color: #fff; border: 0; border-radius: 9px; padding: 0 26px; font-size: 15px; font-weight: 600; cursor: pointer; }
|
| 40 |
+
.search-btn:hover { background: var(--blue-d); }
|
| 41 |
+
.examples { text-align: center; color: var(--muted); font-size: 13px; margin-top: 10px; }
|
| 42 |
+
.examples a { color: var(--blue); cursor: pointer; }
|
| 43 |
+
.examples a:hover { text-decoration: underline; }
|
| 44 |
+
|
| 45 |
+
.status { color: #5a6773; font-size: 14px; margin: 22px 0 10px; min-height: 20px; }
|
| 46 |
+
.results { display: flex; flex-direction: column; gap: 14px; }
|
| 47 |
+
|
| 48 |
+
/* empty state */
|
| 49 |
+
.empty { text-align: center; color: var(--muted); padding: 48px 10px; }
|
| 50 |
+
.empty-title { font-size: 26px; font-weight: 400; color: #6b7885; margin: 0 0 10px; }
|
| 51 |
+
.empty-sub { font-size: 15px; margin: 0 0 18px; }
|
| 52 |
+
.empty-try { font-size: 14px; margin-bottom: 8px; }
|
| 53 |
+
.example-chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
| 54 |
+
.example-chip { background: #fff; border: 1px solid var(--line); color: var(--blue); border-radius: 16px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
|
| 55 |
+
.example-chip:hover { background: var(--blue); color: #fff; border-color: var(--blue); }
|
| 56 |
+
.empty-link { color: var(--blue); cursor: pointer; }
|
| 57 |
+
.empty-link:hover { text-decoration: underline; }
|
| 58 |
+
.empty-dot { color: var(--muted); }
|
| 59 |
+
|
| 60 |
+
/* code-result filter chips (narrow the table by code type) */
|
| 61 |
+
.filter-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 12px; }
|
| 62 |
+
.filter-bar:empty { display: none; }
|
| 63 |
+
.filter-bar-label { font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: var(--muted); margin-right: 2px; }
|
| 64 |
+
.fchip { background: #fff; border: 1px solid var(--line); color: #5a6773; border-radius: 14px; padding: 4px 12px; font-size: 12px; cursor: pointer; font-variant-numeric: tabular-nums; }
|
| 65 |
+
.fchip:hover { border-color: var(--blue); color: var(--blue); }
|
| 66 |
+
.fchip.on { background: var(--blue); border-color: var(--blue); color: #fff; }
|
| 67 |
+
.fchip-clear { background: none; border: 0; color: var(--blue); font-size: 12px; cursor: pointer; padding: 4px 6px; }
|
| 68 |
+
.fchip-clear:hover { text-decoration: underline; }
|
| 69 |
+
.filter-count { font-size: 12px; color: var(--muted); }
|
| 70 |
+
.filter-empty { color: var(--muted); font-size: 14px; padding: 18px 4px; text-align: center; }
|
| 71 |
+
|
| 72 |
+
/* code results table */
|
| 73 |
+
.table-wrap { border: 1px solid var(--line); border-radius: 10px; overflow-x: auto; background: #fff; }
|
| 74 |
+
.code-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
| 75 |
+
.code-table th { text-align: left; font-weight: 600; color: #6b7885; background: #f6f8fa; border-bottom: 1px solid var(--line); padding: 12px 14px; white-space: nowrap; }
|
| 76 |
+
.code-table td { border-bottom: 1px solid #eef1f4; padding: 11px 14px; vertical-align: top; }
|
| 77 |
+
.code-table tbody tr:last-child td { border-bottom: 0; }
|
| 78 |
+
.code-table tbody tr:hover { background: #f9fbfc; }
|
| 79 |
+
.ann-cell { text-align: center; width: 62px; }
|
| 80 |
+
.ann-cell input { accent-color: var(--blue); width: 15px; height: 15px; cursor: pointer; }
|
| 81 |
+
.col-rank { color: var(--muted); font-variant-numeric: tabular-nums; width: 54px; }
|
| 82 |
+
.col-type { color: #5a6773; white-space: nowrap; }
|
| 83 |
+
.col-code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #2c5e7e; word-break: break-all; max-width: 280px; }
|
| 84 |
+
.col-desc { color: var(--text); min-width: 160px; }
|
| 85 |
+
.col-rel { text-align: right; font-variant-numeric: tabular-nums; color: var(--text); width: 84px; white-space: nowrap; }
|
| 86 |
+
|
| 87 |
+
/* phenotype card head: rank badge + single relevance */
|
| 88 |
+
.rank-badge { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--blue); background: #e8f2f9; border: 1px solid #bcdcec; border-radius: 6px; padding: 1px 8px; font-size: 13px; }
|
| 89 |
+
.card-head-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
| 90 |
+
.rel-label { font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); }
|
| 91 |
+
.rel-value { font-size: 17px; font-weight: 700; color: #1f6f9e; font-variant-numeric: tabular-nums; }
|
| 92 |
+
|
| 93 |
+
.card { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; }
|
| 94 |
+
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
|
| 95 |
+
.card-head-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
| 96 |
+
.card-head h3 { margin: 0; font-size: 17px; }
|
| 97 |
+
.tag { font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); border: 1px solid var(--line); border-radius: 5px; padding: 2px 7px; }
|
| 98 |
+
.tag-ok { color: var(--ok); border-color: #bfe3cd; }
|
| 99 |
+
|
| 100 |
+
.ann-box { display: flex; gap: 12px; flex-shrink: 0; }
|
| 101 |
+
.ann-opt { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #5a6773; cursor: pointer; }
|
| 102 |
+
.ann-opt input { accent-color: var(--blue); width: 15px; height: 15px; }
|
| 103 |
+
|
| 104 |
+
.summary { color: #5a6773; margin: 10px 0 12px; }
|
| 105 |
+
.scores { display: flex; flex-direction: column; gap: 5px; max-width: 440px; margin-bottom: 10px; }
|
| 106 |
+
.score { display: flex; align-items: center; gap: 9px; font-size: 12px; }
|
| 107 |
+
.score-primary .score-label, .score-primary .score-val { color: var(--text); font-weight: 700; }
|
| 108 |
+
.score-primary .score-fill { background: #1f6f9e; }
|
| 109 |
+
.score-label { width: 64px; color: var(--muted); }
|
| 110 |
+
.score-track { flex: 1; height: 6px; background: #eef1f4; border-radius: 4px; overflow: hidden; }
|
| 111 |
+
.score-fill { height: 100%; background: var(--blue); }
|
| 112 |
+
.score-val { width: 44px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
| 113 |
+
|
| 114 |
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
| 115 |
+
.chip-static { font-size: 11px; color: #5a6773; background: #f1f5f8; border: 1px solid var(--line); border-radius: 5px; padding: 2px 8px; }
|
| 116 |
+
.warning { background: #fdf6e3; border: 1px solid #e8d9a8; color: var(--warn); border-radius: 7px; padding: 7px 10px; font-size: 13px; margin: 6px 0; }
|
| 117 |
+
.card-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
|
| 118 |
+
.card-foot-actions { display: flex; align-items: center; gap: 14px; }
|
| 119 |
+
.link { background: none; border: 0; color: var(--blue); cursor: pointer; font-size: 14px; padding: 0; }
|
| 120 |
+
.link:hover { text-decoration: underline; }
|
| 121 |
+
.pid { color: var(--muted); font-size: 12px; }
|
| 122 |
+
|
| 123 |
+
.submit-row { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 26px; }
|
| 124 |
+
.annotator { padding: 10px 14px; border: 1px solid var(--line); border-radius: 7px; font-size: 14px; min-width: 220px; }
|
| 125 |
+
.submit-btn { background: var(--blue); color: #fff; border: 0; border-radius: 7px; padding: 11px 22px; font-size: 14px; cursor: pointer; }
|
| 126 |
+
.submit-btn:hover { background: var(--blue-d); }
|
| 127 |
+
.submit-msg { color: var(--ok); font-size: 13px; }
|
| 128 |
+
|
| 129 |
+
.hidden { display: none; }
|
| 130 |
+
|
| 131 |
+
.drawer { position: fixed; top: 0; right: 0; height: 100%; width: min(560px, 92vw); background: #fff; border-left: 1px solid var(--line); padding: 24px 26px; overflow-y: auto; z-index: 20; box-shadow: -8px 0 30px rgba(0,0,0,.15); }
|
| 132 |
+
.drawer.hidden, .overlay.hidden { display: none; }
|
| 133 |
+
.drawer-close { position: absolute; top: 14px; right: 16px; background: none; border: 0; color: var(--muted); font-size: 26px; cursor: pointer; line-height: 1; }
|
| 134 |
+
.drawer h2 { margin: 0 36px 8px 0; }
|
| 135 |
+
.drawer h4 { margin: 18px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: .4px; color: var(--blue); }
|
| 136 |
+
.drawer-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
| 137 |
+
.drawer ul { margin: 6px 0; padding-left: 18px; color: #5a6773; font-size: 13px; }
|
| 138 |
+
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 10; }
|
| 139 |
+
.codegroup { border: 1px solid var(--line); border-radius: 8px; margin: 6px 0; padding: 4px 10px; }
|
| 140 |
+
.codegroup summary { cursor: pointer; font-size: 14px; }
|
| 141 |
+
.codes { margin-top: 8px; max-height: 280px; overflow-y: auto; }
|
| 142 |
+
.code-row { display: flex; gap: 10px; padding: 3px 0; border-top: 1px solid #eef1f4; font-size: 12px; }
|
| 143 |
+
.code { color: #2c5e7e; font-family: ui-monospace, monospace; min-width: 90px; }
|
| 144 |
+
.code-label { color: var(--text); }
|
| 145 |
+
.code-label-gap { color: var(--muted); font-style: italic; }
|
| 146 |
+
|
| 147 |
+
/* Algorithm components (professor 1.4) */
|
| 148 |
+
.algo-components { border: 1px solid var(--line); border-radius: 8px; padding: 4px 12px; }
|
| 149 |
+
.algo-row { display: flex; gap: 12px; padding: 6px 0; border-top: 1px solid #eef1f4; font-size: 13px; }
|
| 150 |
+
.algo-row:first-child { border-top: 0; }
|
| 151 |
+
.algo-key { flex-shrink: 0; width: 120px; color: var(--muted); font-weight: 600; }
|
| 152 |
+
.algo-val { color: #5a6773; }
|
| 153 |
+
|
| 154 |
+
/* More information -> CIPHER (professor 1.5) */
|
| 155 |
+
.drawer-more { margin-top: 18px; }
|
| 156 |
+
.cipher-link { display: inline-block; color: var(--blue); font-weight: 600; text-decoration: none; background: none; border: 0; cursor: pointer; padding: 0; font-size: 14px; }
|
| 157 |
+
.cipher-link:hover { text-decoration: underline; }
|
| 158 |
+
|
| 159 |
+
/* knowledge-graph drawer (click a code -> parent/child ontology) */
|
| 160 |
+
.graph-drawer { z-index: 40; width: clamp(560px, 66vw, 1400px); }
|
| 161 |
+
.graph-overlay { z-index: 30; background: rgba(0,0,0,.25); }
|
| 162 |
+
.code-link { background: none; border: 0; padding: 0; font: inherit; color: #2c5e7e; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; }
|
| 163 |
+
.code-link:hover { color: var(--blue); text-decoration-style: solid; }
|
| 164 |
+
.code-cell-btn { word-break: break-all; text-align: left; }
|
| 165 |
+
.graph-sub { color: #5a6773; font-size: 13px; margin: 2px 0 14px; }
|
| 166 |
+
.graph-note { color: var(--muted); font-size: 12px; margin: 8px 2px 0; }
|
| 167 |
+
.graph-scroll { overflow: auto; max-width: 100%; max-height: calc(100vh - 200px); border: 1px solid var(--line); border-radius: 10px; background: #fbfdfe; margin: 4px 0; }
|
| 168 |
+
.graph-svg { display: block; }
|
| 169 |
+
.graph-edge { fill: none; stroke: #cfd8e0; stroke-width: 1.4; }
|
| 170 |
+
.graph-edge-path { stroke: var(--blue); stroke-width: 2.5; } /* the clicked root→…→code path */
|
| 171 |
+
.graph-rect { fill: #fff; stroke: var(--line); stroke-width: 1; }
|
| 172 |
+
.graph-rect-root { fill: #eef5fa; stroke: #bcdcec; }
|
| 173 |
+
.graph-rect-path { fill: #eaf3fb; stroke: #7fb4d6; stroke-width: 1.5; } /* ancestors on the path */
|
| 174 |
+
.graph-rect-current { fill: #cfe6f6; stroke: var(--blue); stroke-width: 2.5; } /* the clicked code */
|
| 175 |
+
.graph-rect-more { fill: #f6f8fa; stroke: #cbd5dd; stroke-dasharray: 4 3; } /* "+N more" stub */
|
| 176 |
+
.graph-node-label { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #2c5e7e; }
|
| 177 |
+
.graph-node-cur { fill: #124b6b; font-weight: 800; }
|
| 178 |
+
.graph-node-more { fill: var(--muted); font-family: system-ui, sans-serif; font-weight: 500; }
|
| 179 |
+
.graph-node-sub { font: 11px system-ui, sans-serif; fill: #6b7885; }
|
| 180 |
+
.graph-node-clickable { cursor: pointer; }
|
| 181 |
+
.graph-node-clickable:hover .graph-rect { stroke: var(--blue); }
|
| 182 |
+
.graph-node-clickable:hover .graph-node-label { fill: var(--blue); }
|
| 183 |
+
|
| 184 |
+
/* phenotype "view code hierarchy" trigger next to the code-groups heading */
|
| 185 |
+
.cg-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
| 186 |
+
.graph-btn { background: #eef5fa; color: var(--blue); border: 1px solid #bcdcec; border-radius: 6px; padding: 5px 11px; font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
| 187 |
+
.graph-btn:hover { background: var(--blue); color: #fff; border-color: var(--blue); }
|
| 188 |
+
|
| 189 |
+
/* collected-codes basket */
|
| 190 |
+
.collect-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 28px; background: #f8fbfd; border: 1px solid #bcdcec; color: var(--blue); border-radius: 6px; padding: 4px 9px; font: inherit; font-size: 12px; font-weight: 600; line-height: 1; cursor: pointer; white-space: nowrap; transition: background-color .15s, border-color .15s, color .15s; }
|
| 191 |
+
.collect-btn:hover { background: #eef5fa; border-color: #7fb4d6; }
|
| 192 |
+
.collect-btn:focus-visible { outline: 2px solid #8bc7ed; outline-offset: 2px; }
|
| 193 |
+
.collect-btn.on { background: #e8f2f9; color: #1f6f9e; border-color: #7fb4d6; }
|
| 194 |
+
.collect-mark { display: inline-flex; align-items: center; justify-content: center; width: 12px; height: 12px; font-size: 14px; font-weight: 700; line-height: 1; }
|
| 195 |
+
.collect-btn.on .collect-mark { font-size: 12px; }
|
| 196 |
+
.collect-icon { width: 30px; height: 30px; min-height: 30px; padding: 0; }
|
| 197 |
+
.collect-icon .collect-mark { width: auto; height: auto; }
|
| 198 |
+
.basket-actions { display: flex; gap: 18px; align-items: center; margin: 10px 0 16px; }
|
| 199 |
+
.basket-list { display: flex; flex-direction: column; gap: 6px; }
|
| 200 |
+
.basket-row { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
|
| 201 |
+
.basket-main { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; flex-wrap: wrap; }
|
| 202 |
+
.basket-desc { color: #5a6773; font-size: 13px; }
|
| 203 |
+
.basket-rm { background: none; border: 0; color: var(--muted); font-size: 18px; cursor: pointer; line-height: 1; }
|
| 204 |
+
.basket-rm:hover { color: var(--warn); }
|
src/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runtime dependencies. The Dockerfile installs the CPU-only torch wheel first.
|
| 2 |
+
torch==2.13.0
|
| 3 |
+
sentence-transformers==5.6.0
|
| 4 |
+
fastapi==0.139.0
|
| 5 |
+
uvicorn[standard]==0.51.0
|
| 6 |
+
numpy==2.5.1
|
| 7 |
+
faiss-cpu==1.14.3
|