import { sql } from 'drizzle-orm'; import type { Database } from '@cancerindex/database'; import { normalizeLabel } from '@cancerindex/shared'; export type SearchType = 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'publication'; /** Match tier — lower is better (CLAUDE.md §68, §312: exact > alias > prefix > fuzzy). */ export const TIER = { exact: 0, alias: 1, prefix: 2, fuzzy: 3 } as const; export type Tier = (typeof TIER)[keyof typeof TIER]; export interface SearchHit { type: SearchType; id: string; slug: string; name: string; subtitle: string | null; tier: Tier; /** Similarity or secondary signal used inside a tier (higher is better). */ score: number; } export interface SearchResult { type: SearchType; id: string; slug: string; name: string; subtitle: string | null; score: number; match: 'exact' | 'alias' | 'prefix' | 'fuzzy'; } const TIER_NAME: Record = { 0: 'exact', 1: 'alias', 2: 'prefix', 3: 'fuzzy' }; /** Entity ordering inside a tier: cancers first (the index is cancer-centric), then genes, drugs, variants, trials, publications. */ const TYPE_ORDER: Record = { cancer: 0, gene: 1, drug: 2, variant: 3, trial: 4, publication: 5 }; /** * Deterministic ordering (pure, unit-tested): tier asc, score desc, type order, shorter name first, * then name, then id. One result per (type, id). Returns the top `limit` results. */ export function rankSearchHits(hits: SearchHit[], limit = 20): SearchResult[] { const best = new Map(); for (const h of hits) { const key = `${h.type}:${h.id}`; const prev = best.get(key); if (!prev || h.tier < prev.tier || (h.tier === prev.tier && h.score > prev.score)) best.set(key, h); } return [...best.values()] .sort((a, b) => a.tier - b.tier || b.score - a.score || TYPE_ORDER[a.type] - TYPE_ORDER[b.type] || a.name.length - b.name.length || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) .slice(0, limit) .map((h) => ({ type: h.type, id: h.id, slug: h.slug, name: h.name, subtitle: h.subtitle, score: Math.round((4 - h.tier + h.score) * 1000) / 1000, match: TIER_NAME[h.tier] })); } /** Cross-entity search over aliases, symbols, slugs and registry ids. */ export async function searchAll(db: Database, q: string, types?: SearchType[], limit = 20): Promise { const raw = q.trim(); const norm = normalizeLabel(raw); if (!norm) return []; const want = new Set(types && types.length ? types : ['cancer', 'gene', 'variant', 'drug', 'trial', 'publication']); const hits: SearchHit[] = []; const per = Math.max(limit, 20); const fuzzyOk = norm.length >= 4; const tasks: Array> = []; if (want.has('cancer')) { tasks.push( db .execute<{ id: string; slug: string; name: string; subtitle: string | null; tier: number; score: number }>(sql` SELECT c.id, c.slug, c.canonical_name AS name, c.primary_ncit_code AS subtitle, min(CASE WHEN a.normalized = ${norm} AND a.alias_type = 'preferred' THEN 0 WHEN a.normalized = ${norm} THEN 1 WHEN a.normalized LIKE ${norm + '%'} THEN 2 ELSE 3 END) AS tier, max(similarity(a.normalized, ${norm})) AS score FROM cancers c JOIN cancer_aliases a ON a.cancer_id = c.id WHERE c.status = 'active' AND (a.normalized = ${norm} OR a.normalized LIKE ${norm + '%'} ${fuzzyOk ? sql`OR a.normalized % ${norm}` : sql``}) GROUP BY c.id, c.slug, c.canonical_name, c.primary_ncit_code ORDER BY tier, score DESC, c.canonical_name LIMIT ${per}`) .then((rows) => { for (const r of rows) hits.push({ type: 'cancer', id: r.id, slug: r.slug, name: r.name, subtitle: r.subtitle ? `NCIt ${r.subtitle}` : null, tier: Number(r.tier) as Tier, score: Number(r.score) }); }), ); } if (want.has('gene')) { const up = raw.toUpperCase(); tasks.push( db .execute<{ id: string; symbol: string; name: string | null; tier: number; score: number }>(sql` SELECT g.id, g.symbol, g.name, min(CASE WHEN upper(g.symbol) = ${up} THEN 0 WHEN upper(a.alias) = ${up} THEN 1 WHEN upper(g.symbol) LIKE ${up + '%'} OR upper(a.alias) LIKE ${up + '%'} THEN 2 ELSE 3 END) AS tier, greatest(max(similarity(upper(g.symbol), ${up})), max(similarity(upper(coalesce(a.alias, '')), ${up})), max(similarity(lower(coalesce(g.name,'')), ${norm}))) AS score FROM genes g LEFT JOIN gene_aliases a ON a.gene_id = g.id WHERE upper(g.symbol) LIKE ${up + '%'} OR upper(a.alias) LIKE ${up + '%'} ${fuzzyOk ? sql`OR lower(g.name) % ${norm}` : sql``} GROUP BY g.id, g.symbol, g.name ORDER BY tier, score DESC, g.symbol LIMIT ${per}`) .then((rows) => { for (const r of rows) hits.push({ type: 'gene', id: r.id, slug: r.symbol, name: r.symbol, subtitle: r.name, tier: Number(r.tier) as Tier, score: Number(r.score) }); }), ); } if (want.has('drug')) { tasks.push( db .execute<{ id: string; slug: string; name: string; kind: string | null; tier: number; score: number }>(sql` SELECT d.id, d.slug, d.name, d.kind, min(CASE WHEN a.normalized = ${norm} AND a.alias_type = 'generic' THEN 0 WHEN a.normalized = ${norm} THEN 1 WHEN a.normalized LIKE ${norm + '%'} THEN 2 ELSE 3 END) AS tier, max(similarity(a.normalized, ${norm})) AS score FROM drugs d JOIN drug_aliases a ON a.drug_id = d.id WHERE a.normalized = ${norm} OR a.normalized LIKE ${norm + '%'} ${fuzzyOk ? sql`OR a.normalized % ${norm}` : sql``} GROUP BY d.id, d.slug, d.name, d.kind ORDER BY tier, score DESC, d.name LIMIT ${per}`) .then((rows) => { for (const r of rows) hits.push({ type: 'drug', id: r.id, slug: r.slug, name: r.name, subtitle: r.kind, tier: Number(r.tier) as Tier, score: Number(r.score) }); }), ); } if (want.has('variant')) { const slugLike = norm.replace(/ /g, '-'); tasks.push( db .execute<{ id: string; slug: string; name: string; gene_symbol: string | null; tier: number; score: number }>(sql` SELECT v.id, v.slug, v.name, v.gene_symbol, CASE WHEN v.slug = ${slugLike} OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) = ${norm} THEN 0 WHEN lower(v.name) = ${norm} THEN 1 WHEN v.slug LIKE ${slugLike + '%'} OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) LIKE ${norm + '%'} THEN 2 ELSE 3 END AS tier, similarity(lower(coalesce(v.gene_symbol,'') || ' ' || v.name), ${norm}) AS score FROM variants v WHERE v.slug LIKE ${slugLike + '%'} OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) LIKE ${norm + '%'} OR lower(v.name) = ${norm} ${fuzzyOk ? sql`OR lower(coalesce(v.gene_symbol,'') || ' ' || v.name) % ${norm}` : sql``} ORDER BY tier, score DESC, v.name LIMIT ${per}`) .then((rows) => { for (const r of rows) hits.push({ type: 'variant', id: r.id, slug: r.slug, name: r.gene_symbol ? `${r.gene_symbol} ${r.name}` : r.name, subtitle: 'variant', tier: Number(r.tier) as Tier, score: Number(r.score) }); }), ); } if (want.has('trial')) { const up = raw.toUpperCase(); tasks.push( db .execute<{ id: string; nct_id: string; brief_title: string; overall_status: string | null; tier: number; score: number }>(sql` SELECT t.id, t.nct_id, t.brief_title, t.overall_status, CASE WHEN t.nct_id = ${up} THEN 0 WHEN upper(coalesce(t.acronym,'')) = ${up} THEN 1 WHEN t.nct_id LIKE ${up + '%'} THEN 2 ELSE 3 END AS tier, similarity(lower(t.brief_title), ${norm}) AS score FROM clinical_trials t WHERE t.nct_id LIKE ${up + '%'} OR upper(coalesce(t.acronym,'')) = ${up} ${fuzzyOk ? sql`OR lower(t.brief_title) % ${norm}` : sql``} ORDER BY tier, score DESC, t.nct_id LIMIT ${per}`) .then((rows) => { for (const r of rows) hits.push({ type: 'trial', id: r.id, slug: r.nct_id, name: r.nct_id, subtitle: r.brief_title, tier: Number(r.tier) as Tier, score: Number(r.score) }); }), ); } if (want.has('publication') && /^\d{1,9}$/.test(raw)) { tasks.push( db .execute<{ id: string; pmid: string; title: string; journal: string | null; pub_year: number | null }>(sql` SELECT id, pmid, title, journal, pub_year FROM publications WHERE pmid = ${raw} LIMIT 1`) .then((rows) => { for (const r of rows) hits.push({ type: 'publication', id: r.id, slug: r.pmid, name: r.title, subtitle: [r.journal, r.pub_year].filter(Boolean).join(' · ') || `PMID ${r.pmid}`, tier: TIER.exact, score: 1 }); }), ); } await Promise.all(tasks); return rankSearchHits(hits, limit); }