""" HPO Mapper 2 — FULL SCRIPT (HF InferenceClient chat.completions + user-selectable LLM) ✅ Embedding-based HPO mapping (SQLite embeddings) ✅ LLM QC using huggingface_hub.InferenceClient.chat.completions (your example style) ✅ User-selectable QC LLM (dropdown + custom entry) ✅ Robust QC parsing (handles empty content + retries + diagnostics) ✅ HPO ontology tree (auto-download hp.json if missing) ✅ Gene enrichment via Enrichr public API (no enrichrpy dependency) ✅ Single + Bulk modes + Embedding iframe views ✅ Runtime status panel shows DB/HPO readiness and a QC test for the currently selected model HF Space requirements: pip install gradio sentence-transformers huggingface_hub requests numpy pandas Secrets: HF_TOKEN = your Hugging Face token Notes: - Some models/providers may not support chat.completions via HF Inference API for your account. If QC fails, you will see ERR: ... in the QC box and qc_raw column. """ import os import io import json import html import tempfile import zipfile import sqlite3 from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Dict, List, Any, Optional, Tuple import gradio as gr import numpy as np from numpy.linalg import norm import pandas as pd import requests from huggingface_hub import hf_hub_download, InferenceClient from sentence_transformers import SentenceTransformer # ========================= # ENV & CONSTANTS # ========================= HF_TOKEN = os.environ.get("HF_TOKEN") if not HF_TOKEN: raise ValueError("Missing HF_TOKEN. Add it as a Secret in your Space.") # Matches your example usage: no provider param, use chat.completions client = InferenceClient(api_key=HF_TOKEN) # Default QC model DEFAULT_QC_MODEL = os.getenv("QC_MODEL_NAME", "openai/gpt-oss-120b") # Your requested models (plus default). User can also type any model id. QC_MODEL_CHOICES = [ DEFAULT_QC_MODEL, "google/gemma-3-27b-it", "meta-llama/Llama-3.1-8B-Instruct", "deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3-VL-8B-Instruct", ] # Embedding model EMBEDDING_MODEL = "nomic-ai/nomic-embed-text-v1.5" embedder = SentenceTransformer(EMBEDDING_MODEL, trust_remote_code=True) # HPO DB (with synonyms + embeddings) db_filename = "hpo_genes_with_synonyms.db" db_repo = "UoS-HGIG/HPOmapper2" db_path = os.path.join(os.getcwd(), db_filename) if not os.path.exists(db_path): db_path = hf_hub_download( repo_id=db_repo, filename=db_filename, repo_type="space", token=HF_TOKEN, ) # HPO ontology JSON file (auto-download if missing) HPO_JSON_PATH = "hp.json" HPO_JSON_URL = "https://raw.githubusercontent.com/obophenotype/human-phenotype-ontology/master/hp.json" # Embedding view HTML files (these live in the Space repo itself, so they are # on local disk in the container; no need to go through huggingface.co URLs, # which are served with headers that stop browsers rendering them in iframes) EMBED_FILES = { "2D": "hp00118_branch_2d.html", "3D": "hp00118_branch_3d.html", } # Optional image VISUAL_ABSTRACT_PATH = "HPO Mapper Visual Abstract(2).jpeg" # ========================= # UTILS # ========================= def cosine_sim(a: np.ndarray, b: np.ndarray) -> float: denom = (norm(a) * norm(b)) if denom == 0: return -1.0 return float(np.dot(a, b) / denom) def safe_read_gradio_file(uploaded_file) -> Tuple[Optional[str], Optional[bytes]]: if uploaded_file is None: return None, None # Gradio 4/5: gr.File passes a filepath string (sometimes a str subclass # carrying a .name attribute). Handle that first; the old file-like and # dict payloads are kept as fallbacks for older Gradio versions. path = None if isinstance(uploaded_file, (str, os.PathLike)): path = str(uploaded_file) else: candidate = getattr(uploaded_file, "name", None) or getattr(uploaded_file, "path", None) if isinstance(candidate, str): path = candidate if path and os.path.exists(path): try: with open(path, "rb") as f: return os.path.basename(path), f.read() except Exception: pass try: filename = getattr(uploaded_file, "name", None) or getattr(uploaded_file, "orig_name", None) file_bytes = uploaded_file.read() if filename and file_bytes: return filename, file_bytes except Exception: pass try: if isinstance(uploaded_file, dict): filename = uploaded_file.get("name") data = uploaded_file.get("data") if isinstance(data, bytes): return filename, data if isinstance(data, str) and os.path.exists(data): with open(data, "rb") as f: return filename, f.read() except Exception: pass return None, None # ========================= # HPO MAPPER CORE # ========================= _emb_cache = None # (ids, names, matrix) with matrix rows L2-normalised def _load_embedding_matrix(): """Load all HPO embeddings from SQLite ONCE and keep them in memory as a normalised float32 matrix. The old code re-read and re-parsed the entire table for every input row, which made bulk runs scale as rows x DB size.""" global _emb_cache if _emb_cache is not None: return _emb_cache conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute("SELECT hpo_id, hpo_name, embedding FROM hpo_embeddings") ids, names, vecs = [], [], [] for hpo_id, hpo_name, embedding_str in cursor.fetchall(): ids.append(hpo_id) names.append(hpo_name) vecs.append(np.asarray(json.loads(embedding_str), dtype=np.float32)) conn.close() matrix = np.vstack(vecs) norms = np.linalg.norm(matrix, axis=1, keepdims=True) norms[norms == 0] = 1.0 _emb_cache = (ids, names, matrix / norms) return _emb_cache def find_best_hpo_matches_batch( pairs: List[Tuple[str, str]], threshold: float ) -> List[Optional[dict]]: """Match many (finding, region) pairs at once: batched encoding, then a single matrix multiply per chunk. Returns a list aligned with `pairs` (dict on a hit, None when the best similarity is below threshold).""" if not pairs: return [] ids, names, matrix = _load_embedding_matrix() texts = [f"{f} in {r}" if r else f for f, r in pairs] results: List[Optional[dict]] = [] chunk = 256 # bounds the size of the similarity matrix in memory for start in range(0, len(texts), chunk): batch = texts[start:start + chunk] q = np.asarray( embedder.encode(batch, batch_size=64, show_progress_bar=False), dtype=np.float32, ) if q.ndim == 1: q = q[None, :] qnorms = np.linalg.norm(q, axis=1, keepdims=True) qnorms[qnorms == 0] = 1.0 sims = (q / qnorms) @ matrix.T best_idx = sims.argmax(axis=1) for row_i, col_i in enumerate(best_idx): score = float(sims[row_i, col_i]) if score >= threshold: results.append( {"hpo_id": ids[col_i], "hpo_term": names[col_i], "similarity": score} ) else: results.append(None) return results def find_best_hpo_match(finding: str, region: str, threshold: float): return find_best_hpo_matches_batch([(finding, region or "")], threshold)[0] def get_genes_for_hpo(hpo_id: str): conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute("SELECT genes FROM hpo_gene WHERE hpo_id = ?", (hpo_id,)) result = cursor.fetchone() conn.close() return result[0].split(", ") if result and result[0] else [] # ========================= # QC (InferenceClient.chat.completions) — Robust # ========================= def build_qc_prompt(row: dict) -> str: return ( "You validate a mapping from a clinical/pathology finding to an HPO term.\n" "Return EXACTLY ONE character: 1 or 0.\n\n" f"Finding: {row.get('finding','')}\n" f"Region: {row.get('region','')}\n" f"HPO ID: {row.get('hpo_id','')}\n" f"HPO Term: {row.get('hpo_term','')}\n" f"Similarity: {row.get('similarity','')}\n\n" "Output:\n" "1 = incorrect mapping\n" "0 = correct mapping\n" ) def _extract_text(msg) -> str: # Different providers may populate different fields; check a few. for attr in ("content", "text", "output_text"): v = getattr(msg, attr, None) if isinstance(v, str) and v.strip(): return v.strip() return "" def _parse_first_bit(text: str) -> Optional[str]: if not text: return None for ch in text: if ch in ("0", "1"): return ch return None def call_qc_model(prompt: str, model_name: str) -> str: """ Returns "0"/"1" or "ERR: ...". Retries with larger max_tokens if content is empty / length-truncated. """ try: # Attempt 1: modest tokens comp = client.chat.completions.create( model=model_name, messages=[ {"role": "system", "content": "Reply with exactly ONE character: 0 or 1. No other text."}, {"role": "user", "content": prompt}, ], temperature=0.0, max_tokens=128, ) choice = comp.choices[0] msg = choice.message text = _extract_text(msg) bit = _parse_first_bit(text) if bit: return bit fr1 = getattr(choice, "finish_reason", None) # Attempt 2: bigger budget + no system prompt (some backends behave better) comp2 = client.chat.completions.create( model=model_name, messages=[{"role": "user", "content": "Return exactly one character: 0 or 1.\n\n" + prompt}], temperature=0.0, max_tokens=512, ) choice2 = comp2.choices[0] msg2 = choice2.message text2 = _extract_text(msg2) bit2 = _parse_first_bit(text2) if bit2: return bit2 fr2 = getattr(choice2, "finish_reason", None) return f"ERR: No 0/1 found. fr1={fr1!r} raw1={text!r} fr2={fr2!r} raw2={text2!r}" except Exception as e: return f"ERR: {type(e).__name__}: {e}" def qc_connectivity_check(model_name: str) -> str: resp = call_qc_model("Return exactly one character: 0", model_name) if resp == "0": return f"QC OK (model={model_name})" return f"QC ERROR (model={model_name}): {resp}" # ========================= # HPO TREE VISUALISATION (hp.json) # ========================= _hpo_graph_cache = None def ensure_hp_json() -> Tuple[bool, str]: if os.path.exists(HPO_JSON_PATH): return True, "hp.json present." try: r = requests.get(HPO_JSON_URL, timeout=90) r.raise_for_status() with open(HPO_JSON_PATH, "wb") as f: f.write(r.content) return True, "hp.json downloaded." except Exception as e: return False, f"hp.json download failed: {type(e).__name__}: {e}" def load_hpo_graph(): global _hpo_graph_cache if _hpo_graph_cache is not None: return _hpo_graph_cache ok, _ = ensure_hp_json() if not ok: _hpo_graph_cache = None return None try: with open(HPO_JSON_PATH, "r", encoding="utf-8") as f: obj = json.load(f) except Exception: _hpo_graph_cache = None return None graphs = obj.get("graphs", []) if not graphs: _hpo_graph_cache = None return None g = graphs[0] nodes = g.get("nodes", []) edges = g.get("edges", []) def _norm_id(x: Optional[str]) -> Optional[str]: # obographs hp.json uses IRIs like # http://purl.obolibrary.org/obo/HP_0000118 while the DB stores # HP:0000118. Normalise so tree lookups actually connect. if not x: return x x = x.rsplit("/", 1)[-1] return x.replace("_", ":", 1) if x.startswith("HP_") else x id2label = {} for n in nodes: nid = _norm_id(n.get("id")) meta = n.get("meta") or {} lbl = meta.get("lbl") or n.get("lbl") or nid if nid: id2label[nid] = lbl parents = {} for e in edges: sub = _norm_id(e.get("sub")) obj_ = _norm_id(e.get("obj")) pred = e.get("pred", "") if "is_a" not in pred and "subClassOf" not in pred: continue if not sub or not obj_: continue parents.setdefault(sub, []).append(obj_) _hpo_graph_cache = {"id2label": id2label, "parents": parents, "root": "HP:0000118"} return _hpo_graph_cache def get_paths_to_root(hpo_id: str, parents_map: Dict[str, List[str]], root: str) -> List[List[str]]: paths = [] def dfs(curr, path): if curr == root: paths.append(path + [curr]) return if curr not in parents_map: return for p in parents_map[curr]: if p in path: continue dfs(p, path + [curr]) dfs(hpo_id, []) fixed = [] for p in paths: if p and p[0] != hpo_id: fixed.append([hpo_id] + p) else: fixed.append(p) return fixed def build_minimal_tree_for_matches(match_ids: List[str]): g = load_hpo_graph() if not g: return None root = g["root"] parents = g["parents"] keep_edges = set() for mid in match_ids: if not mid: continue for path in get_paths_to_root(mid, parents, root): for i in range(len(path) - 1): keep_edges.add((path[i + 1], path[i])) # (parent, child) adj = {} for parent, child in keep_edges: adj.setdefault(parent, []).append(child) id2label = g["id2label"] for p in adj: adj[p].sort(key=lambda x: id2label.get(x, x)) def make_node(nid): return {"id": nid, "children": [make_node(ch) for ch in adj.get(nid, [])]} return make_node(root) def tree_to_html(tree: dict, id2label: dict, highlight: set) -> str: def any_descendant_hit(n): for ch in n.get("children", []): if ch["id"] in highlight or any_descendant_hit(ch): return True return False def node_html(n): nid = n["id"] label = id2label.get(nid, nid) cls = "hpo-hit" if nid in highlight else "" title = f"{label} ({nid})" if not n["children"]: return f"
{title}
" open_attr = " open" if (nid in highlight or any_descendant_hit(n)) else "" inner = "".join([node_html(ch) for ch in n["children"]]) return ( f"" f"{title}" f"
{inner}
" f"" ) return node_html(tree) def build_hpo_paths_html(match_ids: List[str]) -> str: ok, msg = ensure_hp_json() if not ok: return f"
Ontology view unavailable: {msg}
" g = load_hpo_graph() if not g: return "
Ontology view unavailable: could not parse hp.json.
" match_ids = [m for m in match_ids if m] if not match_ids: return "
No matched HPO IDs to display.
" tree = build_minimal_tree_for_matches(match_ids) if not tree: return "
Could not build ontology tree from hp.json.
" style = """ """ return ( style + "
" + "
Ontology paths up to HP:0000118 (Phenotypic abnormality).
" + tree_to_html(tree, g["id2label"], set(match_ids)) + "
" ) # ========================= # ENRICHR (API) # ========================= def enrichr_query(genes: List[str], library: str = "GO_Biological_Process_2021") -> List[dict]: genes = [g for g in genes if g] if len(genes) < 3: return [] add_url = "https://maayanlab.cloud/Enrichr/addList" enrich_url = "https://maayanlab.cloud/Enrichr/enrich" payload = {"list": "\n".join(genes), "description": "HPO Mapper gene list"} r1 = requests.post(add_url, files=payload, timeout=30) r1.raise_for_status() user_list_id = r1.json()["userListId"] r2 = requests.get(enrich_url, params={"userListId": user_list_id, "backgroundType": library}, timeout=30) r2.raise_for_status() data = r2.json() out = [] rows = data.get(library, []) for row in rows[:20]: out.append({ "rank": row[0], "term": row[1], "p_value": row[2], "z_score": row[3], "combined_score": row[4], "genes": row[5], "adj_p_value": row[6] if len(row) > 6 else None, }) return out def build_enrichr_html_from_rows(rows: List[Dict[str, Any]]) -> str: genes = [] for r in rows or []: g = (r.get("genes") or "").strip() if not g: continue genes.extend([x.strip() for x in g.split(";") if x.strip()]) genes = sorted(set(genes)) if len(genes) < 3: return "
Not enough genes for enrichment (need ~3).
" try: top = enrichr_query(genes) if not top: return "
No enrichment results returned.
" rows_html = [] for t in top: adj = t["adj_p_value"] adj_str = f"{adj:.2e}" if isinstance(adj, (int, float)) else "" rows_html.append( "" f"{t['rank']}" f"{t['term']}" f"{t['p_value']:.2e}" f"{adj_str}" f"{t['combined_score']:.2f}" f"{t['genes']}" "" ) style = """ """ return ( style + "
" + "
Top GO Biological Process enrichments (Enrichr API).
" + "" + "" + "".join(rows_html) + "
RankTermPAdj PCombinedGenes
" ) except Exception as e: return f"
Enrichment failed: {type(e).__name__}: {e}
" # ========================= # EMBEDDING IFRAMES # ========================= def _load_embed_html(view: str) -> Optional[str]: fname = EMBED_FILES.get(view, EMBED_FILES["2D"]) path = os.path.join(os.getcwd(), fname) if not os.path.exists(path): # Fallback: pull it from the Space repo (e.g. if cwd differs) try: path = hf_hub_download( repo_id=db_repo, filename=fname, repo_type="space", token=HF_TOKEN ) except Exception: return None try: with open(path, "r", encoding="utf-8") as f: return f.read() except Exception: return None def get_embedding_iframe(view: str) -> str: content = _load_embed_html(view) if content is None: return ( "
Embedding HTML not found. Make sure " f"{EMBED_FILES.get(view)} is committed to the Space repo.
" ) # srcdoc inlines the document, so no cross-origin request is made and the # plot's JS runs inside the sandboxed frame. return ( "
" f"" "
" ) # ========================= # BULK PIPELINE # ========================= def run_qc_on_df(df: pd.DataFrame, qc_model_name: str, progress_cb=None) -> pd.DataFrame: df = df.copy() # Identical finding/region/HPO mappings need only ONE QC verdict, and the # network-bound calls can run concurrently. prompts: Dict[Tuple[str, str, str], str] = {} for _, row in df.iterrows(): if row.get("hpo_id") and row.get("hpo_term"): key = (row.get("finding", ""), row.get("region", ""), row.get("hpo_id", "")) if key not in prompts: prompts[key] = build_qc_prompt(row.to_dict()) results: Dict[Tuple[str, str, str], str] = {} if prompts: total = len(prompts) done = 0 with ThreadPoolExecutor(max_workers=6) as ex: futures = { ex.submit(call_qc_model, p, qc_model_name): k for k, p in prompts.items() } for fut in as_completed(futures): key = futures[fut] try: results[key] = fut.result() except Exception as e: results[key] = f"ERR: {type(e).__name__}: {e}" done += 1 if progress_cb: progress_cb(done, total) flags, raws = [], [] for _, row in df.iterrows(): if row.get("hpo_id") and row.get("hpo_term"): key = (row.get("finding", ""), row.get("region", ""), row.get("hpo_id", "")) qc = results.get(key, "") raws.append(qc) flags.append("1" if qc == "1" else "") else: raws.append("") flags.append("") df["qc_raw"] = raws df["flag"] = flags return df def run_mapping_on_rows(rows: List[Dict[str, str]], threshold: float) -> pd.DataFrame: # Pathology tables repeat findings heavily, so map each unique # finding/region pair once and reuse the result for every occurrence. unique_pairs = sorted({(r["finding"], r.get("region", "") or "") for r in rows}) matches = find_best_hpo_matches_batch(unique_pairs, threshold) match_by_pair = dict(zip(unique_pairs, matches)) gene_cache: Dict[str, List[str]] = {} out = [] for r in rows: finding = r["finding"] region = r.get("region", "") or "" sid = r.get("subject_id", "") or "" match = match_by_pair.get((finding, region)) if match: hpo_id = match["hpo_id"] if hpo_id not in gene_cache: gene_cache[hpo_id] = get_genes_for_hpo(hpo_id) genes = gene_cache[hpo_id] out.append({ "subject_id": sid, "finding": finding, "region": region, "hpo_id": hpo_id, "hpo_term": match["hpo_term"], "similarity": match.get("similarity", ""), "genes": ";".join(genes) if genes else "", "flag": "", "qc_raw": "", }) else: out.append({ "subject_id": sid, "finding": finding, "region": region, "hpo_id": "", "hpo_term": "", "similarity": "", "genes": "", "flag": "", "qc_raw": "", }) return pd.DataFrame(out) def _read_table_file_to_rows(file_bytes: bytes, filename: str) -> List[Dict[str, str]]: suffix = (filename or "").lower() sep = "\t" if suffix.endswith(".tsv") else None df = pd.read_csv(io.BytesIO(file_bytes), sep=sep, engine="python") col_map = {c.lower().strip(): c for c in df.columns} def pick_col(cands): for c in cands: if c in col_map: return col_map[c] return None finding_col = pick_col(["finding", "pathological_finding", "findings"]) if finding_col is None: raise ValueError(f"No finding column found in {filename}") region_col = pick_col(["region", "anatomical_region", "location", "site"]) subject_col = pick_col(["subject_id", "patient", "patient_id", "id"]) rows = [] for _, r in df.iterrows(): finding = "" if pd.isna(r[finding_col]) else str(r[finding_col]).strip() if not finding: continue region = "" if (not region_col or pd.isna(r[region_col])) else str(r[region_col]).strip() subject_id = "" if (not subject_col or pd.isna(r[subject_col])) else str(r[subject_col]).strip() rows.append({"subject_id": subject_id, "finding": finding, "region": region}) return rows def _read_subject_json_to_rows(obj: Any) -> List[Dict[str, str]]: def one_subject(o): sid = str(o.get("subject_id", "") or "") out = [] for item in (o.get("data") or []): finding = str(item.get("finding", "") or "").strip() if not finding: continue region = str(item.get("anatomical_region", "") or "").strip() out.append({"subject_id": sid, "finding": finding, "region": region}) return out if isinstance(obj, list): rows = [] for o in obj: if isinstance(o, dict): rows.extend(one_subject(o)) return rows if isinstance(obj, dict): if "subject_id" in obj and "data" in obj: return one_subject(obj) if "finding" in obj: return [{ "subject_id": str(obj.get("subject_id", "") or ""), "finding": str(obj.get("finding", "") or "").strip(), "region": str(obj.get("anatomical_region", obj.get("region", "")) or "").strip(), }] return [] def parse_bulk_inputs(uploaded_file, pasted_table: Optional[pd.DataFrame]) -> List[Dict[str, str]]: rows: List[Dict[str, str]] = [] if pasted_table is not None and isinstance(pasted_table, pd.DataFrame) and len(pasted_table) > 0: df = pasted_table.copy() df.columns = [str(c).strip() for c in df.columns] lower_cols = {c.lower(): c for c in df.columns} def getcol(name, fallback_idx=None): if name in lower_cols: return lower_cols[name] if fallback_idx is not None and fallback_idx < len(df.columns): return df.columns[fallback_idx] return None sub_c = getcol("subject_id", 0) fin_c = getcol("finding", 1) reg_c = getcol("region", 2) for _, r in df.iterrows(): finding = "" if pd.isna(r[fin_c]) else str(r[fin_c]).strip() if not finding: continue subject_id = "" if (not sub_c or pd.isna(r[sub_c])) else str(r[sub_c]).strip() region = "" if (not reg_c or pd.isna(r[reg_c])) else str(r[reg_c]).strip() rows.append({"subject_id": subject_id, "finding": finding, "region": region}) filename, file_bytes = safe_read_gradio_file(uploaded_file) if filename and file_bytes: if filename.lower().endswith(".zip"): with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zf: for info in zf.infolist(): if info.is_dir(): continue inner_name = info.filename inner_bytes = zf.read(inner_name) lower = inner_name.lower() try: if lower.endswith(".json"): obj = json.loads(inner_bytes.decode("utf-8")) rows.extend(_read_subject_json_to_rows(obj)) elif lower.endswith((".csv", ".tsv", ".txt")): rows.extend(_read_table_file_to_rows(inner_bytes, inner_name)) except Exception: continue elif filename.lower().endswith(".json"): obj = json.loads(file_bytes.decode("utf-8")) rows.extend(_read_subject_json_to_rows(obj)) elif filename.lower().endswith((".csv", ".tsv", ".txt")): rows.extend(_read_table_file_to_rows(file_bytes, filename)) cleaned = [] for r in rows: finding = str(r.get("finding", "") or "").strip() if not finding: continue cleaned.append({ "subject_id": str(r.get("subject_id", "") or "").strip(), "finding": finding, "region": str(r.get("region", "") or "").strip(), }) return cleaned def process_bulk(uploaded_file, threshold, pasted_table, qc_model_name, run_qc=True, progress=gr.Progress()): try: progress(0.0, desc="Parsing inputs") rows = parse_bulk_inputs(uploaded_file, pasted_table) if not rows: return ( "No valid rows found. The file needs a 'finding' column " "(optional: 'region', 'subject_id').", None, None, "
No ontology view.
", "
No enrichment.
", ) n_unique = len({(r["finding"], r.get("region", "") or "") for r in rows}) progress(0.1, desc=f"Mapping {len(rows)} rows ({n_unique} unique finding/region pairs)") df_map = run_mapping_on_rows(rows, threshold) if run_qc: def qc_progress(done, total): progress(0.3 + 0.6 * (done / max(total, 1)), desc=f"LLM QC {done}/{total}") df_out = run_qc_on_df(df_map, qc_model_name, progress_cb=qc_progress) else: df_out = df_map progress(0.92, desc="Building outputs") tmp_dir = tempfile.mkdtemp(prefix="hpo_mapper_qc_") out_path = os.path.join(tmp_dir, "hpo_mapper_qc_results.csv") df_out.to_csv(out_path, index=False) hpo_ids = [x for x in df_out["hpo_id"].dropna().astype(str).tolist() if x.strip()] ontology_html = build_hpo_paths_html(hpo_ids) enrichment_html = build_enrichr_html_from_rows(df_out.to_dict(orient="records")) n_mapped = int((df_out["hpo_id"].astype(str).str.strip() != "").sum()) n_flagged = int((df_out["flag"] == "1").sum()) n_err = int(df_out["qc_raw"].astype(str).str.startswith("ERR").sum()) qc_part = ( f"QC flagged: {n_flagged}. QC errors: {n_err}." if run_qc else "QC skipped." ) status = ( f"Processed {len(df_out)} rows ({n_unique} unique finding/region pairs). " f"Mapped: {n_mapped}. Below threshold: {len(df_out) - n_mapped}. " + qc_part ) return status, out_path, df_out.head(500), ontology_html, enrichment_html except Exception as e: return f"Bulk processing failed: {type(e).__name__}: {e}", None, None, "
No ontology view.
", "
No enrichment.
" # ========================= # SINGLE QUERY UI FUNCTIONS # ========================= def map_only_ui(finding, region, threshold, qc_model_name): # mapping step; qc runs in next step but we keep qc model in state if not finding or not str(finding).strip(): return "", "", "", "", "Enter a finding.", [], "
No ontology view.
", "
No enrichment.
" finding = str(finding).strip() region = str(region).strip() if region else "" match = find_best_hpo_match(finding, region, threshold) if not match: return "", "", "", "", "No match found.", [], "
No matched HPO IDs to display.
", "
Not enough genes for enrichment.
" genes = get_genes_for_hpo(match["hpo_id"]) row_state = { "finding": finding, "region": region, "hpo_id": match["hpo_id"], "hpo_term": match["hpo_term"], "similarity": match.get("similarity", ""), "genes": ";".join(genes) if genes else "", "flag": "", "qc_raw": "", "subject_id": "", "qc_model": qc_model_name, } ontology_html = build_hpo_paths_html([match["hpo_id"]]) enrichment_html = build_enrichr_html_from_rows([row_state]) return ( match["hpo_id"], match["hpo_term"], ", ".join(genes), f"{match.get('similarity', 0):.4f}", "QC running…", [row_state], ontology_html, enrichment_html, ) def qc_only_ui(row_state_records): if not row_state_records or not isinstance(row_state_records, list): return "—", row_state_records, "
No ontology view.
", "
No enrichment.
" row = row_state_records[0] if not row.get("hpo_id") or not row.get("hpo_term"): return "—", row_state_records, "
No ontology view.
", "
No enrichment.
" qc_model_name = row.get("qc_model") or DEFAULT_QC_MODEL qc = call_qc_model(build_qc_prompt(row), qc_model_name) row["qc_raw"] = qc if qc.startswith("ERR:"): row["flag"] = "" qc_display = qc else: row["flag"] = "1" if qc == "1" else "" qc_display = row["flag"] ontology_html = build_hpo_paths_html([row["hpo_id"]]) enrichment_html = build_enrichr_html_from_rows([row]) return qc_display, row_state_records, ontology_html, enrichment_html # ========================= # GRADIO APP # ========================= custom_css = "#visual-abstract img { max-width: 600px; width: 100%; height: auto; display: block; margin-left: auto; margin-right: auto; }" description_md = ( "Enter a pathological finding (e.g., *chronic inflammation*) and optional anatomical region " "(e.g., *terminal ileum*) to map it to the closest HPO term using Nomic embeddings.\n\n" "QC is performed by **InferenceClient.chat.completions**. Choose the QC model from the dropdown " "(you can also type any HF model id).\n\n" "If QC fails, you will see **ERR:** in the QC box and in the bulk output `qc_raw` column.\n" ) example_json_md = ( "Example JSON:\n\n" "```json\n" "{\n" ' \"subject_id\": \"U099\",\n' ' \"data\": [\n' " {\n" ' \"finding\": \"Post COVID-19 condition, unspecified\",\n' ' \"anatomical_region\": \"\"\n' " }\n" " ]\n" "}\n" "```" ) with gr.Blocks(title="HPO Mapper 2 — Selectable LLM QC", css=custom_css) as demo: gr.Markdown("# HPO Mapper 2 — Selectable LLM QC (Hugging Face InferenceClient.chat.completions)") gr.Markdown( "**Preprint:** Kadhim AZ, Green Z, Boags A, et al. " "[Human Phenotype Ontology (HPO) Mapper: Semantic Mapping of Clinical Findings " "to the Human Phenotype Ontology Using AI-Powered Embeddings and LLM-Based " "Quality Control](https://www.medrxiv.org/content/10.64898/2025.12.20.25342726v2). " "*medRxiv* (2025). " "doi: [10.64898/2025.12.20.25342726](https://doi.org/10.64898/2025.12.20.25342726)" ) if os.path.exists(VISUAL_ABSTRACT_PATH): gr.Image(value=VISUAL_ABSTRACT_PATH, show_label=False, elem_id="visual-abstract") gr.Markdown(description_md) mapped_state = gr.State([]) with gr.Tab("Single Query"): qc_model_in = gr.Dropdown( label="QC LLM (Hugging Face model id)", choices=QC_MODEL_CHOICES, value=DEFAULT_QC_MODEL, allow_custom_value=True, ) finding_in = gr.Textbox(label="Pathological Finding") region_in = gr.Textbox(label="Anatomical Region (optional)") threshold_in = gr.Slider(0.0, 1.0, step=0.01, value=0.76, label="Similarity Threshold") hpo_id_out = gr.Textbox(label="HPO ID") hpo_term_out = gr.Textbox(label="HPO Term") genes_out = gr.Textbox(label="Genes annotated to this HPO term") sim_out = gr.Textbox(label="Similarity") qc_flag_out = gr.Textbox(label="LLM QC (blank=OK, 1=incorrect, ERR=API issue)") run_btn = gr.Button("Run Mapping → QC") gr.Markdown("### Visual outputs") ontology_html_out = gr.HTML(label="HPO Ontology Tree") enrichr_html_out = gr.HTML(label="Gene Enrichment (Enrichr)") event = run_btn.click( fn=map_only_ui, inputs=[finding_in, region_in, threshold_in, qc_model_in], outputs=[hpo_id_out, hpo_term_out, genes_out, sim_out, qc_flag_out, mapped_state, ontology_html_out, enrichr_html_out], ) event.then( fn=qc_only_ui, inputs=[mapped_state], outputs=[qc_flag_out, mapped_state, ontology_html_out, enrichr_html_out], ) with gr.Tab("Bulk Mapping + QC"): gr.Markdown("Upload CSV/TSV/JSON or ZIP, and/or paste rows below. Output includes qc_raw for debugging.") gr.Markdown(example_json_md) bulk_qc_model = gr.Dropdown( label="QC LLM (Hugging Face model id)", choices=QC_MODEL_CHOICES, value=DEFAULT_QC_MODEL, allow_custom_value=True, ) bulk_file = gr.File(label="Upload CSV/TSV/JSON/ZIP", file_types=[".csv", ".tsv", ".txt", ".json", ".zip"]) bulk_table = gr.Dataframe( headers=["subject_id", "finding", "region"], datatype=["str", "str", "str"], row_count=12, col_count=3, label="Paste or type rows here (optional)", ) bulk_threshold = gr.Slider(0.0, 1.0, step=0.01, value=0.76, label="Similarity Threshold") bulk_run_qc = gr.Checkbox(value=True, label="Run LLM QC (uncheck for fast mapping-only runs)") bulk_btn = gr.Button("Run Bulk Mapping → QC") bulk_status = gr.Textbox(label="Status / Summary", interactive=False) bulk_file_out = gr.File(label="Download Results CSV") bulk_preview_out = gr.Dataframe(label="Preview Results (first 500 rows)", interactive=False, wrap=True) bulk_ontology_html = gr.HTML(label="HPO Ontology Tree") bulk_enrichr_html = gr.HTML(label="Gene Enrichment (Enrichr)") bulk_btn.click( fn=process_bulk, inputs=[bulk_file, bulk_threshold, bulk_table, bulk_qc_model, bulk_run_qc], outputs=[bulk_status, bulk_file_out, bulk_preview_out, bulk_ontology_html, bulk_enrichr_html], ) with gr.Tab("Embedding Visualisation"): view_mode = gr.Dropdown(choices=["2D", "3D"], value="3D", label="View mode") html_view = gr.HTML(value=get_embedding_iframe("3D"), label="Embedding Viewer") view_mode.change(fn=get_embedding_iframe, inputs=view_mode, outputs=html_view) with gr.Accordion("Runtime status", open=True): runtime_status = gr.Textbox(label="Runtime status", interactive=False) runtime_model = gr.Dropdown( label="Model to test in Runtime status", choices=QC_MODEL_CHOICES, value=DEFAULT_QC_MODEL, allow_custom_value=True, ) test_btn = gr.Button("Test QC model now") gr.Markdown( f"- HF token env: `HF_TOKEN`\n" f"- hp.json: `{HPO_JSON_PATH}` (auto-download)\n" f"- DB: `{db_path}`\n" ) def run_runtime_test(model_name: str): qc_line = qc_connectivity_check(model_name) hpo_ok, hpo_msg = ensure_hp_json() hpo_line = "HPO OK (hp.json ready)" if hpo_ok else f"HPO ERROR: {hpo_msg}" db_line = "DB OK" if os.path.exists(db_path) else "DB ERROR: missing db" return f"{qc_line}\n{hpo_line}\n{db_line}" test_btn.click(fn=run_runtime_test, inputs=[runtime_model], outputs=[runtime_status]) # initial load demo.load(fn=run_runtime_test, inputs=[runtime_model], outputs=runtime_status) if __name__ == "__main__": demo.queue().launch()