Web: paginate approvals and gene cohort tables
8 changed files +64 −17
modified
apps/web/src/app/cancer/[slug]/[[...tab]]/page.tsx
+1 −1
@@ -69,7 +69,7 @@ export default async function CancerPage({ params, searchParams }: { params: Pro | |||||
| 69 | } | 69 | } | ||
| 70 | case 'drugs': { | 70 | case 'drugs': { | ||
| 71 | const sp = await searchParams; | 71 | const sp = await searchParams; | ||
| 72 | − body = <DrugsTab b={b} jurisdiction={str(sp, 'jurisdiction') || null} />; | 72 | + body = <DrugsTab b={b} jurisdiction={str(sp, 'jurisdiction') || null} aPage={int(sp, 'aPage', 1, 1, 100_000)} />; | ||
| 73 | break; | 73 | break; | ||
| 74 | } | 74 | } | ||
| 75 | case 'trials': { | 75 | case 'trials': { | ||
modified
apps/web/src/app/drug/[slug]/page.tsx
+3 −2
@@ -34,6 +34,7 @@ export default async function DrugPage({ params, searchParams }: { params: Promi | |||||
| 34 | const [evTotal, tTotal] = await Promise.all([evidenceForDrugCount(d.id), trialsForDrugCount(d.id)]); | 34 | const [evTotal, tTotal] = await Promise.all([evidenceForDrugCount(d.id), trialsForDrugCount(d.id)]); | ||
| 35 | const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal); | 35 | const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal); | ||
| 36 | const tp = pageInfo(int(sp, 'tPage', 1, 1, 100_000), TRIAL_PAGE_SIZE, tTotal); | 36 | const tp = pageInfo(int(sp, 'tPage', 1, 1, 100_000), TRIAL_PAGE_SIZE, tTotal); | ||
| 37 | + const aPage = int(sp, 'aPage', 1, 1, 100_000); | ||||
| 37 | const [approvals, evidence, trials] = await Promise.all([approvalsForDrug(d.id), evTotal ? evidenceForDrug(d.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), tTotal ? trialsForDrug(d.id, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([])]); | 38 | const [approvals, evidence, trials] = await Promise.all([approvalsForDrug(d.id), evTotal ? evidenceForDrug(d.id, { page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), tTotal ? trialsForDrug(d.id, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([])]); | ||
| 38 | const prov = await loadProvenance([...approvals.map((a) => a.provenance_id), ...evidence.map((e) => e.provenance_id)]); | 39 | const prov = await loadProvenance([...approvals.map((a) => a.provenance_id), ...evidence.map((e) => e.provenance_id)]); | ||
| 39 | const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort(); | 40 | const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort(); | ||
@@ -41,7 +42,7 @@ export default async function DrugPage({ params, searchParams }: { params: Promi | |||||
| 41 | const selected = jurisdictions.includes(wanted) ? wanted : null; | 42 | const selected = jurisdictions.includes(wanted) ? wanted : null; | ||
| 42 | const shown = selected ? approvals.filter((a) => a.jurisdiction === selected) : approvals; | 43 | const shown = selected ? approvals.filter((a) => a.jurisdiction === selected) : approvals; | ||
| 43 | const aliases = d.aliases ?? []; | 44 | const aliases = d.aliases ?? []; | ||
| 44 | − const current = { jurisdiction: selected ?? '', evPage: ev.page > 1 ? ev.page : '', tPage: tp.page > 1 ? tp.page : '' }; | 45 | + const current = { jurisdiction: selected ?? '', evPage: ev.page > 1 ? ev.page : '', tPage: tp.page > 1 ? tp.page : '', aPage: aPage > 1 ? aPage : '' }; | ||
| 45 | const href = (o: Record<string, string | number | null | undefined>, hash?: string) => `/drug/${d.slug}${withParams(current, o)}${hash ? `#${hash}` : ''}`; | 46 | const href = (o: Record<string, string | number | null | undefined>, hash?: string) => `/drug/${d.slug}${withParams(current, o)}${hash ? `#${hash}` : ''}`; | ||
| 46 | 47 | ||||
| 47 | return ( | 48 | return ( | ||
@@ -89,7 +90,7 @@ export default async function DrugPage({ params, searchParams }: { params: Promi | |||||
| 89 | </Link> | 90 | </Link> | ||
| 90 | ))} | 91 | ))} | ||
| 91 | </nav> | 92 | </nav> | ||
| 92 | − <ApprovalsTable rows={shown} prov={prov} showDrug={false} /> | 93 | + <ApprovalsTable rows={shown} prov={prov} showDrug={false} page={aPage} hrefFor={(p) => href({ aPage: p > 1 ? p : '' }, 'approvals')} /> | ||
| 93 | <Freshness dataUpdatedAt={approvals.reduce<Date | string | null>((m, a) => (m == null || String(a.updated_at) > String(m) ? a.updated_at : m), null)} /> | 94 | <Freshness dataUpdatedAt={approvals.reduce<Date | string | null>((m, a) => (m == null || String(a.updated_at) > String(m) ? a.updated_at : m), null)} /> | ||
| 94 | </> | 95 | </> | ||
| 95 | ) : ( | 96 | ) : ( | ||
modified
apps/web/src/app/gene/[symbol]/page.tsx
+3 −2
@@ -35,6 +35,7 @@ export default async function GenePage({ params, searchParams }: { params: Promi | |||||
| 35 | const sp = await searchParams; | 35 | const sp = await searchParams; | ||
| 36 | const evPageReq = int(sp, 'evPage', 1, 1, 100_000); | 36 | const evPageReq = int(sp, 'evPage', 1, 1, 100_000); | ||
| 37 | const vPageReq = int(sp, 'vPage', 1, 1, 100_000); | 37 | const vPageReq = int(sp, 'vPage', 1, 1, 100_000); | ||
| 38 | + const cPageReq = int(sp, 'cPage', 1, 1, 100_000); | ||||
| 38 | const pPageReq = int(sp, 'pPage', 1, 1, 100_000); | 39 | const pPageReq = int(sp, 'pPage', 1, 1, 100_000); | ||
| 39 | 40 | ||||
| 40 | const [evTotal, vTotal, pTotal] = await Promise.all([evidenceForGeneCount(g.id, g.symbol), variantsForGeneCount(g.id), recentPublicationsForCount('gene', [g.id])]); | 41 | const [evTotal, vTotal, pTotal] = await Promise.all([evidenceForGeneCount(g.id, g.symbol), variantsForGeneCount(g.id), recentPublicationsForCount('gene', [g.id])]); | ||
@@ -50,7 +51,7 @@ export default async function GenePage({ params, searchParams }: { params: Promi | |||||
| 50 | ]); | 51 | ]); | ||
| 51 | const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...freqs.map((f) => f.provenance_id)]); | 52 | const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...freqs.map((f) => f.provenance_id)]); | ||
| 52 | 53 | ||||
| 53 | − const current = { evPage: ev.page > 1 ? ev.page : '', vPage: vp.page > 1 ? vp.page : '', pPage: pp.page > 1 ? pp.page : '' }; | 54 | + const current = { evPage: ev.page > 1 ? ev.page : '', vPage: vp.page > 1 ? vp.page : '', pPage: pp.page > 1 ? pp.page : '', cPage: cPageReq > 1 ? cPageReq : '' }; | ||
| 54 | const href = (o: Record<string, string | number | null | undefined>, hash: string) => `/gene/${g.symbol}${withParams(current, o)}#${hash}`; | 55 | const href = (o: Record<string, string | number | null | undefined>, hash: string) => `/gene/${g.symbol}${withParams(current, o)}#${hash}`; | ||
| 55 | 56 | ||||
| 56 | return ( | 57 | return ( | ||
@@ -126,7 +127,7 @@ export default async function GenePage({ params, searchParams }: { params: Promi | |||||
| 126 | </Section> | 127 | </Section> | ||
| 127 | 128 | ||||
| 128 | <Section id="frequencies" kicker="Cohorts" title={`Alteration frequency by cohort (${fmtInt(freqs.length)})`} description="Frequency = cases affected / cases profiled within one cohort. Cohorts are never pooled."> | 129 | <Section id="frequencies" kicker="Cohorts" title={`Alteration frequency by cohort (${fmtInt(freqs.length)})`} description="Frequency = cases affected / cases profiled within one cohort. Cohorts are never pooled."> | ||
| 129 | − {freqs.length ? <GeneFrequencyTable rows={freqs} prov={prov} /> : <EmptyState compact>No cohort frequency recorded for this gene.</EmptyState>} | 130 | + {freqs.length ? <GeneFrequencyTable rows={freqs} prov={prov} page={cPageReq} hrefFor={(p) => href({ cPage: p > 1 ? p : '' }, 'frequencies')} /> : <EmptyState compact>No cohort frequency recorded for this gene.</EmptyState>} | ||
| 130 | </Section> | 131 | </Section> | ||
| 131 | 132 | ||||
| 132 | <Section id="publications" kicker="Literature" title={`Linked publications (${fmtInt(pTotal)})`} description={pTotal ? `${PUBLICATION_PAGE_SIZE} per page, newest first.` : undefined}> | 133 | <Section id="publications" kicker="Literature" title={`Linked publications (${fmtInt(pTotal)})`} description={pTotal ? `${PUBLICATION_PAGE_SIZE} per page, newest first.` : undefined}> | ||
modified
apps/web/src/components/cancer/tabs/drugs.tsx
+2 −2
@@ -10,7 +10,7 @@ import { loadProvenance } from '@/lib/queries/provenance'; | |||||
| 10 | import { fmtInt } from '@/lib/format'; | 10 | import { fmtInt } from '@/lib/format'; | ||
| 11 | import type { CancerBundle } from '../load'; | 11 | import type { CancerBundle } from '../load'; | ||
| 12 | 12 | ||||
| 13 | −export async function DrugsTab({ b, jurisdiction }: { b: CancerBundle; jurisdiction: string | null }) { | 13 | +export async function DrugsTab({ b, jurisdiction, aPage = 1 }: { b: CancerBundle; jurisdiction: string | null; aPage?: number }) { | ||
| 14 | // Therapies mentioned in curated evidence (not approvals) — aggregated in SQL (no evidence rows | 14 | // Therapies mentioned in curated evidence (not approvals) — aggregated in SQL (no evidence rows | ||
| 15 | // are shipped to the page), listed separately and never called "approved". | 15 | // are shipped to the page), listed separately and never called "approved". | ||
| 16 | const [approvals, therapies] = await Promise.all([approvalsForCancer(b.descendants), therapyMentionsForCancer(b.descendants)]); | 16 | const [approvals, therapies] = await Promise.all([approvalsForCancer(b.descendants), therapyMentionsForCancer(b.descendants)]); | ||
@@ -43,7 +43,7 @@ export async function DrugsTab({ b, jurisdiction }: { b: CancerBundle; jurisdict | |||||
| 43 | </Link> | 43 | </Link> | ||
| 44 | ))} | 44 | ))} | ||
| 45 | </nav> | 45 | </nav> | ||
| 46 | − <ApprovalsTable rows={shownApprovals} prov={prov} /> | 46 | + <ApprovalsTable rows={shownApprovals} prov={prov} page={aPage} hrefFor={(p) => `/cancer/${b.cancer.slug}/drugs?${new URLSearchParams({ ...(selected ? { jurisdiction: selected } : {}), ...(p > 1 ? { aPage: String(p) } : {}) }).toString()}#approvals`} /> | ||
| 47 | <Freshness dataUpdatedAt={approvals.reduce((m, a) => (a.updated_at > m ? a.updated_at : m), approvals[0]!.updated_at)} /> | 47 | <Freshness dataUpdatedAt={approvals.reduce((m, a) => (a.updated_at > m ? a.updated_at : m), approvals[0]!.updated_at)} /> | ||
| 48 | </> | 48 | </> | ||
| 49 | ) : ( | 49 | ) : ( | ||
modified
apps/web/src/components/data/approvals-table.tsx
+7 −1
@@ -5,13 +5,18 @@ import type { ApprovalRow } from '@/lib/queries/drugs'; | |||||
| 5 | import type { ProvRow } from '@/lib/queries/provenance'; | 5 | import type { ProvRow } from '@/lib/queries/provenance'; | ||
| 6 | import { toInfo } from '@/lib/queries/provenance'; | 6 | import { toInfo } from '@/lib/queries/provenance'; | ||
| 7 | import { fmtDate, fmtInt } from '@/lib/format'; | 7 | import { fmtDate, fmtInt } from '@/lib/format'; | ||
| 8 | +import { Pager } from '@/components/ui/pager'; | ||||
| 9 | +import { pageInfo } from '@/lib/pagination'; | ||||
| 8 | 10 | ||||
| 9 | /** | 11 | /** | ||
| 10 | * Jurisdiction-aware regulatory status (§13). Never a bare "approved". Approvals may come from | 12 | * Jurisdiction-aware regulatory status (§13). Never a bare "approved". Approvals may come from | ||
| 11 | * different authorities' datasets, so rows keep a compact badge with a dataset · version title; | 13 | * different authorities' datasets, so rows keep a compact badge with a dataset · version title; | ||
| 12 | * the caption carries one full popover (first row's provenance) and the claim label. | 14 | * the caption carries one full popover (first row's provenance) and the claim label. | ||
| 13 | */ | 15 | */ | ||
| 14 | −export function ApprovalsTable({ rows, prov, showDrug = true, showCancer = true }: { rows: ApprovalRow[]; prov: Map<number, ProvRow>; showDrug?: boolean; showCancer?: boolean }) { | 16 | +export function ApprovalsTable({ rows: allRows, prov, showDrug = true, showCancer = true, page = 1, pageSize = 50, hrefFor }: { rows: ApprovalRow[]; prov: Map<number, ProvRow>; showDrug?: boolean; showCancer?: boolean; page?: number; pageSize?: number; hrefFor?: (page: number) => string }) { | ||
| 17 | + // Optional server-side pagination (URL state); without hrefFor the first page only is shown with a count. | ||||
| 18 | + const info = pageInfo(page, pageSize, allRows.length); | ||||
| 19 | + const rows = allRows.slice(info.offset, info.offset + pageSize); | ||||
| 15 | const first = rows[0]; | 20 | const first = rows[0]; | ||
| 16 | const caption = first ? (toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }) : null; | 21 | const caption = first ? (toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }) : null; | ||
| 17 | const sources = new Set(rows.map((r) => r.source_slug)); | 22 | const sources = new Set(rows.map((r) => r.source_slug)); | ||
@@ -84,6 +89,7 @@ export function ApprovalsTable({ rows, prov, showDrug = true, showCancer = true | |||||
| 84 | </tbody> | 89 | </tbody> | ||
| 85 | </table> | 90 | </table> | ||
| 86 | </div> | 91 | </div> | ||
| 92 | + {hrefFor ? <Pager total={allRows.length} pageSize={pageSize} page={info.page} hrefFor={hrefFor} label="Approval pages" noun="approval records" /> : allRows.length > rows.length ? <p className="mt-1 text-[12px] text-ink-3">Showing {rows.length} of {fmtInt(allRows.length)} approval records.</p> : null} | ||||
| 87 | </div> | 93 | </div> | ||
| 88 | ); | 94 | ); | ||
| 89 | } | 95 | } | ||
modified
apps/web/src/components/data/frequency-table.tsx
+8 −3
@@ -6,6 +6,8 @@ import type { FreqRow } from '@/lib/queries/genomics'; | |||||
| 6 | import type { ProvRow } from '@/lib/queries/provenance'; | 6 | import type { ProvRow } from '@/lib/queries/provenance'; | ||
| 7 | import { toInfo } from '@/lib/queries/provenance'; | 7 | import { toInfo } from '@/lib/queries/provenance'; | ||
| 8 | import { fmtInt, fmtPct, humanize } from '@/lib/format'; | 8 | import { fmtInt, fmtPct, humanize } from '@/lib/format'; | ||
| 9 | +import { Pager } from '@/components/ui/pager'; | ||||
| 10 | +import { pageInfo } from '@/lib/pagination'; | ||||
| 9 | 11 | ||||
| 10 | /** | 12 | /** | ||
| 11 | * Gene alteration frequencies with mandatory denominators (§260-261), one table per cohort. | 13 | * Gene alteration frequencies with mandatory denominators (§260-261), one table per cohort. | ||
@@ -116,15 +118,17 @@ export function FrequencyTables({ rows, prov, cohortFilter, showCancer = false } | |||||
| 116 | * Rows come from different cohorts (different releases), so each compact badge carries a `title` | 118 | * Rows come from different cohorts (different releases), so each compact badge carries a `title` | ||
| 117 | * with dataset · version · retrieved; the caption shows one popover for the source. | 119 | * with dataset · version · retrieved; the caption shows one popover for the source. | ||
| 118 | */ | 120 | */ | ||
| 119 | −export function GeneFrequencyTable({ rows, prov }: { rows: FreqRow[]; prov: Map<number, ProvRow> }) { | 121 | +export function GeneFrequencyTable({ rows, prov, page = 1, pageSize = 50, hrefFor }: { rows: FreqRow[]; prov: Map<number, ProvRow>; page?: number; pageSize?: number; hrefFor?: (page: number) => string }) { | ||
| 120 | − const sorted = [...rows].sort((a, b) => b.frequency - a.frequency); | 122 | + const all = [...rows].sort((a, b) => b.frequency - a.frequency); | ||
| 123 | + const info = pageInfo(page, pageSize, all.length); | ||||
| 124 | + const sorted = all.slice(info.offset, info.offset + pageSize); | ||||
| 121 | const first = sorted[0]; | 125 | const first = sorted[0]; | ||
| 122 | const caption = first ? (toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }) : null; | 126 | const caption = first ? (toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }) : null; | ||
| 123 | return ( | 127 | return ( | ||
| 124 | <div> | 128 | <div> | ||
| 125 | {caption ? ( | 129 | {caption ? ( | ||
| 126 | <TableProvenance p={caption} claim={<ClaimBadge kind="observed" />}> | 130 | <TableProvenance p={caption} claim={<ClaimBadge kind="observed" />}> | ||
| 127 | − {sorted.length} cohort row{sorted.length === 1 ? '' : 's'} · frequency = affected / profiled, as published by each cohort; cohorts are not pooled. Hover a row badge for its release. | 131 | + {all.length} cohort row{all.length === 1 ? '' : 's'}{all.length > sorted.length ? ` (showing ${sorted.length}, sorted by frequency)` : ''} · frequency = affected / profiled, as published by each cohort; cohorts are not pooled. Hover a row badge for its release. | ||
| 128 | </TableProvenance> | 132 | </TableProvenance> | ||
| 129 | ) : null} | 133 | ) : null} | ||
| 130 | <div className="ci-table-wrap"> | 134 | <div className="ci-table-wrap"> | ||
@@ -182,6 +186,7 @@ export function GeneFrequencyTable({ rows, prov }: { rows: FreqRow[]; prov: Map< | |||||
| 182 | </tbody> | 186 | </tbody> | ||
| 183 | </table> | 187 | </table> | ||
| 184 | </div> | 188 | </div> | ||
| 189 | + {hrefFor ? <Pager total={all.length} pageSize={pageSize} page={info.page} hrefFor={hrefFor} label="Cohort pages" noun="cohorts" className="mt-2 text-[12px]" /> : null} | ||||
| 185 | </div> | 190 | </div> | ||
| 186 | ); | 191 | ); | ||
| 187 | } | 192 | } | ||
modified
docs/connectors/cbioportal.md
+16 −2
@@ -50,9 +50,23 @@ TCGA cohorts also present in GDC are kept as **separate cohorts** (different pro | |||||
| 50 | 50 | ||||
| 51 | `dry_run` fetches studies + cancer types and the top-5 mutated genes of `acc_tcga` without writing. | 51 | `dry_run` fetches studies + cancer types and the top-5 mutated genes of `acc_tcga` without writing. | ||
| 52 | 52 | ||||
| 53 | −## Observed run (cancerindex_b, 2026-09-08) | 53 | +## Observed run (cancerindex_b, 2026-09-08 — OncoTree + NCIt + HGNC loaded) | ||
| 54 | 54 | ||||
| 55 | −See the run summary appended below (10-record smoke, then 20-minute run). | 55 | +| | | | ||
| 56 | +|---|---| | ||||
| 57 | +| Smoke (`--max-records 10`) | 5 studies (acbc_mskcc_2015 … acc_tcga_pan_can_atlas_2018), 12 requests, 5 s, all `EXACT_IDENTIFIER` | | ||||
| 58 | +| Full pass (`--max-minutes 20`) | **545 studies / 545 cohorts** in 6–8 min, 1,080 requests, 0 HTTP failures, 0 studies rejected | | ||||
| 59 | +| Cancer mapping | **489 / 545 (89.7 %)**: 432 `EXACT_IDENTIFIER` (OncoTree code), 57 `CURATED_BROADER` (tissue-level rewrite); 48 `mixed` pan-cancer cohorts unmappable by design (MSK-IMPACT 2017, GENIE-derived pan-cancer sets, mixed_allen_2018…); **98.4 % of non-mixed studies mapped**; 8 unresolved (tissue-level sites without a site-level malignancy alias in the ontology yet) | | ||||
| 60 | +| Programs | 139 distinct — MSK 171, TCGA 88, TCGA GDC 33, Broad 15, DFCI 10, CPTAC GDC 10, CPTAC 6…; 17 studies without a parenthetical source (`program null`) | | ||||
| 61 | +| Frequencies | **100,444 rows** in 539 cohorts (9,972 distinct genes, all resolved to a `genes` row — 0 minimal genes created after HGNC), 90,521 rows with a `cancerId`; denominators 1 – 54,331; **7 rejected** by `validateFrequency` (bowel_colitis_msk_2022: FAT3 11/1, SMOX 4/1… — portal gene-panel matrix inconsistency, never stored) | | ||||
| 62 | +| Publications | 378 PMIDs → publication stubs + `publication_entity_edges` to the cohort's cancer | | ||||
| 63 | +| Counters | `genomic_cohorts` ranking eligible 109 cancers (all scopes) / 9 top-level; e.g. Malignant Breast Neoplasm 7 cohorts, Malignant Lung Neoplasm 5 | | ||||
| 64 | + | ||||
| 65 | +`msk_impact_2017` (10,945 samples, `mixed`, program MSK, PMID 28481359) top 5 — `cases_affected / cases_profiled`: TP53 4,538/10,945 (0.415), KRAS 1,643/10,945 (0.150), TERT 1,460/10,945 (0.133), PIK3CA 1,355/10,945 (0.124), APC 1,121/10,945 (0.102) — identical to the portal's study-summary table; genes only on the 410-gene panel carry the panel-aware denominator 8,136. | ||||
| 66 | + | ||||
| 67 | +`acc_tcga` (90 sequenced) top 3: ZFPM1 47/90, MUC5B 35/90, GARS1 34/90 (Firehose Legacy calls; the PanCancer Atlas cohort `acc_tcga_pan_can_atlas_2018` of the same tumours gives TP53 18/91, CTNNB1 14/91 — kept as separate cohorts, as is GDC's TCGA-ACC). | ||||
| 68 | + | ||||
| 69 | +Idempotent: a second full pass (run twice today after rule changes, `--reset-cursor`) updated cohorts and frequencies in place, reused cohort ids and provenance rows for unchanged studies, and created no duplicate genes or publications. | ||||
| 56 | 70 | ||||
| 57 | ## Limitations / notes | 71 | ## Limitations / notes | ||
| 58 | 72 | ||||
modified
packages/connectors/src/connectors/chembl/index.ts
+24 −4
@@ -123,6 +123,7 @@ export class ChemblConnector extends Connector { | |||||
| 123 | const geneCache = new GeneCache(ctx.db); | 123 | const geneCache = new GeneCache(ctx.db); | ||
| 124 | const targetCache = new Map<string, TargetInfo>(); | 124 | const targetCache = new Map<string, TargetInfo>(); | ||
| 125 | const neoplasmMesh = await this.loadNeoplasmMeshSet(ctx); | 125 | const neoplasmMesh = await this.loadNeoplasmMeshSet(ctx); | ||
| 126 | + const meshCodes = await this.loadMeshCodes(ctx); | ||||
| 126 | const stats: Stats = { considered: 0, skippedNonMolecule: 0, matched: 0, matchedByPrefName: 0, matchedBySynonym: 0, unresolved: 0, aliases: 0, mechanisms: 0, targetEdges: 0, genesLinked: 0, indications: 0, indicationEdges: 0, indicationsUnresolvedCancer: 0, indicationsNonCancer: 0, malformed: 0 }; | 127 | const stats: Stats = { considered: 0, skippedNonMolecule: 0, matched: 0, matchedByPrefName: 0, matchedBySynonym: 0, unresolved: 0, aliases: 0, mechanisms: 0, targetEdges: 0, genesLinked: 0, indications: 0, indicationEdges: 0, indicationsUnresolvedCancer: 0, indicationsNonCancer: 0, malformed: 0 }; | ||
| 127 | 128 | ||||
| 128 | let sinceFlush = 0; | 129 | let sinceFlush = 0; | ||
@@ -134,7 +135,7 @@ export class ChemblConnector extends Connector { | |||||
| 134 | } | 135 | } | ||
| 135 | stats.considered++; | 136 | stats.considered++; | ||
| 136 | try { | 137 | try { | ||
| 137 | − await this.processDrug(ctx, drug, { resolver, geneCache, targetCache, neoplasmMesh, stats }); | 138 | + await this.processDrug(ctx, drug, { resolver, geneCache, targetCache, neoplasmMesh, meshCodes, stats }); | ||
| 138 | } catch (e) { | 139 | } catch (e) { | ||
| 139 | // One bad drug must not kill the pass; the error is logged and the drug is retried next pass. | 140 | // One bad drug must not kill the pass; the error is logged and the drug is retried next pass. | ||
| 140 | ctx.counters.rejected++; | 141 | ctx.counters.rejected++; | ||
@@ -171,6 +172,24 @@ export class ChemblConnector extends Connector { | |||||
| 171 | return set; | 172 | return set; | ||
| 172 | } | 173 | } | ||
| 173 | 174 | ||||
| 175 | + /** | ||||
| 176 | + * MeSH UI → canonical cancer for every match type. `CancerResolver.byCode` only caches | ||||
| 177 | + * EXACT_IDENTIFIER / CURATED_EXACT / ONTOLOGY_EXACT codes, but the mesh connector also records | ||||
| 178 | + * deliberate ALIAS (entry-term) and CURATED_BROADER mappings — an indication coded D015464 (CML, | ||||
| 179 | + * mapped as ALIAS) must still resolve. Ambiguous UIs (two cancers) are dropped. | ||||
| 180 | + */ | ||||
| 181 | + private async loadMeshCodes(ctx: RunContext): Promise<Map<string, { cancerId: string; matchType: string }>> { | ||||
| 182 | + const map = new Map<string, { cancerId: string; matchType: string }>(); | ||||
| 183 | + const ambiguous = new Set<string>(); | ||||
| 184 | + for (const r of await ctx.db.select({ cancerId: cancerCodes.cancerId, code: cancerCodes.code, matchType: cancerCodes.matchType }).from(cancerCodes).where(eq(cancerCodes.system, 'mesh'))) { | ||||
| 185 | + const prev = map.get(r.code); | ||||
| 186 | + if (prev && prev.cancerId !== r.cancerId) ambiguous.add(r.code); | ||||
| 187 | + else map.set(r.code, { cancerId: r.cancerId, matchType: r.matchType }); | ||||
| 188 | + } | ||||
| 189 | + for (const c of ambiguous) map.delete(c); | ||||
| 190 | + return map; | ||||
| 191 | + } | ||||
| 192 | + | ||||
| 174 | private async persistCursor(ctx: RunContext): Promise<void> { | 193 | private async persistCursor(ctx: RunContext): Promise<void> { | ||
| 175 | await ctx.db.update(connectorCursors).set({ cursor: ctx.cursor, updatedAt: new Date() }).where(eq(connectorCursors.connectorId, manifest.id)); | 194 | await ctx.db.update(connectorCursors).set({ cursor: ctx.cursor, updatedAt: new Date() }).where(eq(connectorCursors.connectorId, manifest.id)); | ||
| 176 | } | 195 | } | ||
@@ -188,8 +207,8 @@ export class ChemblConnector extends Connector { | |||||
| 188 | } | 207 | } | ||
| 189 | } | 208 | } | ||
| 190 | 209 | ||||
| 191 | − private async processDrug(ctx: RunContext, drug: DrugRow, env: { resolver: CancerResolver; geneCache: GeneCache; targetCache: Map<string, TargetInfo>; neoplasmMesh: Set<string>; stats: Stats }): Promise<void> { | 210 | + private async processDrug(ctx: RunContext, drug: DrugRow, env: { resolver: CancerResolver; geneCache: GeneCache; targetCache: Map<string, TargetInfo>; neoplasmMesh: Set<string>; meshCodes: Map<string, { cancerId: string; matchType: string }>; stats: Stats }): Promise<void> { | ||
| 192 | − const { resolver, geneCache, targetCache, neoplasmMesh, stats } = env; | 211 | + const { resolver, geneCache, targetCache, neoplasmMesh, meshCodes, stats } = env; | ||
| 193 | if (isNonMoleculeName(drug.name)) { | 212 | if (isNonMoleculeName(drug.name)) { | ||
| 194 | stats.skippedNonMolecule++; | 213 | stats.skippedNonMolecule++; | ||
| 195 | return; | 214 | return; | ||
@@ -285,7 +304,8 @@ export class ChemblConnector extends Connector { | |||||
| 285 | 304 | ||||
| 286 | // 7. drug INVESTIGATED_FOR cancer — MeSH-coded indications resolved by UI. | 305 | // 7. drug INVESTIGATED_FOR cancer — MeSH-coded indications resolved by UI. | ||
| 287 | for (const ind of indications) { | 306 | for (const ind of indications) { | ||
| 288 | − const cancer = resolver.byCode('mesh', ind.meshId); | 307 | + const byCode = resolver.byCode('mesh', ind.meshId); | ||
| 308 | + const cancer = byCode ? { cancerId: byCode.cancerId } : meshCodes.get(ind.meshId); | ||||
| 289 | if (!cancer) { | 309 | if (!cancer) { | ||
| 290 | if (neoplasmMesh.has(ind.meshId) || NEOPLASM_HEADING_RE.test(ind.meshHeading)) { | 310 | if (neoplasmMesh.has(ind.meshId) || NEOPLASM_HEADING_RE.test(ind.meshHeading)) { | ||
| 291 | stats.indicationsUnresolvedCancer++; | 311 | stats.indicationsUnresolvedCancer++; | ||
| 292 | 312 | ||||