SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
8.8 KB · 136 lines typescript
Raw Blame History
1import { sql } from 'drizzle-orm';2import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';3import { z } from 'zod';4import { RESEARCH_GAP_FORMULA_VERSION, RESEARCH_GAP_THRESHOLDS } from '@cancerindex/ranking';5import { NotFound } from '../lib/errors.js';6import { AnyRecord, AnyList, camelRows, num, ok, respond } from '../lib/respond.js';78const scopeQuery = z.object({9  geography: z.string().default('USA').describe('ISO3 of the burden scope (WORLD once licensed global estimates exist)'),10  year: z.coerce.number().int().optional().describe('Burden year; omitted = latest year available for the geography and sex'),11  sex: z.enum(['all', 'male', 'female']).default('all'),12  source: z.string().optional().describe('Burden source (slug or CI-SOURCE id); omitted = the source with the most component rows in the scope'),13});1415type ScopeRow = {16  geography: string;17  year: number;18  sex: string;19  burden_source_id: string;20  source_slug: string;21  source_name: string;22  n_total: number;23  n_eligible: number;24  formula_version: string;25  computed_at: Date | string;26};2728const SCOPES_SQL = sql`29  SELECT r.geography, r.year, r.sex, r.burden_source_id, s.slug AS source_slug, s.name AS source_name,30         count(*)::int AS n_total, count(*) FILTER (WHERE r.eligible)::int AS n_eligible,31         max(r.formula_version) AS formula_version, max(r.updated_at) AS computed_at32  FROM research_gap_components r JOIN sources s ON s.id = r.burden_source_id33  GROUP BY r.geography, r.year, r.sex, r.burden_source_id, s.slug, s.name`;3435/**36 * Research Gap routes (SPEC §34, §113): `GET /research-gap` → components (deaths, trials,37 * publications, shares, log-ratios, per-1,000 values) per top-level cancer for one burden scope;38 * `GET /research-gap/scopes` → the scopes with computed components. Everything here is a39 * computed metric (formula version on every row); it is a signal about burden vs registered40 * research activity, not a judgement of research quality or funding.41 */42export const researchGapRoutes: FastifyPluginAsyncZod = async (app) => {43  app.get('/research-gap/scopes', { schema: { tags: ['intelligence'], summary: 'Burden scopes (geography, year, sex, source) with research-gap components and their eligible counts', response: ok(AnyList) } }, async () => {44    const rows = await app.db.execute<ScopeRow>(sql`${SCOPES_SQL} ORDER BY r.geography, r.year DESC, r.sex, s.slug`);45    const data = camelRows(rows).map((r) => ({ ...r, scopeKey: `geo=${r.geography as string}|sex=${r.sex as string}|age=all|year=${r.year as number}|level=top` }));46    return respond(app, data, rows.map((r) => r.burden_source_id));47  });4849  app.get('/research-gap', { schema: { tags: ['intelligence'], summary: 'Research-gap components for one burden scope: deaths, active trials, publications (5 y), shares over the eligible set, log2 gap ratios and per-1,000-deaths intensities per top-level cancer', querystring: scopeQuery, response: ok(AnyRecord) } }, async (req) => {50    const q = req.query;51    const geography = q.geography.toUpperCase();52    const scopes = await app.db.execute<ScopeRow>(sql`${SCOPES_SQL} HAVING upper(r.geography) = ${geography} AND r.sex = ${q.sex}53      ${q.year !== undefined ? sql`AND r.year = ${q.year}` : sql``}54      ${q.source ? sql`AND (s.slug = ${q.source} OR r.burden_source_id = ${q.source})` : sql``}55      ORDER BY r.year DESC, count(*) DESC, s.slug LIMIT 1`);56    const scope = scopes[0];57    if (!scope) {58      const any = await app.db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM research_gap_components`);59      if (num(any[0]?.n) === 0) return respond(app, { scope: null, rows: [], status: 'not_available', message: 'Research-gap components have not been computed yet (run `pnpm cix intel`).' }, []);60      throw new NotFound('research-gap scope', `geography=${geography} sex=${q.sex} year=${q.year ?? 'latest'} source=${q.source ?? 'any'}`);61    }62    const scopeKey = `geo=${scope.geography}|sex=${scope.sex}|age=all|year=${scope.year}|level=top`;63    const rows = await app.db.execute<Record<string, unknown>>(sql`64      SELECT r.id AS component_id, r.cancer_id, c.slug, c.canonical_name AS name, c.short_name, c.hematologic,65             r.deaths, r.incidence, r.active_trials, r.phase3_trials, r.publications_5y, r.approved_drugs,66             r.death_share, r.trial_share, r.publication_share, r.trial_gap_ratio, r.research_gap_ratio,67             r.trials_per_1000_deaths, r.publications_per_1000_deaths, r.eligible, r.ineligible_reason,68             r.formula_version, r.inputs, r.updated_at AS computed_at,69             tg.rank AS trial_gap_rank, rg.rank AS research_gap_rank70      FROM research_gap_components r JOIN cancers c ON c.id = r.cancer_id71      LEFT JOIN rankings tg ON tg.cancer_id = r.cancer_id AND tg.metric_slug = 'trial_gap_ratio' AND tg.scope_key = ${scopeKey}72        AND tg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'trial_gap_ratio' AND scope_key = ${scopeKey} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1)73      LEFT JOIN rankings rg ON rg.cancer_id = r.cancer_id AND rg.metric_slug = 'research_gap_ratio' AND rg.scope_key = ${scopeKey}74        AND rg.snapshot_id = (SELECT id FROM ranking_snapshots WHERE metric_slug = 'research_gap_ratio' AND scope_key = ${scopeKey} AND is_current AND ${scope.burden_source_id} = ANY(source_ids) LIMIT 1)75      WHERE r.geography = ${scope.geography} AND r.year = ${scope.year} AND r.sex = ${scope.sex} AND r.burden_source_id = ${scope.burden_source_id}76      ORDER BY r.eligible DESC, r.research_gap_ratio DESC NULLS LAST, c.canonical_name`);77    const eligible = rows.filter((r) => r.eligible === true);78    const sums = {79      deaths: eligible.reduce((s, r) => s + num(r.deaths), 0),80      activeTrials: eligible.reduce((s, r) => s + num(r.active_trials), 0),81      publications5y: eligible.reduce((s, r) => s + num(r.publications_5y), 0),82      eligible: eligible.length,83    };84    const metrics = await app.db.execute<{ slug: string; name: string; formula: string; formula_version: string; unit: string }>(sql`85      SELECT slug, name, formula, formula_version, unit FROM metric_definitions WHERE slug IN ('trial_gap_ratio','research_gap_ratio','trials_per_1000_deaths','publications_per_1000_deaths') ORDER BY slug`);86    const data = {87      scope: {88        geography: scope.geography,89        year: Number(scope.year),90        sex: scope.sex,91        ageGroup: 'all',92        entityLevel: 'top',93        scopeKey,94        burdenSource: { id: scope.burden_source_id, slug: scope.source_slug, name: scope.source_name },95        activitySources: { activeTrials: 'clinicaltrials', publications5y: 'pubmed' },96        formulaVersion: scope.formula_version ?? RESEARCH_GAP_FORMULA_VERSION,97        computedAt: scope.computed_at,98        totalEntities: Number(scope.n_total),99        eligibleEntities: Number(scope.n_eligible),100        sums,101      },102      thresholds: { minDeaths: RESEARCH_GAP_THRESHOLDS.minDeaths, minActivity: RESEARCH_GAP_THRESHOLDS.minActivity, highConfidenceDeaths: RESEARCH_GAP_THRESHOLDS.highConfidenceDeaths },103      metrics: camelRows(metrics).map((m) => ({ ...m, ranking: `/v1/rankings?metric=${m.slug as string}&geography=${scope.geography}&sex=${scope.sex}&year=${scope.year}&level=top` })),104      claim: 'computed_metric',105      notes: [106        'Shares are computed over the eligible top-level cancers of the scope (deaths ≥ minDeaths); a log2 ratio of +1 means the cancer has twice the share of deaths that its share of trials (or publications) would suggest, −1 half.',107        'Trial counts aggregate a cancer and its NCIt descendants; literature counts are query-based per entity and not aggregated over descendants. Burden depends on the epidemiology source of the scope. A gap is a quantitative signal, not an accusation.',108      ],109      rows: rows.map((r) => ({110        componentId: r.component_id,111        cancer: { id: r.cancer_id, slug: r.slug, name: r.name, shortName: r.short_name, hematologic: r.hematologic },112        deaths: r.deaths,113        incidence: r.incidence,114        activeTrials: r.active_trials,115        phase3Trials: r.phase3_trials,116        publications5y: r.publications_5y,117        approvedDrugs: r.approved_drugs,118        deathShare: r.death_share,119        trialShare: r.trial_share,120        publicationShare: r.publication_share,121        trialGapRatio: r.trial_gap_ratio,122        researchGapRatio: r.research_gap_ratio,123        trialsPer1000Deaths: r.trials_per_1000_deaths,124        publicationsPer1000Deaths: r.publications_per_1000_deaths,125        ranks: { trialGapRatio: r.trial_gap_rank, researchGapRatio: r.research_gap_rank },126        eligible: r.eligible,127        ineligibleReason: r.ineligible_reason,128        formulaVersion: r.formula_version,129        inputs: r.inputs,130        computedAt: r.computed_at,131      })),132    };133    return respond(app, data, [scope.burden_source_id, 'clinicaltrials', 'pubmed']);134  });135};136