SPB Git

spb/cancerindex

Public

Web: paginate approvals and gene cohort tables

Simon-Pierre Boucher committed (Sep 8, 2026) parent 602dc11 Browse files

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
Menu

Theme