spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { and, eq, inArray, sql } from 'drizzle-orm';2import { normalizeLabel, uninvertMeshTerm, type MatchType } from '@cancerindex/shared';3import { type Database, cancerAliases, cancerCodes, cancerHierarchy, cancers } from '@cancerindex/database';4import { baseDiseaseLabel } from './qualifiers.js';56export interface CancerMatch {7 cancerId: string;8 matchType: MatchType;9 confidence: number;10 via: string; // human-readable explanation11}1213/**14 * Cancer label reconciliation (CLAUDE.md §69): identifiers → curated aliases → normalized strings.15 * Never uses embeddings or an LLM to merge. Returns null (→ unresolved queue) when unsure.16 */17export class CancerResolver {18 private aliasCache = new Map<string, { cancerId: string; aliasType: string }[]>();19 private codeCache = new Map<string, { cancerId: string; matchType: MatchType }>();2021 constructor(private readonly db: Database) {}2223 /** Preload alias + code lookups in memory for bulk reconciliation. */24 async warm(): Promise<void> {25 const rows = await this.db26 .select({ cancerId: cancerAliases.cancerId, normalized: cancerAliases.normalized, aliasType: cancerAliases.aliasType })27 .from(cancerAliases)28 .innerJoin(cancers, eq(cancers.id, cancerAliases.cancerId))29 .where(eq(cancers.status, 'active'));30 this.aliasCache.clear();31 for (const r of rows) {32 const arr = this.aliasCache.get(r.normalized) ?? [];33 arr.push({ cancerId: r.cancerId, aliasType: r.aliasType });34 this.aliasCache.set(r.normalized, arr);35 }36 // Codes recorded through an alias or a lineage decision are still identifiers of that entity; the37 // stored match type is carried through so callers can judge confidence (exact codes win on conflict).38 const codes = await this.db.select({ cancerId: cancerCodes.cancerId, system: cancerCodes.system, code: cancerCodes.code, matchType: cancerCodes.matchType }).from(cancerCodes).where(inArray(cancerCodes.matchType, ['EXACT_IDENTIFIER', 'CURATED_EXACT', 'ONTOLOGY_EXACT', 'ALIAS', 'CURATED_BROADER']));39 this.codeCache.clear();40 const rank: Record<string, number> = { EXACT_IDENTIFIER: 5, CURATED_EXACT: 4, ONTOLOGY_EXACT: 3, ALIAS: 2, CURATED_BROADER: 1 };41 for (const c of codes) {42 const key = `${c.system}:${c.code}`;43 const cur = this.codeCache.get(key);44 if (!cur || (rank[c.matchType] ?? 0) > (rank[cur.matchType] ?? 0)) this.codeCache.set(key, { cancerId: c.cancerId, matchType: c.matchType as MatchType });45 }46 const edges = await this.db.select({ parentId: cancerHierarchy.parentId, childId: cancerHierarchy.childId }).from(cancerHierarchy);47 this.parents.clear();48 for (const e of edges) {49 const arr = this.parents.get(e.childId) ?? [];50 arr.push(e.parentId);51 this.parents.set(e.childId, arr);52 }53 }5455 private parents = new Map<string, string[]>();5657 /** True when `ancestor` is reachable from `id` by walking parent edges (any hierarchy type), bounded depth. */58 private isAncestor(ancestor: string, id: string): boolean {59 const seen = new Set<string>();60 let frontier = [id];61 for (let depth = 0; depth < 14 && frontier.length; depth++) {62 const next: string[] = [];63 for (const n of frontier) {64 for (const p of this.parents.get(n) ?? []) {65 if (p === ancestor) return true;66 if (!seen.has(p)) {67 seen.add(p);68 next.push(p);69 }70 }71 }72 frontier = next;73 }74 return false;75 }7677 /**78 * Ambiguous alias shared by several concepts: if one candidate is an ancestor of every other79 * candidate (e.g. "Breast Cancer" → Malignant Breast Neoplasm ⊃ Breast Carcinoma ⊃ Childhood80 * Breast Carcinoma), the broadest concept is the safe reading of a registry label.81 */82 private broadestOfLineage(ids: string[]): string | null {83 for (const cand of ids) if (ids.every((o) => o === cand || this.isAncestor(cand, o))) return cand;84 return null;85 }8687 get warmed(): boolean {88 return this.aliasCache.size > 0;89 }9091 /** Resolve by external identifier (NCIt code, DOID, OncoTree code, UMLS CUI, MeSH UI…). */92 byCode(system: string, code: string): CancerMatch | null {93 const hit = this.codeCache.get(`${system}:${code}`);94 if (!hit) return null;95 const confidence = hit.matchType === 'ALIAS' ? 0.9 : hit.matchType === 'CURATED_BROADER' ? 0.8 : 1;96 return { cancerId: hit.cancerId, matchType: hit.matchType, confidence, via: `${system}:${code}${hit.matchType === 'EXACT_IDENTIFIER' ? '' : ` (code recorded as ${hit.matchType})`}` };97 }9899 /** Resolve a free-text label. Ambiguous alias hits (≠ 1 distinct cancer) are returned as null. */100 byLabel(label: string, opts: { allowMeshInversion?: boolean } = {}): CancerMatch | null {101 const candidates = [label];102 if (opts.allowMeshInversion && label.includes(',')) candidates.push(uninvertMeshTerm(label));103 // Common registry phrasings: "Cancer of the X" → "X cancer"; "Carcinoma of X" → "X carcinoma"104 const m = /^(cancer|carcinoma|neoplasm|tumou?r|sarcoma|lymphoma|leukemia|adenocarcinoma|melanoma) of (the )?(.+)$/i.exec(label);105 if (m) candidates.push(`${m[3]} ${m[1]}`);106 // Qualified states ("Stage IV Pancreatic Cancer", "Metastatic Breast Cancer") → base disease (CLAUDE.md §217).107 const base = baseDiseaseLabel(label);108 const qualified = base !== label.trim() && base.length > 0;109 if (qualified) candidates.push(base);110 for (const c of candidates) {111 const isBase = qualified && c === base;112 const norm = normalizeLabel(c);113 if (!norm) continue;114 const hits = this.aliasCache.get(norm);115 if (!hits || hits.length === 0) continue;116 const distinct = [...new Set(hits.map((h) => h.cancerId))];117 if (distinct.length === 1) {118 const preferred = hits.some((h) => h.aliasType === 'preferred');119 if (isBase) return { cancerId: distinct[0]!, matchType: 'CURATED_BROADER', confidence: 0.78, via: `qualified state "${label}" → base disease "${c}"` };120 return { cancerId: distinct[0]!, matchType: preferred ? 'ONTOLOGY_EXACT' : 'ALIAS', confidence: preferred ? 0.98 : 0.9, via: `alias "${c}"` };121 }122 // Ambiguous: prefer the cancer where this string is the preferred name…123 const pref = hits.filter((h) => h.aliasType === 'preferred');124 const prefDistinct = [...new Set(pref.map((h) => h.cancerId))];125 if (prefDistinct.length === 1) return { cancerId: prefDistinct[0]!, matchType: isBase ? 'CURATED_BROADER' : 'ONTOLOGY_EXACT', confidence: isBase ? 0.78 : 0.9, via: `preferred name "${c}" (alias also used by ${distinct.length - 1} other concept(s))${isBase ? ` via base of "${label}"` : ''}` };126 // …then a curated display name (top-level set, CLAUDE.md §247)…127 const disp = [...new Set(hits.filter((h) => h.aliasType === 'display').map((h) => h.cancerId))];128 if (disp.length === 1) return { cancerId: disp[0]!, matchType: isBase ? 'CURATED_BROADER' : 'CURATED_EXACT', confidence: isBase ? 0.78 : 0.92, via: `curated display name "${c}"${isBase ? ` via base of "${label}"` : ''}` };129 // …then the broadest concept when all candidates sit on one lineage.130 const broad = this.broadestOfLineage(distinct);131 if (broad) return { cancerId: broad, matchType: 'CURATED_BROADER', confidence: 0.8, via: `alias "${c}" shared by ${distinct.length} concepts on one lineage → broadest` };132 return null;133 }134 return null;135 }136137 /** Try identifiers first, then labels (CLAUDE.md §143, §221). */138 resolve(input: { codes?: Array<{ system: string; code: string }>; labels?: string[]; allowMeshInversion?: boolean }): CancerMatch | null {139 for (const c of input.codes ?? []) {140 const hit = this.byCode(c.system, c.code);141 if (hit) return hit;142 }143 for (const l of input.labels ?? []) {144 const hit = this.byLabel(l, { allowMeshInversion: input.allowMeshInversion });145 if (hit) return hit;146 }147 return null;148 }149150 /** Fuzzy suggestion for the curation queue only (never auto-accepted). */151 async suggest(label: string): Promise<{ cancerId: string; name: string; score: number } | null> {152 const norm = normalizeLabel(label);153 if (norm.length < 4) return null;154 const rows = await this.db.execute<{ cancer_id: string; canonical_name: string; score: number }>(sql`155 SELECT a.cancer_id, c.canonical_name, similarity(a.normalized, ${norm}) AS score156 FROM cancer_aliases a JOIN cancers c ON c.id = a.cancer_id157 WHERE c.status = 'active' AND a.normalized % ${norm}158 ORDER BY score DESC LIMIT 1`);159 const r = rows[0];160 return r && r.score >= 0.6 ? { cancerId: r.cancer_id, name: r.canonical_name, score: Number(r.score) } : null;161 }162}163164export async function loadCancerIdBySlug(db: Database, slug: string): Promise<string | null> {165 const [row] = await db.select({ id: cancers.id }).from(cancers).where(and(eq(cancers.slug, slug), eq(cancers.status, 'active'))).limit(1);166 return row?.id ?? null;167}168