Biomarkers: 54 NCIt-verified canonical biomarkers (seed), /biomarkers and /biomarker/[slug] with derived links, /v1/biomarkers API, methodology, sitemap, nav; fix silent failure of the trials 'active' status filter (array param inside ANY)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
16 changed files +2,053 −8
modified
apps/api/src/app.ts
+3 −0
@@ -32,6 +32,7 @@ import { approvalRoutes } from './routes/approvals.js'; | ||
| 32 | 32 | import { intelligenceRoutes } from './routes/intelligence.js'; |
| 33 | 33 | import { researchGapRoutes } from './routes/research-gap.js'; |
| 34 | 34 | import { trialSiteRoutes } from './routes/trial-sites.js'; |
| 35 | +import { biomarkerRoutes } from './routes/biomarkers.js'; | |
| 35 | 36 | |
| 36 | 37 | export interface BuildOptions { |
| 37 | 38 | db?: Database; |
@@ -111,6 +112,7 @@ export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance | ||
| 111 | 112 | { name: 'changes' }, |
| 112 | 113 | { name: 'epidemiology', description: 'Time-aware incidence/mortality/prevalence observations with provenance (Data Explorer)' }, |
| 113 | 114 | { name: 'approvals', description: 'Jurisdiction-aware regulatory approvals and the drug development pipeline' }, |
| 115 | + { name: 'biomarkers', description: 'Curated canonical biomarkers (NCIt-verified) with derived links to cancers, drugs, approvals and trials' }, | |
| 114 | 116 | { name: 'graph', description: 'Cancer–gene–variant–drug–trial knowledge graph (contextual neighbourhoods)' }, |
| 115 | 117 | { name: 'intelligence', description: 'Derived clinical-trial intelligence and research-gap components (computed metrics with formula versions)' }, |
| 116 | 118 | { name: 'admin', description: 'Operator endpoints (x-admin-token)' }, |
@@ -168,6 +170,7 @@ export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance | ||
| 168 | 170 | await v1.register(intelligenceRoutes); |
| 169 | 171 | await v1.register(researchGapRoutes); |
| 170 | 172 | await v1.register(trialSiteRoutes); |
| 173 | + await v1.register(biomarkerRoutes); | |
| 171 | 174 | await v1.register(adminRoutes, { prefix: '/admin' }); |
| 172 | 175 | }, |
| 173 | 176 | { prefix: '/v1' }, |
added
apps/api/src/routes/biomarkers.ts
+230 −0
@@ -0,0 +1,230 @@ | ||
| 1 | +import { sql, type SQL } from 'drizzle-orm'; | |
| 2 | +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; | |
| 3 | +import { z } from 'zod'; | |
| 4 | +import { isCiId } from '@cancerindex/shared'; | |
| 5 | +import { paginate } from '../lib/envelope.js'; | |
| 6 | +import { NotFound } from '../lib/errors.js'; | |
| 7 | +import { pageQuery } from '../lib/pagination.js'; | |
| 8 | +import { AnyList, AnyRecord, camel, camelRows, num, ok, respond } from '../lib/respond.js'; | |
| 9 | +import { pluck } from '../lib/sources.js'; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Biomarkers (SPEC §17, §52, §121). Rows are curated metadata (identity, verified NCIt concept, | |
| 13 | + * anchor gene, aliases, assay conventions); every link below is DERIVED at request time with the | |
| 14 | + * rules of docs/methodology/biomarkers.md (formula `biomarker-links-v1`), mirroring | |
| 15 | + * apps/web/src/lib/queries/biomarkers.ts: | |
| 16 | + * scope gene_ids = anchor gene ∪ measurement.genes; variant_ids = measurement.variantSlugs. | |
| 17 | + * evidence CIViC ACCEPTED, type PREDICTIVE | PROGNOSTIC | DIAGNOSTIC, variant (or gene) overlap. | |
| 18 | + * cancers distinct mapped cancers of that evidence. | |
| 19 | + * drugs PREDICTIVE therapies ∪ PREDICTS_RESPONSE_TO edge targets from in-scope variants. | |
| 20 | + * approvals of those drugs (matchedBy = drug) or whose indication text names the marker (indication). | |
| 21 | + * trials interventions ∩ scope drugs AND conditions ∩ scope cancers. | |
| 22 | + * literature publication_entity_edges on scope variants (molecular) or genes. | |
| 23 | + */ | |
| 24 | +export const BIOMARKER_LINKS_FORMULA = 'biomarker-links-v1'; | |
| 25 | +const ACTIVE_STATUSES = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING']; | |
| 26 | +const KINDS = ['gene_mutation', 'protein_expression', 'hormone_receptor', 'immune_marker', 'msi', 'tmb', 'hrd', 'ctdna', 'methylation', 'signature', 'cell_surface', 'other'] as const; | |
| 27 | + | |
| 28 | +const SCOPE_COLUMNS = sql` | |
| 29 | + b.id, b.slug, b.name, b.kind, b.gene_id, b.ncit_code, b.description, b.measurement, b.created_at, b.updated_at, | |
| 30 | + ARRAY(SELECT g.id FROM genes g WHERE g.id = b.gene_id OR g.symbol IN (SELECT jsonb_array_elements_text(coalesce(b.measurement->'genes', '[]'::jsonb))))::text[] AS gene_ids, | |
| 31 | + ARRAY(SELECT v.id FROM variants v WHERE v.slug IN (SELECT jsonb_array_elements_text(coalesce(b.measurement->'variantSlugs', '[]'::jsonb))))::text[] AS variant_ids`; | |
| 32 | +// MATERIALIZED: otherwise the scope sub-selects (45k genes / 417k variants) are re-evaluated per joined row when the CTE is inlined (22 s for MSI-H, measured 2026-09-11). | |
| 33 | +const scopeCte = (where: SQL) => sql`WITH b AS MATERIALIZED (SELECT ${SCOPE_COLUMNS} FROM biomarkers b WHERE ${where})`; | |
| 34 | +const EV_SCOPE = sql`e.status = 'ACCEPTED' AND e.evidence_type IN ('PREDICTIVE', 'PROGNOSTIC', 'DIAGNOSTIC') | |
| 35 | + AND CASE WHEN cardinality(b.variant_ids) > 0 THEN e.variant_ids && b.variant_ids ELSE cardinality(b.gene_ids) > 0 AND e.gene_ids && b.gene_ids END`; | |
| 36 | +const VAR_SCOPE = sql`CASE WHEN cardinality(b.variant_ids) > 0 THEN v.id = ANY(b.variant_ids) ELSE v.gene_id = ANY(b.gene_ids) END`; | |
| 37 | +const DSET = sql`dset AS ( | |
| 38 | + SELECT DISTINCT bid, drug_id FROM ( | |
| 39 | + SELECT b.id AS bid, t AS drug_id FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} AND e.evidence_type = 'PREDICTIVE' CROSS JOIN LATERAL unnest(e.therapy_ids) t | |
| 40 | + UNION | |
| 41 | + SELECT b.id, k.target_entity_id FROM b JOIN variants v ON ${VAR_SCOPE} | |
| 42 | + JOIN knowledge_edges k ON k.source_entity_type = 'variant' AND k.source_entity_id = v.id AND k.target_entity_type = 'drug' AND k.relationship_type = 'PREDICTS_RESPONSE_TO' AND k.status = 'active' | |
| 43 | + ) u)`; | |
| 44 | +const CSET = sql`cset AS (SELECT DISTINCT b.id AS bid, e.cancer_id FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} WHERE e.cancer_id IS NOT NULL)`; | |
| 45 | +const TERM_MATCH = sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(coalesce(b.measurement->'indicationTerms', '[]'::jsonb)) term WHERE a.indication ILIKE '%' || term || '%')`; | |
| 46 | +const ACTIVE = sql`t.overall_status = ANY(${sql.param(ACTIVE_STATUSES)}::text[])`; | |
| 47 | +/** (bid, trial_id) = intervention on a scope drug AND condition in a scope cancer; MATERIALIZED so LIMIT-ed queries never scan clinical_trials row by row. */ | |
| 48 | +const TSET = sql`tset AS MATERIALIZED ( | |
| 49 | + SELECT DISTINCT d.bid, ti.trial_id FROM dset d JOIN trial_interventions ti ON ti.drug_id = d.drug_id | |
| 50 | + JOIN trial_conditions tc ON tc.trial_id = ti.trial_id JOIN cset c ON c.bid = d.bid AND c.cancer_id = tc.cancer_id)`; | |
| 51 | + | |
| 52 | +const derived = { category: 'computed_metric', formulaVersion: BIOMARKER_LINKS_FORMULA, methodology: '/methodology#biomarkers', doc: 'docs/methodology/biomarkers.md' } as const; | |
| 53 | + | |
| 54 | +function shapeBiomarker(r: Record<string, unknown>) { | |
| 55 | + const { gene_ids, variant_ids, measurement, ...rest } = r; | |
| 56 | + const m = (measurement ?? {}) as Record<string, unknown>; | |
| 57 | + return { ...camel(rest), aliases: (m.aliases as string[] | undefined) ?? [], tumorAgnosticCurated: m.tumorAgnostic === true, ncit: m.ncit ?? null, verification: m.verification ?? null, measurement: m, scope: { geneIds: gene_ids ?? [], variantIds: variant_ids ?? [] } }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export const biomarkerRoutes: FastifyPluginAsyncZod = async (app) => { | |
| 61 | + app.get( | |
| 62 | + '/biomarkers', | |
| 63 | + { | |
| 64 | + schema: { | |
| 65 | + tags: ['biomarkers'], | |
| 66 | + summary: 'List canonical biomarkers with derived link counts (cancers, drugs, approvals, active trials)', | |
| 67 | + querystring: z.object({ kind: z.enum(KINDS).optional().describe('Biomarker kind'), q: z.string().trim().min(1).max(80).optional().describe('Name, alias, gene symbol or NCIt code'), ...pageQuery }), | |
| 68 | + response: ok(AnyList, true), | |
| 69 | + }, | |
| 70 | + }, | |
| 71 | + async (req) => { | |
| 72 | + const q = req.query; | |
| 73 | + const conds = [sql`true`]; | |
| 74 | + if (q.kind) conds.push(sql`b.kind = ${q.kind}`); | |
| 75 | + if (q.q) { | |
| 76 | + const like = `%${q.q}%`; | |
| 77 | + conds.push(sql`(b.name ILIKE ${like} OR b.slug ILIKE ${like} OR b.ncit_code ILIKE ${q.q + '%'} | |
| 78 | + OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(coalesce(b.measurement->'aliases', '[]'::jsonb)) a WHERE a ILIKE ${like}) | |
| 79 | + OR EXISTS (SELECT 1 FROM genes g WHERE (g.id = b.gene_id OR g.symbol IN (SELECT jsonb_array_elements_text(coalesce(b.measurement->'genes', '[]'::jsonb)))) AND g.symbol ILIKE ${q.q + '%'}))`); | |
| 80 | + } | |
| 81 | + const rows = await app.db.execute<Record<string, unknown> & { total: string }>(sql` | |
| 82 | + ${scopeCte(sql.join(conds, sql` AND `))}, ${DSET}, ${CSET}, ${TSET} | |
| 83 | + SELECT b.*, count(*) OVER() AS total, | |
| 84 | + coalesce((SELECT array_agg(g.symbol ORDER BY g.symbol) FROM genes g WHERE g.id = ANY(b.gene_ids)), '{}') AS gene_symbols, | |
| 85 | + (SELECT count(*) FROM cset c WHERE c.bid = b.id) AS cancers_n, | |
| 86 | + (SELECT count(*) FROM dset d WHERE d.bid = b.id) AS drugs_n, | |
| 87 | + (SELECT count(*) FROM drug_approvals a WHERE a.drug_id IN (SELECT d.drug_id FROM dset d WHERE d.bid = b.id) OR ${TERM_MATCH}) AS approvals_n, | |
| 88 | + (SELECT count(*) FROM drug_approvals a WHERE a.tumor_agnostic AND (a.drug_id IN (SELECT d.drug_id FROM dset d WHERE d.bid = b.id) OR ${TERM_MATCH})) AS tumor_agnostic_n, | |
| 89 | + (SELECT count(*) FROM tset x JOIN clinical_trials t ON t.id = x.trial_id WHERE x.bid = b.id AND ${ACTIVE}) AS active_trials_n | |
| 90 | + FROM b ORDER BY b.kind, b.name LIMIT ${q.limit} OFFSET ${q.offset}`); | |
| 91 | + const total = rows.length ? num(rows[0]!.total) : 0; | |
| 92 | + const data = rows.map((r) => { | |
| 93 | + const { total: _t, gene_symbols, cancers_n, drugs_n, approvals_n, tumor_agnostic_n, active_trials_n, ...rest } = r; | |
| 94 | + return { | |
| 95 | + ...shapeBiomarker(rest), | |
| 96 | + geneSymbols: gene_symbols ?? [], | |
| 97 | + derived: { cancers: num(cancers_n), drugs: num(drugs_n), approvals: num(approvals_n), tumorAgnosticApprovals: num(tumor_agnostic_n), activeTrials: num(active_trials_n), ...derived }, | |
| 98 | + }; | |
| 99 | + }); | |
| 100 | + return respond(app, data, ['ncit-evs', 'hgnc', 'civic', 'clinicaltrials', 'openfda', 'health-canada-dpd'], paginate(total, q.limit, q.offset)); | |
| 101 | + }, | |
| 102 | + ); | |
| 103 | + | |
| 104 | + app.get( | |
| 105 | + '/biomarkers/:slug', | |
| 106 | + { | |
| 107 | + schema: { | |
| 108 | + tags: ['biomarkers'], | |
| 109 | + summary: 'Biomarker: curated metadata + derived cancers, drugs (direction/level), approvals (tumor-agnostic flagged), trials and literature', | |
| 110 | + params: z.object({ slug: z.string().min(1).describe('Biomarker slug or CI-BIO-… id') }), | |
| 111 | + querystring: z.object({ trialLimit: z.coerce.number().int().min(0).max(200).default(50), publicationLimit: z.coerce.number().int().min(0).max(200).default(50) }), | |
| 112 | + response: ok(AnyRecord), | |
| 113 | + }, | |
| 114 | + }, | |
| 115 | + async (req) => { | |
| 116 | + const ref = req.params.slug.trim(); | |
| 117 | + const where = isCiId(ref, 'BIO') ? sql`b.id = ${ref}` : sql`b.slug = ${ref.toLowerCase()}`; | |
| 118 | + const head = await app.db.execute<Record<string, unknown>>(sql`${scopeCte(where)} SELECT b.*, coalesce((SELECT array_agg(g.symbol ORDER BY g.symbol) FROM genes g WHERE g.id = ANY(b.gene_ids)), '{}') AS gene_symbols FROM b LIMIT 1`); | |
| 119 | + const row = head[0]; | |
| 120 | + if (!row) throw new NotFound('biomarker', ref); | |
| 121 | + const id = row.id as string; | |
| 122 | + const geneIds = (row.gene_ids as string[]) ?? []; | |
| 123 | + const variantIds = (row.variant_ids as string[]) ?? []; | |
| 124 | + const byId = scopeCte(sql`b.id = ${id}`); | |
| 125 | + const db = app.db; | |
| 126 | + const lit = variantIds.length ? { type: 'variant', ids: variantIds } : { type: 'gene', ids: geneIds }; | |
| 127 | + | |
| 128 | + const [genes, variants, cancers, drugs, approvals, trialCounts, trials, pubs] = await Promise.all([ | |
| 129 | + geneIds.length ? db.execute<Record<string, unknown>>(sql`SELECT id, symbol, name, hgnc_id, is_cancer_gene FROM genes WHERE id = ANY(${sql.param(geneIds)}::text[]) ORDER BY symbol`) : Promise.resolve([]), | |
| 130 | + variantIds.length ? db.execute<Record<string, unknown>>(sql`SELECT id, slug, name, gene_symbol, variant_type, civic_variant_id FROM variants WHERE id = ANY(${sql.param(variantIds)}::text[]) ORDER BY gene_symbol, name`) : Promise.resolve([]), | |
| 131 | + db.execute<Record<string, unknown>>(sql` | |
| 132 | + ${byId} | |
| 133 | + SELECT c.id, c.slug, c.canonical_name AS name, count(*) AS n, | |
| 134 | + count(*) FILTER (WHERE e.evidence_type = 'PREDICTIVE') AS predictive, count(*) FILTER (WHERE e.evidence_type = 'PROGNOSTIC') AS prognostic, count(*) FILTER (WHERE e.evidence_type = 'DIAGNOSTIC') AS diagnostic, | |
| 135 | + count(*) FILTER (WHERE e.evidence_level = 'A') AS level_a, count(*) FILTER (WHERE e.evidence_level = 'B') AS level_b, count(*) FILTER (WHERE e.evidence_level = 'C') AS level_c, | |
| 136 | + count(*) FILTER (WHERE e.evidence_level = 'D') AS level_d, count(*) FILTER (WHERE e.evidence_level = 'E') AS level_e, min(p.source_id) AS source_id | |
| 137 | + FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} JOIN cancers c ON c.id = e.cancer_id LEFT JOIN provenance p ON p.id = e.provenance_id | |
| 138 | + GROUP BY c.id, c.slug, c.canonical_name ORDER BY n DESC, c.canonical_name LIMIT 200`), | |
| 139 | + db.execute<Record<string, unknown>>(sql` | |
| 140 | + ${byId}, ${DSET}, | |
| 141 | + ev AS (SELECT t AS drug_id, e.significance, e.evidence_level, e.cancer_id, e.civic_id FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} AND e.evidence_type = 'PREDICTIVE' CROSS JOIN LATERAL unnest(e.therapy_ids) t), | |
| 142 | + ke AS (SELECT k.target_entity_id AS drug_id, k.direction, k.evidence_level, k.cancer_context_ids FROM b JOIN variants v ON ${VAR_SCOPE} | |
| 143 | + JOIN knowledge_edges k ON k.source_entity_type = 'variant' AND k.source_entity_id = v.id AND k.target_entity_type = 'drug' AND k.relationship_type = 'PREDICTS_RESPONSE_TO' AND k.status = 'active') | |
| 144 | + SELECT d.id, d.slug, d.name, d.kind, | |
| 145 | + (SELECT count(*) FROM ev WHERE ev.drug_id = d.id) AS evidence_n, | |
| 146 | + (SELECT count(*) FROM ev WHERE ev.drug_id = d.id AND ev.significance = 'SENSITIVITYRESPONSE') AS sensitivity, | |
| 147 | + (SELECT count(*) FROM ev WHERE ev.drug_id = d.id AND ev.significance = 'RESISTANCE') AS resistance, | |
| 148 | + (SELECT min(ev.evidence_level) FROM ev WHERE ev.drug_id = d.id) AS best_level, | |
| 149 | + (SELECT array_agg(DISTINCT ev.civic_id) FROM ev WHERE ev.drug_id = d.id) AS civic_ids, | |
| 150 | + (SELECT count(*) FROM ke WHERE ke.drug_id = d.id) AS edge_n, | |
| 151 | + (SELECT count(*) FROM ke WHERE ke.drug_id = d.id AND ke.direction = 'sensitivity') AS edge_sensitivity, | |
| 152 | + (SELECT count(*) FROM ke WHERE ke.drug_id = d.id AND ke.direction = 'resistance') AS edge_resistance, | |
| 153 | + coalesce((SELECT array_agg(DISTINCT ev.cancer_id) FILTER (WHERE ev.cancer_id IS NOT NULL) FROM ev WHERE ev.drug_id = d.id), '{}') AS cancer_ids | |
| 154 | + FROM dset JOIN drugs d ON d.id = dset.drug_id ORDER BY evidence_n DESC, edge_n DESC, d.name`), | |
| 155 | + db.execute<Record<string, unknown>>(sql` | |
| 156 | + ${byId}, ${DSET} | |
| 157 | + SELECT a.id, a.drug_id, d.slug AS drug_slug, d.name AS drug_name, a.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, a.tumor_agnostic, a.jurisdiction, a.authority, a.indication, | |
| 158 | + a.line_of_therapy, a.disease_stage, a.approval_type, a.accelerated, a.conditional, a.approval_date, a.withdrawal_date, a.status, a.application_number, a.source_id, p.source_url, p.retrieved_at, p.dataset_version, | |
| 159 | + CASE WHEN a.drug_id IN (SELECT d2.drug_id FROM dset d2) AND ${TERM_MATCH} THEN 'both' WHEN a.drug_id IN (SELECT d2.drug_id FROM dset d2) THEN 'drug' ELSE 'indication' END AS matched_by | |
| 160 | + FROM b, drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id LEFT JOIN provenance p ON p.id = a.provenance_id | |
| 161 | + WHERE a.drug_id IN (SELECT d2.drug_id FROM dset d2) OR ${TERM_MATCH} | |
| 162 | + ORDER BY a.tumor_agnostic DESC, d.name, a.jurisdiction, a.approval_date DESC NULLS LAST LIMIT 500`), | |
| 163 | + db.execute<{ total: string; active: string; recruiting: string; phase3: string }>(sql` | |
| 164 | + ${byId}, ${DSET}, ${CSET}, ${TSET} | |
| 165 | + SELECT count(*) AS total, count(*) FILTER (WHERE ${ACTIVE}) AS active, count(*) FILTER (WHERE t.overall_status = 'RECRUITING') AS recruiting, count(*) FILTER (WHERE ${ACTIVE} AND 'PHASE3' = ANY(t.phases)) AS phase3 | |
| 166 | + FROM tset x JOIN clinical_trials t ON t.id = x.trial_id`), | |
| 167 | + req.query.trialLimit | |
| 168 | + ? db.execute<Record<string, unknown>>(sql` | |
| 169 | + ${byId}, ${DSET}, ${CSET}, ${TSET} | |
| 170 | + SELECT t.id, t.nct_id, t.brief_title, t.phases, t.overall_status, t.study_type, t.start_date, t.enrollment_count, t.lead_sponsor, t.countries, t.last_update_posted_date | |
| 171 | + FROM tset x JOIN clinical_trials t ON t.id = x.trial_id WHERE ${ACTIVE} ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${req.query.trialLimit}`) | |
| 172 | + : Promise.resolve([]), | |
| 173 | + req.query.publicationLimit && lit.ids.length | |
| 174 | + ? db.execute<Record<string, unknown>>(sql` | |
| 175 | + SELECT p.id, p.pmid, p.doi, p.title, p.journal, p.pub_year, p.retracted, x.method, x.confidence, x.edge_status, x.source_id | |
| 176 | + FROM (SELECT DISTINCT ON (e.publication_id) e.publication_id, e.method, e.confidence, e.status AS edge_status, e.source_id FROM publication_entity_edges e | |
| 177 | + WHERE e.entity_type = ${lit.type} AND e.entity_id = ANY(${sql.param(lit.ids)}::text[]) AND e.status <> 'rejected' ORDER BY e.publication_id, e.status) x | |
| 178 | + JOIN publications p ON p.id = x.publication_id ORDER BY p.pub_year DESC NULLS LAST, p.id LIMIT ${req.query.publicationLimit}`) | |
| 179 | + : Promise.resolve([]), | |
| 180 | + ]); | |
| 181 | + | |
| 182 | + const tc = trialCounts[0] ?? { total: '0', active: '0', recruiting: '0', phase3: '0' }; | |
| 183 | + const tumorAgnosticRows = approvals.filter((a) => a.tumor_agnostic === true); | |
| 184 | + const { gene_symbols, ...restRow } = row; | |
| 185 | + const data = { | |
| 186 | + ...shapeBiomarker(restRow), | |
| 187 | + geneSymbols: gene_symbols ?? [], | |
| 188 | + genes: camelRows(genes), | |
| 189 | + variants: camelRows(variants), | |
| 190 | + noScopeReason: geneIds.length || variantIds.length ? null : (((row.measurement as Record<string, unknown> | null)?.notes as string | undefined) ?? 'Not anchored to a gene or variant entity; gene-derived links cannot be computed.'), | |
| 191 | + tumorAgnostic: { | |
| 192 | + curated: ((row.measurement as Record<string, unknown> | null)?.tumorAgnostic ?? false) === true, | |
| 193 | + approvalRows: tumorAgnosticRows.map((a) => ({ id: a.id, drug: { id: a.drug_id, slug: a.drug_slug, name: a.drug_name }, jurisdiction: a.jurisdiction, authority: a.authority, approvalDate: a.approval_date, status: a.status, matchedBy: a.matched_by, category: 'regulatory_status' })), | |
| 194 | + rule: 'Rows of drug_approvals with tumor_agnostic = true (source flag) reached through the derived drug set or an indication-text match; nothing is asserted without a row.', | |
| 195 | + }, | |
| 196 | + cancers: cancers.map((r) => ({ | |
| 197 | + cancer: { id: r.id, slug: r.slug, name: r.name }, | |
| 198 | + evidenceItems: num(r.n), | |
| 199 | + byType: { predictive: num(r.predictive), prognostic: num(r.prognostic), diagnostic: num(r.diagnostic) }, | |
| 200 | + byLevel: { A: num(r.level_a), B: num(r.level_b), C: num(r.level_c), D: num(r.level_d), E: num(r.level_e) }, | |
| 201 | + sourceId: r.source_id, | |
| 202 | + category: 'curated_evidence', | |
| 203 | + })), | |
| 204 | + drugs: drugs.map((r) => ({ | |
| 205 | + drug: { id: r.id, slug: r.slug, name: r.name, kind: r.kind }, | |
| 206 | + civic: { evidenceItems: num(r.evidence_n), sensitivity: num(r.sensitivity), resistance: num(r.resistance), bestLevel: r.best_level ?? null, civicEvidenceIds: r.civic_ids ?? [] }, | |
| 207 | + knowledgeEdges: { edges: num(r.edge_n), sensitivity: num(r.edge_sensitivity), resistance: num(r.edge_resistance) }, | |
| 208 | + cancerIds: r.cancer_ids ?? [], | |
| 209 | + category: 'curated_evidence', | |
| 210 | + })), | |
| 211 | + approvals: approvals.map((r) => { | |
| 212 | + const { drug_slug, drug_name, cancer_slug, cancer_name, source_url, retrieved_at, dataset_version, source_id, matched_by, ...rest } = r; | |
| 213 | + return { ...camel(rest), drug: { id: r.drug_id, slug: drug_slug, name: drug_name }, cancer: r.cancer_id ? { id: r.cancer_id, slug: cancer_slug, name: cancer_name } : null, matchedBy: matched_by, provenance: { sourceId: source_id, url: source_url, retrievedAt: retrieved_at, datasetVersion: dataset_version, category: 'regulatory_status' } }; | |
| 214 | + }), | |
| 215 | + trials: { | |
| 216 | + counts: { total: num(tc.total), active: num(tc.active), recruiting: num(tc.recruiting), phase3Active: num(tc.phase3) }, | |
| 217 | + activeStatuses: ACTIVE_STATUSES, | |
| 218 | + rows: camelRows(trials), | |
| 219 | + category: 'published_evidence', | |
| 220 | + }, | |
| 221 | + publications: { entityType: lit.type, rows: pubs.map((r) => { | |
| 222 | + const { method, confidence, edge_status, source_id, ...rest } = r; | |
| 223 | + return { ...camel(rest), edge: { method, confidence, status: edge_status, sourceId: source_id } }; | |
| 224 | + }) }, | |
| 225 | + derived: { cancers: cancers.length, drugs: drugs.length, approvals: approvals.length, tumorAgnosticApprovals: tumorAgnosticRows.length, activeTrials: num(tc.active), ...derived }, | |
| 226 | + }; | |
| 227 | + return respond(app, data, ['ncit-evs', 'hgnc', 'civic', 'clinicaltrials', 'pubmed', ...pluck(cancers, 'source_id'), ...pluck(approvals, 'source_id'), ...pluck(pubs, 'source_id')]); | |
| 228 | + }, | |
| 229 | + ); | |
| 230 | +}; | |
modified
apps/web/qa/smoke.mjs
+3 −0
@@ -51,6 +51,9 @@ const ROUTES = [ | ||
| 51 | 51 | { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 }, |
| 52 | 52 | { path: '/methodology/trial-map', expect: ['ISO'] }, |
| 53 | 53 | { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] }, |
| 54 | + { path: '/biomarkers', expect: ['Biomarkers', 'HER2'] }, | |
| 55 | + { path: '/biomarker/braf-v600e', expect: ['BRAF', 'NCIt'] }, | |
| 56 | + { path: '/api/v1/biomarkers?limit=3', expect: ['data'], kind: 'json', optionalLocal: true }, | |
| 54 | 57 | { path: '/year/2025', expect: ['2025 in cancer', 'Phase III'] }, |
| 55 | 58 | { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] }, |
| 56 | 59 | { path: '/data-updates', expect: ['Data update log', 'ING-'] }, |
added
apps/web/src/app/biomarker/[slug]/loading.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { PageSkeleton } from '@/components/ui/skeleton'; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return <PageSkeleton title="Loading biomarker" />; | |
| 5 | +} | |
added
apps/web/src/app/biomarker/[slug]/page.tsx
+443 −0
@@ -0,0 +1,443 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { notFound, permanentRedirect } from 'next/navigation'; | |
| 4 | +import { ExternalLink } from 'lucide-react'; | |
| 5 | +import { PageHeader, Section, KV, Note } from '@/components/ui/section'; | |
| 6 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 7 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 8 | +import { Freshness } from '@/components/ui/freshness'; | |
| 9 | +import { Pager } from '@/components/ui/pager'; | |
| 10 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 11 | +import { ApprovalsTable } from '@/components/data/approvals-table'; | |
| 12 | +import { EvidenceTable } from '@/components/data/evidence-table'; | |
| 13 | +import { TrialTable } from '@/components/data/trial-list'; | |
| 14 | +import { PublicationList } from '@/components/data/publication-list'; | |
| 15 | +import { GraphLink } from '@/components/graph/graph-link'; | |
| 16 | +import { | |
| 17 | + getBiomarkerBySlug, | |
| 18 | + biomarkerGenes, | |
| 19 | + biomarkerVariants, | |
| 20 | + biomarkerCancers, | |
| 21 | + biomarkerDrugs, | |
| 22 | + biomarkerEvidence, | |
| 23 | + biomarkerEvidenceCount, | |
| 24 | + biomarkerApprovals, | |
| 25 | + biomarkerTrialCounts, | |
| 26 | + biomarkerActiveTrials, | |
| 27 | + literatureScope, | |
| 28 | + noScopeReason, | |
| 29 | + kindLabel, | |
| 30 | + BIOMARKER_LINKS_FORMULA, | |
| 31 | +} from '@/lib/queries/biomarkers'; | |
| 32 | +import { EVIDENCE_PAGE_SIZE, EVIDENCE_LEVEL_LABEL } from '@/lib/queries/evidence'; | |
| 33 | +import { TRIAL_PAGE_SIZE } from '@/lib/queries/trials'; | |
| 34 | +import { recentPublicationsFor, recentPublicationsForCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications'; | |
| 35 | +import { loadProvenance } from '@/lib/queries/provenance'; | |
| 36 | +import { jsonLd } from '@/lib/seo'; | |
| 37 | +import { SITE_URL } from '@/lib/site'; | |
| 38 | +import { fmtDate, fmtInt, humanize } from '@/lib/format'; | |
| 39 | +import { pageInfo } from '@/lib/pagination'; | |
| 40 | +import { int, str, withParams, type SP } from '@/lib/search-params'; | |
| 41 | + | |
| 42 | +export const revalidate = 3600; | |
| 43 | + | |
| 44 | +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { | |
| 45 | + const b = await getBiomarkerBySlug((await params).slug); | |
| 46 | + return b ? { title: `${b.name} — biomarker`, description: b.description ?? `${b.name}: what is measured, associated cancers, drugs with predictive evidence, approvals and trials.`, alternates: { canonical: `/biomarker/${b.slug}` } } : { title: 'Biomarker' }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +const EVS = (code: string) => `https://evsexplore.semantics.cancer.gov/evsexplore/concept/ncit/${code}`; | |
| 50 | + | |
| 51 | +export default async function BiomarkerPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) { | |
| 52 | + const { slug } = await params; | |
| 53 | + const b = await getBiomarkerBySlug(slug); | |
| 54 | + if (!b) notFound(); | |
| 55 | + if (b.slug !== slug) permanentRedirect(`/biomarker/${b.slug}`); | |
| 56 | + const sp = await searchParams; | |
| 57 | + const m = b.measurement; | |
| 58 | + const hasScope = b.gene_ids.length > 0 || b.variant_ids.length > 0; | |
| 59 | + const reason = noScopeReason(b); | |
| 60 | + const lit = literatureScope(b); | |
| 61 | + | |
| 62 | + const [evTotal, trialCounts, pTotal] = await Promise.all([hasScope ? biomarkerEvidenceCount(b, 'PREDICTIVE') : Promise.resolve(0), hasScope ? biomarkerTrialCounts(b) : Promise.resolve({ total: 0, active: 0, recruiting: 0, phase3: 0 }), lit.ids.length ? recentPublicationsForCount(lit.entityType, lit.ids) : Promise.resolve(0)]); | |
| 63 | + const ev = pageInfo(int(sp, 'evPage', 1, 1, 100_000), EVIDENCE_PAGE_SIZE, evTotal); | |
| 64 | + const tp = pageInfo(int(sp, 'tPage', 1, 1, 100_000), TRIAL_PAGE_SIZE, trialCounts.active); | |
| 65 | + const pp = pageInfo(int(sp, 'pPage', 1, 1, 100_000), PUBLICATION_PAGE_SIZE, pTotal); | |
| 66 | + const aPage = int(sp, 'aPage', 1, 1, 100_000); | |
| 67 | + | |
| 68 | + const [genes, variants, cancers, drugs, evidence, approvals, trials, pubs] = await Promise.all([ | |
| 69 | + biomarkerGenes(b), | |
| 70 | + biomarkerVariants(b), | |
| 71 | + hasScope ? biomarkerCancers(b) : Promise.resolve([]), | |
| 72 | + hasScope ? biomarkerDrugs(b) : Promise.resolve([]), | |
| 73 | + evTotal ? biomarkerEvidence(b, { type: 'PREDICTIVE', page: ev.page, pageSize: ev.pageSize }) : Promise.resolve([]), | |
| 74 | + biomarkerApprovals(b), | |
| 75 | + trialCounts.active ? biomarkerActiveTrials(b, { page: tp.page, pageSize: tp.pageSize }) : Promise.resolve([]), | |
| 76 | + pTotal ? recentPublicationsFor(lit.entityType, lit.ids, { page: pp.page, pageSize: pp.pageSize }) : Promise.resolve([]), | |
| 77 | + ]); | |
| 78 | + const prov = await loadProvenance([...evidence.map((e) => e.provenance_id), ...approvals.map((a) => a.provenance_id)]); | |
| 79 | + | |
| 80 | + const jurisdictions = [...new Set(approvals.map((a) => a.jurisdiction))].sort(); | |
| 81 | + const wanted = str(sp, 'jurisdiction'); | |
| 82 | + const selected = jurisdictions.includes(wanted) ? wanted : null; | |
| 83 | + const shownApprovals = selected ? approvals.filter((a) => a.jurisdiction === selected) : approvals; | |
| 84 | + const tumorAgnosticRows = approvals.filter((a) => a.tumor_agnostic); | |
| 85 | + const byIndication = approvals.filter((a) => a.matched_by === 'indication').length; | |
| 86 | + | |
| 87 | + const current = { jurisdiction: selected ?? '', evPage: ev.page > 1 ? ev.page : '', tPage: tp.page > 1 ? tp.page : '', pPage: pp.page > 1 ? pp.page : '', aPage: aPage > 1 ? aPage : '' }; | |
| 88 | + const href = (o: Record<string, string | number | null | undefined>, hash?: string) => `/biomarker/${b.slug}${withParams(current, o)}${hash ? `#${hash}` : ''}`; | |
| 89 | + const aliases = m.aliases ?? []; | |
| 90 | + | |
| 91 | + const ld: Record<string, unknown> = { '@context': 'https://schema.org', '@type': 'MedicalTest', name: b.name, url: `${SITE_URL}/biomarker/${b.slug}` }; | |
| 92 | + if (b.description) ld.description = b.description; | |
| 93 | + if (aliases.length) ld.alternateName = aliases.slice(0, 20); | |
| 94 | + if (b.ncit_code) ld.code = [{ '@type': 'MedicalCode', codeValue: b.ncit_code, codingSystem: 'NCIt' }]; | |
| 95 | + if (m.assays?.length) ld.usesDevice = m.assays.map((a) => ({ '@type': 'MedicalDevice', name: a })); | |
| 96 | + | |
| 97 | + const computedNote = (rule: string) => ( | |
| 98 | + <span className="inline-flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3"> | |
| 99 | + <ClaimBadge kind="computed" /> | |
| 100 | + <span title={rule}> | |
| 101 | + rule <span className="ci-mono">{BIOMARKER_LINKS_FORMULA}</span> | |
| 102 | + </span> | |
| 103 | + </span> | |
| 104 | + ); | |
| 105 | + | |
| 106 | + return ( | |
| 107 | + <article> | |
| 108 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(ld) }} /> | |
| 109 | + <PageHeader kicker={`Biomarker · ${kindLabel(b.kind)}`} title={b.name} lede={b.description ?? undefined}> | |
| 110 | + <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> | |
| 111 | + <span className="ci-mono text-ink-3">{b.id}</span> | |
| 112 | + {genes.map((g) => ( | |
| 113 | + <span key={g.id} className="inline-flex items-center gap-1"> | |
| 114 | + <Link className="ci-link ci-mono" href={`/gene/${g.symbol}`}> | |
| 115 | + {g.symbol} | |
| 116 | + </Link> | |
| 117 | + <GraphLink type="gene" entityRef={g.symbol} label="graph →" /> | |
| 118 | + </span> | |
| 119 | + ))} | |
| 120 | + {b.ncit_code ? ( | |
| 121 | + <a className="ci-link inline-flex items-center gap-1" href={EVS(b.ncit_code)} target="_blank" rel="noopener noreferrer" title={m.ncit?.name ? `NCIt preferred name: ${m.ncit.name}` : undefined}> | |
| 122 | + NCIt {b.ncit_code} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 123 | + </a> | |
| 124 | + ) : null} | |
| 125 | + {tumorAgnosticRows.length ? ( | |
| 126 | + <Badge tone="accent" title={`${tumorAgnosticRows.length} approval row${tumorAgnosticRows.length === 1 ? '' : 's'} flagged tumor_agnostic by the source (listed below)`}> | |
| 127 | + Tumor-agnostic · {tumorAgnosticRows.length} approval row{tumorAgnosticRows.length === 1 ? '' : 's'} | |
| 128 | + </Badge> | |
| 129 | + ) : m.tumorAgnostic ? ( | |
| 130 | + <Badge tone="outline" title="Curated flag: an FDA tissue-agnostic indication exists for this marker, but no approval row with tumor_agnostic = true is ingested yet"> | |
| 131 | + Tumor-agnostic (curated) · no approval row ingested yet | |
| 132 | + </Badge> | |
| 133 | + ) : null} | |
| 134 | + </p> | |
| 135 | + {aliases.length ? ( | |
| 136 | + <p className="mt-1 text-[12.5px] text-ink-3"> | |
| 137 | + <span className="ci-kicker mr-2">Also called</span> | |
| 138 | + {aliases.join(' · ')} | |
| 139 | + </p> | |
| 140 | + ) : null} | |
| 141 | + </PageHeader> | |
| 142 | + | |
| 143 | + {tumorAgnosticRows.length ? ( | |
| 144 | + <section id="tumor-agnostic" aria-labelledby="tumor-agnostic-title" className="ci-rule mb-6 pt-4"> | |
| 145 | + <p className="ci-kicker mb-1">Tissue-agnostic indications</p> | |
| 146 | + <h2 id="tumor-agnostic-title" className="text-lg"> | |
| 147 | + Approval rows flagged tumor-agnostic ({tumorAgnosticRows.length}) | |
| 148 | + </h2> | |
| 149 | + <ul className="ci-rows mt-2"> | |
| 150 | + {tumorAgnosticRows.map((a) => ( | |
| 151 | + <li key={a.id}> | |
| 152 | + <span> | |
| 153 | + <Link className="ci-link" href={`/drug/${a.drug_slug}#approvals`}> | |
| 154 | + {a.drug_name} | |
| 155 | + </Link>{' '} | |
| 156 | + <span className="ci-mono">{a.jurisdiction}</span> <span className="text-ink-3">{a.authority}</span> · {fmtDate(a.approval_date)} · <Badge tone="outline">{a.matched_by === 'indication' ? 'matched on indication text' : a.matched_by === 'both' ? 'drug + indication text' : 'drug in derived set'}</Badge> | |
| 157 | + </span> | |
| 158 | + <span className="text-[12px] text-ink-3"> | |
| 159 | + <ClaimBadge kind="regulatory" /> {a.status} | |
| 160 | + </span> | |
| 161 | + </li> | |
| 162 | + ))} | |
| 163 | + </ul> | |
| 164 | + <p className="mt-2 text-[12px] text-ink-3">The flag is the source's (openFDA label text), never inferred; the full indication text of each row is in the Approvals section below.</p> | |
| 165 | + </section> | |
| 166 | + ) : null} | |
| 167 | + | |
| 168 | + <div className="grid gap-8 lg:grid-cols-[1fr_320px]"> | |
| 169 | + <div className="space-y-8"> | |
| 170 | + <Section id="cancers" kicker="Derived" title={`Associated cancers (${fmtInt(cancers.length)})`} description="Cancers mapped in ACCEPTED CIViC predictive, prognostic or diagnostic items whose variant (molecular markers) or gene (gene-level markers) is in this biomarker's scope. Counts by type and native level; each row links to the cancer's evidence tab." actions={computedNote('Associated cancers = distinct mapped cancer_id of in-scope CIViC evidence (status ACCEPTED; types PREDICTIVE, PROGNOSTIC, DIAGNOSTIC).')}> | |
| 171 | + {cancers.length ? ( | |
| 172 | + <div className="ci-table-wrap"> | |
| 173 | + <table className="ci-table"> | |
| 174 | + <thead> | |
| 175 | + <tr> | |
| 176 | + <th scope="col">Cancer</th> | |
| 177 | + <th scope="col" className="num">Items</th> | |
| 178 | + <th scope="col" className="num">Predictive</th> | |
| 179 | + <th scope="col" className="num">Prognostic</th> | |
| 180 | + <th scope="col" className="num">Diagnostic</th> | |
| 181 | + <th scope="col" title="CIViC evidence levels as curated: A validated · B clinical · C case study · D preclinical · E inferential">Levels A / B / C / D / E</th> | |
| 182 | + </tr> | |
| 183 | + </thead> | |
| 184 | + <tbody> | |
| 185 | + {cancers.map((c) => ( | |
| 186 | + <tr key={c.cancer_id}> | |
| 187 | + <td className="min-w-[220px]"> | |
| 188 | + <Link className="ci-link" href={`/cancer/${c.slug}/evidence`}> | |
| 189 | + {c.name} | |
| 190 | + </Link> | |
| 191 | + </td> | |
| 192 | + <td className="num">{fmtInt(c.n)}</td> | |
| 193 | + <td className="num">{c.predictive || '—'}</td> | |
| 194 | + <td className="num">{c.prognostic || '—'}</td> | |
| 195 | + <td className="num">{c.diagnostic || '—'}</td> | |
| 196 | + <td className="ci-num whitespace-nowrap text-[12.5px]"> | |
| 197 | + {[c.level_a, c.level_b, c.level_c, c.level_d, c.level_e].map((n, i) => ( | |
| 198 | + <span key={i} className={n ? '' : 'text-ink-4'} title={EVIDENCE_LEVEL_LABEL[['A', 'B', 'C', 'D', 'E'][i]!]}> | |
| 199 | + {i > 0 ? ' / ' : ''} | |
| 200 | + {n} | |
| 201 | + </span> | |
| 202 | + ))} | |
| 203 | + </td> | |
| 204 | + </tr> | |
| 205 | + ))} | |
| 206 | + </tbody> | |
| 207 | + </table> | |
| 208 | + </div> | |
| 209 | + ) : ( | |
| 210 | + <EmptyState compact knows={genes.map((g) => ({ label: `Gene ${g.symbol}`, href: `/gene/${g.symbol}` }))}> | |
| 211 | + {reason ?? 'No ACCEPTED CIViC predictive, prognostic or diagnostic item maps a cancer to this biomarker yet.'} | |
| 212 | + </EmptyState> | |
| 213 | + )} | |
| 214 | + </Section> | |
| 215 | + | |
| 216 | + <Section id="drugs" kicker="Derived" title={`Drugs with predictive evidence (${fmtInt(drugs.length)})`} description="Therapies named in in-scope PREDICTIVE CIViC items, plus targets of PREDICTS_RESPONSE_TO knowledge edges from in-scope variants. Sensitivity and resistance are counted separately and never merged into a verdict." actions={computedNote('Drug set = therapy_ids of in-scope PREDICTIVE evidence ∪ targets of active PREDICTS_RESPONSE_TO knowledge edges whose source variant is in scope.')}> | |
| 217 | + {drugs.length ? ( | |
| 218 | + <> | |
| 219 | + <div className="ci-table-wrap"> | |
| 220 | + <table className="ci-table"> | |
| 221 | + <thead> | |
| 222 | + <tr> | |
| 223 | + <th scope="col">Drug</th> | |
| 224 | + <th scope="col" className="num" title="PREDICTIVE CIViC items in scope naming this therapy">CIViC items</th> | |
| 225 | + <th scope="col" className="num" title="Items with significance SENSITIVITY/RESPONSE">Sensitivity</th> | |
| 226 | + <th scope="col" className="num" title="Items with significance RESISTANCE">Resistance</th> | |
| 227 | + <th scope="col" title="Best (lowest letter) native CIViC level among those items">Best level</th> | |
| 228 | + <th scope="col" className="num" title="Active PREDICTS_RESPONSE_TO knowledge edges (sensitivity / resistance)">Edges (S / R)</th> | |
| 229 | + <th scope="col">Cancer context</th> | |
| 230 | + </tr> | |
| 231 | + </thead> | |
| 232 | + <tbody> | |
| 233 | + {drugs.map((d) => ( | |
| 234 | + <tr key={d.drug_id}> | |
| 235 | + <td className="min-w-[180px]"> | |
| 236 | + <Link className="ci-link font-medium" href={`/drug/${d.slug}`}> | |
| 237 | + {d.name} | |
| 238 | + </Link> | |
| 239 | + {d.kind ? <span className="block text-[11px] text-ink-3">{humanize(d.kind)}</span> : null} | |
| 240 | + </td> | |
| 241 | + <td className="num">{d.evidence_n || '—'}</td> | |
| 242 | + <td className="num">{d.sensitivity ? <Badge tone="ok">{d.sensitivity}</Badge> : '—'}</td> | |
| 243 | + <td className="num">{d.resistance ? <Badge tone="danger">{d.resistance}</Badge> : '—'}</td> | |
| 244 | + <td>{d.best_level ? <abbr title={EVIDENCE_LEVEL_LABEL[d.best_level] ?? d.best_level}>{d.best_level}</abbr> : '—'}</td> | |
| 245 | + <td className="num ci-num"> | |
| 246 | + {d.edge_n ? ( | |
| 247 | + <> | |
| 248 | + {d.edge_sensitivity} / {d.edge_resistance} | |
| 249 | + </> | |
| 250 | + ) : ( | |
| 251 | + '—' | |
| 252 | + )} | |
| 253 | + </td> | |
| 254 | + <td className="max-w-[360px] text-[12.5px]"> | |
| 255 | + {d.cancer_slugs.length ? ( | |
| 256 | + d.cancer_slugs.slice(0, 4).map((s, i) => ( | |
| 257 | + <span key={s}> | |
| 258 | + {i > 0 ? ', ' : ''} | |
| 259 | + <Link className="ci-link" href={`/cancer/${s}`}> | |
| 260 | + {d.cancer_names[i]} | |
| 261 | + </Link> | |
| 262 | + </span> | |
| 263 | + )) | |
| 264 | + ) : ( | |
| 265 | + <span className="text-ink-3">—</span> | |
| 266 | + )} | |
| 267 | + {d.cancer_slugs.length > 4 ? <span className="text-ink-3"> +{d.cancer_slugs.length - 4}</span> : null} | |
| 268 | + </td> | |
| 269 | + </tr> | |
| 270 | + ))} | |
| 271 | + </tbody> | |
| 272 | + </table> | |
| 273 | + </div> | |
| 274 | + <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3"> | |
| 275 | + <ClaimBadge kind="curated" /> | |
| 276 | + <SourceBadge p={{ sourceSlug: 'civic', sourceName: 'CIViC', layer: 'canonical' }} /> items and knowledge edges as curated at the source. | |
| 277 | + </div> | |
| 278 | + </> | |
| 279 | + ) : ( | |
| 280 | + <EmptyState compact>{reason ?? 'No PREDICTIVE CIViC item or knowledge edge links a therapy to this biomarker yet.'}</EmptyState> | |
| 281 | + )} | |
| 282 | + </Section> | |
| 283 | + | |
| 284 | + <Section id="evidence" kicker="Curated evidence" title={`Predictive evidence items (${fmtInt(evTotal)})`} description={`ACCEPTED CIViC predictive items in scope, grouped by molecular profile and therapy with native level, direction and cancer context. ${EVIDENCE_PAGE_SIZE} per page.`}> | |
| 285 | + {evidence.length ? ( | |
| 286 | + <> | |
| 287 | + <EvidenceTable | |
| 288 | + items={evidence} | |
| 289 | + prov={prov} | |
| 290 | + showCancer | |
| 291 | + summary={ | |
| 292 | + <> | |
| 293 | + Showing {fmtInt(ev.from)}–{fmtInt(ev.to)} of {fmtInt(evTotal)} evidence items | |
| 294 | + </> | |
| 295 | + } | |
| 296 | + /> | |
| 297 | + <Pager total={evTotal} pageSize={ev.pageSize} page={ev.page} hrefFor={(p) => href({ evPage: p > 1 ? p : '' }, 'evidence')} label="Evidence pages" noun="evidence items" /> | |
| 298 | + </> | |
| 299 | + ) : ( | |
| 300 | + <EmptyState compact>{reason ?? 'No ACCEPTED predictive evidence item in scope.'}</EmptyState> | |
| 301 | + )} | |
| 302 | + </Section> | |
| 303 | + | |
| 304 | + <Section id="approvals" kicker="Regulatory" title={`Approvals mentioning the linked drugs (${fmtInt(approvals.length)})`} description="Approval records of the derived drug set, plus records whose indication text names this biomarker. Each record names its authority, jurisdiction, indication text and status — a drug approved in one jurisdiction for one indication is not 'approved' in general, and an approval listed here is not necessarily restricted to this biomarker." actions={computedNote(`Approvals = drug_approvals rows whose drug is in the derived set (matched by drug) or whose indication text contains a curated phrase (${(m.indicationTerms ?? []).join(' | ') || 'none'}).`)}> | |
| 305 | + {approvals.length ? ( | |
| 306 | + <> | |
| 307 | + <nav aria-label="Jurisdiction" className="mb-3 flex flex-wrap gap-1.5 text-[12.5px]"> | |
| 308 | + <Link href={href({ jurisdiction: '', aPage: '' }, 'approvals')} aria-current={!selected ? 'page' : undefined} className="ci-chip"> | |
| 309 | + All | |
| 310 | + </Link> | |
| 311 | + {jurisdictions.map((j) => ( | |
| 312 | + <Link key={j} href={href({ jurisdiction: j, aPage: '' }, 'approvals')} aria-current={selected === j ? 'page' : undefined} className="ci-chip ci-mono"> | |
| 313 | + {j} | |
| 314 | + </Link> | |
| 315 | + ))} | |
| 316 | + </nav> | |
| 317 | + <ApprovalsTable rows={shownApprovals} prov={prov} page={aPage} hrefFor={(p) => href({ aPage: p > 1 ? p : '' }, 'approvals')} /> | |
| 318 | + <p className="mt-2 text-[12px] text-ink-3"> | |
| 319 | + {byIndication ? `${byIndication} record${byIndication === 1 ? '' : 's'} matched on indication text only (drug not in the derived set). ` : ''} | |
| 320 | + Health Canada DIN rows carry no indication text and only match by drug. | |
| 321 | + </p> | |
| 322 | + </> | |
| 323 | + ) : ( | |
| 324 | + <EmptyState compact knows={[{ label: 'Approvals explorer', href: '/approvals' }]}> | |
| 325 | + No approval record reaches this biomarker through its derived drugs or indication text. Absence here is not evidence of absence: only ingested jurisdictions are covered. | |
| 326 | + </EmptyState> | |
| 327 | + )} | |
| 328 | + </Section> | |
| 329 | + | |
| 330 | + <Section id="trials" kicker="Clinical trials" title={`Active trials (${fmtInt(trialCounts.active)})`} description={hasScope ? `Trials with an active ClinicalTrials.gov status, an intervention mapped to a derived drug and a condition mapped to an associated cancer. ${fmtInt(trialCounts.recruiting)} recruiting · ${fmtInt(trialCounts.phase3)} active phase 3 · ${fmtInt(trialCounts.total)} in any status. Most recently updated first, ${TRIAL_PAGE_SIZE} per page.` : undefined} actions={computedNote('Trials = clinical_trials with trial_interventions.drug_id in the derived drug set AND trial_conditions.cancer_id in the associated cancers; active = RECRUITING, NOT_YET_RECRUITING, ENROLLING_BY_INVITATION, ACTIVE_NOT_RECRUITING.')}> | |
| 331 | + {trials.length ? ( | |
| 332 | + <> | |
| 333 | + <TrialTable | |
| 334 | + rows={trials} | |
| 335 | + summary={ | |
| 336 | + <> | |
| 337 | + Showing {fmtInt(tp.from)}–{fmtInt(tp.to)} of {fmtInt(trialCounts.active)} active studies | |
| 338 | + </> | |
| 339 | + } | |
| 340 | + /> | |
| 341 | + <Pager total={trialCounts.active} pageSize={tp.pageSize} page={tp.page} hrefFor={(p) => href({ tPage: p > 1 ? p : '' }, 'trials')} label="Trial pages" noun="studies" /> | |
| 342 | + <p className="mt-2 text-[12px] text-ink-3">A study listed here tests a linked drug in a linked cancer; it does not necessarily select participants on this biomarker.</p> | |
| 343 | + </> | |
| 344 | + ) : ( | |
| 345 | + <EmptyState compact knows={[{ label: 'Trials explorer', href: '/trials' }]}> | |
| 346 | + {reason ?? 'No active registered study combines a derived drug with an associated cancer.'} | |
| 347 | + </EmptyState> | |
| 348 | + )} | |
| 349 | + </Section> | |
| 350 | + | |
| 351 | + <Section id="publications" kicker="Literature" title={`Linked publications (${fmtInt(pTotal)})`} description={pTotal ? `Publications linked to the scope ${lit.entityType === 'variant' ? 'variant(s)' : 'gene(s)'} through PubMed entity edges; ${PUBLICATION_PAGE_SIZE} per page, newest first.` : undefined}> | |
| 352 | + {pubs.length ? ( | |
| 353 | + <> | |
| 354 | + <PublicationList | |
| 355 | + rows={pubs} | |
| 356 | + summary={ | |
| 357 | + <> | |
| 358 | + Showing {fmtInt(pp.from)}–{fmtInt(pp.to)} of {fmtInt(pTotal)} publications | |
| 359 | + </> | |
| 360 | + } | |
| 361 | + /> | |
| 362 | + <Pager total={pTotal} pageSize={pp.pageSize} page={pp.page} hrefFor={(p) => href({ pPage: p > 1 ? p : '' }, 'publications')} label="Publication pages" noun="publications" /> | |
| 363 | + </> | |
| 364 | + ) : ( | |
| 365 | + <EmptyState compact>{reason ?? `No publication is linked to the scope ${lit.entityType === 'variant' ? 'variants' : 'genes'} yet.`}</EmptyState> | |
| 366 | + )} | |
| 367 | + </Section> | |
| 368 | + </div> | |
| 369 | + | |
| 370 | + <aside className="space-y-8"> | |
| 371 | + <Section id="measurement" kicker="What is measured" title="Measurement" level={3}> | |
| 372 | + <KV | |
| 373 | + items={[ | |
| 374 | + { k: 'Kind', v: kindLabel(b.kind) }, | |
| 375 | + { k: 'Assays', v: m.assays?.length ? m.assays.join(', ') : null }, | |
| 376 | + { k: 'Scoring', v: m.scoring }, | |
| 377 | + { k: 'Notes', v: m.notes }, | |
| 378 | + { k: 'NCIt concept', v: b.ncit_code ? ( | |
| 379 | + <a className="ci-link inline-flex items-center gap-1" href={EVS(b.ncit_code)} target="_blank" rel="noopener noreferrer"> | |
| 380 | + {m.ncit?.name ?? b.ncit_code} <span className="ci-mono text-ink-3">{b.ncit_code}</span> <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 381 | + </a> | |
| 382 | + ) : null }, | |
| 383 | + { k: 'Genes', v: genes.length ? ( | |
| 384 | + <span> | |
| 385 | + {genes.map((g, i) => ( | |
| 386 | + <span key={g.id}> | |
| 387 | + {i > 0 ? ', ' : ''} | |
| 388 | + <Link className="ci-link ci-mono" href={`/gene/${g.symbol}`}> | |
| 389 | + {g.symbol} | |
| 390 | + </Link> | |
| 391 | + {g.name ? <span className="text-ink-3"> {g.name}</span> : null} | |
| 392 | + </span> | |
| 393 | + ))} | |
| 394 | + </span> | |
| 395 | + ) : <span className="text-ink-3">none (see notes)</span> }, | |
| 396 | + { k: 'Variant anchors', v: variants.length ? ( | |
| 397 | + <span> | |
| 398 | + {variants.map((v, i) => ( | |
| 399 | + <span key={v.id}> | |
| 400 | + {i > 0 ? ', ' : ''} | |
| 401 | + <Link className="ci-link" href={`/variant/${v.slug}`}> | |
| 402 | + {v.gene_symbol ? `${v.gene_symbol} ` : ''} | |
| 403 | + {v.name} | |
| 404 | + </Link> | |
| 405 | + </span> | |
| 406 | + ))} | |
| 407 | + </span> | |
| 408 | + ) : null }, | |
| 409 | + { k: 'Indication phrases', v: m.indicationTerms?.length ? <span className="text-[12.5px]">{m.indicationTerms.join(' · ')}</span> : null }, | |
| 410 | + ]} | |
| 411 | + /> | |
| 412 | + {m.sources?.length ? ( | |
| 413 | + <div className="mt-3"> | |
| 414 | + <p className="ci-kicker mb-1">Sources</p> | |
| 415 | + <ul className="m-0 list-none space-y-0.5 p-0 text-[12.5px]"> | |
| 416 | + {m.sources.map((s) => ( | |
| 417 | + <li key={s.url}> | |
| 418 | + <a className="ci-link inline-flex items-center gap-1" href={s.url} target="_blank" rel="noopener noreferrer"> | |
| 419 | + {s.label} <ExternalLink className="h-3 w-3" aria-hidden /> | |
| 420 | + </a> | |
| 421 | + </li> | |
| 422 | + ))} | |
| 423 | + </ul> | |
| 424 | + </div> | |
| 425 | + ) : null} | |
| 426 | + <div className="mt-3 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3"> | |
| 427 | + <ClaimBadge kind="curated" /> | |
| 428 | + <SourceBadge p={{ sourceSlug: 'ncit-evs', sourceName: 'NCI Thesaurus (NCIt)', dataset: m.verification ? `EVS REST API, NCIt ${m.verification.ncitVersion}` : undefined, retrievedAt: m.verification?.verifiedAt ?? null, layer: 'canonical', note: 'Identity metadata curated by CancerIndex; the NCIt code was fetched from the EVS REST API.' }} /> | |
| 429 | + </div> | |
| 430 | + <Freshness dataUpdatedAt={b.updated_at} sourceVersion={m.verification ? `NCIt ${m.verification.ncitVersion}` : null} extra={m.verification ? `code verified ${m.verification.verifiedAt}` : undefined} /> | |
| 431 | + </Section> | |
| 432 | + <Note> | |
| 433 | + A biomarker page describes what a test measures and lists the source records that mention it. It does not grade clinical utility, set thresholds or give individual guidance. Derived counts follow rule <span className="ci-mono">{BIOMARKER_LINKS_FORMULA}</span> —{' '} | |
| 434 | + <Link className="ci-link" href="/methodology#biomarkers"> | |
| 435 | + methodology | |
| 436 | + </Link> | |
| 437 | + . | |
| 438 | + </Note> | |
| 439 | + </aside> | |
| 440 | + </div> | |
| 441 | + </article> | |
| 442 | + ); | |
| 443 | +} | |
added
apps/web/src/app/biomarkers/loading.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { PageSkeleton } from '@/components/ui/skeleton'; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return <PageSkeleton title="Loading biomarkers" />; | |
| 5 | +} | |
added
apps/web/src/app/biomarkers/page.tsx
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { PageHeader, Note } from '@/components/ui/section'; | |
| 4 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 5 | +import { Badge, ClaimBadge } from '@/components/ui/badge'; | |
| 6 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 7 | +import { Freshness } from '@/components/ui/freshness'; | |
| 8 | +import { listBiomarkers, biomarkerKindFacets, kindLabel, BIOMARKER_KINDS, BIOMARKER_LINKS_FORMULA } from '@/lib/queries/biomarkers'; | |
| 9 | +import { fmtInt } from '@/lib/format'; | |
| 10 | +import { str, oneOf, withParams, type SP } from '@/lib/search-params'; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { | |
| 13 | + title: 'Biomarkers', | |
| 14 | + description: 'Canonical oncology biomarkers with verified NCIt concepts, anchor genes, assay conventions, and links derived from curated evidence, approvals and trials.', | |
| 15 | + alternates: { canonical: '/biomarkers' }, | |
| 16 | +}; | |
| 17 | +// Filtered list: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by all list pages. | |
| 18 | +export const revalidate = 600; | |
| 19 | + | |
| 20 | +const KIND_FILTER = ['', ...BIOMARKER_KINDS] as const; | |
| 21 | + | |
| 22 | +export default async function BiomarkersPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 23 | + const sp = await searchParams; | |
| 24 | + const q = str(sp, 'q'); | |
| 25 | + const kind = oneOf(sp, 'kind', KIND_FILTER, ''); | |
| 26 | + const [rows, facets] = await Promise.all([listBiomarkers({ kind: kind || undefined, q: q || undefined }), biomarkerKindFacets()]); | |
| 27 | + const href = (o: Record<string, string | number | null | undefined>) => `/biomarkers${withParams({ q, kind }, o)}`; | |
| 28 | + const total = facets.reduce((s, f) => s + f.n, 0); | |
| 29 | + const latest = rows.reduce<Date | string | null>((m, r) => (m == null || String(r.updated_at) > String(m) ? r.updated_at : m), null); | |
| 30 | + const verification = rows[0]?.measurement.verification; | |
| 31 | + | |
| 32 | + return ( | |
| 33 | + <div> | |
| 34 | + <PageHeader | |
| 35 | + kicker="Biomarkers" | |
| 36 | + title="Biomarkers" | |
| 37 | + lede="A biomarker entry is an identity record — what is measured, by which assay family, the NCIt concept it maps to and the gene it is anchored to. Every link to cancers, drugs, approvals and trials is derived at query time from curated evidence and registries; nothing on this page grades clinical utility or states a threshold of its own." | |
| 38 | + /> | |
| 39 | + | |
| 40 | + <form method="get" action="/biomarkers" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]"> | |
| 41 | + <label className="flex flex-col gap-1"> | |
| 42 | + <span className="ci-kicker">Name, alias, gene or NCIt code</span> | |
| 43 | + <input name="q" defaultValue={q} placeholder="e.g. HER2, dMMR, CD274, C98342" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" /> | |
| 44 | + </label> | |
| 45 | + {kind ? <input type="hidden" name="kind" value={kind} /> : null} | |
| 46 | + <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2"> | |
| 47 | + Apply | |
| 48 | + </button> | |
| 49 | + </form> | |
| 50 | + | |
| 51 | + <nav aria-label="Kind" className="mt-3 flex flex-wrap gap-1.5 text-[12.5px]"> | |
| 52 | + <Link href={href({ kind: '' })} aria-current={!kind ? 'page' : undefined} className="ci-chip"> | |
| 53 | + All <span className="ci-num text-ink-3">{fmtInt(total)}</span> | |
| 54 | + </Link> | |
| 55 | + {facets.map((f) => ( | |
| 56 | + <Link key={f.kind} href={href({ kind: f.kind })} aria-current={kind === f.kind ? 'page' : undefined} className="ci-chip"> | |
| 57 | + {kindLabel(f.kind)} <span className="ci-num text-ink-3">{f.n}</span> | |
| 58 | + </Link> | |
| 59 | + ))} | |
| 60 | + </nav> | |
| 61 | + | |
| 62 | + <div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-ink-2"> | |
| 63 | + <span> | |
| 64 | + <span className="ci-num font-medium text-ink">{fmtInt(rows.length)}</span> biomarker{rows.length === 1 ? '' : 's'} | |
| 65 | + </span> | |
| 66 | + <SourceBadge p={{ sourceSlug: 'ncit-evs', sourceName: 'NCI Thesaurus (NCIt)', dataset: verification ? `EVS REST API, NCIt ${verification.ncitVersion}` : 'EVS REST API', retrievedAt: verification?.verifiedAt ?? null, layer: 'canonical', note: 'Identity metadata is curated; every NCIt code was fetched from the EVS REST API and recorded with its version and date.' }} /> | |
| 67 | + <ClaimBadge kind="curated" /> | |
| 68 | + <span className="text-ink-3">identity ·</span> | |
| 69 | + <ClaimBadge kind="computed" /> | |
| 70 | + <span className="text-ink-3"> | |
| 71 | + counts, rule <span className="ci-mono">{BIOMARKER_LINKS_FORMULA}</span> ·{' '} | |
| 72 | + <Link className="ci-link" href="/methodology#biomarkers"> | |
| 73 | + methodology | |
| 74 | + </Link> | |
| 75 | + </span> | |
| 76 | + </div> | |
| 77 | + | |
| 78 | + {rows.length === 0 ? ( | |
| 79 | + <div className="mt-3"> | |
| 80 | + <EmptyState title={total === 0 ? 'Biomarkers not yet available' : 'No biomarker matches'} knows={[{ label: 'Genes', href: '/genes' }, { label: 'Drugs', href: '/drugs' }, { label: 'Approvals', href: '/approvals' }]}> | |
| 81 | + {total === 0 ? 'The biomarker catalogue has not been seeded on this environment (pnpm db:seed).' : 'Try a different name, alias, gene symbol or NCIt code, or clear the kind filter.'} | |
| 82 | + </EmptyState> | |
| 83 | + </div> | |
| 84 | + ) : ( | |
| 85 | + <> | |
| 86 | + <div className="ci-table-wrap mt-3"> | |
| 87 | + <table className="ci-table"> | |
| 88 | + <thead> | |
| 89 | + <tr> | |
| 90 | + <th scope="col">Biomarker</th> | |
| 91 | + <th scope="col">Kind</th> | |
| 92 | + <th scope="col">Gene(s)</th> | |
| 93 | + <th scope="col">NCIt</th> | |
| 94 | + <th scope="col" className="num" title="Distinct cancers mapped in ACCEPTED CIViC predictive / prognostic / diagnostic items in the biomarker's scope (computed)"> | |
| 95 | + Cancers | |
| 96 | + </th> | |
| 97 | + <th scope="col" className="num" title="Drugs with predictive evidence: CIViC therapies in scope ∪ PREDICTS_RESPONSE_TO knowledge-edge targets (computed)"> | |
| 98 | + Drugs | |
| 99 | + </th> | |
| 100 | + <th scope="col" className="num" title="Approval records of those drugs, or whose indication text names the biomarker (computed)"> | |
| 101 | + Approvals | |
| 102 | + </th> | |
| 103 | + <th scope="col" title="Tumor-agnostic = at least one approval row with the source flag tumor_agnostic = true among those approvals; the curated flag alone is never shown as a badge"> | |
| 104 | + Tissue-agnostic | |
| 105 | + </th> | |
| 106 | + <th scope="col" className="num" title="Trials with an active ClinicalTrials.gov status, an intervention mapped to a scope drug and a condition mapped to a scope cancer (computed)"> | |
| 107 | + Active trials | |
| 108 | + </th> | |
| 109 | + </tr> | |
| 110 | + </thead> | |
| 111 | + <tbody> | |
| 112 | + {rows.map((b) => { | |
| 113 | + const noScope = b.gene_ids.length === 0 && b.variant_ids.length === 0; | |
| 114 | + return ( | |
| 115 | + <tr key={b.id}> | |
| 116 | + <td className="min-w-[220px]"> | |
| 117 | + <Link className="ci-link font-medium" href={`/biomarker/${b.slug}`}> | |
| 118 | + {b.name} | |
| 119 | + </Link> | |
| 120 | + {b.measurement.aliases?.length ? <span className="block text-[11.5px] text-ink-3">{b.measurement.aliases.slice(0, 4).join(' · ')}</span> : null} | |
| 121 | + </td> | |
| 122 | + <td> | |
| 123 | + <Badge tone="outline">{kindLabel(b.kind)}</Badge> | |
| 124 | + </td> | |
| 125 | + <td className="ci-mono text-[12px]"> | |
| 126 | + {b.gene_symbols.length ? ( | |
| 127 | + b.gene_symbols.map((s, i) => ( | |
| 128 | + <span key={s}> | |
| 129 | + {i > 0 ? ', ' : ''} | |
| 130 | + <Link className="ci-link" href={`/gene/${s}`}> | |
| 131 | + {s} | |
| 132 | + </Link> | |
| 133 | + </span> | |
| 134 | + )) | |
| 135 | + ) : ( | |
| 136 | + <span className="font-sans text-ink-3" title={b.measurement.notes ?? 'No anchor gene'}> | |
| 137 | + none | |
| 138 | + </span> | |
| 139 | + )} | |
| 140 | + {b.variant_ids.length ? <span className="block font-sans text-[11px] text-ink-3">{b.variant_ids.length} variant anchor{b.variant_ids.length === 1 ? '' : 's'}</span> : null} | |
| 141 | + </td> | |
| 142 | + <td className="ci-mono text-[12px]"> | |
| 143 | + {b.ncit_code ? ( | |
| 144 | + <a className="ci-link" href={`https://evsexplore.semantics.cancer.gov/evsexplore/concept/ncit/${b.ncit_code}`} target="_blank" rel="noopener noreferrer" title={b.measurement.ncit?.name ?? undefined}> | |
| 145 | + {b.ncit_code} | |
| 146 | + </a> | |
| 147 | + ) : ( | |
| 148 | + '—' | |
| 149 | + )} | |
| 150 | + </td> | |
| 151 | + <td className="num">{noScope ? <span className="text-ink-4" title="No gene or variant anchor: not computed">—</span> : fmtInt(b.cancers_n)}</td> | |
| 152 | + <td className="num">{noScope ? <span className="text-ink-4">—</span> : fmtInt(b.drugs_n)}</td> | |
| 153 | + <td className="num">{fmtInt(b.approvals_n)}</td> | |
| 154 | + <td> | |
| 155 | + {b.tumor_agnostic_n > 0 ? ( | |
| 156 | + <Badge tone="accent" title={`${b.tumor_agnostic_n} approval row${b.tumor_agnostic_n === 1 ? '' : 's'} flagged tumor_agnostic by the source`}> | |
| 157 | + Tumor-agnostic · {b.tumor_agnostic_n} | |
| 158 | + </Badge> | |
| 159 | + ) : b.measurement.tumorAgnostic ? ( | |
| 160 | + <span className="text-[11.5px] text-ink-3" title="Curated flag set, but no approval row with tumor_agnostic = true is ingested for this marker yet"> | |
| 161 | + curated · no row yet | |
| 162 | + </span> | |
| 163 | + ) : ( | |
| 164 | + <span className="text-ink-4">—</span> | |
| 165 | + )} | |
| 166 | + </td> | |
| 167 | + <td className="num">{noScope ? <span className="text-ink-4">—</span> : fmtInt(b.active_trials_n)}</td> | |
| 168 | + </tr> | |
| 169 | + ); | |
| 170 | + })} | |
| 171 | + </tbody> | |
| 172 | + </table> | |
| 173 | + </div> | |
| 174 | + <Freshness dataUpdatedAt={latest} sourceVersion={verification ? `NCIt ${verification.ncitVersion}` : null} extra={verification ? `codes verified ${verification.verifiedAt}` : undefined} /> | |
| 175 | + <div className="mt-4"> | |
| 176 | + <Note> | |
| 177 | + Counts are computed over ingested sources only (CIViC, ClinicalTrials.gov, openFDA, Health Canada, PubMed) and say nothing about biomarkers or links that are not yet ingested. A trial counted here tests a linked drug in a linked cancer; it does not necessarily select patients on the biomarker. | |
| 178 | + </Note> | |
| 179 | + </div> | |
| 180 | + </> | |
| 181 | + )} | |
| 182 | + </div> | |
| 183 | + ); | |
| 184 | +} | |
modified
apps/web/src/app/methodology/page.tsx
+10 −1
@@ -20,7 +20,7 @@ export default async function MethodologyPage() { | ||
| 20 | 20 | <PageHeader kicker="Methodology" title="How the index is built" lede="CancerIndex separates layers — raw, normalized, canonical, derived, ranked — and keeps them separable. This page documents the rules applied at each step and lists every metric with its exact formula and version." /> |
| 21 | 21 | |
| 22 | 22 | <nav aria-label="On this page" className="flex flex-wrap gap-x-4 gap-y-1 border-y border-rule py-2 text-[13px]"> |
| 23 | − {['layers', 'normalization', 'reconciliation', 'hierarchy', 'uncertainty', 'metrics', 'versioning', 'country-scopes', 'cagr', 'compare', 'gap-caveat', 'research-gap', 'trial-intelligence', 'trial-map', 'pipeline', 'knowledge-graph', 'data-explorer', 'not-computed', 'limitations'].map((id) => ( | |
| 23 | + {['layers', 'normalization', 'reconciliation', 'hierarchy', 'uncertainty', 'metrics', 'versioning', 'country-scopes', 'cagr', 'compare', 'gap-caveat', 'research-gap', 'trial-intelligence', 'trial-map', 'pipeline', 'biomarkers', 'knowledge-graph', 'data-explorer', 'not-computed', 'limitations'].map((id) => ( | |
| 24 | 24 | <a key={id} href={`#${id}`} className="ci-link"> |
| 25 | 25 | {id === 'cagr' ? 'CAGR' : humanize(id)} |
| 26 | 26 | </a> |
@@ -253,6 +253,15 @@ export default async function MethodologyPage() { | ||
| 253 | 253 | </p> |
| 254 | 254 | </Section> |
| 255 | 255 | |
| 256 | + <Section id="biomarkers" kicker="§17 · §52 · §121" title="Biomarkers"> | |
| 257 | + <p> | |
| 258 | + A biomarker entry is <em>curated metadata</em>: 54 canonical oncology biomarkers (HER2, EGFR mutations, ALK/ROS1/RET/NTRK fusions, KRAS G12C, BRAF V600E, PD-L1, MSI-H/dMMR, TMB-H, BRCA1/2, HRD, PSMA, CD19, BCMA…), each with its kind, anchor gene(s), an NCIt biomarker concept verified against the NCI EVS API (version and date shown on the page), assay families and scoring conventions with source links. Every link shown on a biomarker page is derived at query time from the anchor gene(s) — CIViC evidence grouped by cancer with native levels, drugs with predictive evidence and their direction, jurisdiction-aware approvals for those drugs, active studies of those drugs in the associated cancers, publications — under the rule <code>biomarker-links-v1</code>; nothing is asserted about clinical utility beyond the source-native evidence levels. Tumor-agnostic status is shown from actual approval rows flagged as such, never from the curated list alone. | |
| 259 | + </p> | |
| 260 | + <p className="mt-2 text-[13px]"> | |
| 261 | + <Link className="ci-link" href="/biomarkers">Biomarker table</Link> · full method: <code>docs/methodology/biomarkers.md</code>. | |
| 262 | + </p> | |
| 263 | + </Section> | |
| 264 | + | |
| 256 | 265 | <Section id="knowledge-graph" kicker="§18 · §80" title="Knowledge graph"> |
| 257 | 266 | <p> |
| 258 | 267 | The graph page shows the neighbourhood of one entity. Two families of links are never merged: <strong>source-native edges</strong> from <code>knowledge_edges</code> (CIViC, ChEMBL, openFDA…) with their native relationship, direction, evidence level, cancer context and provenance, aggregated for display per neighbour, relationship, direction, level and source; and <strong>derived registry links</strong> (dashed) that are counts computed at query time — studies per cancer, alteration frequency per cohort (≥ 5 %, ≥ 20 cases, largest denominator), approvals, drugs investigated in trials. At most 60 nodes are drawn, 25 per relationship group unless expanded; the table under the graph is complete for the expanded groups. CancerIndex never infers an edge and no language model writes into the graph. |
added
apps/web/src/lib/queries/biomarkers.ts
+414 −0
@@ -0,0 +1,414 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | +import type { EvidenceItem } from '@/lib/queries/evidence'; | |
| 4 | +import { EVIDENCE_DESCRIPTION_CHARS } from '@/lib/queries/evidence'; | |
| 5 | +import type { ApprovalRow } from '@/lib/queries/drugs'; | |
| 6 | +import type { TrialListRow } from '@/lib/queries/trials'; | |
| 7 | +import { ACTIVE_STATUSES } from '@/lib/queries/trials'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Biomarkers (SPEC §17, §52, §121). The `biomarkers` table holds curated metadata only (identity, | |
| 11 | + * verified NCIt concept, anchor gene, aliases, assay conventions — seeded from | |
| 12 | + * packages/database/src/seed-data/biomarkers.ts). Every link to cancers, drugs, approvals, trials | |
| 13 | + * and publications is DERIVED HERE at query time from source-native records, with these rules | |
| 14 | + * (docs/methodology/biomarkers.md, formula `biomarker-links-v1`): | |
| 15 | + * | |
| 16 | + * scope gene_ids = anchor gene ∪ measurement.genes (HGNC symbols → genes.id); | |
| 17 | + * variant_ids = measurement.variantSlugs → variants.id. When variant_ids is non-empty | |
| 18 | + * the marker is molecular-level and evidence is restricted to those variants; otherwise | |
| 19 | + * the whole gene(s). | |
| 20 | + * evidence CIViC items with status ACCEPTED and type PREDICTIVE | PROGNOSTIC | DIAGNOSTIC whose | |
| 21 | + * variant_ids (or gene_ids) overlap the scope. Levels A–E kept native. | |
| 22 | + * cancers distinct mapped cancer_id of that evidence. | |
| 23 | + * drugs therapy_ids of the PREDICTIVE evidence ∪ targets of active PREDICTS_RESPONSE_TO | |
| 24 | + * knowledge edges whose source variant is in scope. Direction from the source. | |
| 25 | + * approvals drug_approvals of those drugs (matched_by = drug), plus rows whose indication text | |
| 26 | + * contains a curated `indicationTerms` phrase (matched_by = indication). tumor_agnostic | |
| 27 | + * is the source flag, never inferred. | |
| 28 | + * trials clinical_trials with an intervention mapped to a scope drug AND a condition mapped to | |
| 29 | + * a scope cancer; "active" = ClinicalTrials.gov statuses in ACTIVE_STATUSES. | |
| 30 | + * literature publication_entity_edges on the scope variants (molecular markers) or genes. | |
| 31 | + */ | |
| 32 | +export const BIOMARKER_LINKS_FORMULA = 'biomarker-links-v1'; | |
| 33 | + | |
| 34 | +export const BIOMARKER_KINDS = ['gene_mutation', 'protein_expression', 'hormone_receptor', 'immune_marker', 'msi', 'tmb', 'hrd', 'ctdna', 'methylation', 'signature', 'cell_surface', 'other'] as const; | |
| 35 | +export type BiomarkerKind = (typeof BIOMARKER_KINDS)[number]; | |
| 36 | +export const BIOMARKER_KIND_LABEL: Record<string, string> = { | |
| 37 | + gene_mutation: 'Gene alteration', | |
| 38 | + protein_expression: 'Protein expression', | |
| 39 | + hormone_receptor: 'Hormone receptor', | |
| 40 | + immune_marker: 'Immune marker', | |
| 41 | + msi: 'MSI / MMR', | |
| 42 | + tmb: 'Tumor mutational burden', | |
| 43 | + hrd: 'HR deficiency', | |
| 44 | + ctdna: 'Circulating tumor DNA', | |
| 45 | + methylation: 'Methylation', | |
| 46 | + signature: 'Signature', | |
| 47 | + cell_surface: 'Cell-surface target', | |
| 48 | + other: 'Other', | |
| 49 | +}; | |
| 50 | +export function kindLabel(kind: string): string { | |
| 51 | + return BIOMARKER_KIND_LABEL[kind] ?? kind.replace(/_/g, ' '); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** Mirror of the seed's `measurement` jsonb (written only by the seed). */ | |
| 55 | +export interface BiomarkerMeasurement { | |
| 56 | + assays?: string[]; | |
| 57 | + scoring?: string; | |
| 58 | + notes?: string; | |
| 59 | + sources?: Array<{ label: string; url: string }>; | |
| 60 | + aliases?: string[]; | |
| 61 | + genes?: string[]; | |
| 62 | + variantSlugs?: string[]; | |
| 63 | + indicationTerms?: string[]; | |
| 64 | + tumorAgnostic?: boolean; | |
| 65 | + ncit?: { code: string; name: string; conceptKind: string }; | |
| 66 | + verification?: { authority: string; endpoint: string; ncitVersion: string; verifiedAt: string }; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export interface BiomarkerRow { | |
| 70 | + id: string; | |
| 71 | + slug: string; | |
| 72 | + name: string; | |
| 73 | + kind: string; | |
| 74 | + gene_id: string | null; | |
| 75 | + ncit_code: string | null; | |
| 76 | + description: string | null; | |
| 77 | + measurement: BiomarkerMeasurement; | |
| 78 | + updated_at: Date; | |
| 79 | + /** Resolved scope (see module doc). */ | |
| 80 | + gene_ids: string[]; | |
| 81 | + variant_ids: string[]; | |
| 82 | + gene_symbols: string[]; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export interface BiomarkerListRow extends BiomarkerRow { | |
| 86 | + cancers_n: number; | |
| 87 | + drugs_n: number; | |
| 88 | + approvals_n: number; | |
| 89 | + tumor_agnostic_n: number; | |
| 90 | + active_trials_n: number; | |
| 91 | +} | |
| 92 | + | |
| 93 | +/* ------------------------------------------------------------------------------------------------ | |
| 94 | + * Scope fragments — one CTE `b` carrying gene_ids / variant_ids, reused by every derived block. | |
| 95 | + * ---------------------------------------------------------------------------------------------- */ | |
| 96 | + | |
| 97 | +const SCOPE_COLUMNS = sql` | |
| 98 | + b.id, b.slug, b.name, b.kind, b.gene_id, b.ncit_code, b.description, b.measurement, b.updated_at, | |
| 99 | + ARRAY(SELECT g.id FROM genes g WHERE g.id = b.gene_id OR g.symbol IN (SELECT jsonb_array_elements_text(coalesce(b.measurement->'genes', '[]'::jsonb))))::text[] AS gene_ids, | |
| 100 | + ARRAY(SELECT v.id FROM variants v WHERE v.slug IN (SELECT jsonb_array_elements_text(coalesce(b.measurement->'variantSlugs', '[]'::jsonb))))::text[] AS variant_ids`; | |
| 101 | + | |
| 102 | +// MATERIALIZED: the scope arrays are sub-selects over 45k genes / 417k variants; if PostgreSQL inlines | |
| 103 | +// this single-reference CTE they are re-evaluated per joined evidence row (22 s for MSI-H, measured | |
| 104 | +// 2026-09-11) instead of once. | |
| 105 | +const scopeCte = (where: ReturnType<typeof sql>) => sql`WITH b AS MATERIALIZED (SELECT ${SCOPE_COLUMNS} FROM biomarkers b WHERE ${where})`; | |
| 106 | +const scopeById = (id: string) => scopeCte(sql`b.id = ${id}`); | |
| 107 | + | |
| 108 | +/** Evidence in scope (alias `e` = civic_evidence_items, `b` = scope CTE). */ | |
| 109 | +const EV_SCOPE = sql`e.status = 'ACCEPTED' AND e.evidence_type IN ('PREDICTIVE', 'PROGNOSTIC', 'DIAGNOSTIC') | |
| 110 | + AND CASE WHEN cardinality(b.variant_ids) > 0 THEN e.variant_ids && b.variant_ids ELSE cardinality(b.gene_ids) > 0 AND e.gene_ids && b.gene_ids END`; | |
| 111 | + | |
| 112 | +/** Variants in scope (alias `v`). */ | |
| 113 | +const VAR_SCOPE = sql`CASE WHEN cardinality(b.variant_ids) > 0 THEN v.id = ANY(b.variant_ids) ELSE v.gene_id = ANY(b.gene_ids) END`; | |
| 114 | + | |
| 115 | +/** Derived drug set per biomarker: (bid, drug_id). */ | |
| 116 | +const DSET = sql`dset AS ( | |
| 117 | + SELECT DISTINCT bid, drug_id FROM ( | |
| 118 | + SELECT b.id AS bid, t AS drug_id FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} AND e.evidence_type = 'PREDICTIVE' CROSS JOIN LATERAL unnest(e.therapy_ids) t | |
| 119 | + UNION | |
| 120 | + SELECT b.id, k.target_entity_id FROM b JOIN variants v ON ${VAR_SCOPE} | |
| 121 | + JOIN knowledge_edges k ON k.source_entity_type = 'variant' AND k.source_entity_id = v.id AND k.target_entity_type = 'drug' AND k.relationship_type = 'PREDICTS_RESPONSE_TO' AND k.status = 'active' | |
| 122 | + ) u)`; | |
| 123 | +/** Derived cancer set per biomarker: (bid, cancer_id). */ | |
| 124 | +const CSET = sql`cset AS (SELECT DISTINCT b.id AS bid, e.cancer_id FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} WHERE e.cancer_id IS NOT NULL)`; | |
| 125 | + | |
| 126 | +/** | |
| 127 | + * Derived trial set per biomarker: (bid, trial_id) = trials with an intervention mapped to a scope | |
| 128 | + * drug AND a condition mapped to a scope cancer. Plain hash joins, MATERIALIZED once: a LIMIT-ed | |
| 129 | + * outer query must never walk clinical_trials in date order evaluating EXISTS per row (20 s for HER2 | |
| 130 | + * vs ~120 ms for this form, measured 2026-09-11). | |
| 131 | + */ | |
| 132 | +const TSET = sql`tset AS MATERIALIZED ( | |
| 133 | + SELECT DISTINCT d.bid, ti.trial_id FROM dset d JOIN trial_interventions ti ON ti.drug_id = d.drug_id | |
| 134 | + JOIN trial_conditions tc ON tc.trial_id = ti.trial_id JOIN cset c ON c.bid = d.bid AND c.cancer_id = tc.cancer_id)`; | |
| 135 | + | |
| 136 | +/** Indication-text match on curated terms (alias `a` = drug_approvals). */ | |
| 137 | +const TERM_MATCH = sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(coalesce(b.measurement->'indicationTerms', '[]'::jsonb)) term WHERE a.indication ILIKE '%' || term || '%')`; | |
| 138 | + | |
| 139 | +// sql.param binds the whole array as ONE parameter; interpolating a JS array directly expands to a | |
| 140 | +// ($1, $2, …) tuple, which is not a valid text[] cast. | |
| 141 | +const ACTIVE = sql`t.overall_status = ANY(${sql.param(ACTIVE_STATUSES)}::text[])`; | |
| 142 | + | |
| 143 | +/* ------------------------------------------------------------------------------------------------ | |
| 144 | + * List | |
| 145 | + * ---------------------------------------------------------------------------------------------- */ | |
| 146 | + | |
| 147 | +export async function biomarkerKindFacets(): Promise<Array<{ kind: string; n: number }>> { | |
| 148 | + const rows = await safe(() => run<{ kind: string; n: string }>(sql`SELECT kind, count(*) AS n FROM biomarkers GROUP BY kind ORDER BY n DESC, kind`), []); | |
| 149 | + return rows.map((r) => ({ kind: r.kind, n: Number(r.n) })); | |
| 150 | +} | |
| 151 | + | |
| 152 | +export async function listBiomarkers(opts: { kind?: string; q?: string } = {}): Promise<BiomarkerListRow[]> { | |
| 153 | + const conds = [sql`true`]; | |
| 154 | + if (opts.kind) conds.push(sql`b.kind = ${opts.kind}`); | |
| 155 | + if (opts.q) { | |
| 156 | + const like = `%${opts.q}%`; | |
| 157 | + conds.push(sql`(b.name ILIKE ${like} OR b.slug ILIKE ${like} OR b.ncit_code ILIKE ${opts.q + '%'} | |
| 158 | + OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(coalesce(b.measurement->'aliases', '[]'::jsonb)) a WHERE a ILIKE ${like}) | |
| 159 | + OR EXISTS (SELECT 1 FROM genes g WHERE (g.id = b.gene_id OR g.symbol IN (SELECT jsonb_array_elements_text(coalesce(b.measurement->'genes', '[]'::jsonb)))) AND g.symbol ILIKE ${opts.q + '%'}))`); | |
| 160 | + } | |
| 161 | + const rows = await safe( | |
| 162 | + () => | |
| 163 | + run<Omit<BiomarkerListRow, 'cancers_n' | 'drugs_n' | 'approvals_n' | 'tumor_agnostic_n' | 'active_trials_n'> & { cancers_n: string; drugs_n: string; approvals_n: string; tumor_agnostic_n: string; active_trials_n: string }>(sql` | |
| 164 | + ${scopeCte(sql.join(conds, sql` AND `))}, ${DSET}, ${CSET}, ${TSET} | |
| 165 | + SELECT b.*, | |
| 166 | + coalesce((SELECT array_agg(g.symbol ORDER BY g.symbol) FROM genes g WHERE g.id = ANY(b.gene_ids)), '{}') AS gene_symbols, | |
| 167 | + (SELECT count(*) FROM cset c WHERE c.bid = b.id) AS cancers_n, | |
| 168 | + (SELECT count(*) FROM dset d WHERE d.bid = b.id) AS drugs_n, | |
| 169 | + (SELECT count(*) FROM drug_approvals a WHERE a.drug_id IN (SELECT d.drug_id FROM dset d WHERE d.bid = b.id) OR ${TERM_MATCH}) AS approvals_n, | |
| 170 | + (SELECT count(*) FROM drug_approvals a WHERE a.tumor_agnostic AND (a.drug_id IN (SELECT d.drug_id FROM dset d WHERE d.bid = b.id) OR ${TERM_MATCH})) AS tumor_agnostic_n, | |
| 171 | + (SELECT count(*) FROM tset x JOIN clinical_trials t ON t.id = x.trial_id WHERE x.bid = b.id AND ${ACTIVE}) AS active_trials_n | |
| 172 | + FROM b ORDER BY b.kind, b.name`), | |
| 173 | + [], | |
| 174 | + ); | |
| 175 | + return rows.map((r) => ({ ...r, cancers_n: Number(r.cancers_n), drugs_n: Number(r.drugs_n), approvals_n: Number(r.approvals_n), tumor_agnostic_n: Number(r.tumor_agnostic_n), active_trials_n: Number(r.active_trials_n) })); | |
| 176 | +} | |
| 177 | + | |
| 178 | +export async function countBiomarkers(): Promise<number> { | |
| 179 | + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM biomarkers`), [{ n: '0' }]); | |
| 180 | + return Number(r[0]?.n ?? 0); | |
| 181 | +} | |
| 182 | + | |
| 183 | +export async function biomarkerSlugsForSitemap(): Promise<Array<{ slug: string; updated_at: Date }>> { | |
| 184 | + return safe(() => run<{ slug: string; updated_at: Date }>(sql`SELECT slug, updated_at FROM biomarkers ORDER BY id`), []); | |
| 185 | +} | |
| 186 | + | |
| 187 | +/* ------------------------------------------------------------------------------------------------ | |
| 188 | + * Detail | |
| 189 | + * ---------------------------------------------------------------------------------------------- */ | |
| 190 | + | |
| 191 | +export interface BiomarkerGene { | |
| 192 | + id: string; | |
| 193 | + symbol: string; | |
| 194 | + name: string | null; | |
| 195 | + is_cancer_gene: boolean; | |
| 196 | +} | |
| 197 | +export interface BiomarkerVariant { | |
| 198 | + id: string; | |
| 199 | + slug: string; | |
| 200 | + name: string; | |
| 201 | + gene_symbol: string | null; | |
| 202 | + variant_type: string | null; | |
| 203 | +} | |
| 204 | + | |
| 205 | +export async function getBiomarkerBySlug(slug: string): Promise<BiomarkerRow | null> { | |
| 206 | + const rows = await safe( | |
| 207 | + () => | |
| 208 | + run<BiomarkerRow>(sql` | |
| 209 | + ${scopeCte(sql`b.slug = ${slug} OR b.id = ${slug}`)} | |
| 210 | + SELECT b.*, coalesce((SELECT array_agg(g.symbol ORDER BY g.symbol) FROM genes g WHERE g.id = ANY(b.gene_ids)), '{}') AS gene_symbols FROM b LIMIT 1`), | |
| 211 | + [] as BiomarkerRow[], | |
| 212 | + ); | |
| 213 | + return rows[0] ?? null; | |
| 214 | +} | |
| 215 | + | |
| 216 | +export async function biomarkerGenes(b: BiomarkerRow): Promise<BiomarkerGene[]> { | |
| 217 | + if (b.gene_ids.length === 0) return []; | |
| 218 | + return safe(() => run<BiomarkerGene>(sql`SELECT g.id, g.symbol, g.name, g.is_cancer_gene FROM genes g WHERE g.id = ANY(${sql.param(b.gene_ids)}::text[]) ORDER BY g.symbol`), []); | |
| 219 | +} | |
| 220 | + | |
| 221 | +export async function biomarkerVariants(b: BiomarkerRow): Promise<BiomarkerVariant[]> { | |
| 222 | + if (b.variant_ids.length === 0) return []; | |
| 223 | + return safe(() => run<BiomarkerVariant>(sql`SELECT v.id, v.slug, v.name, v.gene_symbol, v.variant_type FROM variants v WHERE v.id = ANY(${sql.param(b.variant_ids)}::text[]) ORDER BY v.gene_symbol, v.name`), []); | |
| 224 | +} | |
| 225 | + | |
| 226 | +export interface BiomarkerCancerRow { | |
| 227 | + cancer_id: string; | |
| 228 | + slug: string; | |
| 229 | + name: string; | |
| 230 | + n: number; | |
| 231 | + predictive: number; | |
| 232 | + prognostic: number; | |
| 233 | + diagnostic: number; | |
| 234 | + level_a: number; | |
| 235 | + level_b: number; | |
| 236 | + level_c: number; | |
| 237 | + level_d: number; | |
| 238 | + level_e: number; | |
| 239 | +} | |
| 240 | + | |
| 241 | +/** Associated cancers = mapped cancers of the in-scope evidence, with counts by type and native level. */ | |
| 242 | +export async function biomarkerCancers(b: BiomarkerRow): Promise<BiomarkerCancerRow[]> { | |
| 243 | + const rows = await safe( | |
| 244 | + () => | |
| 245 | + run<Record<keyof BiomarkerCancerRow, string>>(sql` | |
| 246 | + ${scopeById(b.id)} | |
| 247 | + SELECT c.id AS cancer_id, c.slug, c.canonical_name AS name, count(*) AS n, | |
| 248 | + count(*) FILTER (WHERE e.evidence_type = 'PREDICTIVE') AS predictive, count(*) FILTER (WHERE e.evidence_type = 'PROGNOSTIC') AS prognostic, count(*) FILTER (WHERE e.evidence_type = 'DIAGNOSTIC') AS diagnostic, | |
| 249 | + count(*) FILTER (WHERE e.evidence_level = 'A') AS level_a, count(*) FILTER (WHERE e.evidence_level = 'B') AS level_b, count(*) FILTER (WHERE e.evidence_level = 'C') AS level_c, | |
| 250 | + count(*) FILTER (WHERE e.evidence_level = 'D') AS level_d, count(*) FILTER (WHERE e.evidence_level = 'E') AS level_e | |
| 251 | + FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} JOIN cancers c ON c.id = e.cancer_id | |
| 252 | + GROUP BY c.id, c.slug, c.canonical_name ORDER BY n DESC, c.canonical_name`), | |
| 253 | + [], | |
| 254 | + ); | |
| 255 | + return rows.map((r) => ({ cancer_id: r.cancer_id, slug: r.slug, name: r.name, n: Number(r.n), predictive: Number(r.predictive), prognostic: Number(r.prognostic), diagnostic: Number(r.diagnostic), level_a: Number(r.level_a), level_b: Number(r.level_b), level_c: Number(r.level_c), level_d: Number(r.level_d), level_e: Number(r.level_e) })); | |
| 256 | +} | |
| 257 | + | |
| 258 | +export interface BiomarkerDrugRow { | |
| 259 | + drug_id: string; | |
| 260 | + slug: string; | |
| 261 | + name: string; | |
| 262 | + kind: string | null; | |
| 263 | + /** PREDICTIVE CIViC items in scope naming this therapy. */ | |
| 264 | + evidence_n: number; | |
| 265 | + sensitivity: number; | |
| 266 | + resistance: number; | |
| 267 | + /** Best (lowest letter) native CIViC level among those items; null when only knowledge edges link the drug. */ | |
| 268 | + best_level: string | null; | |
| 269 | + /** Active PREDICTS_RESPONSE_TO knowledge edges from in-scope variants to this drug. */ | |
| 270 | + edge_n: number; | |
| 271 | + edge_sensitivity: number; | |
| 272 | + edge_resistance: number; | |
| 273 | + cancer_names: string[]; | |
| 274 | + cancer_slugs: string[]; | |
| 275 | +} | |
| 276 | + | |
| 277 | +/** Drugs with predictive evidence: CIViC therapies ∪ knowledge-edge targets, with direction counts. */ | |
| 278 | +export async function biomarkerDrugs(b: BiomarkerRow): Promise<BiomarkerDrugRow[]> { | |
| 279 | + const rows = await safe( | |
| 280 | + () => | |
| 281 | + run<Record<string, unknown>>(sql` | |
| 282 | + ${scopeById(b.id)}, ${DSET}, | |
| 283 | + ev AS ( | |
| 284 | + SELECT t AS drug_id, e.significance, e.evidence_level, e.cancer_id | |
| 285 | + FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} AND e.evidence_type = 'PREDICTIVE' CROSS JOIN LATERAL unnest(e.therapy_ids) t), | |
| 286 | + ke AS ( | |
| 287 | + SELECT k.target_entity_id AS drug_id, k.direction | |
| 288 | + FROM b JOIN variants v ON ${VAR_SCOPE} | |
| 289 | + JOIN knowledge_edges k ON k.source_entity_type = 'variant' AND k.source_entity_id = v.id AND k.target_entity_type = 'drug' AND k.relationship_type = 'PREDICTS_RESPONSE_TO' AND k.status = 'active') | |
| 290 | + SELECT d.id AS drug_id, d.slug, d.name, d.kind, | |
| 291 | + (SELECT count(*) FROM ev WHERE ev.drug_id = d.id) AS evidence_n, | |
| 292 | + (SELECT count(*) FROM ev WHERE ev.drug_id = d.id AND ev.significance ILIKE '%SENSITIV%') AS sensitivity, | |
| 293 | + (SELECT count(*) FROM ev WHERE ev.drug_id = d.id AND ev.significance ILIKE '%RESIST%') AS resistance, | |
| 294 | + (SELECT min(ev.evidence_level) FROM ev WHERE ev.drug_id = d.id) AS best_level, | |
| 295 | + (SELECT count(*) FROM ke WHERE ke.drug_id = d.id) AS edge_n, | |
| 296 | + (SELECT count(*) FROM ke WHERE ke.drug_id = d.id AND ke.direction = 'sensitivity') AS edge_sensitivity, | |
| 297 | + (SELECT count(*) FROM ke WHERE ke.drug_id = d.id AND ke.direction = 'resistance') AS edge_resistance, | |
| 298 | + coalesce((SELECT array_agg(DISTINCT c.canonical_name ORDER BY c.canonical_name) FROM ev JOIN cancers c ON c.id = ev.cancer_id WHERE ev.drug_id = d.id), '{}') AS cancer_names, | |
| 299 | + coalesce((SELECT array_agg(DISTINCT c.slug ORDER BY c.slug) FROM ev JOIN cancers c ON c.id = ev.cancer_id WHERE ev.drug_id = d.id), '{}') AS cancer_slugs | |
| 300 | + FROM dset JOIN drugs d ON d.id = dset.drug_id | |
| 301 | + ORDER BY evidence_n DESC, edge_n DESC, d.name`), | |
| 302 | + [], | |
| 303 | + ); | |
| 304 | + return rows.map((r) => ({ | |
| 305 | + drug_id: r.drug_id as string, | |
| 306 | + slug: r.slug as string, | |
| 307 | + name: r.name as string, | |
| 308 | + kind: (r.kind as string | null) ?? null, | |
| 309 | + evidence_n: Number(r.evidence_n), | |
| 310 | + sensitivity: Number(r.sensitivity), | |
| 311 | + resistance: Number(r.resistance), | |
| 312 | + best_level: (r.best_level as string | null) ?? null, | |
| 313 | + edge_n: Number(r.edge_n), | |
| 314 | + edge_sensitivity: Number(r.edge_sensitivity), | |
| 315 | + edge_resistance: Number(r.edge_resistance), | |
| 316 | + cancer_names: (r.cancer_names as string[]) ?? [], | |
| 317 | + cancer_slugs: (r.cancer_slugs as string[]) ?? [], | |
| 318 | + })); | |
| 319 | +} | |
| 320 | + | |
| 321 | +/* Evidence table rows — same shape as lib/queries/evidence.ts so <EvidenceTable> renders them. */ | |
| 322 | +const EVIDENCE_SELECT = sql` | |
| 323 | + SELECT e.id, e.civic_id, e.name, e.molecular_profile_id, e.molecular_profile_name, e.gene_symbols, e.gene_ids, e.variant_ids, e.disease_name, e.cancer_id, e.cancer_match_type, | |
| 324 | + e.therapy_names, e.therapy_ids, e.therapy_interaction_type, e.evidence_type, e.evidence_level, e.evidence_direction, e.significance, e.evidence_rating, e.status, | |
| 325 | + e.pmid, e.source_citation, e.provenance_id, e.updated_at, | |
| 326 | + c.slug AS cancer_slug, c.canonical_name AS cancer_name, | |
| 327 | + (SELECT array_agg(v.slug ORDER BY v.slug) FROM variants v WHERE v.id = ANY(e.variant_ids)) AS variant_slugs, | |
| 328 | + (SELECT array_agg(coalesce(v.gene_symbol || ' ', '') || v.name ORDER BY v.slug) FROM variants v WHERE v.id = ANY(e.variant_ids)) AS variant_names, | |
| 329 | + (SELECT array_agg(d.slug ORDER BY d.slug) FROM drugs d WHERE d.id = ANY(e.therapy_ids)) AS therapy_slugs, | |
| 330 | + (SELECT array_agg(d.name ORDER BY d.slug) FROM drugs d WHERE d.id = ANY(e.therapy_ids)) AS therapy_slug_names, | |
| 331 | + left(e.description, ${EVIDENCE_DESCRIPTION_CHARS}) AS description | |
| 332 | + FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} LEFT JOIN cancers c ON c.id = e.cancer_id`; | |
| 333 | + | |
| 334 | +export type BiomarkerEvidenceType = 'PREDICTIVE' | 'PROGNOSTIC' | 'DIAGNOSTIC'; | |
| 335 | + | |
| 336 | +export async function biomarkerEvidence(b: BiomarkerRow, opts: { type?: BiomarkerEvidenceType; page: number; pageSize: number }): Promise<EvidenceItem[]> { | |
| 337 | + const typeFilter = opts.type ? sql`WHERE e.evidence_type = ${opts.type}` : sql``; | |
| 338 | + return safe( | |
| 339 | + () => | |
| 340 | + run<EvidenceItem>(sql`${scopeById(b.id)} ${EVIDENCE_SELECT} ${typeFilter} | |
| 341 | + ORDER BY c.canonical_name NULLS LAST, e.disease_name NULLS LAST, array_to_string(e.therapy_names, '+'), e.evidence_level NULLS LAST, e.civic_id | |
| 342 | + LIMIT ${opts.pageSize} OFFSET ${(Math.max(1, opts.page) - 1) * opts.pageSize}`), | |
| 343 | + [] as EvidenceItem[], | |
| 344 | + ); | |
| 345 | +} | |
| 346 | +export async function biomarkerEvidenceCount(b: BiomarkerRow, type?: BiomarkerEvidenceType): Promise<number> { | |
| 347 | + const typeFilter = type ? sql`AND e.evidence_type = ${type}` : sql``; | |
| 348 | + const r = await safe(() => run<{ n: string }>(sql`${scopeById(b.id)} SELECT count(*) AS n FROM b JOIN civic_evidence_items e ON ${EV_SCOPE} ${typeFilter}`), [{ n: '0' }]); | |
| 349 | + return Number(r[0]?.n ?? 0); | |
| 350 | +} | |
| 351 | + | |
| 352 | +export type BiomarkerApprovalRow = ApprovalRow & { matched_by: 'drug' | 'indication' | 'both' }; | |
| 353 | + | |
| 354 | +/** Approvals of the scope drugs (matched_by = drug) and/or whose indication names the biomarker (matched_by = indication). */ | |
| 355 | +export async function biomarkerApprovals(b: BiomarkerRow): Promise<BiomarkerApprovalRow[]> { | |
| 356 | + return safe( | |
| 357 | + () => | |
| 358 | + run<BiomarkerApprovalRow>(sql` | |
| 359 | + ${scopeById(b.id)}, ${DSET} | |
| 360 | + SELECT a.*, a.raw->>'dpdStatus' AS source_status, d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, s.name AS source_name, | |
| 361 | + CASE WHEN a.drug_id IN (SELECT d2.drug_id FROM dset d2) AND ${TERM_MATCH} THEN 'both' WHEN a.drug_id IN (SELECT d2.drug_id FROM dset d2) THEN 'drug' ELSE 'indication' END AS matched_by | |
| 362 | + FROM b, drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id | |
| 363 | + WHERE a.drug_id IN (SELECT d2.drug_id FROM dset d2) OR ${TERM_MATCH} | |
| 364 | + ORDER BY a.tumor_agnostic DESC, d.name, a.jurisdiction, a.approval_date DESC NULLS LAST LIMIT 500`), | |
| 365 | + [] as BiomarkerApprovalRow[], | |
| 366 | + ); | |
| 367 | +} | |
| 368 | + | |
| 369 | +export interface BiomarkerTrialCounts { | |
| 370 | + total: number; | |
| 371 | + active: number; | |
| 372 | + recruiting: number; | |
| 373 | + phase3: number; | |
| 374 | +} | |
| 375 | + | |
| 376 | +/** Trials of the scope drugs in the scope cancers (counts by status/phase). */ | |
| 377 | +export async function biomarkerTrialCounts(b: BiomarkerRow): Promise<BiomarkerTrialCounts> { | |
| 378 | + const r = await safe( | |
| 379 | + () => | |
| 380 | + run<{ total: string; active: string; recruiting: string; phase3: string }>(sql` | |
| 381 | + ${scopeById(b.id)}, ${DSET}, ${CSET}, ${TSET} | |
| 382 | + SELECT count(*) AS total, count(*) FILTER (WHERE ${ACTIVE}) AS active, count(*) FILTER (WHERE t.overall_status = 'RECRUITING') AS recruiting, | |
| 383 | + count(*) FILTER (WHERE ${ACTIVE} AND 'PHASE3' = ANY(t.phases)) AS phase3 | |
| 384 | + FROM tset x JOIN clinical_trials t ON t.id = x.trial_id`), | |
| 385 | + [{ total: '0', active: '0', recruiting: '0', phase3: '0' }], | |
| 386 | + ); | |
| 387 | + const x = r[0]!; | |
| 388 | + return { total: Number(x.total), active: Number(x.active), recruiting: Number(x.recruiting), phase3: Number(x.phase3) }; | |
| 389 | +} | |
| 390 | + | |
| 391 | +const TRIAL_LIST_COLUMNS = sql`t.id, t.nct_id, t.brief_title, t.acronym, t.phases, t.overall_status, t.enrollment_count, t.lead_sponsor, t.lead_sponsor_class, t.countries, t.last_update_posted_date, t.updated_at`; | |
| 392 | + | |
| 393 | +/** Active trials in scope, most recently updated first. */ | |
| 394 | +export async function biomarkerActiveTrials(b: BiomarkerRow, p: { page: number; pageSize: number }): Promise<TrialListRow[]> { | |
| 395 | + return safe( | |
| 396 | + () => | |
| 397 | + run<TrialListRow>(sql` | |
| 398 | + ${scopeById(b.id)}, ${DSET}, ${CSET}, ${TSET} | |
| 399 | + SELECT ${TRIAL_LIST_COLUMNS} FROM tset x JOIN clinical_trials t ON t.id = x.trial_id WHERE ${ACTIVE} | |
| 400 | + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${p.pageSize} OFFSET ${(Math.max(1, p.page) - 1) * p.pageSize}`), | |
| 401 | + [] as TrialListRow[], | |
| 402 | + ); | |
| 403 | +} | |
| 404 | + | |
| 405 | +/** Which entity type / ids the literature block should query (variants for molecular markers, genes otherwise). */ | |
| 406 | +export function literatureScope(b: BiomarkerRow): { entityType: 'variant' | 'gene'; ids: string[] } { | |
| 407 | + return b.variant_ids.length ? { entityType: 'variant', ids: b.variant_ids } : { entityType: 'gene', ids: b.gene_ids }; | |
| 408 | +} | |
| 409 | + | |
| 410 | +/** Why a derived block is empty for non-gene markers (rendered in the EmptyState). */ | |
| 411 | +export function noScopeReason(b: BiomarkerRow): string | null { | |
| 412 | + if (b.gene_ids.length || b.variant_ids.length) return null; | |
| 413 | + return b.measurement.notes ?? 'This biomarker is not anchored to a gene or variant entity, so gene-derived links cannot be computed.'; | |
| 414 | +} | |
modified
apps/web/src/lib/queries/trials.ts
+1 −1
@@ -59,7 +59,7 @@ export interface TrialFilters { | ||
| 59 | 59 | function trialWhere(f: TrialFilters) { |
| 60 | 60 | const parts = [sql`true`]; |
| 61 | 61 | if (f.q) parts.push(sql`(t.nct_id ILIKE ${f.q + '%'} OR t.brief_title ILIKE ${'%' + f.q + '%'} OR t.acronym ILIKE ${f.q} OR t.lead_sponsor ILIKE ${'%' + f.q + '%'})`); |
| 62 | − if (f.status === 'active') parts.push(sql`t.overall_status = ANY(${ACTIVE_STATUSES}::text[])`); | |
| 62 | + if (f.status === 'active') parts.push(sql`t.overall_status = ANY(${sql.param(ACTIVE_STATUSES)}::text[])`); | |
| 63 | 63 | else if (f.status) parts.push(sql`t.overall_status = ${f.status}`); |
| 64 | 64 | if (f.phase) parts.push(sql`${f.phase} = ANY(t.phases)`); |
| 65 | 65 | if (f.country) parts.push(sql`${f.country} = ANY(t.countries)`); |
modified
apps/web/src/lib/site.ts
+1 −0
@@ -11,6 +11,7 @@ export const NAV = [ | ||
| 11 | 11 | { href: '/trials', label: 'Trials' }, |
| 12 | 12 | { href: '/drugs', label: 'Drugs' }, |
| 13 | 13 | { href: '/genes', label: 'Genes' }, |
| 14 | + { href: '/biomarkers', label: 'Biomarkers' }, | |
| 14 | 15 | { href: '/approvals', label: 'Approvals' }, |
| 15 | 16 | { href: '/rankings', label: 'Rankings' }, |
| 16 | 17 | { href: '/research-gap', label: 'Research gap' }, |
modified
apps/web/src/lib/sitemap.ts
+3 −1
@@ -6,6 +6,7 @@ import { drugSlugsForSitemap, countDrugs } from '@/lib/queries/drugs'; | ||
| 6 | 6 | import { trialNctForSitemap, countTrials } from '@/lib/queries/trials'; |
| 7 | 7 | import { listSources } from '@/lib/queries/sources'; |
| 8 | 8 | import { listMetrics } from '@/lib/queries/rankings'; |
| 9 | +import { biomarkerSlugsForSitemap } from '@/lib/queries/biomarkers'; | |
| 9 | 10 | import { toDate } from '@/lib/format'; |
| 10 | 11 | |
| 11 | 12 | /** |
@@ -44,7 +45,7 @@ export async function sitemapChunks(): Promise<Chunk[]> { | ||
| 44 | 45 | export async function sitemapEntries(id: number): Promise<Entry[]> { |
| 45 | 46 | const now = new Date(); |
| 46 | 47 | if (id === 0) { |
| 47 | − const [sources, metrics] = await Promise.all([listSources(), listMetrics()]); | |
| 48 | + const [sources, metrics, biomarkers] = await Promise.all([listSources(), listMetrics(), biomarkerSlugsForSitemap()]); | |
| 48 | 49 | return [ |
| 49 | 50 | { url: SITE_URL, lastModified: now, changeFrequency: 'daily', priority: 1 }, |
| 50 | 51 | ...NAV.map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.8 })), |
@@ -52,6 +53,7 @@ export async function sitemapEntries(id: number): Promise<Entry[]> { | ||
| 52 | 53 | ...FOOTER_NAV.filter((f) => !(NAV as readonly { href: string }[]).some((n) => n.href === f.href) && !(MORE_NAV as readonly { href: string }[]).some((m) => m.href === f.href)).map((n) => ({ url: `${SITE_URL}${n.href}`, lastModified: now, changeFrequency: 'weekly' as const, priority: 0.5 })), |
| 53 | 54 | ...sources.map((s) => ({ url: `${SITE_URL}/source/${s.slug}`, lastModified: toDate(s.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.6 })), |
| 54 | 55 | ...metrics.filter((m) => m.snapshot_count > 0).map((m) => ({ url: `${SITE_URL}/rankings/${m.slug}`, lastModified: now, changeFrequency: 'daily' as const, priority: 0.7 })), |
| 56 | + ...biomarkers.map((b) => ({ url: `${SITE_URL}/biomarker/${b.slug}`, lastModified: toDate(b.updated_at) ?? now, changeFrequency: 'weekly' as const, priority: 0.6 })), | |
| 55 | 57 | ]; |
| 56 | 58 | } |
| 57 | 59 | const chunk = (await sitemapChunks()).find((c) => c.id === id); |
added
docs/methodology/biomarkers.md
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +# Biomarkers — canonical catalogue and derived links | |
| 2 | + | |
| 3 | +The biomarker database (`/biomarkers`, `/biomarker/<slug>`; API `GET /v1/biomarkers`, | |
| 4 | +`GET /v1/biomarkers/:slug`) implements SPEC §17 (biomarkers as first-class entities), §52 | |
| 5 | +(biomarker → therapy context) and §121 (tumor-agnostic indications). It follows CLAUDE.md §8: | |
| 6 | +**curated metadata is allowed only when every code is verified against the authority**, and every | |
| 7 | +scientific link is derived from source records — never typed in. | |
| 8 | + | |
| 9 | +## 1. What a biomarker entity is | |
| 10 | + | |
| 11 | +One row of `biomarkers` (`CI-BIO-…`) is an **identity record**, seeded from | |
| 12 | +`packages/database/src/seed-data/biomarkers.ts` (idempotent by `slug`; ids are minted once): | |
| 13 | + | |
| 14 | +| Field | Content | Rule | | |
| 15 | +|---|---|---| | |
| 16 | +| `slug`, `name`, `kind` | e.g. `braf-v600e`, "BRAF V600E", `gene_mutation` | `kind` is one of the twelve frozen values of the schema | | |
| 17 | +| `gene_id` | anchor gene (HGNC symbol resolved to `genes.id` at seed time) | unresolved symbols are reported and left `null`, never invented; `null` for non-gene markers | | |
| 18 | +| `ncit_code` | NCIt concept of the **biomarker / gene alteration** ("HER2/Neu Positive", "BRAF NP_004324.2:p.V600E", "NTRK Gene Fusion Positive"…), never the bare gene concept | **every code verified live** against the NCI EVS REST API (`GET /api/v1/concept/ncit/{code}`) on **2026-09-11, NCIt 26.08e** — all returned `active: true`, `conceptStatus: DEFAULT`; the API's preferred name is stored in `measurement.ncit.name` | | |
| 19 | +| `description` | one neutral sentence: what is measured, by which assay family, and whether it is used as a diagnostic, predictive or prognostic marker | no dosing, no thresholds of our own, no individual advice | | |
| 20 | +| `measurement` (jsonb) | `assays` (IHC, FISH/ISH, NGS, PCR, flow cytometry, PET imaging…), `scoring` (conventions as published: PD-L1 TPS/CPS/IC, HER2 IHC 0/1+/2+/3+ with ISH reflex, MSI-PCR vs MMR IHC, BCR-ABL1 %IS…), `notes`, `sources` (NCI / FDA pages, all HTTP 200 on 2026-09-11), `aliases`, `genes`, `variantSlugs`, `indicationTerms`, `tumorAgnostic`, `ncit`, `verification` | the seed is the only writer of this shape; there is no alias table, so aliases live here | | |
| 21 | + | |
| 22 | +The catalogue holds 54 markers: HER2, ER, PR, EGFR (any) + exon 19 deletion / L858R / T790M / | |
| 23 | +exon 20 insertion, ALK, ROS1, KRAS G12C, KRAS (any), NRAS, BRAF V600E, BRAF (any), MET exon 14 / | |
| 24 | +amplification, RET fusion, NTRK fusion, HER2 mutation, PD-L1, MSI-H, dMMR, TMB-H, BRCA1, BRCA2, HRD, | |
| 25 | +PIK3CA, ESR1 mutation, AR, AR-V7, PSMA, SSTR, CD19, CD20, BCMA, CD38, CD30, CD33, FLT3, IDH1, IDH2, | |
| 26 | +NPM1, KIT, PDGFRA, FGFR2 fusion, FGFR3 alteration, BCR-ABL1, JAK2 V617F, TP53, CDKN2A deletion, | |
| 27 | +ctDNA, Ki-67, del(17p). | |
| 28 | + | |
| 29 | +**Relationships stated in the metadata, not inferred:** PD-L1 is the protein of gene *CD274*; | |
| 30 | +HER2 is the protein of *ERBB2*; MSI-H (DNA phenotype, PCR/NGS) and dMMR (loss of MLH1/MSH2/MSH6/PMS2 | |
| 31 | +protein by IHC) are two measurements of one biological state and are kept as two entries whose notes | |
| 32 | +cross-reference each other; HER2 overexpression and HER2 (ERBB2) mutation are different markers. | |
| 33 | + | |
| 34 | +## 2. Scope of a biomarker (what the links are computed from) | |
| 35 | + | |
| 36 | +Formula `biomarker-links-v1`, identical in `apps/web/src/lib/queries/biomarkers.ts` and | |
| 37 | +`apps/api/src/routes/biomarkers.ts`: | |
| 38 | + | |
| 39 | +- **gene_ids** = anchor `gene_id` ∪ `measurement.genes` resolved by HGNC symbol | |
| 40 | + (NTRK fusion → NTRK1/NTRK2/NTRK3; MSI-H and dMMR → MLH1/MSH2/MSH6/PMS2; BCR-ABL1 → ABL1 + BCR; | |
| 41 | + del(17p) → TP53). | |
| 42 | +- **variant_ids** = `measurement.variantSlugs` resolved against `variants` (checked to exist in the | |
| 43 | + production copy on 2026-09-11). When present the marker is *molecular-level* (BRAF V600E, EGFR | |
| 44 | + L858R, KRAS G12C, MET exon 14, JAK2 V617F, HER2 amplification…) and every link is restricted to | |
| 45 | + those variants instead of the whole gene. | |
| 46 | +- Markers with **neither** (TMB-H, HRD, ctDNA) have no gene-derived blocks: the pages show | |
| 47 | + "Data not yet available" with the reason recorded in `measurement.notes`. Only the | |
| 48 | + indication-text rule (§3.4) applies to them. | |
| 49 | + | |
| 50 | +## 3. Derived links (computed at query time; no biomarker link table is written) | |
| 51 | + | |
| 52 | +Every derived count carries the **Computed** claim badge and names its rule; every table of source | |
| 53 | +rows carries the **native** claim badge of the source (Curated for CIViC, Regulatory for approvals, | |
| 54 | +Published for trials and PubMed). | |
| 55 | + | |
| 56 | +### 3.1 Evidence and associated cancers | |
| 57 | +CIViC evidence items with `status = 'ACCEPTED'` and `evidence_type ∈ {PREDICTIVE, PROGNOSTIC, | |
| 58 | +DIAGNOSTIC}` whose `variant_ids` (molecular markers) or `gene_ids` (gene-level markers) overlap the | |
| 59 | +scope. Levels A–E and directions are shown exactly as curated (never re-scaled, never collapsed). | |
| 60 | +**Associated cancers** = distinct mapped `cancer_id` of that evidence, with counts by type and by | |
| 61 | +level. Submitted and rejected items are excluded; PREDISPOSING, ONCOGENIC and FUNCTIONAL items are | |
| 62 | +not part of the biomarker scope (they belong to the gene page). | |
| 63 | + | |
| 64 | +### 3.2 Drugs with predictive evidence | |
| 65 | +Union of (a) `therapy_ids` of the in-scope PREDICTIVE items and (b) targets of active | |
| 66 | +`knowledge_edges` `PREDICTS_RESPONSE_TO` whose source variant is in scope. Per drug the page shows | |
| 67 | +the number of items, sensitivity vs resistance counts (CIViC `significance`), the best native level, | |
| 68 | +the number of knowledge edges with their direction, and the cancer contexts — as separate columns, | |
| 69 | +never merged into a verdict. | |
| 70 | + | |
| 71 | +### 3.3 Trials | |
| 72 | +`clinical_trials` having an intervention mapped (`trial_interventions.drug_id`) to a scope drug | |
| 73 | +**and** a condition mapped (`trial_conditions.cancer_id`) to a scope cancer. Counts: total, active | |
| 74 | +(`RECRUITING`, `NOT_YET_RECRUITING`, `ENROLLING_BY_INVITATION`, `ACTIVE_NOT_RECRUITING`), recruiting, | |
| 75 | +active phase 3. The table lists active trials, newest update first. A trial listed here tests the | |
| 76 | +*drug* in the *cancer*; it does not mean the trial selects patients on the biomarker. | |
| 77 | + | |
| 78 | +### 3.4 Approvals | |
| 79 | +`drug_approvals` rows (a) whose `drug_id` is a scope drug — `matchedBy = drug` — and/or (b) whose | |
| 80 | +indication text contains one of the curated `indicationTerms` phrases (case-insensitive substring, | |
| 81 | +e.g. "microsatellite instability-high", "NTRK", "PD-L1") — `matchedBy = indication`. Each row keeps | |
| 82 | +its authority, jurisdiction, indication text, status and date. Health Canada DIN rows carry no | |
| 83 | +indication text, so they only ever match by drug. | |
| 84 | + | |
| 85 | +### 3.5 Tumor-agnostic rule (§121) | |
| 86 | +`measurement.tumorAgnostic = true` is a curated statement (MSI-H, dMMR, NTRK fusion, TMB-H, BRAF | |
| 87 | +V600E, RET fusion, HER2). The UI and API **never assert it alone**: the badge is accompanied by the | |
| 88 | +actual `drug_approvals` rows with `tumor_agnostic = true` (the source's flag, set by the openFDA | |
| 89 | +connector from the label text) reached through §3.4. When no such row is ingested yet, the page says | |
| 90 | +so explicitly ("no tumor-agnostic approval row ingested yet") instead of implying one exists. | |
| 91 | + | |
| 92 | +### 3.6 Literature | |
| 93 | +`publication_entity_edges` (status ≠ rejected) on the scope **variants** for molecular markers, or | |
| 94 | +on the scope **genes** otherwise. Bibliographic data as recorded by PubMed. | |
| 95 | + | |
| 96 | +## 4. What is not claimed | |
| 97 | + | |
| 98 | +- No clinical-utility grading of our own (no "Tier", "Level 1", "actionable" labels): only the | |
| 99 | + source-native CIViC level and direction and the regulator's status are shown. | |
| 100 | +- No threshold or cut-off is stated as CancerIndex's; scoring conventions are quoted as published | |
| 101 | + and labels/indications are the authority's own text. | |
| 102 | +- No "approved for biomarker X" statement: approvals are listed per authority and jurisdiction with | |
| 103 | + their indication text, and the match rule (drug set / indication text) is displayed per row. | |
| 104 | +- No patient-level guidance: biomarker pages describe what a test measures, not what a person | |
| 105 | + should do. | |
| 106 | +- Absence of a link is not evidence of absence: only ingested sources (CIViC, ClinicalTrials.gov, | |
| 107 | + openFDA, Health Canada DPD, PubMed edges) are covered. | |
| 108 | + | |
| 109 | +## 5. Maintenance | |
| 110 | + | |
| 111 | +- Add a marker: append to `BIOMARKER_SEED` with a **verified** NCIt code (fetch the concept, record | |
| 112 | + the date/version in the header if the version changed), run `pnpm db:seed`, run | |
| 113 | + `packages/database/test/biomarkers.test.ts`. | |
| 114 | +- Changing the derivation rules = bump `BIOMARKER_LINKS_FORMULA` in both the web queries and the API | |
| 115 | + route, and update §3 here. | |
added
packages/database/src/seed-data/biomarkers.ts
+480 −0
@@ -0,0 +1,480 @@ | ||
| 1 | +/** | |
| 2 | + * Canonical oncology biomarkers — explicitly curated metadata (CLAUDE.md §8, SPEC §17, §52, §121). | |
| 3 | + * | |
| 4 | + * What this file IS: the identity of each biomarker entity (slug, name, kind, anchor gene, NCIt | |
| 5 | + * concept, aliases) and a neutral description of what is measured and how the result is used | |
| 6 | + * clinically (diagnostic / predictive / prognostic). Assay families and scoring conventions are | |
| 7 | + * recorded as metadata with links to the authority pages they come from. | |
| 8 | + * | |
| 9 | + * What this file is NOT: it carries no clinical-utility grading, no thresholds of its own, no | |
| 10 | + * dosing and no individual advice. Every link biomarker → cancer / drug / trial / approval / | |
| 11 | + * publication is DERIVED AT QUERY TIME from source-native records (CIViC evidence, knowledge | |
| 12 | + * edges, ClinicalTrials.gov, openFDA / Health Canada, PubMed) — see docs/methodology/biomarkers.md. | |
| 13 | + * | |
| 14 | + * NCIt codes: EVERY code below was verified live against the NCI EVS REST API on 2026-09-11 | |
| 15 | + * (`GET https://api-evsrest.nci.nih.gov/api/v1/concept/ncit/<code>?include=minimal`), NCIt version | |
| 16 | + * 26.08e; each returned `active: true`, `conceptStatus: DEFAULT`, and the name recorded in | |
| 17 | + * `ncitName` is the preferred name returned by the API. Where NCIt has a biomarker / gene-alteration | |
| 18 | + * concept ("… Positive", "… Gene Mutation", "… Fusion Positive", "p.V600E"…) that concept is used | |
| 19 | + * (`ncitConceptKind: 'biomarker'`). No row falls back to the bare gene concept. | |
| 20 | + * | |
| 21 | + * Gene anchors: `geneSymbol` is the HGNC symbol resolved to `genes.id` at seed time (seed.ts reports | |
| 22 | + * unresolved symbols and never invents). `genes` lists additional HGNC symbols for multi-gene | |
| 23 | + * markers (MSI-H/dMMR → MMR genes, NTRK → NTRK1/2/3, BCR-ABL1 → BCR + ABL1). `variantSlugs` anchors | |
| 24 | + * molecular-level markers to variant entities present in `variants` (checked on 2026-09-11 against | |
| 25 | + * the production copy); when present, derived links are restricted to those variants instead of | |
| 26 | + * the whole gene. | |
| 27 | + * | |
| 28 | + * Tumor-agnostic flag: `tumorAgnostic: true` is a curated statement that an FDA tissue-agnostic | |
| 29 | + * indication exists for this marker. The UI/API never assert it alone — they list the actual | |
| 30 | + * `drug_approvals` rows with `tumor_agnostic = true` reached through the derived drug set (or an | |
| 31 | + * indication-text match on `indicationTerms`), and say when none is ingested yet. | |
| 32 | + */ | |
| 33 | + | |
| 34 | +export const BIOMARKER_KINDS = ['gene_mutation', 'protein_expression', 'hormone_receptor', 'immune_marker', 'msi', 'tmb', 'hrd', 'ctdna', 'methylation', 'signature', 'cell_surface', 'other'] as const; | |
| 35 | +export type BiomarkerKind = (typeof BIOMARKER_KINDS)[number]; | |
| 36 | + | |
| 37 | +export const BIOMARKER_KIND_LABEL: Record<BiomarkerKind, string> = { | |
| 38 | + gene_mutation: 'Gene alteration', | |
| 39 | + protein_expression: 'Protein expression', | |
| 40 | + hormone_receptor: 'Hormone receptor', | |
| 41 | + immune_marker: 'Immune marker', | |
| 42 | + msi: 'Microsatellite instability / MMR', | |
| 43 | + tmb: 'Tumor mutational burden', | |
| 44 | + hrd: 'Homologous recombination deficiency', | |
| 45 | + ctdna: 'Circulating tumor DNA', | |
| 46 | + methylation: 'Methylation', | |
| 47 | + signature: 'Signature', | |
| 48 | + cell_surface: 'Cell-surface target', | |
| 49 | + other: 'Other', | |
| 50 | +}; | |
| 51 | + | |
| 52 | +/** Verification record stored on every row (`measurement.verification`) and shown in the UI. */ | |
| 53 | +export const NCIT_VERIFICATION = { | |
| 54 | + authority: 'NCI Thesaurus (NCIt) via the EVS REST API', | |
| 55 | + endpoint: 'https://api-evsrest.nci.nih.gov/api/v1/concept/ncit/{code}?include=minimal', | |
| 56 | + ncitVersion: '26.08e', | |
| 57 | + verifiedAt: '2026-09-11', | |
| 58 | +} as const; | |
| 59 | + | |
| 60 | +/** Authority pages cited in `measurement.sources` (all returned HTTP 200 on 2026-09-11). */ | |
| 61 | +export const BIOMARKER_SOURCES = { | |
| 62 | + fdaCdx: { label: 'FDA — List of cleared or approved companion diagnostic devices', url: 'https://www.fda.gov/medical-devices/in-vitro-diagnostics/list-cleared-or-approved-companion-diagnostic-devices-in-vitro-and-imaging-tools' }, | |
| 63 | + nciBiomarkerTesting: { label: 'NCI — Biomarker testing for cancer treatment', url: 'https://www.cancer.gov/about-cancer/treatment/types/biomarker-testing-cancer-treatment' }, | |
| 64 | + nciTumorMarkers: { label: 'NCI — Tumor markers fact sheet', url: 'https://www.cancer.gov/about-cancer/diagnosis-staging/diagnosis/tumor-markers-fact-sheet' }, | |
| 65 | + nciCheckpoint: { label: 'NCI — Immune checkpoint inhibitors', url: 'https://www.cancer.gov/about-cancer/treatment/types/immunotherapy/checkpoint-inhibitors' }, | |
| 66 | + fdaOncologyApprovals: { label: 'FDA — Oncology (cancer) / hematologic malignancies approval notifications', url: 'https://www.fda.gov/drugs/resources-information-approved-drugs/oncology-cancer-hematologic-malignancies-approval-notifications' }, | |
| 67 | +} as const; | |
| 68 | +type SourceKey = keyof typeof BIOMARKER_SOURCES; | |
| 69 | + | |
| 70 | +export type AssayFamily = 'IHC' | 'FISH' | 'ISH' | 'NGS' | 'PCR' | 'RT-PCR' | 'Sequencing' | 'Flow cytometry' | 'Karyotype' | 'PET imaging' | 'Liquid biopsy' | 'MSI-PCR' | 'Fragment analysis'; | |
| 71 | + | |
| 72 | +export interface BiomarkerSeed { | |
| 73 | + slug: string; | |
| 74 | + name: string; | |
| 75 | + kind: BiomarkerKind; | |
| 76 | + /** HGNC symbol of the anchor gene, resolved to genes.id at seed time; null for non-gene markers. */ | |
| 77 | + geneSymbol: string | null; | |
| 78 | + /** Additional HGNC symbols the marker depends on (multi-gene markers). */ | |
| 79 | + genes?: string[]; | |
| 80 | + /** Variant slugs present in `variants` that define a molecular-level marker. */ | |
| 81 | + variantSlugs?: string[]; | |
| 82 | + ncitCode: string; | |
| 83 | + ncitName: string; | |
| 84 | + ncitConceptKind: 'biomarker'; | |
| 85 | + aliases: string[]; | |
| 86 | + /** One neutral sentence: what is measured and how it is used (diagnostic / predictive / prognostic). */ | |
| 87 | + description: string; | |
| 88 | + assays: AssayFamily[]; | |
| 89 | + /** Scoring / reporting conventions as published by the authority (no thresholds of our own). */ | |
| 90 | + scoring?: string; | |
| 91 | + notes?: string; | |
| 92 | + sources: SourceKey[]; | |
| 93 | + /** Curated statement that an FDA tissue-agnostic indication exists (verified against approval rows at query time). */ | |
| 94 | + tumorAgnostic?: boolean; | |
| 95 | + /** Terms matched (case-insensitive, whole phrase) against approval indication text at query time. */ | |
| 96 | + indicationTerms?: string[]; | |
| 97 | +} | |
| 98 | + | |
| 99 | +const S = BIOMARKER_SOURCES; | |
| 100 | + | |
| 101 | +export const BIOMARKER_SEED: BiomarkerSeed[] = [ | |
| 102 | + // ── Breast / hormone / HER2 ───────────────────────────────────────────────────────────────── | |
| 103 | + { | |
| 104 | + slug: 'her2', | |
| 105 | + name: 'HER2 (ERBB2) overexpression / amplification', | |
| 106 | + kind: 'protein_expression', | |
| 107 | + geneSymbol: 'ERBB2', | |
| 108 | + variantSlugs: ['erbb2-amplification', 'erbb2-overexpression', 'erbb2-overexpression-civic-875'], | |
| 109 | + ncitCode: 'C68748', | |
| 110 | + ncitName: 'HER2/Neu Positive', | |
| 111 | + ncitConceptKind: 'biomarker', | |
| 112 | + aliases: ['HER2', 'HER2/neu', 'ERBB2', 'HER2-positive', 'HER2 amplification', 'HER2-low'], | |
| 113 | + description: 'HER2 protein overexpression or ERBB2 gene amplification measured on tumor tissue; used as a predictive marker for HER2-directed therapy in breast, gastric/gastro-esophageal and other carcinomas.', | |
| 114 | + assays: ['IHC', 'ISH', 'FISH', 'NGS'], | |
| 115 | + scoring: 'IHC scored 0, 1+, 2+ or 3+ (2+ is equivocal and reflexed to in situ hybridization); ISH reported as HER2/CEP17 ratio and mean HER2 copy number; "HER2-low" denotes IHC 1+ or IHC 2+/ISH-negative in the breast label wording.', | |
| 116 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 117 | + tumorAgnostic: true, | |
| 118 | + indicationTerms: ['HER2-positive', 'HER2 (IHC 3+)', 'HER2-low', 'HER2-overexpressing', 'HER2 overexpressing', 'HER2-expressing'], | |
| 119 | + }, | |
| 120 | + { | |
| 121 | + slug: 'er', | |
| 122 | + name: 'Estrogen receptor (ER)', | |
| 123 | + kind: 'hormone_receptor', | |
| 124 | + geneSymbol: 'ESR1', | |
| 125 | + ncitCode: 'C15492', | |
| 126 | + ncitName: 'Estrogen Receptor Positive', | |
| 127 | + ncitConceptKind: 'biomarker', | |
| 128 | + aliases: ['ER', 'ER-positive', 'ESR1 expression', 'estrogen receptor alpha'], | |
| 129 | + description: 'Estrogen receptor protein expression measured by immunohistochemistry on tumor tissue; used as a predictive marker for endocrine therapy and as part of breast cancer subtyping.', | |
| 130 | + assays: ['IHC'], | |
| 131 | + scoring: 'Reported as percentage of nuclei staining and intensity (Allred / H-score conventions); "hormone receptor-positive" in labels denotes ER- and/or PR-positive disease.', | |
| 132 | + sources: ['nciBiomarkerTesting', 'nciTumorMarkers'], | |
| 133 | + indicationTerms: ['hormone receptor (HR)-positive', 'hormone receptor-positive', 'estrogen receptor-positive', 'ER-positive'], | |
| 134 | + }, | |
| 135 | + { | |
| 136 | + slug: 'pr', | |
| 137 | + name: 'Progesterone receptor (PR)', | |
| 138 | + kind: 'hormone_receptor', | |
| 139 | + geneSymbol: 'PGR', | |
| 140 | + ncitCode: 'C15496', | |
| 141 | + ncitName: 'Progesterone Receptor Positive', | |
| 142 | + ncitConceptKind: 'biomarker', | |
| 143 | + aliases: ['PR', 'PgR', 'PR-positive', 'PGR expression'], | |
| 144 | + description: 'Progesterone receptor protein expression measured by immunohistochemistry on tumor tissue; used together with ER as a predictive and prognostic marker in breast cancer.', | |
| 145 | + assays: ['IHC'], | |
| 146 | + scoring: 'Reported as percentage of nuclei staining and intensity, alongside ER.', | |
| 147 | + sources: ['nciBiomarkerTesting', 'nciTumorMarkers'], | |
| 148 | + indicationTerms: ['progesterone receptor-positive', 'PR-positive'], | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + slug: 'erbb2-mutation', | |
| 152 | + name: 'HER2 (ERBB2) activating mutation', | |
| 153 | + kind: 'gene_mutation', | |
| 154 | + geneSymbol: 'ERBB2', | |
| 155 | + variantSlugs: ['erbb2-mutation', 'erbb2-exon-20-insertion'], | |
| 156 | + ncitCode: 'C96866', | |
| 157 | + ncitName: 'ERBB2 Gene Mutation', | |
| 158 | + ncitConceptKind: 'biomarker', | |
| 159 | + aliases: ['HER2 mutation', 'ERBB2 mutation', 'HER2 exon 20 insertion', 'HER2-mutant NSCLC'], | |
| 160 | + description: 'Activating ERBB2 (HER2) mutations, chiefly exon 20 insertions, detected by sequencing of tumor tissue or plasma; used as a predictive marker distinct from HER2 overexpression, notably in non-small cell lung cancer.', | |
| 161 | + assays: ['NGS', 'Liquid biopsy'], | |
| 162 | + notes: 'Distinct from HER2 protein overexpression / gene amplification (see biomarker "her2").', | |
| 163 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 164 | + indicationTerms: ['HER2 (ERBB2)-mutant', 'HER2 (ERBB2) activating mutations', 'HER2-mutant'], | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + slug: 'esr1-mutation', | |
| 168 | + name: 'ESR1 mutation', | |
| 169 | + kind: 'gene_mutation', | |
| 170 | + geneSymbol: 'ESR1', | |
| 171 | + variantSlugs: ['esr1-mutation', 'esr1-d538g', 'esr1-y537s'], | |
| 172 | + ncitCode: 'C136629', | |
| 173 | + ncitName: 'ESR1 Gene Mutation', | |
| 174 | + ncitConceptKind: 'biomarker', | |
| 175 | + aliases: ['ESR1-mutated', 'ESR1 ligand-binding domain mutation', 'D538G', 'Y537S'], | |
| 176 | + description: 'Acquired mutations in the ESR1 ligand-binding domain detected in tumor tissue or circulating tumor DNA; used as a predictive marker in ER-positive breast cancer after endocrine therapy.', | |
| 177 | + assays: ['NGS', 'PCR', 'Liquid biopsy'], | |
| 178 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 179 | + indicationTerms: ['ESR1-mutated', 'ESR1 mutation'], | |
| 180 | + }, | |
| 181 | + { | |
| 182 | + slug: 'pik3ca-mutation', | |
| 183 | + name: 'PIK3CA mutation', | |
| 184 | + kind: 'gene_mutation', | |
| 185 | + geneSymbol: 'PIK3CA', | |
| 186 | + variantSlugs: ['pik3ca-mutation', 'pik3ca-h1047r', 'pik3ca-e545k'], | |
| 187 | + ncitCode: 'C96271', | |
| 188 | + ncitName: 'PIK3CA Gene Mutation', | |
| 189 | + ncitConceptKind: 'biomarker', | |
| 190 | + aliases: ['PIK3CA-mutated', 'PIK3CA-altered', 'H1047R', 'E545K'], | |
| 191 | + description: 'Activating PIK3CA mutations detected in tumor tissue or plasma; used as a predictive marker for PI3K-pathway inhibitors, notably in hormone receptor-positive breast cancer.', | |
| 192 | + assays: ['NGS', 'PCR', 'Liquid biopsy'], | |
| 193 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 194 | + indicationTerms: ['PIK3CA-mutated', 'PIK3CA mutation'], | |
| 195 | + }, | |
| 196 | + { | |
| 197 | + slug: 'ki-67', | |
| 198 | + name: 'Ki-67 labeling index', | |
| 199 | + kind: 'protein_expression', | |
| 200 | + geneSymbol: 'MKI67', | |
| 201 | + ncitCode: 'C157250', | |
| 202 | + ncitName: 'Ki-67 Labeling Index', | |
| 203 | + ncitConceptKind: 'biomarker', | |
| 204 | + aliases: ['Ki-67', 'Ki67', 'MKI67', 'proliferation index'], | |
| 205 | + description: 'Percentage of tumor nuclei staining for the proliferation antigen Ki-67 by immunohistochemistry; used as a prognostic marker and, in some breast cancer indications, as an eligibility criterion.', | |
| 206 | + assays: ['IHC'], | |
| 207 | + scoring: 'Reported as percentage of positive nuclei; laboratory-specific cut-offs, not standardized across indications.', | |
| 208 | + sources: ['nciTumorMarkers', 'fdaCdx'], | |
| 209 | + indicationTerms: ['Ki-67'], | |
| 210 | + }, | |
| 211 | + | |
| 212 | + // ── Lung and other solid tumors: driver alterations ───────────────────────────────────────── | |
| 213 | + { | |
| 214 | + slug: 'egfr-mutation', | |
| 215 | + name: 'EGFR mutation (any sensitizing or resistance mutation)', | |
| 216 | + kind: 'gene_mutation', | |
| 217 | + geneSymbol: 'EGFR', | |
| 218 | + ncitCode: 'C98357', | |
| 219 | + ncitName: 'EGFR Gene Mutation', | |
| 220 | + ncitConceptKind: 'biomarker', | |
| 221 | + aliases: ['EGFR-mutant', 'EGFR-mutated', 'EGFR activating mutation'], | |
| 222 | + description: 'Somatic mutations of the EGFR tyrosine kinase domain detected by sequencing or PCR in tumor tissue or plasma; used as a predictive marker for EGFR tyrosine kinase inhibitors, chiefly in non-small cell lung cancer.', | |
| 223 | + assays: ['NGS', 'PCR', 'Liquid biopsy'], | |
| 224 | + notes: 'Gene-level entry; exon 19 deletion, L858R, T790M and exon 20 insertion are separate molecular markers below.', | |
| 225 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 226 | + indicationTerms: ['EGFR exon 19 deletions or exon 21 L858R', 'EGFR mutation', 'EGFR-mutated', 'EGFR mutations'], | |
| 227 | + }, | |
| 228 | + { slug: 'egfr-exon-19-deletion', name: 'EGFR exon 19 deletion', kind: 'gene_mutation', geneSymbol: 'EGFR', variantSlugs: ['egfr-exon-19-deletion'], ncitCode: 'C126892', ncitName: 'EGFR Exon 19 Deletion Mutation', ncitConceptKind: 'biomarker', aliases: ['ex19del', 'EGFR del19', 'exon 19 del'], description: 'In-frame deletions in EGFR exon 19 detected by sequencing or PCR; a sensitizing EGFR mutation used as a predictive marker for EGFR tyrosine kinase inhibitors in non-small cell lung cancer.', assays: ['NGS', 'PCR', 'Liquid biopsy'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['exon 19 deletions'] }, | |
| 229 | + { slug: 'egfr-l858r', name: 'EGFR L858R', kind: 'gene_mutation', geneSymbol: 'EGFR', variantSlugs: ['egfr-l858r'], ncitCode: 'C98515', ncitName: 'EGFR NP_005219.2:p.L858R', ncitConceptKind: 'biomarker', aliases: ['p.L858R', 'exon 21 L858R', 'c.2573T>G'], description: 'EGFR exon 21 point mutation p.L858R detected by sequencing or PCR; a sensitizing EGFR mutation used as a predictive marker for EGFR tyrosine kinase inhibitors in non-small cell lung cancer.', assays: ['NGS', 'PCR', 'Liquid biopsy'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['L858R'] }, | |
| 230 | + { slug: 'egfr-t790m', name: 'EGFR T790M', kind: 'gene_mutation', geneSymbol: 'EGFR', variantSlugs: ['egfr-t790m'], ncitCode: 'C98503', ncitName: 'EGFR NP_005219.2:p.T790M', ncitConceptKind: 'biomarker', aliases: ['p.T790M', 'gatekeeper mutation', 'c.2369C>T'], description: 'EGFR exon 20 point mutation p.T790M detected in tumor tissue or plasma; an acquired resistance mutation to earlier-generation EGFR inhibitors used as a predictive marker for third-generation agents.', assays: ['NGS', 'PCR', 'Liquid biopsy'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['T790M'] }, | |
| 231 | + { slug: 'egfr-exon-20-insertion', name: 'EGFR exon 20 insertion', kind: 'gene_mutation', geneSymbol: 'EGFR', variantSlugs: ['egfr-exon-20-insertion'], ncitCode: 'C125605', ncitName: 'EGFR Exon 20 Insertion Mutation', ncitConceptKind: 'biomarker', aliases: ['ex20ins', 'EGFR exon 20 ins'], description: 'In-frame insertions in EGFR exon 20 detected by sequencing; a class of EGFR mutations with distinct sensitivity used as a predictive marker in non-small cell lung cancer.', assays: ['NGS', 'Liquid biopsy'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['exon 20 insertion'] }, | |
| 232 | + { | |
| 233 | + slug: 'alk-fusion', | |
| 234 | + name: 'ALK rearrangement / fusion', | |
| 235 | + kind: 'gene_mutation', | |
| 236 | + geneSymbol: 'ALK', | |
| 237 | + ncitCode: 'C142135', | |
| 238 | + ncitName: 'ALK Fusion Positive', | |
| 239 | + ncitConceptKind: 'biomarker', | |
| 240 | + aliases: ['ALK-positive', 'ALK rearrangement', 'EML4-ALK', 'ALK fusion'], | |
| 241 | + description: 'ALK gene rearrangements (most often EML4-ALK) detected by FISH, immunohistochemistry or sequencing; used as a predictive marker for ALK inhibitors in non-small cell lung cancer and some lymphomas.', | |
| 242 | + assays: ['FISH', 'IHC', 'NGS', 'RT-PCR'], | |
| 243 | + notes: 'Gene-level entry: derived links cover every ALK fusion partner recorded as a variant.', | |
| 244 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 245 | + indicationTerms: ['anaplastic lymphoma kinase (ALK)-positive', 'ALK-positive', 'ALK rearrangement'], | |
| 246 | + }, | |
| 247 | + { slug: 'ros1-fusion', name: 'ROS1 rearrangement / fusion', kind: 'gene_mutation', geneSymbol: 'ROS1', ncitCode: 'C131071', ncitName: 'ROS1 Fusion Positive', ncitConceptKind: 'biomarker', aliases: ['ROS1-positive', 'ROS1 rearrangement', 'CD74-ROS1'], description: 'ROS1 gene rearrangements detected by FISH, immunohistochemistry or sequencing; used as a predictive marker for ROS1-directed tyrosine kinase inhibitors in non-small cell lung cancer.', assays: ['FISH', 'IHC', 'NGS', 'RT-PCR'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['ROS1-positive', 'ROS1 rearrangement'] }, | |
| 248 | + { slug: 'kras-g12c', name: 'KRAS G12C', kind: 'gene_mutation', geneSymbol: 'KRAS', variantSlugs: ['kras-g12c'], ncitCode: 'C98365', ncitName: 'KRAS NP_004976.2:p.G12C', ncitConceptKind: 'biomarker', aliases: ['p.G12C', 'KRAS G12C-mutated', 'c.34G>T'], description: 'KRAS codon 12 point mutation p.G12C detected by sequencing or PCR in tumor tissue or plasma; used as a predictive marker for KRAS G12C inhibitors in non-small cell lung and colorectal cancer.', assays: ['NGS', 'PCR', 'Liquid biopsy'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['KRAS G12C'] }, | |
| 249 | + { slug: 'kras-mutation', name: 'KRAS mutation (any)', kind: 'gene_mutation', geneSymbol: 'KRAS', ncitCode: 'C41361', ncitName: 'KRAS Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['KRAS-mutant', 'KRAS-mutated', 'RAS mutation'], description: 'Activating KRAS mutations (codons 12, 13, 61 and others) detected by sequencing or PCR; used as a predictive marker, including as a negative predictor of response to anti-EGFR antibodies in colorectal cancer.', assays: ['NGS', 'PCR', 'Liquid biopsy'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['KRAS wild-type', 'RAS wild-type', 'KRAS-mutated', 'KRAS mutation'] }, | |
| 250 | + { slug: 'nras-mutation', name: 'NRAS mutation', kind: 'gene_mutation', geneSymbol: 'NRAS', ncitCode: 'C41381', ncitName: 'NRAS Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['NRAS-mutant', 'NRAS-mutated'], description: 'Activating NRAS mutations detected by sequencing or PCR; used as a predictive marker in colorectal cancer (anti-EGFR antibodies) and studied in melanoma.', assays: ['NGS', 'PCR'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['NRAS wild-type', 'NRAS mutation'] }, | |
| 251 | + { | |
| 252 | + slug: 'braf-v600e', | |
| 253 | + name: 'BRAF V600E', | |
| 254 | + kind: 'gene_mutation', | |
| 255 | + geneSymbol: 'BRAF', | |
| 256 | + variantSlugs: ['braf-v600e'], | |
| 257 | + ncitCode: 'C98342', | |
| 258 | + ncitName: 'BRAF NP_004324.2:p.V600E', | |
| 259 | + ncitConceptKind: 'biomarker', | |
| 260 | + aliases: ['p.V600E', 'BRAF V600E-mutant', 'c.1799T>A'], | |
| 261 | + description: 'BRAF codon 600 point mutation p.V600E detected by sequencing, PCR or mutation-specific immunohistochemistry; used as a predictive marker for BRAF/MEK inhibitor therapy across melanoma, thyroid, colorectal, lung and other cancers.', | |
| 262 | + assays: ['NGS', 'PCR', 'IHC', 'Liquid biopsy'], | |
| 263 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 264 | + tumorAgnostic: true, | |
| 265 | + indicationTerms: ['BRAF V600E', 'BRAF V600'], | |
| 266 | + }, | |
| 267 | + { slug: 'braf-mutation', name: 'BRAF mutation (any)', kind: 'gene_mutation', geneSymbol: 'BRAF', ncitCode: 'C40430', ncitName: 'BRAF Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['BRAF-mutant', 'BRAF-mutated', 'non-V600 BRAF', 'class II/III BRAF'], description: 'Any BRAF mutation (V600 and non-V600 classes) detected by sequencing; used as a predictive and, in some settings, prognostic marker.', assays: ['NGS', 'PCR'], notes: 'Gene-level entry; V600E is a separate molecular marker.', sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['BRAF mutation', 'BRAF-mutated', 'BRAF wild-type'] }, | |
| 268 | + { slug: 'met-exon-14-skipping', name: 'MET exon 14 skipping', kind: 'gene_mutation', geneSymbol: 'MET', variantSlugs: ['met-exon-14-skipping-mutation', 'met-exon-14-mutation'], ncitCode: 'C131179', ncitName: 'MET Exon 14 Skipping Mutation', ncitConceptKind: 'biomarker', aliases: ['METex14', 'MET exon 14 alteration', 'METΔ14'], description: 'MET alterations causing exon 14 skipping detected by RNA- or DNA-based sequencing; used as a predictive marker for MET tyrosine kinase inhibitors in non-small cell lung cancer.', assays: ['NGS', 'RT-PCR'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['MET exon 14 skipping'] }, | |
| 269 | + { slug: 'met-amplification', name: 'MET amplification', kind: 'gene_mutation', geneSymbol: 'MET', variantSlugs: ['met-amplification'], ncitCode: 'C43532', ncitName: 'c-MET Gene Amplification', ncitConceptKind: 'biomarker', aliases: ['MET amp', 'c-MET amplification', 'MET copy-number gain'], description: 'Increased MET gene copy number detected by FISH or sequencing; studied as a predictive marker and as a resistance mechanism to EGFR inhibitors in non-small cell lung cancer.', assays: ['FISH', 'NGS'], scoring: 'Reported as MET/CEP7 ratio or gene copy number; thresholds are assay- and study-specific.', sources: ['nciBiomarkerTesting'], indicationTerms: ['MET amplification'] }, | |
| 270 | + { | |
| 271 | + slug: 'ret-fusion', | |
| 272 | + name: 'RET fusion', | |
| 273 | + kind: 'gene_mutation', | |
| 274 | + geneSymbol: 'RET', | |
| 275 | + ncitCode: 'C131069', | |
| 276 | + ncitName: 'RET Fusion Positive', | |
| 277 | + ncitConceptKind: 'biomarker', | |
| 278 | + aliases: ['RET rearrangement', 'RET fusion-positive', 'KIF5B-RET', 'CCDC6-RET'], | |
| 279 | + description: 'RET gene fusions detected by sequencing or FISH; used as a predictive marker for selective RET inhibitors in non-small cell lung, thyroid and other solid tumors.', | |
| 280 | + assays: ['NGS', 'FISH', 'RT-PCR'], | |
| 281 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 282 | + tumorAgnostic: true, | |
| 283 | + indicationTerms: ['RET gene fusion', 'RET fusion-positive', 'RET-mutant'], | |
| 284 | + }, | |
| 285 | + { | |
| 286 | + slug: 'ntrk-fusion', | |
| 287 | + name: 'NTRK gene fusion', | |
| 288 | + kind: 'gene_mutation', | |
| 289 | + geneSymbol: null, | |
| 290 | + genes: ['NTRK1', 'NTRK2', 'NTRK3'], | |
| 291 | + ncitCode: 'C183255', | |
| 292 | + ncitName: 'NTRK Gene Fusion Positive', | |
| 293 | + ncitConceptKind: 'biomarker', | |
| 294 | + aliases: ['NTRK fusion-positive', 'TRK fusion', 'ETV6-NTRK3', 'NTRK1/2/3 fusion'], | |
| 295 | + description: 'Fusions involving NTRK1, NTRK2 or NTRK3 detected by sequencing, FISH or pan-TRK immunohistochemistry; used as a tissue-agnostic predictive marker for TRK inhibitors in solid tumors.', | |
| 296 | + assays: ['NGS', 'FISH', 'IHC', 'RT-PCR'], | |
| 297 | + notes: 'No single anchor gene: derived links use the three NTRK genes listed.', | |
| 298 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 299 | + tumorAgnostic: true, | |
| 300 | + indicationTerms: ['NTRK', 'neurotrophic tyrosine receptor kinase', 'neurotrophic receptor tyrosine kinase'], | |
| 301 | + }, | |
| 302 | + { slug: 'fgfr2-fusion', name: 'FGFR2 fusion / rearrangement', kind: 'gene_mutation', geneSymbol: 'FGFR2', variantSlugs: ['fgfr2-fusion', 'fgfr2-v-fusion'], ncitCode: 'C150616', ncitName: 'FGFR2 Fusion Positive', ncitConceptKind: 'biomarker', aliases: ['FGFR2 rearrangement', 'FGFR2-BICC1', 'FGFR2 fusion-positive'], description: 'FGFR2 gene fusions or rearrangements detected by sequencing or FISH; used as a predictive marker for FGFR inhibitors in cholangiocarcinoma.', assays: ['NGS', 'FISH', 'RT-PCR'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['FGFR2 fusion', 'FGFR2 rearrangement'] }, | |
| 303 | + { slug: 'fgfr3-alteration', name: 'FGFR3 alteration (mutation or fusion)', kind: 'gene_mutation', geneSymbol: 'FGFR3', ncitCode: 'C150618', ncitName: 'FGFR3 Gene Alteration Positive', ncitConceptKind: 'biomarker', aliases: ['FGFR3 mutation', 'FGFR3 fusion', 'FGFR3-TACC3', 'S249C'], description: 'Activating FGFR3 point mutations or fusions detected by sequencing of tumor tissue; used as a predictive marker for FGFR inhibitors in urothelial carcinoma.', assays: ['NGS', 'RT-PCR'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['FGFR3', 'FGFR3 genetic alterations'] }, | |
| 304 | + | |
| 305 | + // ── Immune / genomic-instability markers ──────────────────────────────────────────────────── | |
| 306 | + { | |
| 307 | + slug: 'pd-l1', | |
| 308 | + name: 'PD-L1 expression', | |
| 309 | + kind: 'immune_marker', | |
| 310 | + geneSymbol: 'CD274', | |
| 311 | + variantSlugs: ['cd274-expression', 'cd274-overexpression'], | |
| 312 | + ncitCode: 'C128554', | |
| 313 | + ncitName: 'PD-L1 Positive', | |
| 314 | + ncitConceptKind: 'biomarker', | |
| 315 | + aliases: ['PD-L1', 'CD274', 'B7-H1', 'programmed death-ligand 1', 'PD-L1 TPS', 'PD-L1 CPS'], | |
| 316 | + description: 'Programmed death-ligand 1 protein expression measured by immunohistochemistry on tumor and/or immune cells; used as a predictive marker for PD-1/PD-L1 checkpoint inhibitors in many carcinomas.', | |
| 317 | + assays: ['IHC'], | |
| 318 | + scoring: 'Reported as Tumor Proportion Score (TPS, % tumor cells), Combined Positive Score (CPS, PD-L1-positive tumor and immune cells per 100 tumor cells) or immune-cell (IC) percentage; scores and cut-offs are assay- and indication-specific as stated in each label.', | |
| 319 | + notes: 'Gene CD274 encodes PD-L1; derived links use CIViC evidence recorded on CD274 expression.', | |
| 320 | + sources: ['fdaCdx', 'nciCheckpoint', 'nciBiomarkerTesting'], | |
| 321 | + indicationTerms: ['PD-L1'], | |
| 322 | + }, | |
| 323 | + { | |
| 324 | + slug: 'msi-h', | |
| 325 | + name: 'Microsatellite instability-high (MSI-H)', | |
| 326 | + kind: 'msi', | |
| 327 | + geneSymbol: null, | |
| 328 | + genes: ['MLH1', 'MSH2', 'MSH6', 'PMS2'], | |
| 329 | + ncitCode: 'C36493', | |
| 330 | + ncitName: 'High-Frequency Microsatellite Instability', | |
| 331 | + ncitConceptKind: 'biomarker', | |
| 332 | + aliases: ['MSI-H', 'MSI-high', 'microsatellite instability', 'MSI'], | |
| 333 | + description: 'Length instability of microsatellite loci detected by PCR or sequencing of tumor DNA, the genomic consequence of mismatch-repair deficiency; used as a tissue-agnostic predictive marker for immune checkpoint inhibitors and as a screening marker for Lynch syndrome.', | |
| 334 | + assays: ['MSI-PCR', 'NGS', 'Liquid biopsy'], | |
| 335 | + scoring: 'PCR panels report MSI-H, MSI-L or MSS from the number of unstable loci; NGS reports an MSI score from panel-wide microsatellite sites.', | |
| 336 | + notes: 'MSI-H and dMMR are two measurements of one biological state: MSI-H is the DNA phenotype (PCR/NGS), dMMR is loss of MMR protein expression (IHC of MLH1, MSH2, MSH6, PMS2). Labels often say "MSI-H or dMMR".', | |
| 337 | + sources: ['fdaCdx', 'nciCheckpoint', 'nciBiomarkerTesting'], | |
| 338 | + tumorAgnostic: true, | |
| 339 | + indicationTerms: ['microsatellite instability-high', 'MSI-H'], | |
| 340 | + }, | |
| 341 | + { | |
| 342 | + slug: 'dmmr', | |
| 343 | + name: 'Mismatch repair deficiency (dMMR)', | |
| 344 | + kind: 'msi', | |
| 345 | + geneSymbol: null, | |
| 346 | + genes: ['MLH1', 'MSH2', 'MSH6', 'PMS2'], | |
| 347 | + ncitCode: 'C136712', | |
| 348 | + ncitName: 'Mismatch Repair Deficiency', | |
| 349 | + ncitConceptKind: 'biomarker', | |
| 350 | + aliases: ['dMMR', 'MMR-deficient', 'MMR deficiency', 'loss of MMR protein expression'], | |
| 351 | + description: 'Loss of nuclear expression of one or more mismatch-repair proteins (MLH1, MSH2, MSH6, PMS2) by immunohistochemistry; used as a tissue-agnostic predictive marker for immune checkpoint inhibitors and as a screening marker for Lynch syndrome.', | |
| 352 | + assays: ['IHC'], | |
| 353 | + scoring: 'Reported per protein as retained or lost nuclear staining with internal control; MLH1 loss is commonly followed by MLH1 promoter methylation / BRAF V600E testing to separate sporadic from hereditary cases.', | |
| 354 | + notes: 'See biomarker "msi-h" for the relationship between dMMR (protein, IHC) and MSI-H (DNA phenotype, PCR/NGS).', | |
| 355 | + sources: ['fdaCdx', 'nciCheckpoint', 'nciBiomarkerTesting'], | |
| 356 | + tumorAgnostic: true, | |
| 357 | + indicationTerms: ['mismatch repair deficient', 'dMMR'], | |
| 358 | + }, | |
| 359 | + { | |
| 360 | + slug: 'tmb-h', | |
| 361 | + name: 'Tumor mutational burden-high (TMB-H)', | |
| 362 | + kind: 'tmb', | |
| 363 | + geneSymbol: null, | |
| 364 | + ncitCode: 'C156025', | |
| 365 | + ncitName: 'High Tumor Mutation Burden', | |
| 366 | + ncitConceptKind: 'biomarker', | |
| 367 | + aliases: ['TMB-H', 'TMB-high', 'tumor mutation burden', 'TMB'], | |
| 368 | + description: 'Number of somatic mutations per megabase of sequenced tumor genome estimated from a large sequencing panel; used as a tissue-agnostic predictive marker for PD-1 blockade in solid tumors.', | |
| 369 | + assays: ['NGS', 'Liquid biopsy'], | |
| 370 | + scoring: 'Reported in mutations per megabase (mut/Mb); the FDA-approved tissue-agnostic indication uses the cut-off of the companion diagnostic named in the label; values are not comparable across panels.', | |
| 371 | + notes: 'A genome-wide measurement with no anchor gene: gene-derived blocks are empty by construction; approvals are matched on indication text.', | |
| 372 | + sources: ['fdaCdx', 'nciCheckpoint', 'nciBiomarkerTesting'], | |
| 373 | + tumorAgnostic: true, | |
| 374 | + indicationTerms: ['tumor mutational burden-high', 'tumor mutational burden', 'TMB-H'], | |
| 375 | + }, | |
| 376 | + { | |
| 377 | + slug: 'hrd', | |
| 378 | + name: 'Homologous recombination deficiency (HRD)', | |
| 379 | + kind: 'hrd', | |
| 380 | + geneSymbol: null, | |
| 381 | + ncitCode: 'C120465', | |
| 382 | + ncitName: 'Homologous Recombination Deficiency', | |
| 383 | + ncitConceptKind: 'biomarker', | |
| 384 | + aliases: ['HRD', 'HRD-positive', 'genomic instability score', 'HRR deficiency'], | |
| 385 | + description: 'A genomic scar signature (loss of heterozygosity, telomeric allelic imbalance, large-scale state transitions) and/or deleterious BRCA1/2 status estimated from tumor sequencing; used as a predictive marker for PARP inhibitors in ovarian cancer.', | |
| 386 | + assays: ['NGS'], | |
| 387 | + scoring: 'Reported as a genomic instability score with an assay-specific positivity threshold, combined with BRCA1/2 mutation status; not comparable across assays.', | |
| 388 | + notes: 'A genome-wide signature with no single anchor gene (BRCA1/2 are separate biomarkers): gene-derived blocks are empty by construction; approvals are matched on indication text.', | |
| 389 | + sources: ['fdaCdx', 'nciBiomarkerTesting'], | |
| 390 | + indicationTerms: ['homologous recombination deficiency', 'HRD-positive', 'HRD positive'], | |
| 391 | + }, | |
| 392 | + { | |
| 393 | + slug: 'ctdna', | |
| 394 | + name: 'Circulating tumor DNA (ctDNA)', | |
| 395 | + kind: 'ctdna', | |
| 396 | + geneSymbol: null, | |
| 397 | + ncitCode: 'C113243', | |
| 398 | + ncitName: 'Circulating Tumor-Derived DNA', | |
| 399 | + ncitConceptKind: 'biomarker', | |
| 400 | + aliases: ['ctDNA', 'liquid biopsy', 'cell-free tumor DNA', 'plasma genotyping'], | |
| 401 | + description: 'Tumor-derived DNA fragments in plasma analyzed by sequencing or PCR; used as a specimen for genotyping (mutations, fusions, MSI, TMB) and studied as a prognostic marker for minimal residual disease.', | |
| 402 | + assays: ['Liquid biopsy', 'NGS', 'PCR'], | |
| 403 | + notes: 'A specimen type rather than a single analyte: derived blocks have no gene to anchor to; molecular markers detected in plasma appear under their own entries.', | |
| 404 | + sources: ['nciBiomarkerTesting', 'fdaCdx'], | |
| 405 | + indicationTerms: ['plasma specimen', 'circulating tumor DNA'], | |
| 406 | + }, | |
| 407 | + | |
| 408 | + // ── Hereditary / DNA-repair genes ─────────────────────────────────────────────────────────── | |
| 409 | + { slug: 'brca1-mutation', name: 'BRCA1 mutation', kind: 'gene_mutation', geneSymbol: 'BRCA1', ncitCode: 'C19635', ncitName: 'BRCA1 Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['BRCA1-mutated', 'gBRCA1', 'germline BRCA1', 'somatic BRCA1'], description: 'Pathogenic germline or somatic BRCA1 variants detected by sequencing of blood or tumor; used as a predictive marker for PARP inhibitors and as a hereditary cancer-risk marker.', assays: ['NGS', 'Sequencing'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['BRCA-mutated', 'BRCA1', 'gBRCAm', 'BRCA mutation'] }, | |
| 410 | + { slug: 'brca2-mutation', name: 'BRCA2 mutation', kind: 'gene_mutation', geneSymbol: 'BRCA2', ncitCode: 'C19636', ncitName: 'BRCA2 Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['BRCA2-mutated', 'gBRCA2', 'germline BRCA2', 'somatic BRCA2'], description: 'Pathogenic germline or somatic BRCA2 variants detected by sequencing of blood or tumor; used as a predictive marker for PARP inhibitors and as a hereditary cancer-risk marker.', assays: ['NGS', 'Sequencing'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['BRCA-mutated', 'BRCA2', 'gBRCAm', 'BRCA mutation'] }, | |
| 411 | + { slug: 'tp53-mutation', name: 'TP53 mutation', kind: 'gene_mutation', geneSymbol: 'TP53', ncitCode: 'C118396', ncitName: 'TP53 Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['TP53-mutated', 'p53 mutation', 'TP53-mutant'], description: 'Somatic TP53 mutations detected by sequencing of tumor tissue or marrow; used mainly as a prognostic and risk-stratification marker across solid and hematologic malignancies.', assays: ['NGS', 'Sequencing', 'IHC'], sources: ['nciBiomarkerTesting'], indicationTerms: ['TP53 mutation'] }, | |
| 412 | + { slug: 'cdkn2a-deletion', name: 'CDKN2A deletion / loss', kind: 'gene_mutation', geneSymbol: 'CDKN2A', variantSlugs: ['cdkn2a-deletion', 'cdkn2a-loss'], ncitCode: 'C41615', ncitName: 'CDKN2A Gene Deletion', ncitConceptKind: 'biomarker', aliases: ['CDKN2A loss', 'p16 loss', 'CDKN2A homozygous deletion', '9p21 deletion'], description: 'Homozygous deletion or loss of CDKN2A (p16INK4a) detected by FISH, sequencing or p16 immunohistochemistry; used as a diagnostic and prognostic marker in mesothelioma, glioma and other tumors.', assays: ['FISH', 'NGS', 'IHC'], sources: ['nciBiomarkerTesting'] }, | |
| 413 | + | |
| 414 | + // ── Prostate / neuroendocrine cell-surface targets ────────────────────────────────────────── | |
| 415 | + { slug: 'ar', name: 'Androgen receptor (AR)', kind: 'hormone_receptor', geneSymbol: 'AR', ncitCode: 'C94297', ncitName: 'Androgen Receptor Positive', ncitConceptKind: 'biomarker', aliases: ['AR', 'AR-positive', 'androgen receptor expression'], description: 'Androgen receptor expression or alteration assessed by immunohistochemistry or sequencing; the therapeutic target of androgen-deprivation and AR-pathway inhibitors in prostate cancer and a subtyping marker in breast cancer.', assays: ['IHC', 'NGS'], sources: ['nciBiomarkerTesting', 'nciTumorMarkers'] }, | |
| 416 | + { slug: 'ar-v7', name: 'AR-V7 splice variant', kind: 'other', geneSymbol: 'AR', variantSlugs: ['ar-ar-v7'], ncitCode: 'C135616', ncitName: 'Androgen Receptor Splice Variant 7 Positive', ncitConceptKind: 'biomarker', aliases: ['AR-V7', 'androgen receptor variant 7', 'AR splice variant 7'], description: 'A constitutively active androgen receptor splice variant lacking the ligand-binding domain, detected in circulating tumor cells or tissue by RT-PCR or immunostaining; studied as a predictive marker of resistance to AR-pathway inhibitors in castration-resistant prostate cancer.', assays: ['RT-PCR', 'IHC', 'Liquid biopsy'], sources: ['nciBiomarkerTesting'] }, | |
| 417 | + { slug: 'psma', name: 'PSMA (FOLH1) expression', kind: 'cell_surface', geneSymbol: 'FOLH1', ncitCode: 'C153464', ncitName: 'FOLH1 Positive', ncitConceptKind: 'biomarker', aliases: ['PSMA', 'FOLH1', 'prostate-specific membrane antigen', 'PSMA-positive', 'GCPII'], description: 'Prostate-specific membrane antigen expression on tumor cells, assessed in vivo by PSMA-ligand PET imaging; used as an eligibility marker for PSMA-targeted radioligand therapy in metastatic castration-resistant prostate cancer.', assays: ['PET imaging', 'IHC'], scoring: 'PET positivity defined relative to liver uptake and the absence of PSMA-negative lesions, per the imaging agent label.', sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['PSMA-positive', 'prostate-specific membrane antigen (PSMA)-positive'] }, | |
| 418 | + { slug: 'sstr', name: 'Somatostatin receptor (SSTR) expression', kind: 'cell_surface', geneSymbol: 'SSTR2', ncitCode: 'C128873', ncitName: 'Somatostatin Receptor Positive', ncitConceptKind: 'biomarker', aliases: ['SSTR', 'SSTR2', 'somatostatin receptor-positive', 'SSTR imaging'], description: 'Somatostatin receptor (chiefly SSTR2) expression on neuroendocrine tumor cells assessed by somatostatin-analog PET/SPECT imaging or immunohistochemistry; used as an eligibility marker for somatostatin analogs and peptide receptor radionuclide therapy.', assays: ['PET imaging', 'IHC'], notes: 'SSTR2 is the anchor gene; the receptor family has five members and imaging agents bind several of them.', sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['somatostatin receptor-positive', 'somatostatin receptor positive'] }, | |
| 419 | + | |
| 420 | + // ── Hematologic cell-surface targets ──────────────────────────────────────────────────────── | |
| 421 | + { slug: 'cd19', name: 'CD19', kind: 'cell_surface', geneSymbol: 'CD19', ncitCode: 'C129255', ncitName: 'CD19 Positive', ncitConceptKind: 'biomarker', aliases: ['CD19-positive', 'CD19 antigen', 'B-lymphocyte antigen CD19'], description: 'B-lineage surface antigen detected by flow cytometry or immunohistochemistry; the target antigen of CD19-directed antibodies, bispecifics and CAR T-cell therapies in B-cell leukemias and lymphomas.', assays: ['Flow cytometry', 'IHC'], sources: ['nciBiomarkerTesting', 'fdaOncologyApprovals'], indicationTerms: ['CD19-positive', 'CD19-directed'] }, | |
| 422 | + { slug: 'cd20', name: 'CD20 (MS4A1)', kind: 'cell_surface', geneSymbol: 'MS4A1', ncitCode: 'C128631', ncitName: 'CD20 Positive', ncitConceptKind: 'biomarker', aliases: ['CD20', 'CD20-positive', 'MS4A1', 'B-lymphocyte antigen CD20'], description: 'B-lineage surface antigen detected by flow cytometry or immunohistochemistry; the target antigen of anti-CD20 antibodies and CD20xCD3 bispecifics in B-cell lymphomas and chronic lymphocytic leukemia.', assays: ['Flow cytometry', 'IHC'], sources: ['nciBiomarkerTesting', 'fdaOncologyApprovals'], indicationTerms: ['CD20-positive', 'CD20-directed'] }, | |
| 423 | + { slug: 'bcma', name: 'BCMA (TNFRSF17)', kind: 'cell_surface', geneSymbol: 'TNFRSF17', ncitCode: 'C128845', ncitName: 'TNFRSF17 Positive', ncitConceptKind: 'biomarker', aliases: ['BCMA', 'B-cell maturation antigen', 'TNFRSF17', 'CD269'], description: 'Plasma-cell surface antigen detected by flow cytometry or immunohistochemistry; the target antigen of BCMA-directed antibody-drug conjugates, bispecifics and CAR T-cell therapies in multiple myeloma.', assays: ['Flow cytometry', 'IHC'], sources: ['nciBiomarkerTesting', 'fdaOncologyApprovals'], indicationTerms: ['BCMA', 'B-cell maturation antigen'] }, | |
| 424 | + { slug: 'cd38', name: 'CD38', kind: 'cell_surface', geneSymbol: 'CD38', ncitCode: 'C147087', ncitName: 'CD38 Positive', ncitConceptKind: 'biomarker', aliases: ['CD38-positive', 'ADP-ribosyl cyclase 1'], description: 'Surface glycoprotein highly expressed on plasma cells, detected by flow cytometry; the target antigen of anti-CD38 antibodies in multiple myeloma.', assays: ['Flow cytometry', 'IHC'], sources: ['nciBiomarkerTesting', 'fdaOncologyApprovals'], indicationTerms: ['CD38'] }, | |
| 425 | + { slug: 'cd30', name: 'CD30 (TNFRSF8)', kind: 'cell_surface', geneSymbol: 'TNFRSF8', ncitCode: 'C129257', ncitName: 'TNFRSF8 Positive', ncitConceptKind: 'biomarker', aliases: ['CD30', 'CD30-positive', 'TNFRSF8', 'Ki-1 antigen'], description: 'Activation antigen expressed on Hodgkin/Reed-Sternberg and anaplastic large-cell lymphoma cells, detected by immunohistochemistry; the target antigen of CD30-directed antibody-drug conjugates and a diagnostic marker.', assays: ['IHC', 'Flow cytometry'], sources: ['nciBiomarkerTesting', 'fdaOncologyApprovals'], indicationTerms: ['CD30-expressing', 'CD30-positive', 'CD30-directed'] }, | |
| 426 | + { slug: 'cd33', name: 'CD33', kind: 'cell_surface', geneSymbol: 'CD33', ncitCode: 'C132228', ncitName: 'CD33 Positive', ncitConceptKind: 'biomarker', aliases: ['CD33-positive', 'Siglec-3', 'myeloid cell surface antigen CD33'], description: 'Myeloid surface antigen detected by flow cytometry on leukemic blasts; the target antigen of CD33-directed antibody-drug conjugates in acute myeloid leukemia.', assays: ['Flow cytometry'], sources: ['nciBiomarkerTesting', 'fdaOncologyApprovals'], indicationTerms: ['CD33-positive'] }, | |
| 427 | + | |
| 428 | + // ── Hematologic molecular markers ─────────────────────────────────────────────────────────── | |
| 429 | + { slug: 'flt3-mutation', name: 'FLT3 mutation (ITD / TKD)', kind: 'gene_mutation', geneSymbol: 'FLT3', ncitCode: 'C128919', ncitName: 'FLT3 Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['FLT3-ITD', 'FLT3-TKD', 'FLT3 internal tandem duplication', 'FLT3-mutated', 'D835'], description: 'FLT3 internal tandem duplications or tyrosine kinase domain point mutations detected by PCR fragment analysis or sequencing of blood or marrow; used as a predictive marker for FLT3 inhibitors and a prognostic marker in acute myeloid leukemia.', assays: ['PCR', 'Fragment analysis', 'NGS'], scoring: 'ITD reported with allelic ratio (mutant/wild-type signal) where the assay supports it; TKD reported by codon (D835, I836).', notes: 'Gene-level entry covering ITD and TKD variants recorded in `variants` (e.g. slug flt3-itd).', sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['FLT3 mutation-positive', 'FLT3 mutation', 'FLT3-ITD'] }, | |
| 430 | + { slug: 'idh1-mutation', name: 'IDH1 mutation', kind: 'gene_mutation', geneSymbol: 'IDH1', ncitCode: 'C118389', ncitName: 'IDH1 Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['IDH1-mutated', 'IDH1 R132', 'R132H', 'R132C'], description: 'IDH1 codon 132 mutations detected by PCR or sequencing (or R132H-specific immunohistochemistry in glioma); used as a predictive marker for IDH1 inhibitors in acute myeloid leukemia and cholangiocarcinoma and as a diagnostic marker in glioma.', assays: ['PCR', 'NGS', 'IHC'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['IDH1 mutation', 'IDH1-mutated', 'IDH1 or IDH2 mutation'] }, | |
| 431 | + { slug: 'idh2-mutation', name: 'IDH2 mutation', kind: 'gene_mutation', geneSymbol: 'IDH2', ncitCode: 'C118390', ncitName: 'IDH2 Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['IDH2-mutated', 'R140Q', 'R172K'], description: 'IDH2 codon 140 or 172 mutations detected by PCR or sequencing of blood or marrow; used as a predictive marker for IDH2 inhibitors in acute myeloid leukemia and as a diagnostic marker in glioma.', assays: ['PCR', 'NGS'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['IDH2 mutation', 'IDH2-mutated', 'IDH1 or IDH2 mutation'] }, | |
| 432 | + { slug: 'npm1-mutation', name: 'NPM1 mutation', kind: 'gene_mutation', geneSymbol: 'NPM1', variantSlugs: ['npm1-mutation', 'npm1-exon-11-mutation'], ncitCode: 'C82429', ncitName: 'NPM1 Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['NPM1-mutated', 'NPMc+', 'NPM1 exon 12 mutation'], description: 'Frameshift insertions in the last exon of NPM1 detected by PCR or sequencing of blood or marrow; used as a diagnostic, prognostic and measurable-residual-disease marker in acute myeloid leukemia.', assays: ['PCR', 'NGS', 'Fragment analysis'], sources: ['nciBiomarkerTesting'], indicationTerms: ['NPM1 mutation', 'NPM1-mutated'] }, | |
| 433 | + { slug: 'kit-mutation', name: 'KIT mutation', kind: 'gene_mutation', geneSymbol: 'KIT', ncitCode: 'C39712', ncitName: 'KIT Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['c-KIT mutation', 'KIT exon 11', 'KIT D816V', 'CD117'], description: 'Activating KIT mutations (exon 9, 11, 13, 17 and others) detected by sequencing of tumor tissue or marrow; used as a predictive marker for KIT inhibitors in gastrointestinal stromal tumor and as a diagnostic marker in systemic mastocytosis.', assays: ['NGS', 'PCR', 'IHC'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['Kit (CD117) positive', 'KIT mutation', 'KIT D816V'] }, | |
| 434 | + { slug: 'pdgfra-mutation', name: 'PDGFRA mutation', kind: 'gene_mutation', geneSymbol: 'PDGFRA', variantSlugs: ['pdgfra-d842v'], ncitCode: 'C39718', ncitName: 'PDGFRA Gene Mutation', ncitConceptKind: 'biomarker', aliases: ['PDGFRA D842V', 'PDGFRA exon 18', 'PDGFRA-mutant'], description: 'Activating PDGFRA mutations, notably exon 18 D842V, detected by sequencing of tumor tissue; used as a predictive marker for PDGFRA-selective inhibitors in gastrointestinal stromal tumor.', assays: ['NGS', 'PCR'], sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['PDGFRA exon 18', 'PDGFRA D842V', 'PDGFRA mutation'] }, | |
| 435 | + { slug: 'bcr-abl1', name: 'BCR-ABL1 fusion (Philadelphia chromosome)', kind: 'gene_mutation', geneSymbol: 'ABL1', genes: ['BCR'], variantSlugs: ['bcr-abl1-fusion'], ncitCode: 'C94600', ncitName: 'BCR/ABL1 Fusion Gene', ncitConceptKind: 'biomarker', aliases: ['BCR-ABL1', 'BCR::ABL1', 'Philadelphia chromosome', 'Ph+', 't(9;22)', 'p210', 'p190'], description: 'The BCR-ABL1 fusion from t(9;22) detected by karyotype, FISH or quantitative RT-PCR of blood or marrow; the defining diagnostic marker of chronic myeloid leukemia, a predictive marker for ABL tyrosine kinase inhibitors, and a molecular-response marker.', assays: ['RT-PCR', 'FISH', 'Karyotype', 'NGS'], scoring: 'Quantitative RT-PCR reported on the International Scale (BCR-ABL1 %IS); kinase-domain mutations (e.g. T315I) reported separately.', sources: ['fdaCdx', 'nciBiomarkerTesting'], indicationTerms: ['Philadelphia chromosome-positive', 'Ph+', 'BCR-ABL', 'T315I'] }, | |
| 436 | + { slug: 'jak2-v617f', name: 'JAK2 V617F', kind: 'gene_mutation', geneSymbol: 'JAK2', variantSlugs: ['jak2-v617f'], ncitCode: 'C105908', ncitName: 'JAK2 NP_004963.1:p.V617F', ncitConceptKind: 'biomarker', aliases: ['p.V617F', 'JAK2-positive', 'c.1849G>T'], description: 'JAK2 exon 14 point mutation p.V617F detected by allele-specific PCR or sequencing of blood; a diagnostic marker of myeloproliferative neoplasms (polycythemia vera, essential thrombocythemia, primary myelofibrosis).', assays: ['PCR', 'NGS'], scoring: 'Reported qualitatively or as variant allele fraction.', sources: ['nciBiomarkerTesting'], indicationTerms: ['JAK2'] }, | |
| 437 | + { slug: 'del-17p', name: '17p deletion (del(17p))', kind: 'signature', geneSymbol: null, genes: ['TP53'], variantSlugs: ['tp53-deletion', 'tp53-loss'], ncitCode: 'C36499', ncitName: 'Loss of Chromosome 17p', ncitConceptKind: 'biomarker', aliases: ['del(17p)', 'del17p', '17p13 deletion', 'TP53 deletion'], description: 'Loss of the short arm of chromosome 17 (including the TP53 locus) detected by FISH or karyotype of blood or marrow; a prognostic and treatment-selection marker in chronic lymphocytic leukemia and multiple myeloma.', assays: ['FISH', 'Karyotype', 'NGS'], notes: 'A cytogenetic marker: TP53 is the gene it is anchored to for derived links; TP53 point mutations are the separate biomarker "tp53-mutation".', sources: ['nciBiomarkerTesting'], indicationTerms: ['17p deletion', 'del(17p)'] }, | |
| 438 | +]; | |
| 439 | + | |
| 440 | +/** The measurement jsonb stored on each row — the seed is the only writer of this shape. */ | |
| 441 | +export interface BiomarkerMeasurement { | |
| 442 | + assays: AssayFamily[]; | |
| 443 | + scoring?: string; | |
| 444 | + notes?: string; | |
| 445 | + sources: Array<{ label: string; url: string }>; | |
| 446 | + aliases: string[]; | |
| 447 | + genes: string[]; | |
| 448 | + variantSlugs: string[]; | |
| 449 | + indicationTerms: string[]; | |
| 450 | + tumorAgnostic: boolean; | |
| 451 | + ncit: { code: string; name: string; conceptKind: 'biomarker' }; | |
| 452 | + verification: typeof NCIT_VERIFICATION; | |
| 453 | +} | |
| 454 | + | |
| 455 | +export function buildMeasurement(b: BiomarkerSeed): BiomarkerMeasurement { | |
| 456 | + const m: BiomarkerMeasurement = { | |
| 457 | + assays: b.assays, | |
| 458 | + sources: b.sources.map((k) => ({ label: BIOMARKER_SOURCES[k].label, url: BIOMARKER_SOURCES[k].url })), | |
| 459 | + aliases: b.aliases, | |
| 460 | + genes: b.genes ?? [], | |
| 461 | + variantSlugs: b.variantSlugs ?? [], | |
| 462 | + indicationTerms: b.indicationTerms ?? [], | |
| 463 | + tumorAgnostic: b.tumorAgnostic === true, | |
| 464 | + ncit: { code: b.ncitCode, name: b.ncitName, conceptKind: b.ncitConceptKind }, | |
| 465 | + verification: NCIT_VERIFICATION, | |
| 466 | + }; | |
| 467 | + if (b.scoring) m.scoring = b.scoring; | |
| 468 | + if (b.notes) m.notes = b.notes; | |
| 469 | + return m; | |
| 470 | +} | |
| 471 | + | |
| 472 | +/** Every HGNC symbol the seed needs resolved (anchor genes + multi-gene lists), unique. */ | |
| 473 | +export function seedGeneSymbols(seed: BiomarkerSeed[] = BIOMARKER_SEED): string[] { | |
| 474 | + const out = new Set<string>(); | |
| 475 | + for (const b of seed) { | |
| 476 | + if (b.geneSymbol) out.add(b.geneSymbol); | |
| 477 | + for (const g of b.genes ?? []) out.add(g); | |
| 478 | + } | |
| 479 | + return [...out].sort(); | |
| 480 | +} | |
modified
packages/database/src/seed.ts
+46 −5
@@ -2,15 +2,54 @@ import { fileURLToPath } from 'node:url'; | ||
| 2 | 2 | import path from 'node:path'; |
| 3 | 3 | import { eq, sql } from 'drizzle-orm'; |
| 4 | 4 | import { getDb, closeDb } from './client.js'; |
| 5 | −import { geographies, metricDefinitions } from './schema/index.js'; | |
| 5 | +import { biomarkers, geographies, metricDefinitions } from './schema/index.js'; | |
| 6 | 6 | import { mintId } from './ids.js'; |
| 7 | 7 | import { METRIC_CATALOG } from './seed-data/metrics.js'; |
| 8 | 8 | import { GEOGRAPHY_SEED } from './seed-data/geographies.js'; |
| 9 | +import { BIOMARKER_SEED, buildMeasurement, seedGeneSymbols } from './seed-data/biomarkers.js'; | |
| 10 | +import type { Database } from './client.js'; | |
| 9 | 11 | |
| 10 | 12 | /** |
| 11 | − * Seeds only system data (CLAUDE.md §358): metric definitions and canonical geographies. | |
| 12 | − * Scientific data is never seeded — it comes from connectors. Sources are synced from connector | |
| 13 | − * manifests by `pnpm cix sources:sync` (lives in the root CLI to avoid a package cycle). | |
| 13 | + * Canonical biomarkers (SPEC §17, §52, §121): curated metadata only — identity, verified NCIt code, | |
| 14 | + * anchor gene, aliases, assay conventions. Every scientific link is derived at query time. | |
| 15 | + * Idempotent by slug; `CI-BIO` ids are minted once and never reassigned. Anchor genes are resolved | |
| 16 | + * against `genes` by HGNC symbol; an unresolved symbol is reported and left null, never invented. | |
| 17 | + */ | |
| 18 | +export async function seedBiomarkers(db: Database): Promise<{ inserted: number; updated: number; total: number; unresolvedGenes: string[] }> { | |
| 19 | + const symbols = seedGeneSymbols(); | |
| 20 | + const rows = symbols.length ? await db.execute<{ id: string; symbol: string }>(sql`SELECT id, symbol FROM genes WHERE symbol = ANY(${sql.param(symbols)}::text[])`) : []; | |
| 21 | + const geneId = new Map<string, string>(); | |
| 22 | + for (const r of rows) geneId.set(r.symbol, r.id); | |
| 23 | + const unresolvedGenes = symbols.filter((s) => !geneId.has(s)); | |
| 24 | + let inserted = 0; | |
| 25 | + let updated = 0; | |
| 26 | + for (const b of BIOMARKER_SEED) { | |
| 27 | + const values = { | |
| 28 | + name: b.name, | |
| 29 | + kind: b.kind, | |
| 30 | + geneId: b.geneSymbol ? geneId.get(b.geneSymbol) ?? null : null, | |
| 31 | + ncitCode: b.ncitCode, | |
| 32 | + description: b.description, | |
| 33 | + measurement: buildMeasurement(b) as unknown as Record<string, unknown>, | |
| 34 | + }; | |
| 35 | + const [existing] = await db.select({ id: biomarkers.id }).from(biomarkers).where(eq(biomarkers.slug, b.slug)).limit(1); | |
| 36 | + if (existing) { | |
| 37 | + await db.update(biomarkers).set({ ...values, updatedAt: new Date() }).where(eq(biomarkers.id, existing.id)); | |
| 38 | + updated++; | |
| 39 | + } else { | |
| 40 | + await db.insert(biomarkers).values({ id: await mintId(db, 'BIO'), slug: b.slug, ...values }); | |
| 41 | + inserted++; | |
| 42 | + } | |
| 43 | + } | |
| 44 | + const [{ n }] = (await db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM biomarkers`)) as unknown as [{ n: string }]; | |
| 45 | + return { inserted, updated, total: Number(n), unresolvedGenes }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * Seeds only system data (CLAUDE.md §358): metric definitions, canonical geographies and the | |
| 50 | + * curated biomarker catalogue (metadata, not scientific values). Scientific data is never seeded — | |
| 51 | + * it comes from connectors. Sources are synced from connector manifests by `pnpm cix sources:sync` | |
| 52 | + * (lives in the root CLI to avoid a package cycle). | |
| 14 | 53 | */ |
| 15 | 54 | export async function seed(): Promise<void> { |
| 16 | 55 | const db = getDb({ max: 2 }); |
@@ -34,7 +73,9 @@ export async function seed(): Promise<void> { | ||
| 34 | 73 | } |
| 35 | 74 | } |
| 36 | 75 | const [{ n }] = (await db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM metric_definitions`)) as unknown as [{ n: string }]; |
| 37 | − console.log(`[seed] metrics=${n} geographies=${GEOGRAPHY_SEED.length}`); | |
| 76 | + const bio = await seedBiomarkers(db); | |
| 77 | + console.log(`[seed] metrics=${n} geographies=${GEOGRAPHY_SEED.length} biomarkers=${bio.total} (inserted=${bio.inserted} updated=${bio.updated})`); | |
| 78 | + if (bio.unresolvedGenes.length) console.warn(`[seed] biomarkers: unresolved HGNC symbols (gene_id left null): ${bio.unresolvedGenes.join(', ')}`); | |
| 38 | 79 | } |
| 39 | 80 | |
| 40 | 81 | const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); |
added
packages/database/test/biomarkers.test.ts
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { BIOMARKER_KINDS, BIOMARKER_KIND_LABEL, BIOMARKER_SEED, BIOMARKER_SOURCES, NCIT_VERIFICATION, buildMeasurement, seedGeneSymbols } from '../src/seed-data/biomarkers.js'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Pure integrity checks on the curated biomarker catalogue (no database). The NCIt codes themselves | |
| 6 | + * were verified live against the EVS REST API (see the file header); these tests guard the shape | |
| 7 | + * and the editorial rules (CLAUDE.md §8: curated metadata only, verified codes, no advice). | |
| 8 | + */ | |
| 9 | +describe('BIOMARKER_SEED', () => { | |
| 10 | + it('covers the canonical catalogue (≥ 45 entries) with unique slugs and NCIt codes', () => { | |
| 11 | + expect(BIOMARKER_SEED.length).toBeGreaterThanOrEqual(45); | |
| 12 | + const slugs = BIOMARKER_SEED.map((b) => b.slug); | |
| 13 | + expect(new Set(slugs).size).toBe(slugs.length); | |
| 14 | + const codes = BIOMARKER_SEED.map((b) => b.ncitCode); | |
| 15 | + expect(new Set(codes).size).toBe(codes.length); | |
| 16 | + }); | |
| 17 | + | |
| 18 | + it('uses only the frozen kind vocabulary and every kind has a label', () => { | |
| 19 | + for (const b of BIOMARKER_SEED) expect(BIOMARKER_KINDS, b.slug).toContain(b.kind); | |
| 20 | + for (const k of BIOMARKER_KINDS) expect(BIOMARKER_KIND_LABEL[k]).toBeTruthy(); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it('carries a well-formed, biomarker-level NCIt concept on every row', () => { | |
| 24 | + for (const b of BIOMARKER_SEED) { | |
| 25 | + expect(b.ncitCode, b.slug).toMatch(/^C\d{3,7}$/); | |
| 26 | + expect(b.ncitName.length, b.slug).toBeGreaterThan(3); | |
| 27 | + expect(b.ncitConceptKind).toBe('biomarker'); | |
| 28 | + // Never the bare gene concept ("EGFR Gene") — a biomarker/alteration concept is required | |
| 29 | + // ("BCR/ABL1 Fusion Gene" is a fusion concept and passes). | |
| 30 | + expect(b.ncitName, b.slug).not.toMatch(/^[A-Z0-9]+ Gene$/); | |
| 31 | + } | |
| 32 | + expect(NCIT_VERIFICATION.ncitVersion).toMatch(/^\d{2}\.\d{2}[a-z]$/); | |
| 33 | + expect(NCIT_VERIFICATION.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it('is slug-safe and names anchor genes or an explicit gene list (or documents why not)', () => { | |
| 37 | + for (const b of BIOMARKER_SEED) { | |
| 38 | + expect(b.slug, b.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); | |
| 39 | + if (b.geneSymbol) expect(b.geneSymbol).toMatch(/^[A-Z0-9-]+$/); | |
| 40 | + for (const g of b.genes ?? []) expect(g, b.slug).toMatch(/^[A-Z0-9-]+$/); | |
| 41 | + if (!b.geneSymbol && !(b.genes?.length)) { | |
| 42 | + // Non-gene markers must say so in their notes (rendered as the empty-state reason). | |
| 43 | + expect(b.notes, `${b.slug} has no gene and no note`).toBeTruthy(); | |
| 44 | + expect(['tmb', 'hrd', 'ctdna', 'msi', 'signature', 'other', 'methylation', 'gene_mutation']).toContain(b.kind); | |
| 45 | + } | |
| 46 | + } | |
| 47 | + }); | |
| 48 | + | |
| 49 | + it('describes neutrally: one sentence, no dosing, no individual advice', () => { | |
| 50 | + for (const b of BIOMARKER_SEED) { | |
| 51 | + expect(b.description.trim().endsWith('.'), b.slug).toBe(true); | |
| 52 | + expect(b.description, b.slug).not.toMatch(/\b\d+\s?mg\b|\bdose\b|\bdosing\b|\byou should\b|\bpatients should\b|\brecommended\b/i); | |
| 53 | + expect(b.description, b.slug).toMatch(/diagnostic|predictive|prognostic|target|eligibility|screening|subtyping|specimen|marker/i); | |
| 54 | + } | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it('cites only verified authority pages and keeps aliases unique', () => { | |
| 58 | + for (const b of BIOMARKER_SEED) { | |
| 59 | + expect(b.sources.length, b.slug).toBeGreaterThan(0); | |
| 60 | + for (const k of b.sources) expect(BIOMARKER_SOURCES[k].url).toMatch(/^https:\/\/(www\.)?(fda\.gov|cancer\.gov)\//); | |
| 61 | + expect(new Set(b.aliases).size, b.slug).toBe(b.aliases.length); | |
| 62 | + expect(b.assays.length, b.slug).toBeGreaterThan(0); | |
| 63 | + } | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('flags tumor-agnostic only for the markers with FDA tissue-agnostic indications', () => { | |
| 67 | + const flagged = BIOMARKER_SEED.filter((b) => b.tumorAgnostic).map((b) => b.slug).sort(); | |
| 68 | + expect(flagged).toEqual(['braf-v600e', 'dmmr', 'her2', 'msi-h', 'ntrk-fusion', 'ret-fusion', 'tmb-h']); | |
| 69 | + // Each flagged marker needs indication terms so the UI can show the actual approval rows. | |
| 70 | + for (const b of BIOMARKER_SEED.filter((x) => x.tumorAgnostic)) expect(b.indicationTerms?.length, b.slug).toBeGreaterThan(0); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it('states the MSI-H / dMMR relationship on both entries', () => { | |
| 74 | + const msi = BIOMARKER_SEED.find((b) => b.slug === 'msi-h')!; | |
| 75 | + const dmmr = BIOMARKER_SEED.find((b) => b.slug === 'dmmr')!; | |
| 76 | + expect(msi.notes).toMatch(/dMMR/); | |
| 77 | + expect(dmmr.notes).toMatch(/MSI-H/); | |
| 78 | + expect(msi.genes).toEqual(['MLH1', 'MSH2', 'MSH6', 'PMS2']); | |
| 79 | + expect(dmmr.genes).toEqual(msi.genes); | |
| 80 | + }); | |
| 81 | +}); | |
| 82 | + | |
| 83 | +describe('buildMeasurement', () => { | |
| 84 | + it('produces the jsonb shape the queries read (aliases, genes, variantSlugs, terms, verification)', () => { | |
| 85 | + const her2 = BIOMARKER_SEED.find((b) => b.slug === 'her2')!; | |
| 86 | + const m = buildMeasurement(her2); | |
| 87 | + expect(m.aliases).toContain('ERBB2'); | |
| 88 | + expect(m.tumorAgnostic).toBe(true); | |
| 89 | + expect(m.ncit).toEqual({ code: 'C68748', name: 'HER2/Neu Positive', conceptKind: 'biomarker' }); | |
| 90 | + expect(m.verification).toBe(NCIT_VERIFICATION); | |
| 91 | + expect(m.sources[0]).toHaveProperty('url'); | |
| 92 | + expect(m.genes).toEqual([]); | |
| 93 | + expect(m.variantSlugs).toContain('erbb2-amplification'); | |
| 94 | + expect(m.scoring).toMatch(/3\+/); | |
| 95 | + }); | |
| 96 | + it('omits absent optional fields instead of writing null', () => { | |
| 97 | + const cd19 = BIOMARKER_SEED.find((b) => b.slug === 'cd19')!; | |
| 98 | + const m = buildMeasurement(cd19); | |
| 99 | + expect('scoring' in m).toBe(false); | |
| 100 | + expect(m.tumorAgnostic).toBe(false); | |
| 101 | + }); | |
| 102 | +}); | |
| 103 | + | |
| 104 | +describe('seedGeneSymbols', () => { | |
| 105 | + it('returns every anchor and multi-gene symbol once, sorted', () => { | |
| 106 | + const s = seedGeneSymbols(); | |
| 107 | + expect(s).toEqual([...new Set(s)].sort()); | |
| 108 | + for (const g of ['ERBB2', 'EGFR', 'MLH1', 'PMS2', 'NTRK3', 'BCR', 'ABL1', 'TP53']) expect(s).toContain(g); | |
| 109 | + }); | |
| 110 | +}); | |
| 111 | ||