spb/cancerindex
Public
1import { and, eq, inArray, sql } from 'drizzle-orm';2import { normalizeLabel, uninvertMeshTerm, type MatchType } from '@cancerindex/shared';3import { type Database, cancerAliases, cancerCodes, cancers } from '@cancerindex/database';45export interface CancerMatch {6 cancerId: string;7 matchType: MatchType;8 confidence: number;9 via: string; // human-readable explanation10}1112/**13 * Cancer label reconciliation (CLAUDE.md §69): identifiers → curated aliases → normalized strings.14 * Never uses embeddings or an LLM to merge. Returns null (→ unresolved queue) when unsure.15 */16export class CancerResolver {17 private aliasCache = new Map<string, { cancerId: string; aliasType: string }[]>();18 private codeCache = new Map<string, string>();1920 constructor(private readonly db: Database) {}2122 /** Preload alias + code lookups in memory for bulk reconciliation. */23 async warm(): Promise<void> {24 const rows = await this.db25 .select({ cancerId: cancerAliases.cancerId, normalized: cancerAliases.normalized, aliasType: cancerAliases.aliasType })26 .from(cancerAliases)27 .innerJoin(cancers, eq(cancers.id, cancerAliases.cancerId))28 .where(eq(cancers.status, 'active'));29 this.aliasCache.clear();30 for (const r of rows) {31 const arr = this.aliasCache.get(r.normalized) ?? [];32 arr.push({ cancerId: r.cancerId, aliasType: r.aliasType });33 this.aliasCache.set(r.normalized, arr);34 }35 const codes = await this.db.select({ cancerId: cancerCodes.cancerId, system: cancerCodes.system, code: cancerCodes.code }).from(cancerCodes).where(inArray(cancerCodes.matchType, ['EXACT_IDENTIFIER', 'CURATED_EXACT', 'ONTOLOGY_EXACT']));36 this.codeCache.clear();37 for (const c of codes) this.codeCache.set(`${c.system}:${c.code}`, c.cancerId);38 }3940 get warmed(): boolean {41 return this.aliasCache.size > 0;42 }4344 /** Resolve by external identifier (NCIt code, DOID, OncoTree code, UMLS CUI, MeSH UI…). */45 byCode(system: string, code: string): CancerMatch | null {46 const id = this.codeCache.get(`${system}:${code}`);47 return id ? { cancerId: id, matchType: 'EXACT_IDENTIFIER', confidence: 1, via: `${system}:${code}` } : null;48 }4950 /** Resolve a free-text label. Ambiguous alias hits (≠ 1 distinct cancer) are returned as null. */51 byLabel(label: string, opts: { allowMeshInversion?: boolean } = {}): CancerMatch | null {52 const candidates = [label];53 if (opts.allowMeshInversion && label.includes(',')) candidates.push(uninvertMeshTerm(label));54 // Common registry phrasings: "Cancer of the X" → "X cancer"; "Carcinoma of X" → "X carcinoma"55 const m = /^(cancer|carcinoma|neoplasm|tumou?r|sarcoma|lymphoma|leukemia|adenocarcinoma|melanoma) of (the )?(.+)$/i.exec(label);56 if (m) candidates.push(`${m[3]} ${m[1]}`);57 for (const c of candidates) {58 const norm = normalizeLabel(c);59 if (!norm) continue;60 const hits = this.aliasCache.get(norm);61 if (!hits || hits.length === 0) continue;62 const distinct = [...new Set(hits.map((h) => h.cancerId))];63 if (distinct.length === 1) {64 const preferred = hits.some((h) => h.aliasType === 'preferred');65 return { cancerId: distinct[0]!, matchType: preferred ? 'ONTOLOGY_EXACT' : 'ALIAS', confidence: preferred ? 0.98 : 0.9, via: `alias "${c}"` };66 }67 // Ambiguous: prefer the cancer where this string is the preferred name.68 const pref = hits.filter((h) => h.aliasType === 'preferred');69 const prefDistinct = [...new Set(pref.map((h) => h.cancerId))];70 if (prefDistinct.length === 1) return { cancerId: prefDistinct[0]!, matchType: 'ONTOLOGY_EXACT', confidence: 0.9, via: `preferred name "${c}" (alias also used by ${distinct.length - 1} other concept(s))` };71 return null;72 }73 return null;74 }7576 /** Try identifiers first, then labels (CLAUDE.md §143, §221). */77 resolve(input: { codes?: Array<{ system: string; code: string }>; labels?: string[]; allowMeshInversion?: boolean }): CancerMatch | null {78 for (const c of input.codes ?? []) {79 const hit = this.byCode(c.system, c.code);80 if (hit) return hit;81 }82 for (const l of input.labels ?? []) {83 const hit = this.byLabel(l, { allowMeshInversion: input.allowMeshInversion });84 if (hit) return hit;85 }86 return null;87 }8889 /** Fuzzy suggestion for the curation queue only (never auto-accepted). */90 async suggest(label: string): Promise<{ cancerId: string; name: string; score: number } | null> {91 const norm = normalizeLabel(label);92 if (norm.length < 4) return null;93 const rows = await this.db.execute<{ cancer_id: string; canonical_name: string; score: number }>(sql`94 SELECT a.cancer_id, c.canonical_name, similarity(a.normalized, ${norm}) AS score95 FROM cancer_aliases a JOIN cancers c ON c.id = a.cancer_id96 WHERE c.status = 'active' AND a.normalized % ${norm}97 ORDER BY score DESC LIMIT 1`);98 const r = rows[0];99 return r && r.score >= 0.6 ? { cancerId: r.cancer_id, name: r.canonical_name, score: Number(r.score) } : null;100 }101}102103export async function loadCancerIdBySlug(db: Database, slug: string): Promise<string | null> {104 const [row] = await db.select({ id: cancers.id }).from(cancers).where(and(eq(cancers.slug, slug), eq(cancers.status, 'active'))).limit(1);105 return row?.id ?? null;106}107