SPB Git

spb/cancerindex

Public
5.2 KB · 107 lines typescript
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
Files · f2c2adc075a3468bef3da16df60342d05cf7c062
Menu

Theme