SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
20.3 KB · 218 lines markdown
Rendered Raw Blame History
1# Connector `ema` — European Medicines Agency, centrally authorised medicines (EU)23| | |4|---|---|5| Source | https://www.ema.europa.eu/en/medicines/download-medicine-data — EMA "Download medicine data": the **medicines** table (every medicine evaluated under the centralised procedure, human and veterinary; one row per EMA product number) as an xlsx report |6| Tier / category | 3 / regulatory (CLAUDE.md §13 — jurisdiction **EU**, authority **EMA**, `approval_type 'centralised'`; never a bare `approved = true`) |7| Access | Bulk download, no authentication: `https://www.ema.europa.eu/en/documents/report/medicines-output-medicines-report_en.xlsx` (897,597 bytes, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) |8| Docs verified | 2026-09-11 — download page + the live file with `User-Agent: CancerIndex/0.1`; EPAR URL pattern verified (HTTP 200) for xeljanz, keytruda, imfinzi |9| License | EMA legal notice (https://www.ema.europa.eu/en/about-us/legal-notice) — `licenseStatus: approved`, `commercialUse: allowed`, `redistribution: attribution`; attribution: *"Source: European Medicines Agency (www.ema.europa.eu), medicines data download; © EMA, reproduced with acknowledgement"* |10| Code | `packages/connectors/src/connectors/ema/` (`manifest.ts`, `normalize.ts`, `index.ts`, `ema.test.ts`, `fixtures/` incl. `build-fixtures.ts`); dependency `exceljs` (xlsx parsing) |1112## Terms and licence (quoted)1314From the legal notice, section "European Medicines Agency copyright and limited reproduction15notices" (verified 2026-09-11, stored verbatim in `manifest.termsNotes`):1617> Information and documents made available on EMA's webpages are public and may be reproduced and / or distributed, totally or in part, irrespective of the means and / or the formats used, for non-commercial and commercial purposes, provided that EMA is always acknowledged as the source of the material. Such acknowledgement must be included in each copy of the material. Citations may be made from such material without prior permission, provided the source is always acknowledged. The above-mentioned permissions do not apply to content supplied by third parties.1819The medicines table is EMA's own content (no third-party copyright is asserted on it). CancerIndex20reproduces the EMA product number, status, dates, flags and the therapeutic-indication summary as21a dated, sourced regulatory fact per medicine — never as a treatment recommendation.2223Update frequency (download page): *"The website automatically updates these data tables24overnight."* The connector runs weekly (`0 5 * * 3`), one download per run (≈ 3 s end to end),251 request/s, no documented rate limit.2627## Verified file layout (2026-09-11)2829One sheet, **"Medicine"**. Row 1 = `Content type: | Medicine | Output automatically generated from30content on www.ema.europa.eu | 11/09/2026 - 06:00` (the generation timestamp becomes31`datasetVersion = ema-medicines-2026-09-11T06:00`); rows 2–8 empty; **row 9 = header**; data from32row 10 — **2,732 rows** (2,339 Human, 393 Veterinary). Columns are matched by **normalized header33text** (lower-cased, whitespace and embedded newlines collapsed — `"Species\n(veterinary)"`,34`"Pharmacotherapeutic group\n(human)"`), never by position (`COLUMNS` in `normalize.ts`). A missing35**required** column fails the run loudly (`SchemaDriftError: required column(s) missing …`);36unknown columns are kept in `raw.extra` and reported; missing optional columns are warned.3738| Column (header) | Used as |39|---|---|40| `Category` | Human / Veterinary — only Human rows are mapped |41| `Name of medicine` | medicine name → `drug_codes.ema_product.label`, `brand` alias (single-drug medicines), `raw.medicine` |42| `EMA product number` | `EMEA/H/C/004214` → `application_number`, `drug_codes.ema_product`, source record id, edge `source_record_id` |43| `Medicine status` | Authorised, Withdrawn, Application withdrawn, Refused, Lapsed, Opinion, Expired, Revoked, Suspended, Opinion under re-examination, Withdrawn from rolling review → status mapping below |44| `International non-proprietary name (INN) / common name`, `Active substance` | substances (`;` / ` / ` separated; INN preferred, active substance = salt form fallback) |45| `Therapeutic area (MeSH)` | `;`-separated MeSH descriptors → scope + `mesh_therapeutic_area` edges |46| `ATC code (human)`, `Pharmacotherapeutic group (human)` | scope (L01/L02), `drug_codes.atc` (label = group) |47| `Therapeutic indication` | official summary → `indication` (≤ 4,000 chars, full text in `raw.indicationFull`), cancer mapping |48| `Accelerated assessment`, `Conditional approval`, `Biosimilar`, `Generic`, `Orphan medicine`, `PRIME: priority medicine`, `Advanced therapy`, `Exceptional circumstances`, `Additional monitoring` | Yes/No flags → `accelerated`, `conditional`, `raw.flags` |49| `Marketing authorisation developer / applicant / holder` | `raw.holder` |50| `Marketing authorisation date` | **`approval_date`** (initial authorisation) |51| `European Commission decision date` | `raw.ecDecisionDate` only — it is the **latest** Commission decision (Xeljanz: 23/07/2026 vs authorisation 22/03/2017; for withdrawn medicines often the withdrawal decision), so it is never used as approval date |52| `Withdrawal / expiry / revocation / lapse of marketing authorisation date`, `Suspension of marketing authorisation date` | `withdrawal_date` (never derived from anything else) |53| `Last updated date` | `source_records.source_updated_at`, `provenance.updated_at_source` |54| `Medicine URL` | `https://www.ema.europa.eu/en/medicines/human/EPAR/<slug>` → `provenance.source_url` (fallback: the download page) |5556Dates are `DD/MM/YYYY` (`emaDate`: malformed / impossible → null, never fabricated).5758## Ingestion59601. **Download + anomaly guard**: one GET; < 50 KB or < 1,000 parsed rows → `anomaly:` error;61   `ctx.guardCount('medicine', rows)` against the previous successful run (first run = baseline).622. **Raw layer**: `workbook` source record (the file bytes, base64 + sha256, keyed by dataset63   version — `--mode backfill` replays it without HTTP) and one `medicine` record per row (payload =64   the parsed row, every cell verbatim). Rows are processed sorted by product number;65   `ctx.cursor.lastProductNumber` resumes a run stopped by the time budget; pass bookkeeping66   (`passCount`) records `approval_added` / `approval_status_changed` change events from pass 2.673. **Scope** (`classifyOncology`, human rows only) — a medicine is oncology when at least one holds:68   - **`atc_l01_l02`**: an `ATC code (human)` starts with L01 (antineoplastic) or L02 (endocrine therapy);69   - **`mesh_neoplasm`**: a MeSH therapeutic area that (a) carries neoplasm vocabulary70     (`ONCOLOGY_LABEL_RE`) and (b) resolves through `cancer_aliases` (`CancerResolver.byLabel`,71     MeSH inversion; `mesh_heading` aliases from the MeSH connector) to an active cancer entity.72     Generic descriptors (`Neoplasms`, `Cancer`, `Carcinoma`, `Neoplasm Metastasis`…) never count;73     benign conditions the ontology knows as entities (`Leiomyoma`, `Hemangioma`, `Prostatic74     Hyperplasia`, `Dupuytren Contracture`, `Amyloidosis`) fail (a) and are not oncology;75   - **`indication_text`**: the indication matches the ClinicalTrials.gov oncology vocabulary76     (`ONCOLOGY_LABEL_RE`) after neutralising two prose false positives: *tumour necrosis factor*77     (anti-TNF biosimilars) and the lower-case word *all* (the `\ball\b` abbreviation of ALL).78   Everything else is counted (`nonOncology`) and stays in the lake.79   **Cancer mapping and drug minting are only allowed** when the medicine is *oncology by class*80   (ATC L01/L02, or MeSH + oncology text), is **not a diagnostic** (ATC V04 / V08 / V09), and has at81   least one **therapeutic** sentence. Medicines in scope by indication text alone (denosumab82   for bone loss in prostate cancer, G-CSF, epoetins, fentanyl for cancer pain, antiemetics…) get an83   honest EU approval row when their substance is already a known drug, with `cancer_id NULL`,84   no edge and no minted drug.854. **Substances → drugs** (deterministic, no LLM): the INN cell is split on `;` and ` / ` (never on86   commas — vaccine names; a `/` inside brackets is part of the name). Lookup in `drug_aliases.normalized`87   (in-memory index, kept in sync): the full label first, then the molecule with the salt form88   stripped (`parseIngredientName` shared with Health Canada: "abiraterone acetate" → abiraterone,89   "niraparib (tosilate monohydrate)" → niraparib). Exactly one drug → `ALIAS`; several → the90   `generic` alias, else the drug's own name, else **ambiguous** → `unresolved_labels`91   (`ambiguous_alias`: dacomitinib, trastuzumab deruxtecan on 2026-09-11 — two entities each).92   Biosimilars / generics (`Biosimilar` / `Generic` = Yes) resolve to the existing molecule (Herzuma,93   Ontruzant → Trastuzumab; Bortezomib Hospira → Bortezomib) — they never mint.94   - **Unknown INN, single substance, authorised or formerly authorised, oncology by class,95     therapeutic** → a drug is **minted** (`mintId 'DRUG'`; name = INN in Title Case — long common96     names with capitals, brackets or commas are kept verbatim; `slug` with `-ema-<number>` on97     clash; `developmentStatus 'marketed_eu'` when authorised; aliases `generic`, `salt`, `brand`;98     change event `created`).99   - Otherwise → `unresolved_labels` with the reason: `component_of_combination`,100     `not_authorised_medicine` (refused / withdrawn application / pending opinion),101     `diagnostic_agent`, `scope_from_indication_text_only`, `scope_from_mesh_area_only`,102     `non_therapeutic_indication` (epoetin alfa, luspatercept, imetelstat, Gliolan),103     `no_inn_or_active_substance` (older withdrawn entries).104   - Rows are processed in product-number order, so a medicine whose INN is minted by a later row105     of the same pass (Prolia → denosumab minted by Yaxwer) is **retried** at the end of the pass:106     one run converges (`retried 103 … 2 gained rows` on the first run).1075. **Codes and aliases** per resolved drug: `drug_codes` `ema_product` (code = product number,108   label = medicine name) and `atc` (one per code, label = pharmacotherapeutic group), `match_type109   EXACT_IDENTIFIER`; a `brand` alias = medicine name without "(previously …)" for single-drug110   medicines only (a combination name is not an alias of one component).1116. **Status** (`mapEmaStatus`) — `Authorised` → **`approved`** (`conditional = true` when112   Conditional approval = Yes, `accelerated = true` when Accelerated assessment = Yes — a review113   track, noted in `raw.acceleratedNote`); `Withdrawn`, `Lapsed`, `Expired`, `Revoked`, `Suspended`114   → **`withdrawn`** with `withdrawal_date` from the explicit column (null otherwise; a suspension is115   reversible — the verbatim status stays in `raw.emaStatus` and `raw.emaStatusNote` says it is the116   medicine's, not the molecule's); `Refused`, `Application withdrawn`, `Withdrawn from rolling117   review`, `Opinion`, `Opinion under re-examination` → **no approval row** (a refusal or a pending118   opinion is not an approval; counted, lake only; any existing edge of that medicine is superseded).1197. **Approval row** per (medicine, drug) — `drug_approvals`: `jurisdiction 'EU'`, `authority 'EMA'`,120   `approval_type 'centralised'`, `application_number` = product number, `approval_date` =121   marketing authorisation date, `indication` = the official summary (≤ 4,000 chars, "…"), `raw`122   with everything above plus `scope`, `substances`, `flags`, `cancer_match`, `cancerCandidates`,123   `meshCancerIds`, `therapeutic`. Idempotent key `(source, drug, 'EU', product number)`: existing124   rows are updated in place, nothing is deleted. Provenance: one row per medicine and run when the125   source record changed (`sourceUrl` = EPAR page, `dataset 'EMA medicines data (xlsx)'`,126   `datasetVersion`, `evidenceType regulatory`, `accessLevel open`, `geography 'EU'`,127   `publishedAt` = approval date, `updatedAtSource` = last updated date, methodology text); an128   unchanged medicine whose rows exist reuses its previous provenance row.1298. **Cancer mapping** (`resolveIndication`, openFDA `CancerDictionary` / `resolveBullet` /130   `CancerReconciler` imported from `../openfda/normalize.js`): the indication is split into131   paragraphs, then `;` / `:` clauses and sentences (EMA lists indications as "…: as monotherapy132   for …; for treatment of …"). Sentences whose object is supportive care, prevention or diagnosis133   (`isSupportiveCareSentence`: "prevention of skeletal events", "treatment of symptomatic134   anaemia", "patients receiving chemotherapy", "active immunisation…", "visualisation of…",135   "vasomotor symptoms"…) are flagged and never yield a cancer; an introduction ending with ":"136   hands its nature to the clauses it introduces. Each remaining sentence → 0 / 1 cancer137   (`PROBABILISTIC`, several mentions collapse only along one lineage). The whole indication → one138   cancer → **`cancer_id`**; otherwise `cancer_id NULL` and the distinct cancers in139   `raw.cancerCandidates`. `tumor_agnostic` when a sentence pairs "solid tumours" with an agnostic140   biomarker. The dictionary is built **without** the build-time reconciler: for an alias shared by141   a parent and its child ("breast cancer" → Malignant Breast Neoplasm / Breast Carcinoma /142   Childhood Breast Carcinoma) the narrowest-of-lineage rule picked the child, so adult breast-cancer143   indications mapped to *Childhood Breast Carcinoma* (verified 2026-09-11); the resolver's144   preferred / display-name disambiguation decides instead.1459. **Knowledge edges** — for **authorised** medicines only: one `drug APPROVED_FOR cancer` edge per146   cancer named by a therapeutic sentence (`evidenceLevel 'EMA indication text'`), plus one per MeSH147   therapeutic area (`evidenceLevel 'mesh_therapeutic_area'`, only when the text did not already148   name that cancer) — `evidenceCategory regulatory_status`, `cancer_context_ids = [cancer]`,149   `provenance_ids`, `source_record_id` = product number. Edges of a medicine that is no longer150   authorised, or whose indication no longer names the cancer, are set to `status 'superseded'`.151   Multi-indication drugs therefore get one edge per cancer (Keytruda: 15 text edges + 4 MeSH).152153`dry_run` downloads, parses and maps without writing (25 sample lines + totals); `--mode backfill`154replays the lake copy of the workbook (fallback: the per-row `medicine` payloads) — no HTTP, cursor155untouched. Health check: `HEAD` on the xlsx must answer a spreadsheet content-type ≥ 100 KB.156157## Observed runs (local copy of production, 2026-09-11)158159| | |160|---|---|161| Dry run | 2,732 rows (2,339 human, 393 veterinary) in 1.7 s; 620 human medicines in scope — ATC 418, MeSH 462, indication text 573 (text-only 111); out of scope 1,719 |162| First run (`ING-EMA-20260911-000001`) | 3.0 s, 1 download. Statuses among scoped medicines: Authorised 446, Withdrawn 84, Application withdrawn 68, Refused 23, Lapsed 6, Opinion 5, Expired 2, Revoked 1, Opinion under re-examination 1. 622 substances: 475 by alias + 4 after salt stripping, **36 minted**, 2 ambiguous, 113 unresolved (queued), 13 medicines without INN. **459 EU approval rows** on 245 drugs / 456 medicines: 385 `approved` (31 conditional, 12 accelerated), 74 `withdrawn` (Withdrawn 66, Lapsed 5, Expired 2, Revoked 1; 48 with a withdrawal date), 457 with an approval date, **190 with `cancer_id`**, 4 tumour-agnostic, 71 flagged non-therapeutic (supportive care). **859 edges**: 529 `EMA indication text` (188 drugs × 95 cancers), 330 `mesh_therapeutic_area` (126 drugs × 56 cancers). 571 codes (459 `ema_product`, 112 `atc`), 187 brand + 36 generic + 4 salt aliases, 459 provenance rows, 2,732 + 1 source records (2.9 MB in the lake). Retry pass: 103 deferred medicines, 2 gained rows |163| Second run (idempotency) | 2,733 source records unchanged, 0 created / 459 updated approval rows, 0 provenance rows, 0 change events |164| Backfill | replayed `ema-medicines-2026-09-11T06:00` from the lake: same counts, 0 HTTP requests |165| `pnpm cix reconcile-drugs` | linked trial interventions **78,105 → 78,983** (+878, all to the minted drugs: Thalidomide 236, Imiquimod 116, Denosumab 74, Serplulimab 69, Talimogene laherparepvec 43, Duvelisib 38, Tivozanib 32…) |166167Minted (36): 5-Aminolevulinic Acid, Alitretinoin, Bexarotene, Cinacalcet, Denosumab, Dinutuximab168Beta, Duvelisib, Histamine, Hydroxycarbamide, Ibandronic Acid, Imiquimod, Imlunestrant,169Linvoseltamab, Melphalan Flufenamide, Mifamurtide, Moxetumomab Pasudotox, Nogapendekin Alfa170Inbakicept, Obecabtagene Autoleucel, Odronextamab, Padeliporfin, Pixantrone Dimaleate, Radium Ra223171dichloride, Serplulimab, Sugemalimab, Tagraxofusp, Talimogene Laherparepvec, Temoporfin,172Thalidomide, Thyrotropin Alfa, Tisotumab Vedotin, Tivozanib, Toremifene, Vimseltinib, Vinflunine,173two cell therapies kept under their EMA common name (sipuleucel-T "Autologous peripheral-blood174mononuclear cells activated with…", Zalmoxis "Allogeneic T cells genetically modified…").175176Most-connected cancers by EU text edges: NSCLC 33 drugs, malignant breast neoplasm 25, multiple177myeloma 21, RCC 14, ALL 12, colorectal 11, follicular lymphoma 10, prostate 10, AML 9, melanoma 8.178179## Limitations / notes180181- **Medicine-level granularity**: one row per (medicine, active substance) — biosimilars and182  generics each have their own row on the same molecule (trastuzumab: Herceptin + 6 biosimilars).183  The approvals feed counts medicines, not molecules; `raw.flags.biosimilar / generic` say which.184- **Dates**: `approval_date` is the initial EU marketing authorisation of *that medicine* (a185  generic's date for a generic). The Commission decision date is the latest decision, kept in186  `raw` only. Suspensions carry no date in the file (2026-09-11) → `withdrawal_date` null.187- **Refusals / pending opinions** are not approvals: no row, no edge; they are in the lake and188  counted in the run log (`refused`, `notAuthorised`, `pending`) and their unknown INNs are queued189  (`not_authorised_medicine`). Positive CHMP opinions awaiting the Commission decision (`Opinion`,190  5 on 2026-09-11) appear on the next weekly run once authorised.191- **Cancer mapping is conservative**: multi-indication medicines have `cancer_id NULL` (edges carry192  the per-cancer information); a sentence naming ≥ 2 unrelated cancers yields none. MeSH-derived193  edges target the MeSH heading's entity, often the broad "X Neoplasm" umbrella (`malignant =194  false` umbrella of benign + malignant) — hence the separate `evidenceLevel 'mesh_therapeutic_area'`.195- **Gray zone kept, flagged**: medicines oncology by MeSH with a therapeutic sentence outside the196  cancer itself (Cinacalcet — hypercalcaemia of parathyroid carcinoma + hyperparathyroidism;197  Ibandronic acid — one authorised medicine with a non-supportive sentence; Thyrotropin alfa —198  radioiodine ablation adjunct) are minted with their honest indication text and MeSH edge only.199  Conversely luspatercept (MDS anaemia), imetelstat and epoetins are *not* minted because every200  sentence targets anaemia — they land in `unresolved_labels` (`non_therapeutic_indication`).201- **Combinations**: a component whose INN is unknown is queued (`component_of_combination`,202  16 on 2026-09-11); the row is linked to the components that resolved (Akeega → Niraparib and203  Abiraterone Acetate; Phesgo → Pertuzumab and Trastuzumab).204- **Ambiguous aliases** (2): "dacomitinib" and "trastuzumab deruxtecan" each match two drug205  entities — a duplicate-entity question for `drug-duplicates.ts`, not for this connector.206- **Veterinary** rows (393) are recorded in the lake and skipped. `Species`, `ATCvet` and the207  veterinary pharmacotherapeutic group are parsed for the lake only.208- **Finding for `openfda`**: the same `CancerDictionary.build(rows, disambiguate, reconciler)`209  call with the reconciler collapses shared aliases to the child concept (adult "breast cancer" →210  Childhood Breast Carcinoma, "diffuse large B-cell lymphoma" → Childhood DLBCL); the EMA211  connector passes `undefined` at build time. Not changed in the openFDA connector by this work.212- Fixtures: `fixtures/ema-sample.xlsx` (38 real rows — oncology L01/L02, biosimilars, a generic,213  withdrawn / lapsed / expired / revoked / suspended / refused / application-withdrawn / pending214  rows, MeSH-only and text-only rows, combinations, vaccines, veterinary rows; one marketing215  authorisation date corrupted to `99/99/2016`), `ema-header-drift.xlsx` (required column renamed),216  `ema-empty.xlsx` (header only). Regenerate with `pnpm exec tsx217  packages/connectors/src/connectors/ema/fixtures/build-fixtures.ts /tmp/ema.xlsx`.218