diff --git "a/app.py" "b/app.py"
new file mode 100644--- /dev/null
+++ "b/app.py"
@@ -0,0 +1,2832 @@
+import json
+import os
+import re
+import glob
+
+import pandas as pd
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+import dash
+from dash import dcc, html, dash_table, Input, Output, State
+import dash_bootstrap_components as dbc
+from flask import send_from_directory, abort
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+_JSON_PATH_GZ = os.path.join(BASE_DIR, "data", "proteins.json.gz")
+_JSON_PATH = os.path.join(BASE_DIR, "data", "proteins.json")
+JSON_PATH = _JSON_PATH_GZ if os.path.isfile(_JSON_PATH_GZ) else _JSON_PATH
+METADATA_PATH = os.path.join(BASE_DIR, "data", "metadata.json")
+GRID_BASE = os.path.join(BASE_DIR, "data", "clustering")
+
+# ---------------------------------------------------------------------------
+# Load protein data (includes PMID-level UniProt metadata)
+# ---------------------------------------------------------------------------
+def load_data():
+ import gzip as _gzip
+ _open = _gzip.open if JSON_PATH.endswith(".gz") else open
+ with _open(JSON_PATH, "rt", encoding="utf-8") as f:
+ raw = json.load(f)
+
+ with open(METADATA_PATH) as f:
+ meta = json.load(f)
+
+ rows = []
+ for pmid, entry in raw.items():
+ uniprot_ids = entry.get("Uniprot_IDS", []) or []
+ protein_names = entry.get("Protein_names", []) or []
+ organisms = entry.get("Organisms", []) or []
+ sequences = entry.get("Sequences", []) or []
+ n_collected = entry.get("Number_of_proteins_collected", 0)
+
+ # Paper-level metadata from metadata.json
+ m = meta.get(str(pmid), {})
+ groups = m.get("groups", [])
+ title = m.get("title", "")
+ pub_date = m.get("pub_date", "")
+ source = m.get("source", "")
+ url = m.get("url", "")
+
+ for protein in entry.get("proteins", []):
+ row = {"pmid": pmid}
+ row.update({k: (v if v is not None else "") for k, v in protein.items()})
+ row["n_uniprot_entries"] = len(uniprot_ids)
+ row["uniprot_ids"] = ", ".join(uniprot_ids)
+ row["n_proteins_collected"] = n_collected
+ row["groups"] = ", ".join(groups) if groups else "unknown"
+ row["title"] = title
+ row["pub_date"] = pub_date
+ row["source"] = source
+ row["url"] = url
+ rows.append(row)
+
+ return pd.DataFrame(rows), raw, meta
+
+df, RAW, META = load_data()
+
+ALL_GROUPS = sorted({
+ g for entry in META.values() for g in entry.get("groups", [])
+})
+
+# PMID → set of groups, for fast cluster filtering
+PMID_GROUPS = {
+ pmid: set(entry.get("groups", []))
+ for pmid, entry in META.items()
+}
+
+# Column sets
+EXTRACTION_FIELDS = [
+ "pmid", "enzyme_name", "organism_source", "strain", "expression_strain",
+ "plasmid", "molecular_weight", "medium_name", "inducer",
+ "induction_temperature", "lysis_buffer", "elution_buffer", "desalting_process",
+]
+PAPER_FIELDS = ["title", "pub_date", "source", "groups"]
+UNIPROT_FIELDS = ["uniprot_ids", "n_uniprot_entries", "n_proteins_collected"]
+TABLE_FIELDS = EXTRACTION_FIELDS + PAPER_FIELDS + UNIPROT_FIELDS
+
+# ---------------------------------------------------------------------------
+# Scan grid directory structure
+# ---------------------------------------------------------------------------
+def scan_grid():
+ models, mins, thresholds, fields = set(), set(), set(), set()
+ pattern = os.path.join(GRID_BASE, "model=*", "min=*", "t=*_cluster.html")
+ for fpath in glob.glob(pattern):
+ parts = fpath.split(os.sep)
+ for p in parts:
+ if p.startswith("model="):
+ models.add(p.replace("model=", ""))
+ elif p.startswith("min="):
+ mins.add(p.replace("min=", ""))
+ fname = os.path.basename(fpath)
+ m = re.match(r"t=([\d.]+)_(.+)_cluster\.html$", fname)
+ if m:
+ thresholds.add(m.group(1))
+ fields.add(m.group(2))
+ return (
+ sorted(models),
+ sorted(mins, key=float),
+ sorted(thresholds, key=float),
+ sorted(fields),
+ )
+
+MODELS, MINS, THRESHOLDS, CLUSTER_FIELDS = scan_grid()
+METRIC_TYPES = ["silhouette_cosine", "davies_bouldin", "n_clusters"]
+
+# ---------------------------------------------------------------------------
+# Load all metrics CSVs into one DataFrame at startup
+# ---------------------------------------------------------------------------
+def load_all_metrics():
+ rows = []
+ for fpath in glob.glob(os.path.join(GRID_BASE, "model=*", "min=*", "t=*_FIELD_CLUSTER_METRICS.csv")):
+ parts = fpath.replace(GRID_BASE + os.sep, "").split(os.sep)
+ model = parts[0].replace("model=", "")
+ try:
+ chunk = pd.read_csv(fpath)
+ chunk["model"] = model
+ rows.append(chunk)
+ except Exception:
+ pass
+ if not rows:
+ return pd.DataFrame()
+ return pd.concat(rows, ignore_index=True)
+
+METRICS_DF = load_all_metrics()
+METRIC_FIELDS = sorted(METRICS_DF["field"].unique()) if not METRICS_DF.empty else []
+METRIC_MODELS = sorted(METRICS_DF["model"].unique()) if not METRICS_DF.empty else []
+
+# ---------------------------------------------------------------------------
+# Evaluation data
+# ---------------------------------------------------------------------------
+_EVAL_BASE = os.path.join(BASE_DIR, "data", "evaluation_llm_results")
+
+_PURIF_FIELDS = [
+ "enzyme_name", "organism_source", "strain", "expression_strain", "plasmid",
+ "molecular_weight", "medium_name", "inducer", "induction_temperature",
+ "lysis_buffer", "elution_buffer", "desalting_process",
+]
+
+_NLP_METRICS = {
+ "bertscore_f1": "BERTScore F1",
+ "rouge1_f": "ROUGE-1 F",
+ "bleu": "BLEU",
+ "meteor": "METEOR",
+ "cosine_similarity":"Cosine Similarity",
+}
+
+# BLEU and METEOR are stored 0–100; divide by 100 to normalise to 0–1 for display
+_NLP_SCALE = {"bleu": 100.0, "meteor": 100.0}
+
+_LLM_EVAL_CONFIGS = [
+ ("azoreductases","gpt-4.1", False, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-4.1_cleaned.json")),
+ ("azoreductases","gpt-5-mini",False,os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5-mini_cleaned.json")),
+ ("azoreductases","gpt-5", False, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5_cleaned.json")),
+ ("azoreductases","gpt-4.1", True, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-4.1_rag_600_100_cleaned.json")),
+ ("azoreductases","gpt-5-mini",True, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5-mini_rag_600_100_cleaned.json")),
+ ("azoreductases","gpt-5", True, os.path.join(_EVAL_BASE,"cleaned","results_azo_gpt-5_rag_600_100_cleaned.json")),
+ ("sams","gpt-4.1", False, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-4.1_cleaned.json")),
+ ("sams","gpt-5-mini",False, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5-mini_cleaned.json")),
+ ("sams","gpt-5", False, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5_cleaned.json")),
+ ("sams","gpt-4.1", True, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-4.1_rag_600_100_cleaned.json")),
+ ("sams","gpt-5-mini",True, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5-mini_rag_600_100_cleaned.json")),
+ ("sams","gpt-5", True, os.path.join(_EVAL_BASE,"sams","cleaned","sams_gpt-5_rag_600_100_cleaned.json")),
+]
+
+_NLP_EVAL_CONFIGS = [
+ ("azoreductases","gpt-4.1", False, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-4.1_cleaned.json")),
+ ("azoreductases","gpt-5-mini",False,os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5-mini_cleaned.json")),
+ ("azoreductases","gpt-5", False, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5_cleaned.json")),
+ ("azoreductases","gpt-4.1", True, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-4.1_rag_600_100_cleaned.json")),
+ ("azoreductases","gpt-5-mini",True, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5-mini_rag_600_100_cleaned.json")),
+ ("azoreductases","gpt-5", True, os.path.join(_EVAL_BASE,"results_azo_purification_nlp_gpt-5_rag_600_100_cleaned.json")),
+ ("sams","gpt-4.1", False, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-4.1_cleaned.json")),
+ ("sams","gpt-5-mini",False, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5-mini_cleaned.json")),
+ ("sams","gpt-5", False, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5_cleaned.json")),
+ ("sams","gpt-4.1", True, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-4.1_rag_600_100_cleaned.json")),
+ ("sams","gpt-5-mini",True, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5-mini_rag_600_100_cleaned.json")),
+ ("sams","gpt-5", True, os.path.join(_EVAL_BASE,"sams","results_nlp_sams_purification_gpt-5_rag_600_100_cleaned.json")),
+]
+
+_EVAL_LABELS = [
+ "gpt-4.1 (no-RAG)", "gpt-4.1 (RAG)",
+ "gpt-5-mini (no-RAG)", "gpt-5-mini (RAG)",
+ "gpt-5 (no-RAG)", "gpt-5 (RAG)",
+]
+
+
+def _flatten_purif_eval(configs, score_key):
+ rows = []
+ for group, model, rag, fpath in configs:
+ if not os.path.exists(fpath):
+ continue
+ try:
+ with open(fpath) as f:
+ data = json.load(f)
+ except Exception:
+ continue
+ label = f"{model} ({'RAG' if rag else 'no-RAG'})"
+ for pmid, entry in data.items():
+ for pair in entry.get("evaluated_protein_pairs", []):
+ gt_prot = pair.get("gt_protein", {})
+ llm_prot = pair.get("llm_protein", {})
+ for field, scores in pair.get("evaluation_result", {}).items():
+ if field not in _PURIF_FIELDS or not isinstance(scores, dict):
+ continue
+ val = scores.get(score_key)
+ if val is None:
+ continue
+ rows.append({
+ "group": group, "model": model, "rag": rag,
+ "label": label, "pmid": pmid, "field": field,
+ score_key: float(val),
+ "gt_text": str(gt_prot.get(field) or ""),
+ "llm_text": str(llm_prot.get(field) or ""),
+ "explanation": str(scores.get("explanation", "")),
+ })
+ return pd.DataFrame(rows) if rows else pd.DataFrame(
+ columns=["group","model","rag","label","pmid","field",score_key,
+ "gt_text","llm_text","explanation"])
+
+
+def load_eval_data():
+ llm_df = _flatten_purif_eval(_LLM_EVAL_CONFIGS, "similarity_score")
+
+ nlp_rows = []
+ for group, model, rag, fpath in _NLP_EVAL_CONFIGS:
+ if not os.path.exists(fpath):
+ continue
+ try:
+ with open(fpath) as f:
+ data = json.load(f)
+ except Exception:
+ continue
+ label = f"{model} ({'RAG' if rag else 'no-RAG'})"
+ for pmid, entry in data.items():
+ for pair in entry.get("evaluated_protein_pairs", []):
+ for field, scores in pair.get("evaluation_result", {}).items():
+ if field not in _PURIF_FIELDS or not isinstance(scores, dict):
+ continue
+ gt_text = str(scores.get("GT", "") or "")
+ llm_text = str(scores.get("LLM", "") or "")
+ for metric in _NLP_METRICS:
+ val = scores.get(metric)
+ if val is None:
+ continue
+ nlp_rows.append({"group": group, "model": model, "rag": rag,
+ "label": label, "pmid": pmid, "field": field,
+ "metric": metric, "value": float(val),
+ "gt_text": gt_text, "llm_text": llm_text})
+ nlp_df = pd.DataFrame(nlp_rows) if nlp_rows else pd.DataFrame(
+ columns=["group","model","rag","label","pmid","field","metric","value"])
+
+ _classif_sources = [
+ ("azoreductases", os.path.join(os.path.dirname(_EVAL_BASE),
+ "results_method_extraction",
+ "results_azo_method_extraction_metrics.json")),
+ ("sams", os.path.join(BASE_DIR, "data", "sams", "extracted_methods_sams_metrics.json")),
+ ]
+ classif_rows = []
+ for group, fpath in _classif_sources:
+ if not os.path.exists(fpath):
+ continue
+ try:
+ with open(fpath) as f:
+ data = json.load(f)
+ for pmid, scores in data.items():
+ if not isinstance(scores, dict):
+ continue
+ gt_text = str(scores.get("GT", "") or "")
+ llm_text = str(scores.get("LLM", "") or "")
+ for metric in _NLP_METRICS:
+ val = scores.get(metric)
+ if val is not None:
+ classif_rows.append({
+ "group": group, "pmid": pmid,
+ "metric": metric, "value": float(val),
+ "gt_text": gt_text, "llm_text": llm_text,
+ })
+ except Exception:
+ pass
+ classif_df = pd.DataFrame(classif_rows) if classif_rows else pd.DataFrame(
+ columns=["group","pmid","metric","value","gt_text","llm_text"])
+
+ return llm_df, nlp_df, classif_df
+
+
+EVAL_LLM_DF, EVAL_NLP_DF, CLASSIF_DF = load_eval_data()
+
+# ---------------------------------------------------------------------------
+# Classification confusion matrices
+# ---------------------------------------------------------------------------
+_CONFMAT_AZO_PATH = os.path.join(BASE_DIR, "data", "azoreductases_gt", "azo_metadata.json")
+_CONFMAT_SAMS_PATH = os.path.join(BASE_DIR, "data", "confusion_matrix", "confusion_matrix_counts.csv")
+_SAMS_ALL_PATH = os.path.join(BASE_DIR, "data", "sams", "sam_pdfs_enzymology.json")
+_SAMS_FILTERED_PATH = os.path.join(BASE_DIR, "data", "sams", "filtered.json")
+
+
+def _compute_metrics(tp, fp, fn, tn):
+ precision = tp / (tp + fp) if (tp + fp) > 0 else None
+ recall = tp / (tp + fn) if (tp + fn) > 0 else None
+ f1 = (2 * precision * recall / (precision + recall)
+ if precision is not None and recall is not None
+ and (precision + recall) > 0 else None)
+ accuracy = (tp + tn) / (tp + fp + fn + tn) if (tp + fp + fn + tn) > 0 else None
+ return {"TP": tp, "FP": fp, "FN": fn, "TN": tn,
+ "Precision": precision, "Recall": recall, "F1": f1, "Accuracy": accuracy}
+
+
+def load_confusion_matrices():
+ result = {}
+ # Azoreductases: all 31 GT papers classified as enzymology
+ try:
+ with open(_CONFMAT_AZO_PATH) as f:
+ azo_meta = json.load(f)
+ n = len(azo_meta)
+ result["azoreductases"] = _compute_metrics(tp=n, fp=0, fn=0, tn=0)
+ result["azoreductases"]["note"] = (
+ f"All {n} ground-truth papers were classified as enzymology. "
+ "No negative set available, so TN and FP are not applicable."
+ )
+ # All are TP
+ result["azoreductases"]["papers"] = {
+ pmid: {"actual": True, "predicted": True,
+ "title": v.get("title", ""),
+ "pub_date": v.get("pub_date", ""),
+ "source": v.get("source", "")}
+ for pmid, v in azo_meta.items()
+ }
+ except Exception:
+ pass
+ # SAMs: full confusion matrix from CSV (rows=Actual, cols=Predicted)
+ try:
+ cm = pd.read_csv(_CONFMAT_SAMS_PATH, index_col=0)
+ cm.index = cm.index.map(lambda v: bool(v) if not isinstance(v, bool) else v)
+ cm.columns = cm.columns.map(lambda v: bool(v) if v == "True" else (False if v == "False" else v))
+ tp = int(cm.loc[True, True])
+ fn = int(cm.loc[True, False])
+ fp = int(cm.loc[False, True])
+ tn = int(cm.loc[False, False])
+ result["sams"] = _compute_metrics(tp=tp, fp=fp, fn=fn, tn=tn)
+ result["sams"]["matrix"] = cm
+ # Per-paper category assignments
+ with open(_SAMS_ALL_PATH) as f:
+ all_papers = json.load(f)
+ with open(_SAMS_FILTERED_PATH) as f:
+ filtered = json.load(f)
+ pred_pos = set(filtered.keys())
+ papers = {}
+ for pmid, v in all_papers.items():
+ actual = bool(v.get("Count_Enzymology", False))
+ predicted = pmid in pred_pos
+ papers[pmid] = {
+ "actual": actual,
+ "predicted": predicted,
+ "title": v.get("title", ""),
+ "pub_date": v.get("pub_date", ""),
+ "source": v.get("source", ""),
+ "url": v.get("url", ""),
+ }
+ result["sams"]["papers"] = papers
+ except Exception as e:
+ print("SAMs confmat load error:", e)
+ return result
+
+
+CONF_MATRICES = load_confusion_matrices()
+
+# ---------------------------------------------------------------------------
+# Dash app
+# ---------------------------------------------------------------------------
+CUSTOM_CSS = """
+/* ── Global ── */
+body { background-color: #f4f6f9; }
+
+/* ── Navbar ── */
+.navbar-brand { font-size: 1.2rem; font-weight: 700; letter-spacing: 0.02em; }
+.navbar-subtitle { font-size: 0.75rem; opacity: 0.75; display: block; line-height: 1.2; }
+
+/* ── Tabs ── */
+.nav-tabs .nav-link { color: #495057; font-weight: 500; border-radius: 6px 6px 0 0; }
+.nav-tabs .nav-link.active { color: #1a73e8; font-weight: 700; border-bottom: 3px solid #1a73e8; }
+.nav-tabs .nav-link:hover { color: #1a73e8; }
+
+/* ── Filter panel ── */
+.filter-panel {
+ background: #ffffff;
+ border: 1px solid #dee2e6;
+ border-radius: 8px;
+ padding: 16px 20px 12px;
+ margin-bottom: 16px;
+ box-shadow: 0 1px 4px rgba(0,0,0,.06);
+}
+
+/* ── Contact avatar ── */
+.avatar-circle {
+ width: 56px; height: 56px; border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 1.2rem; font-weight: 700; color: #fff;
+ margin-bottom: 10px;
+}
+.person-card { transition: transform .15s, box-shadow .15s; }
+.person-card:hover { transform: translateY(-3px); box-shadow: 0 6px 18px rgba(0,0,0,.12) !important; }
+
+/* ── Accordion ── */
+.accordion-button { font-weight: 600; }
+.accordion-item { border-left: 4px solid #1a73e8 !important; margin-bottom: 6px; border-radius: 6px !important; }
+"""
+
+app = dash.Dash(
+ __name__,
+ external_stylesheets=[dbc.themes.LUX],
+ suppress_callback_exceptions=True,
+ title="ProtoPure",
+)
+app.index_string = app.index_string.replace(
+ "", f""
+)
+server = app.server
+
+
+@server.route("/grid_files/")
+def serve_grid_file(filepath):
+ full = os.path.realpath(os.path.join(GRID_BASE, filepath))
+ if not full.startswith(os.path.realpath(GRID_BASE)):
+ abort(403)
+ if not os.path.isfile(full):
+ abort(404)
+ return send_from_directory(os.path.dirname(full), os.path.basename(full))
+
+
+# ---------------------------------------------------------------------------
+# Layout helpers
+# ---------------------------------------------------------------------------
+def make_dropdown(label, id_, options, value=None, multi=False, clearable=True):
+ return dbc.Col([
+ html.Label(label, className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id=id_,
+ options=[{"label": o, "value": o} for o in options],
+ value=value if value is not None else (options[0] if options else None),
+ multi=multi,
+ clearable=clearable,
+ style={"fontSize": "13px"},
+ ),
+ ])
+
+
+def detail_field(label, value):
+ """Single labeled field for the detail panel."""
+ if not value:
+ return None
+ return html.Div([
+ html.Span(label + ": ", className="fw-semibold text-muted small"),
+ html.Span(str(value), className="small"),
+ ], className="mb-1")
+
+
+# ---------------------------------------------------------------------------
+# Tab layouts
+# ---------------------------------------------------------------------------
+DEFAULT_COLS = ["pmid", "enzyme_name", "organism_source"]
+
+FIELD_LABELS = {c: c.replace("_", " ").title() for c in TABLE_FIELDS}
+FIELD_LABELS.update({
+ "pmid": "PMID", "enzyme_name": "Enzyme Name", "organism_source": "Organism",
+ "expression_strain": "Expression Strain", "uniprot_ids": "UniProt IDs",
+ "n_uniprot_entries": "# UniProt", "n_proteins_collected": "# Proteins",
+ "pub_date": "Publication Date", "source": "Journal",
+})
+
+
+def proteins_tab():
+ search_fields = [
+ {"label": "PMID", "value": "pmid"},
+ {"label": "Enzyme name", "value": "enzyme_name"},
+ {"label": "Organism", "value": "organism_source"},
+ {"label": "Expression strain", "value": "expression_strain"},
+ {"label": "Plasmid", "value": "plasmid"},
+ {"label": "Inducer", "value": "inducer"},
+ {"label": "UniProt ID", "value": "uniprot_ids"},
+ {"label": "Journal", "value": "source"},
+ {"label": "Paper title", "value": "title"},
+ ]
+
+ col_options = [{"label": FIELD_LABELS.get(c, c), "value": c} for c in TABLE_FIELDS]
+
+ return dbc.Container([
+ html.Div([
+ dbc.Row([
+ dbc.Col([
+ html.Label("Filter by group", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="filter-group",
+ options=[{"label": g, "value": g} for g in ALL_GROUPS],
+ multi=True,
+ placeholder="All groups",
+ style={"fontSize": "13px"},
+ ),
+ ], width=4),
+ dbc.Col([
+ html.Label("Search by", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="search-field",
+ options=search_fields,
+ value="enzyme_name",
+ clearable=False,
+ style={"fontSize": "13px"},
+ ),
+ ], width=2),
+ dbc.Col([
+ html.Label("Search value", className="fw-semibold small mb-1"),
+ dbc.InputGroup([
+ dbc.Input(id="search-value", placeholder="Type to filter…", debounce=True, size="sm"),
+ dbc.Button("✕ Clear", id="clear-search", size="sm", color="secondary", outline=True),
+ ]),
+ ], width=6),
+ ], className="mb-2"),
+ dbc.Row([
+ dbc.Col([
+ html.Label("Show columns", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="col-selector",
+ options=col_options,
+ value=DEFAULT_COLS,
+ multi=True,
+ placeholder="Select columns…",
+ style={"fontSize": "13px"},
+ ),
+ ], width=12),
+ ]),
+ ], className="filter-panel"),
+ dbc.Row([
+ dbc.Col(html.Div(id="protein-count", className="text-muted small"), width=10),
+ dbc.Col(
+ dbc.Button("⬇ Download CSV", id="download-btn", size="sm", color="success", outline=True),
+ width=2, className="text-end",
+ ),
+ ], className="mb-2 align-items-center"),
+ dcc.Download(id="download-csv"),
+ # Table
+ dash_table.DataTable(
+ id="protein-table",
+ columns=[{"name": FIELD_LABELS.get(c, c), "id": c} for c in TABLE_FIELDS],
+ hidden_columns=[c for c in TABLE_FIELDS if c not in DEFAULT_COLS],
+ data=df[TABLE_FIELDS].to_dict("records"),
+ page_size=25,
+ page_action="native",
+ sort_action="native",
+ filter_action="none",
+ row_selectable="single",
+ selected_rows=[],
+ style_table={"overflowX": "auto"},
+ style_cell={
+ "fontSize": "12px",
+ "padding": "6px 10px",
+ "textAlign": "left",
+ "maxWidth": "220px",
+ "overflow": "hidden",
+ "textOverflow": "ellipsis",
+ "whiteSpace": "nowrap",
+ },
+ style_header={
+ "fontWeight": "700",
+ "backgroundColor": "#1a3a5c",
+ "color": "#ffffff",
+ "borderBottom": "2px solid #1a3a5c",
+ "fontSize": "11px",
+ "textTransform": "uppercase",
+ "letterSpacing": "0.04em",
+ },
+ style_data_conditional=[
+ {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"},
+ {"if": {"state": "selected"}, "backgroundColor": "#cfe2ff", "border": "1px solid #9ec5fe"},
+ ],
+ tooltip_delay=0,
+ tooltip_duration=None,
+ ),
+ # Detail panel (shown on row click)
+ html.Div(id="detail-panel", className="mt-3"),
+ ], fluid=True, className="pt-3")
+
+
+def clustering_tab():
+ return dbc.Container([
+ dbc.Row([
+ make_dropdown("Model", "dd-model", MODELS, value=MODELS[0] if MODELS else None),
+ make_dropdown("Min community size", "dd-min", MINS, value=MINS[0] if MINS else None),
+ make_dropdown("Threshold", "dd-threshold", THRESHOLDS, value=THRESHOLDS[0] if THRESHOLDS else None),
+ make_dropdown("Field", "dd-field", CLUSTER_FIELDS, value=CLUSTER_FIELDS[0] if CLUSTER_FIELDS else None),
+ ], className="mb-3 g-3"),
+ dbc.Row([
+ dbc.Col([
+ html.Label("Filter by group", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="cluster-group-filter",
+ options=[{"label": g, "value": g} for g in ALL_GROUPS],
+ multi=True,
+ placeholder="All groups",
+ style={"fontSize": "13px"},
+ ),
+ ], width=4),
+ dbc.Col([
+ dbc.RadioItems(
+ id="plot-type",
+ options=[
+ {"label": " UMAP cluster plot", "value": "cluster"},
+ {"label": " Cluster distribution", "value": "distribution"},
+ ],
+ value="cluster",
+ inline=True,
+ className="mb-2 mt-4",
+ ),
+ ], width=4),
+ dbc.Col([
+ html.Label("Show top N clusters", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="top-n-clusters",
+ options=[{"label": str(n), "value": n} for n in [10, 15, 20, 30, 50]] +
+ [{"label": "All", "value": 0}],
+ value=20,
+ clearable=False,
+ style={"fontSize": "13px"},
+ ),
+ ], width=2),
+ ], className="mb-2 align-items-end"),
+ dbc.Row([dbc.Col(html.Div(id="plot-status", className="text-danger small mb-1"))]),
+ dbc.Row([
+ dbc.Col(
+ dcc.Graph(
+ id="cluster-graph",
+ config={"displayModeBar": True, "toImageButtonOptions": {"format": "svg"}},
+ style={"height": "650px"},
+ ),
+ width=12,
+ ),
+ ]),
+ html.Div(id="cluster-point-detail", className="mt-2"),
+ dcc.Store(id="cluster-table-store"),
+ dcc.Download(id="download-cluster-csv"),
+ html.Hr(),
+ html.H6("Cluster metrics for selected parameters", className="mt-2 mb-2 fw-semibold"),
+ dbc.Row([dbc.Col(html.Div(id="metrics-table-container"), width=12)]),
+ ], fluid=True, className="pt-3")
+
+
+_METRIC_LABELS = {
+ "silhouette_cosine": "Silhouette Score",
+ "davies_bouldin": "Davies-Bouldin Index",
+ "n_clusters": "Number of Clusters",
+}
+
+
+def grid_metrics_tab():
+ if METRICS_DF.empty:
+ return dbc.Container([html.P("No metrics data found.", className="text-muted mt-3")])
+
+ return dbc.Container([
+ html.Div([
+ dbc.Row([
+ dbc.Col([
+ html.Label("Field", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="dd-metrics-field",
+ options=[{"label": f.replace("_", " ").title(), "value": f}
+ for f in METRIC_FIELDS],
+ value=METRIC_FIELDS[0] if METRIC_FIELDS else None,
+ clearable=False,
+ style={"fontSize": "13px"},
+ ),
+ ], width=4),
+ dbc.Col([
+ html.Label("Metric", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="dd-metrics-type",
+ options=[{"label": v, "value": k} for k, v in _METRIC_LABELS.items()],
+ value="silhouette_cosine",
+ clearable=False,
+ style={"fontSize": "13px"},
+ ),
+ ], width=4),
+ dbc.Col([
+ html.Label("Model (heatmap)", className="fw-semibold small mb-1"),
+ dcc.Dropdown(
+ id="dd-metrics-model",
+ options=[{"label": m, "value": m} for m in METRIC_MODELS],
+ value=METRIC_MODELS[0] if METRIC_MODELS else None,
+ clearable=False,
+ style={"fontSize": "13px"},
+ ),
+ ], width=4),
+ ]),
+ ], className="filter-panel"),
+ dbc.Row([
+ dbc.Col(
+ dcc.Graph(id="metrics-heatmap",
+ config={"displayModeBar": True,
+ "toImageButtonOptions": {"format": "svg"}},
+ style={"height": "420px"}),
+ width=6,
+ ),
+ dbc.Col(
+ dcc.Graph(id="metrics-model-compare",
+ config={"displayModeBar": True,
+ "toImageButtonOptions": {"format": "svg"}},
+ style={"height": "420px"}),
+ width=6,
+ ),
+ ], className="mt-3"),
+ html.Hr(),
+ html.H6("Best parameter combinations", className="fw-semibold mt-2 mb-2"),
+ html.Div(id="metrics-best-table"),
+ ], fluid=True, className="pt-3")
+
+
+AVATAR_COLORS = ["#1a73e8", "#e8711a", "#1aa85c", "#8e1ae8"]
+
+def person_card(name, email, role, departments, color="#1a73e8"):
+ initials = "".join(p[0].upper() for p in name.split()[:2])
+ return dbc.Card([
+ dbc.CardBody([
+ html.Div(initials, className="avatar-circle", style={"backgroundColor": color}),
+ html.H6(name, className="mb-0 fw-bold"),
+ html.A(email, href=f"mailto:{email}", className="text-muted small d-block mb-2"),
+ html.Span(role, className="badge rounded-pill mb-2",
+ style={"backgroundColor": color, "fontSize": "11px"}),
+ html.Ul([html.Li(d, className="small text-muted") for d in departments],
+ className="mb-0 ps-3") if departments else None,
+ ])
+ ], className="h-100 shadow-sm person-card border-0")
+
+
+TEAM = [
+ dict(
+ name="Ricardo Almada Monter",
+ email="ralmadamonter@ucsd.edu",
+ role="Graduate Student Researcher",
+ departments=["Department of Chemistry & Biochemistry, UC San Diego"],
+ ),
+ dict(
+ name="Jose Martinez Lomeli",
+ email="lomeli90@gmail.com",
+ role="Independent Researcher",
+ departments=[],
+ ),
+ dict(
+ name="Erika Garay",
+ email="ecgaray@health.ucsd.edu",
+ role="Staff Scientist",
+ departments=[
+ "Skaggs School of Pharmacy and Pharmaceutical Sciences, UC San Diego",
+ ],
+ ),
+ dict(
+ name="Adrian Jinich, PhD",
+ email="ajinich@health.ucsd.edu",
+ role="Assistant Professor",
+ departments=[
+ "Skaggs School of Pharmacy and Pharmaceutical Sciences, UC San Diego",
+ "Department of Chemistry & Biochemistry, UC San Diego",
+ ],
+ ),
+]
+
+
+def contact_cards():
+ return dbc.Row(
+ [dbc.Col(person_card(**m, color=AVATAR_COLORS[i % len(AVATAR_COLORS)]), width=3)
+ for i, m in enumerate(TEAM)],
+ className="g-4",
+ )
+
+
+def pipeline_tab():
+ code_block = (
+ "bash scripts/run_pipeline_extraction.sh \\\n"
+ " -s scripts/ \\\n"
+ " -u uniprot_tables/your_table.tsv.gz \\\n"
+ " -a api_keys.txt \\\n"
+ " -l api_keys_llama.txt \\\n"
+ " -e \"your@email.com\" \\\n"
+ " -U \"your@email.com\" \\\n"
+ " -j jsons/output/ \\\n"
+ " -o artifacts/ \\\n"
+ " -g norag \\\n"
+ " -M gpt-4.1-mini"
+ )
+ cluster_block = (
+ "python scripts/create_clustering_plots.py \\\n"
+ " -j jsons/output/your_table_purification_methods_no_rag.json \\\n"
+ " -o clusters/my_run \\\n"
+ " -m neuml/pubmedbert-base-embeddings \\\n"
+ " --clustering \\\n"
+ " -min 5 \\\n"
+ " -t 0.75"
+ )
+ return dbc.Container([
+ html.H3("How to Run the LLM Protein Purification Extraction Pipeline", className="mt-3 mb-1"),
+ html.P([
+ "Step-by-step instructions for running the automated extraction pipeline — "
+ "from a UniProt table to structured protein purification conditions. "
+ "All code is available at ",
+ html.A("github.com/jinichlab/llm_extractor",
+ href="https://github.com/jinichlab/llm_extractor",
+ target="_blank"),
+ ".",
+ ], className="text-muted mb-4"),
+
+ dbc.Accordion([
+
+ # ── Step 0: prerequisites ───────────────────────────────────────
+ dbc.AccordionItem(title="0 · Prerequisites", children=[
+ dbc.ListGroup([
+ dbc.ListGroupItem([html.Code("conda"), " installed (Anaconda or Miniconda)."]),
+ dbc.ListGroupItem([
+ html.Strong("OpenAI API key"), " set as environment variable:",
+ html.Pre("export OPENAI_API_KEY=\"sk-...\"",
+ className="bg-light p-2 rounded mt-1 mb-0"),
+ ]),
+ dbc.ListGroupItem([
+ html.Strong("LlamaCloud API key"), " — sign in at ",
+ html.A("cloud.llamaindex.ai", href="https://cloud.llamaindex.ai",
+ target="_blank"),
+ ", generate a key, and save it to ", html.Code("api_keys_llama.txt"), ".",
+ ]),
+ dbc.ListGroupItem([
+ html.Strong("Publisher API keys"), " (Elsevier / Wiley) — save them to ",
+ html.Code("api_keys.txt"), " one per line:",
+ html.Pre("elsevier your-key\nwiley your-key",
+ className="bg-light p-2 rounded mt-1 mb-0"),
+ ]),
+ ], flush=True),
+ ]),
+
+ # ── Step 1: install ─────────────────────────────────────────────
+ dbc.AccordionItem(title="1 · Install the environment", children=[
+ html.Pre(
+ "conda env create -f environment.yml\nconda activate llm_extractor_enviroment",
+ className="bg-light p-3 rounded mb-0",
+ ),
+ ]),
+
+ # ── Step 2: prepare input ───────────────────────────────────────
+ dbc.AccordionItem(title="2 · Prepare the UniProt input table", children=[
+ html.P([
+ "Download a UniProt table for your protein family (TSV or TSV.GZ) and place it in ",
+ html.Code("uniprot_tables/"),
+ ". The table must include a ",
+ html.Code("PubMed ID"),
+ " column so the pipeline can fetch the papers.",
+ ], className="mb-0"),
+ ]),
+
+ # ── Step 3: run the pipeline ────────────────────────────────────
+ dbc.AccordionItem(title="3 · Run the full pipeline", children=[
+ html.P("From the repository root:", className="mb-2"),
+ html.Pre(code_block, className="bg-light p-3 rounded mb-3"),
+ dbc.Table([
+ html.Thead(html.Tr([html.Th("Flag"), html.Th("Required"), html.Th("Description")])),
+ html.Tbody([
+ html.Tr([html.Td(html.Code("-s")), html.Td("yes"), html.Td("Path to the scripts/ directory")]),
+ html.Tr([html.Td(html.Code("-u")), html.Td("yes"), html.Td("UniProt table (.tsv or .tsv.gz)")]),
+ html.Tr([html.Td(html.Code("-a")), html.Td("yes"), html.Td("Publisher API keys file (api_keys.txt)")]),
+ html.Tr([html.Td(html.Code("-l")), html.Td("yes"), html.Td("LlamaCloud API key file (api_keys_llama.txt)")]),
+ html.Tr([html.Td(html.Code("-e")), html.Td("yes"), html.Td("Email for NCBI Entrez")]),
+ html.Tr([html.Td(html.Code("-U")), html.Td("yes"), html.Td("User hint passed to the extraction step")]),
+ html.Tr([html.Td(html.Code("-j")), html.Td("yes"), html.Td("Output directory for all JSON files")]),
+ html.Tr([html.Td(html.Code("-o")), html.Td("yes"), html.Td("Output directory for PDFs and artifacts")]),
+ html.Tr([html.Td(html.Code("-g")), html.Td("yes"), html.Td("Extraction mode: rag or norag")]),
+ html.Tr([html.Td(html.Code("-m")), html.Td("no"), html.Td("Max papers to download (default: 15)")]),
+ html.Tr([html.Td(html.Code("-M")), html.Td("no"), html.Td("OpenAI model name (default: gpt-4.1-mini)")]),
+ ]),
+ ], bordered=True, size="sm", className="mb-0"),
+ ]),
+
+ # ── Step 4: pipeline stages ─────────────────────────────────────
+ dbc.AccordionItem(title="4 · What the pipeline does (stages)", children=[
+ dbc.ListGroup([
+ dbc.ListGroupItem([html.Strong("1. Download papers"), " — fetches PDFs/XMLs from PubMed via paperscraper."]),
+ dbc.ListGroupItem([html.Strong("2. Classify papers"), " — LlamaParse decides whether each paper reports experimental enzymology."]),
+ dbc.ListGroupItem([html.Strong("3. Filter positives"), " — keeps only papers classified as enzymology."]),
+ dbc.ListGroupItem([html.Strong("4. Extract Methods sections"), " — OpenAI structured output identifies the Methods text."]),
+ dbc.ListGroupItem([
+ html.Strong("5. Extract purification conditions"), " — structured JSON with 12 fields per protein "
+ "(organism, strain, plasmid, inducer, buffers, etc.). ",
+ html.Span("norag", className="badge bg-secondary me-1"),
+ "sends the full Methods text; ",
+ html.Span("rag", className="badge bg-primary"),
+ " retrieves relevant chunks from a ChromaDB vector store first.",
+ ]),
+ ], flush=True),
+ ]),
+
+ # ── Step 5: clustering ──────────────────────────────────────────
+ dbc.AccordionItem(title="5 · Run clustering (optional)", children=[
+ html.P(
+ "After extraction, embed and cluster each field with a biomedical language model. "
+ "Outputs are loaded by this dashboard.",
+ className="mb-2",
+ ),
+ html.Pre(cluster_block, className="bg-light p-3 rounded mb-3"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([html.Code("-t"), " — cosine similarity threshold (lower = broader clusters)"]),
+ dbc.ListGroupItem([html.Code("-min"), " — minimum entries to form a cluster (lower = more clusters)"]),
+ dbc.ListGroupItem([html.Code("-m"), " — embedding model; default ",
+ html.Code("neuml/pubmedbert-base-embeddings"),
+ " is optimised for biomedical text"]),
+ ], flush=True),
+ ]),
+
+ # ── Output files ────────────────────────────────────────────────
+ dbc.AccordionItem(title="Output files", children=[
+ dbc.Table([
+ html.Thead(html.Tr([html.Th("File"), html.Th("Description")])),
+ html.Tbody([
+ html.Tr([html.Td(html.Code("*_papers.json")), html.Td("Paper metadata and download status")]),
+ html.Tr([html.Td(html.Code("df_classification_*.json")),html.Td("LlamaCloud classification results")]),
+ html.Tr([html.Td(html.Code("filtered_*.json")), html.Td("Enzymology-positive papers only")]),
+ html.Tr([html.Td(html.Code("*_method_extraction.json")),html.Td("Extracted Methods sections")]),
+ html.Tr([html.Td(html.Code("*_purification_methods_*.json")), html.Td("Final structured purification data")]),
+ html.Tr([html.Td(html.Code("pdfs_*/")), html.Td("Downloaded PDF/XML files")]),
+ html.Tr([html.Td(html.Code("*_FIELD_CLUSTER_METRICS.csv")), html.Td("Silhouette / Davies-Bouldin scores per field")]),
+ html.Tr([html.Td(html.Code("*_ALL_FIELDS.csv")), html.Td("Combined clustering table across all fields")]),
+ ]),
+ ], bordered=True, size="sm", className="mb-0"),
+ ]),
+
+ ], start_collapsed=True, className="mb-4"),
+
+ ], fluid=True, className="pt-3")
+
+
+def evaluation_tab():
+ label_options = [{"label": lbl, "value": lbl} for lbl in _EVAL_LABELS]
+
+ return dbc.Container([
+ html.H3("Evaluation Results", className="mt-3 mb-1"),
+ html.P(
+ "Pipeline evaluation across protein groups and GPT models: "
+ "methods extraction quality (NLP metrics) and purification conditions accuracy "
+ "(LLM-based scoring and NLP metrics).",
+ className="text-muted mb-4",
+ ),
+
+ dbc.Accordion([
+
+ # ── Section 1: Purification conditions ──────────────────────────
+ dbc.AccordionItem(
+ title="1 · Purification Conditions Evaluation",
+ children=[
+ # Controls row
+ dbc.Row([
+ dbc.Col([
+ html.Label("Protein group", className="fw-semibold small mb-1"),
+ dcc.RadioItems(
+ id="eval-group",
+ options=[
+ {"label": " Azoreductases", "value": "azoreductases"},
+ {"label": " SAMs", "value": "sams"},
+ ],
+ value="azoreductases",
+ inline=True,
+ inputStyle={"marginRight": "4px"},
+ labelStyle={"marginRight": "16px"},
+ ),
+ ], width=12, md=3),
+ dbc.Col([
+ html.Label("Models / configurations", className="fw-semibold small mb-1"),
+ dcc.Checklist(
+ id="eval-model-checklist",
+ options=label_options,
+ value=_EVAL_LABELS,
+ inline=True,
+ inputStyle={"marginRight": "4px"},
+ labelStyle={"marginRight": "14px", "fontSize": "0.85rem"},
+ ),
+ ], width=12, md=9),
+ ], className="mb-4 align-items-start"),
+
+ # Sub-tabs
+ dbc.Tabs([
+ dbc.Tab(label="LLM-based Evaluation", tab_id="eval-tab-llm", children=[
+ html.P(
+ "Mean LLM similarity score (0–10) per extraction field. "
+ "Higher = more similar to the ground truth. "
+ "Click a bar to see examples.",
+ className="text-muted small mt-2 mb-1",
+ ),
+ dcc.Graph(id="eval-llm-graph",
+ config={"displayModeBar": False},
+ style={"height": "460px"}),
+ html.Div(id="eval-llm-examples", className="mt-3"),
+ ]),
+ dbc.Tab(label="NLP-based Evaluation", tab_id="eval-tab-nlp", children=[
+ dbc.Row([
+ dbc.Col([
+ html.Label("Metric", className="fw-semibold small mt-2 mb-1"),
+ dcc.Dropdown(
+ id="eval-nlp-metric",
+ options=[{"label": v, "value": k}
+ for k, v in _NLP_METRICS.items()],
+ value="bertscore_f1",
+ clearable=False,
+ ),
+ ], width=12, md=3),
+ ], className="mb-2"),
+ dcc.Graph(id="eval-nlp-graph",
+ config={"displayModeBar": False},
+ style={"height": "460px"}),
+ html.Div(id="eval-nlp-examples", className="mt-3"),
+ ]),
+ ], id="eval-sub-tabs", active_tab="eval-tab-llm"),
+ ],
+ ),
+
+ # ── Section 2: Methods extraction ───────────────────────────────
+ dbc.AccordionItem(
+ title="2 · Methods Extraction Quality",
+ children=[
+ html.P(
+ "Average NLP metrics comparing the extracted Methods section text "
+ "to the curated ground truth. Click a bar to see the distribution "
+ "and examples.",
+ className="text-muted small mb-3",
+ ),
+ dcc.RadioItems(
+ id="classif-group",
+ options=[
+ {"label": " Azoreductases", "value": "azoreductases"},
+ {"label": " SAMs", "value": "sams"},
+ ],
+ value="azoreductases",
+ inline=True,
+ inputStyle={"marginRight": "4px"},
+ labelStyle={"marginRight": "16px"},
+ className="mb-3",
+ ),
+ dcc.Graph(id="eval-classif-graph",
+ config={"displayModeBar": False},
+ style={"height": "320px"}),
+ html.Div(id="eval-classif-examples", className="mt-3"),
+ ],
+ ),
+
+ # ── Section 3: Classification confusion matrix ───────────────────
+ dbc.AccordionItem(
+ title="3 · Classification Performance (Enzymology Detection)",
+ children=[
+ html.P(
+ "Confusion matrix and classification metrics for the LlamaParse "
+ "paper classification step (enzymology vs. non-enzymology).",
+ className="text-muted small mb-3",
+ ),
+ dcc.RadioItems(
+ id="confmat-group",
+ options=[
+ {"label": " Azoreductases", "value": "azoreductases"},
+ {"label": " SAMs", "value": "sams"},
+ ],
+ value="sams",
+ inline=True,
+ inputStyle={"marginRight": "4px"},
+ labelStyle={"marginRight": "16px"},
+ className="mb-3",
+ ),
+ html.Div(id="confmat-cards"),
+ dcc.Graph(id="confmat-heatmap",
+ config={"displayModeBar": False},
+ style={"height": "340px"}),
+ html.Div(id="confmat-examples", className="mt-3"),
+ ],
+ ),
+
+ ], start_collapsed=False),
+ ], fluid=True, className="pt-3")
+
+
+def contact_tab():
+ return dbc.Container([
+ html.H3("Research Team", className="mt-3 mb-4"),
+ contact_cards(),
+ ], fluid=True, className="pt-3")
+
+
+def readme_tab():
+ return dbc.Container([
+ html.H3("ProtoPure — Dashboard Guide", className="mt-3 mb-1"),
+ html.P(
+ "ProtoPure displays protein purification conditions extracted from the scientific "
+ "literature by an LLM pipeline. Use the tabs to explore the data, inspect "
+ "clustering results, compare models, and find instructions for running the "
+ "pipeline yourself. Click a section below to expand it.",
+ className="text-muted mb-4",
+ ),
+ dbc.Accordion([
+
+ # ── Extraction Data ───────────────────────────────────────────────
+ dbc.AccordionItem(title="Extraction Data", children=[
+ html.P(
+ "One row per extracted protein. Each row combines the 12 structured "
+ "purification fields (organism, strain, plasmid, inducer, buffers, etc.) "
+ "with paper metadata and linked UniProt entries.",
+ className="mb-3",
+ ),
+ html.H6("Filters", className="fw-bold"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([
+ html.Span("Filter by group ", className="fw-semibold"),
+ "Restrict the table to one or more protein families "
+ "(azoreductases, sdrs, sams, etc.). Multiple groups can be selected simultaneously.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Search by / Search value ", className="fw-semibold"),
+ "Choose a field (PMID, Enzyme name, Organism, Expression strain, Plasmid, "
+ "Inducer, UniProt ID, Journal, or Paper title) and type any text. "
+ "Matching is case-insensitive and partial (e.g. 'coli' matches 'Escherichia coli').",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("✕ Clear ", className="fw-semibold"),
+ "Resets the search value and group filter, returning to the full dataset.",
+ ]),
+ ], flush=True, className="mb-3"),
+ html.H6("Table", className="fw-bold mt-2"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([
+ html.Span("Default columns ", className="fw-semibold"),
+ "PMID, Enzyme name, and Organism source are shown by default. "
+ "Use the column selector to add or hide any of the 12 extracted fields.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Hover ", className="fw-semibold"),
+ "over a truncated cell to see its full text.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Click a column header ", className="fw-semibold"),
+ "to sort ascending/descending.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Click a row ", className="fw-semibold"),
+ "to open the detail panel below the table. The panel shows four cards: "
+ "all extracted purification fields, linked UniProt entries (with links to uniprot.org), "
+ "paper metadata (title, journal, date, group badges, PubMed link, full-text link), "
+ "and a full conditions table for every protein in that paper.",
+ ]),
+ ], flush=True, className="mb-3"),
+ html.H6("Download CSV", className="fw-bold mt-2"),
+ html.P(
+ "Downloads the currently visible (filtered) table as a CSV. "
+ "Apply filters first — the download reflects exactly what is shown on screen.",
+ className="mb-0",
+ ),
+ ]),
+
+ # ── Clustering Explorer ───────────────────────────────────────────
+ dbc.AccordionItem(title="Clustering Explorer", children=[
+ html.P(
+ "Explore semantic clusters of extracted field values. Embeddings are computed "
+ "with biomedical language models; community detection groups semantically similar "
+ "entries into clusters. All plots are rendered natively in Plotly — hover, zoom, "
+ "and pan are fully interactive.",
+ className="mb-3",
+ ),
+ html.H6("Parameter dropdowns", className="fw-bold"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([
+ html.Span("Model ", className="fw-semibold"),
+ "Sentence-embedding model used to encode field values "
+ "(e.g. PubMedBERT, BioBERT, SapBERT).",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Min community size ", className="fw-semibold"),
+ "Minimum entries required to form a cluster. Lower = more, smaller clusters.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Threshold ", className="fw-semibold"),
+ "Cosine similarity threshold for cluster membership. Higher = tighter clusters.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Field ", className="fw-semibold"),
+ "Which extraction field to display: enzyme name, organism, lysis buffer, "
+ "inducer, etc. Clustering is performed independently per field.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Filter by group ", className="fw-semibold"),
+ "Restrict the plot to entries from one or more protein families.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Show top N clusters ", className="fw-semibold"),
+ "The largest N clusters each get a distinct colour; all remaining clusters "
+ "are merged into a light-gray \"Other\" trace. Choose \"All\" to colour every "
+ "cluster individually (may be slow for large fields).",
+ ]),
+ ], flush=True, className="mb-3"),
+ html.H6("UMAP cluster plot", className="fw-bold mt-2"),
+ dbc.ListGroup([
+ dbc.ListGroupItem(
+ "2D projection of all field values coloured by cluster. "
+ "Hover over a point to see the original text, PMID, and cluster label."
+ ),
+ dbc.ListGroupItem(
+ "Noise points (not assigned to any cluster) are shown in light gray at low opacity."
+ ),
+ dbc.ListGroupItem([
+ html.Span("Click a point ", className="fw-semibold"),
+ "to show a detail panel with that protein's full purification conditions.",
+ ]),
+ ], flush=True, className="mb-3"),
+ html.H6("Cluster distribution (bar chart)", className="fw-bold mt-2"),
+ dbc.ListGroup([
+ dbc.ListGroupItem(
+ "Bar chart of the top-N clusters sorted by size. "
+ "Quickly see which conditions are most common across the dataset."
+ ),
+ dbc.ListGroupItem([
+ html.Span("Click a bar ", className="fw-semibold"),
+ "to show a table of all proteins in that cluster, with a Download CSV button.",
+ ]),
+ ], flush=True, className="mb-3"),
+ html.H6("Metrics table", className="fw-bold mt-2"),
+ html.P(
+ "Shows Silhouette score (cosine) and Davies-Bouldin index for every field "
+ "under the selected model / min-size / threshold combination. "
+ "Silhouette > 0.6 (highlighted green) indicates well-separated clusters; "
+ "lower Davies-Bouldin indicates more compact, better-separated clusters.",
+ className="mb-0",
+ ),
+ ]),
+
+ # ── Grid Metrics ──────────────────────────────────────────────────
+ dbc.AccordionItem(title="Grid Metrics", children=[
+ html.P(
+ "Interactive charts comparing clustering quality across all combinations of "
+ "embedding model, similarity threshold, and min community size. "
+ "Loaded from the pre-computed metrics CSVs at startup.",
+ className="mb-3",
+ ),
+ html.H6("Controls", className="fw-bold"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([
+ html.Span("Field ", className="fw-semibold"),
+ "Which extraction field to display metrics for.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Metric ", className="fw-semibold"),
+ html.Ul([
+ html.Li([html.Span("Silhouette Score ", className="fw-semibold"),
+ "— ranges −1 to 1; higher is better."]),
+ html.Li([html.Span("Davies-Bouldin Index ", className="fw-semibold"),
+ "— non-negative; lower is better."]),
+ html.Li([html.Span("Number of Clusters ", className="fw-semibold"),
+ "— how many clusters were found at each parameter combination."]),
+ ], className="mb-0 mt-1"),
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Model ", className="fw-semibold"),
+ "Filter the model-comparison bar chart to a single embedding model, "
+ "or select \"All\" to compare all models side by side.",
+ ]),
+ ], flush=True, className="mb-3"),
+ html.H6("Charts", className="fw-bold mt-2"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([
+ html.Span("Heatmap ", className="fw-semibold"),
+ "Threshold (x) vs min community size (y) coloured by the selected metric. "
+ "Hover to see exact values.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Model comparison bar chart ", className="fw-semibold"),
+ "Average metric value per model across all parameter combinations.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Top-10 configurations table ", className="fw-semibold"),
+ "The ten parameter combinations with the best metric score for the selected field.",
+ ]),
+ ], flush=True),
+ ]),
+
+ # ── Evaluation Results ────────────────────────────────────────────
+ dbc.AccordionItem(title="Evaluation Results", children=[
+ html.P(
+ "Pipeline evaluation across two protein groups (Azoreductases and SAMs) "
+ "and six GPT model configurations (gpt-4.1, gpt-5-mini, gpt-5 × no-RAG / RAG). "
+ "Three sections are available.",
+ className="mb-3",
+ ),
+ html.H6("1 · Purification Conditions Evaluation", className="fw-bold"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([
+ html.Span("LLM-based evaluation ", className="fw-semibold"),
+ "Mean LLM similarity score (0–10) per extraction field, with standard-error bars. "
+ "Click a bar to see the score distribution (box plot + histogram) and "
+ "the 5 worst / 5 best protein pairs for that field and model.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("NLP-based evaluation ", className="fw-semibold"),
+ "Same layout using NLP metrics (BERTScore F1, ROUGE-1 F, BLEU, METEOR, "
+ "Cosine Similarity). All metrics normalised to 0–1. "
+ "Use the metric dropdown to switch between metrics. "
+ "Click a bar for distribution and examples.",
+ ]),
+ ], flush=True, className="mb-3"),
+ html.H6("2 · Methods Extraction Quality", className="fw-bold"),
+ html.P(
+ "Average NLP metrics comparing the extracted Methods section text to the curated "
+ "ground truth (Azoreductases: 31 papers; SAMs: 292 papers). "
+ "All metrics normalised to 0–1. "
+ "Click a bar to see the distribution and 5 worst / 5 best paper excerpts.",
+ className="mb-3",
+ ),
+ html.H6("3 · Classification Performance (Enzymology Detection)", className="fw-bold"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([
+ html.Span("Metric cards ", className="fw-semibold"),
+ "Precision, Recall, F1 Score, and Accuracy for the LlamaParse "
+ "enzymology classification step.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Confusion matrix ", className="fw-semibold"),
+ "Interactive heatmap (Actual × Predicted) with counts and percentages. "
+ "Click any cell (TP / FP / FN / TN) to see up to 10 example papers "
+ "from that category with title, date, journal, and PubMed link.",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("Azoreductases ", className="fw-semibold"),
+ "All 31 ground-truth papers were classified as enzymology "
+ "(Precision = Recall = F1 = 1.0; no negative set available).",
+ ]),
+ dbc.ListGroupItem([
+ html.Span("SAMs ", className="fw-semibold"),
+ "Full 2×2 confusion matrix from 292 papers "
+ "(Precision ≈ 0.69, Recall ≈ 0.88, F1 ≈ 0.78).",
+ ]),
+ ], flush=True),
+ ]),
+
+ # ── Extraction Pipeline Instructions ──────────────────────────────
+ dbc.AccordionItem(title="Extraction Pipeline Instructions", children=[
+ html.P([
+ "Step-by-step guide for running the LLM extraction pipeline locally "
+ "to produce your own dataset. Full details are in the ",
+ html.A("Extraction Pipeline Instructions",
+ href="#", id="readme-pipeline-link"),
+ " tab. Source code: ",
+ html.A("github.com/jinichlab/llm_extractor",
+ href="https://github.com/jinichlab/llm_extractor",
+ target="_blank"),
+ ".",
+ ], className="mb-3"),
+ dbc.ListGroup([
+ dbc.ListGroupItem([html.Span("0 · Prerequisites ", className="fw-semibold"),
+ "conda, OpenAI key, LlamaCloud key, publisher API keys."]),
+ dbc.ListGroupItem([html.Span("1 · Install ", className="fw-semibold"),
+ html.Code("conda env create -f environment.yml"),
+ " + ", html.Code("conda activate llm_extractor_enviroment"), "."]),
+ dbc.ListGroupItem([html.Span("2 · Input ", className="fw-semibold"),
+ "UniProt TSV with a PubMed ID column in ",
+ html.Code("uniprot_tables/"), "."]),
+ dbc.ListGroupItem([html.Span("3 · Run ", className="fw-semibold"),
+ html.Code("scripts/run_pipeline_extraction.sh"),
+ " — downloads papers, classifies them, extracts Methods, "
+ "and outputs structured purification JSON."]),
+ dbc.ListGroupItem([html.Span("4 · Cluster (optional) ", className="fw-semibold"),
+ html.Code("scripts/create_clustering_plots.py"),
+ " — embeds and clusters each field; outputs loaded by this dashboard."]),
+ ], flush=True),
+ ]),
+
+ # ── Contact ───────────────────────────────────────────────────────
+ dbc.AccordionItem(title="Contact", children=[contact_cards()]),
+
+ ], start_collapsed=True, always_open=True),
+ ], fluid=True, className="pt-3")
+
+
+# ---------------------------------------------------------------------------
+# App layout
+# ---------------------------------------------------------------------------
+app.layout = dbc.Container([
+ dbc.Navbar(
+ dbc.Container([
+ html.Div([
+ html.Span("⚗ ProtoPure", className="navbar-brand text-white"),
+ html.Span("LLM-Enhanced Systematic Extraction and Comparison of Protein Purification Conditions",
+ className="navbar-subtitle text-white"),
+ ]),
+ ], fluid=True),
+ color="#1a3a5c",
+ dark=True,
+ className="mb-3 rounded shadow-sm px-3 py-2",
+ ),
+ dbc.Tabs([
+ dbc.Tab(label="Extraction Data", tab_id="tab-proteins"),
+ dbc.Tab(label="Clustering Explorer",tab_id="tab-clustering"),
+ dbc.Tab(label="Grid Metrics", tab_id="tab-grid-metrics"),
+ dbc.Tab(label="Evaluation Results", tab_id="tab-evaluation"),
+ dbc.Tab(label="Extraction Pipeline Instructions", tab_id="tab-pipeline"),
+ dbc.Tab(label="README", tab_id="tab-readme"),
+ dbc.Tab(label="Contact", tab_id="tab-contact"),
+ ], id="main-tabs", active_tab="tab-proteins"),
+ html.Div(id="tab-content", className="mt-2"),
+], fluid=True)
+
+
+# ---------------------------------------------------------------------------
+# Callbacks
+# ---------------------------------------------------------------------------
+@app.callback(Output("tab-content", "children"), Input("main-tabs", "active_tab"))
+def render_tab(tab):
+ if tab == "tab-proteins":
+ return proteins_tab()
+ elif tab == "tab-readme":
+ return readme_tab()
+ elif tab == "tab-contact":
+ return contact_tab()
+ elif tab == "tab-clustering":
+ return clustering_tab()
+ elif tab == "tab-grid-metrics":
+ return grid_metrics_tab()
+ elif tab == "tab-evaluation":
+ return evaluation_tab()
+ elif tab == "tab-pipeline":
+ return pipeline_tab()
+ return html.Div()
+
+
+def _apply_filters(groups, search_field, search_value):
+ filtered = df.copy()
+ if groups:
+ filtered = filtered[filtered["groups"].apply(
+ lambda g: any(sel in g.split(", ") for sel in groups)
+ )]
+ if search_value and search_field and search_field in filtered.columns:
+ filtered = filtered[
+ filtered[search_field].astype(str).str.contains(search_value, case=False, na=False)
+ ]
+ return filtered
+
+
+@app.callback(
+ Output("protein-table", "data"),
+ Output("protein-table", "hidden_columns"),
+ Output("protein-table", "tooltip_data"),
+ Output("protein-count", "children"),
+ Input("filter-group", "value"),
+ Input("search-field", "value"),
+ Input("search-value", "value"),
+ Input("col-selector", "value"),
+)
+def filter_proteins(groups, search_field, search_value, selected_cols):
+ filtered = _apply_filters(groups, search_field, search_value)
+ cols = selected_cols if selected_cols else DEFAULT_COLS
+ hidden = [c for c in TABLE_FIELDS if c not in cols]
+ records = filtered[TABLE_FIELDS].to_dict("records")
+ tooltips = [
+ {c: {"value": str(row.get(c, "")), "type": "markdown"} for c in cols}
+ for row in records
+ ]
+ label = f"Showing {len(filtered):,} of {len(df):,} proteins"
+ return records, hidden, tooltips, label
+
+
+@app.callback(
+ Output("detail-panel", "children"),
+ Input("protein-table", "selected_rows"),
+ Input("filter-group", "value"),
+ Input("search-field", "value"),
+ Input("search-value", "value"),
+ prevent_initial_call=True,
+)
+def show_detail(selected_rows, groups, search_field, search_value):
+ if not selected_rows:
+ return html.Div()
+ filtered = _apply_filters(groups, search_field, search_value)
+ clicked = selected_rows[0]
+ if clicked >= len(filtered):
+ return dash.no_update
+ row = filtered.iloc[clicked].to_dict()
+ pmid = str(row.get("pmid", ""))
+ entry = RAW.get(pmid, {})
+
+ uniprot_ids = entry.get("Uniprot_IDS", []) or []
+ protein_names = entry.get("Protein_names", []) or []
+ organisms = entry.get("Organisms", []) or []
+ sequences = entry.get("Sequences", []) or []
+
+ # --- Extracted protein card ---
+ extraction_items = []
+ for field in EXTRACTION_FIELDS[1:]: # skip pmid
+ val = row.get(field, "")
+ if val:
+ item = detail_field(field.replace("_", " ").title(), val)
+ if item:
+ extraction_items.append(item)
+
+ # Paper metadata from META
+ m = META.get(str(pmid), {})
+
+ pubmed_link = html.A(
+ f"PubMed: {pmid}",
+ href=f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
+ target="_blank",
+ className="small",
+ )
+
+ # --- Extracted protein card ---
+ extracted_card = dbc.Card([
+ dbc.CardHeader(html.Span("Extracted Protein", className="fw-bold")),
+ dbc.CardBody(extraction_items or [html.Span("No extraction data.", className="text-muted small")]),
+ ], className="mb-3", color="light")
+
+ # --- Paper metadata card ---
+ paper_links = [pubmed_link]
+ if m.get("url"):
+ paper_links += [
+ html.Span(" · ", className="text-muted mx-1"),
+ html.A("Full text", href=m["url"], target="_blank", className="small"),
+ ]
+
+ paper_body = []
+ if m.get("title"):
+ paper_body.append(html.P(m["title"], className="fw-semibold small mb-2"))
+ meta_line = " · ".join(filter(None, [m.get("source", ""), m.get("pub_date", "")]))
+ if meta_line:
+ paper_body.append(html.P(meta_line, className="text-muted small mb-1"))
+ if m.get("groups"):
+ paper_body.append(html.P(
+ [html.Span("Groups: ", className="fw-semibold")] +
+ [dbc.Badge(g, color="primary", className="me-1") for g in m["groups"]],
+ className="mb-0"
+ ))
+
+ paper_card = dbc.Card([
+ dbc.CardHeader(html.Div(paper_links)),
+ dbc.CardBody(paper_body or [html.Span("No paper metadata available.", className="text-muted small")]),
+ ], className="mb-3")
+
+ # --- UniProt entries card ---
+ if uniprot_ids:
+ uniprot_rows = []
+ for i, uid in enumerate(uniprot_ids):
+ name = protein_names[i] if i < len(protein_names) else "—"
+ org = organisms[i] if i < len(organisms) else "—"
+ seq = sequences[i] if i < len(sequences) else None
+ seq_info = f"{len(seq)} aa" if seq else "—"
+
+ uniprot_rows.append(
+ dbc.ListGroupItem([
+ dbc.Row([
+ dbc.Col([
+ html.A(uid,
+ href=f"https://www.uniprot.org/uniprot/{uid}",
+ target="_blank",
+ className="fw-bold small me-2"),
+ html.Span(org, className="text-muted small"),
+ ], width=4),
+ dbc.Col(html.Span(name, className="small"), width=6),
+ dbc.Col(html.Span(seq_info, className="text-muted small"), width=2),
+ ], align="center"),
+ ])
+ )
+
+ uniprot_card = dbc.Card([
+ dbc.CardHeader(html.Span(
+ f"UniProt Entries for PMID {pmid} ({len(uniprot_ids)} entries)",
+ className="fw-bold",
+ )),
+ dbc.CardBody([
+ dbc.Row([
+ dbc.Col(html.Span("UniProt ID / Organism", className="fw-semibold small"), width=4),
+ dbc.Col(html.Span("Protein Name", className="fw-semibold small"), width=6),
+ dbc.Col(html.Span("Sequence", className="fw-semibold small"), width=2),
+ ], className="px-3 mb-1"),
+ dbc.ListGroup(uniprot_rows, flush=True),
+ ]),
+ ], className="mb-3")
+ else:
+ uniprot_card = dbc.Card([
+ dbc.CardHeader("UniProt Entries"),
+ dbc.CardBody(html.Span("No UniProt entries linked to this PMID.", className="text-muted small")),
+ ], className="mb-3", color="light")
+
+ # --- All proteins from this PMID (conditions table) ---
+ all_proteins = entry.get("proteins", []) or []
+ CONDITION_COLS = [
+ "enzyme_name", "organism_source", "strain", "expression_strain",
+ "plasmid", "molecular_weight", "medium_name", "inducer",
+ "induction_temperature", "lysis_buffer", "elution_buffer", "desalting_process",
+ ]
+ cond_records = [
+ {c: (p.get(c) or "") for c in CONDITION_COLS}
+ for p in all_proteins
+ ]
+ # Mark the selected protein by matching enzyme_name + organism_source
+ sel_idx = next(
+ (i for i, p in enumerate(all_proteins)
+ if str(p.get("enzyme_name", "") or "") == str(row.get("enzyme_name", "") or "")
+ and str(p.get("organism_source", "") or "") == str(row.get("organism_source", "") or "")),
+ None,
+ )
+ cond_col_defs = [
+ {"name": c.replace("_", " ").title(), "id": c} for c in CONDITION_COLS
+ ]
+ conditions_card = dbc.Card([
+ dbc.CardHeader(
+ html.Span(
+ f"All purification conditions from PMID {pmid} ({len(all_proteins)} protein{'s' if len(all_proteins) != 1 else ''})",
+ className="fw-bold",
+ )
+ ),
+ dbc.CardBody(
+ dash_table.DataTable(
+ columns=cond_col_defs,
+ data=cond_records,
+ page_size=10,
+ sort_action="native",
+ style_table={"overflowX": "auto"},
+ style_cell={
+ "fontSize": "12px",
+ "padding": "5px 10px",
+ "textAlign": "left",
+ "maxWidth": "260px",
+ "overflow": "hidden",
+ "textOverflow": "ellipsis",
+ "whiteSpace": "nowrap",
+ },
+ style_header={
+ "fontWeight": "700",
+ "backgroundColor": "#1a3a5c",
+ "color": "#ffffff",
+ "fontSize": "11px",
+ "textTransform": "uppercase",
+ "letterSpacing": "0.04em",
+ },
+ style_data_conditional=(
+ [{"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"}] +
+ ([{"if": {"row_index": sel_idx}, "backgroundColor": "#cfe2ff",
+ "border": "1px solid #9ec5fe"}] if sel_idx is not None else [])
+ ),
+ tooltip_data=[
+ {c: {"value": str(r.get(c, "")), "type": "markdown"} for c in CONDITION_COLS}
+ for r in cond_records
+ ],
+ tooltip_delay=0,
+ tooltip_duration=None,
+ ) if cond_records else html.Span("No protein conditions available.", className="text-muted small")
+ ),
+ ], className="mb-3")
+
+ return html.Div([
+ html.Hr(),
+ html.H6("Selected Row Detail", className="fw-semibold mb-3"),
+ dbc.Row([
+ dbc.Col(extracted_card, width=5),
+ dbc.Col(uniprot_card, width=7),
+ ]),
+ dbc.Row([
+ dbc.Col(paper_card, width=12),
+ ]),
+ dbc.Row([
+ dbc.Col(conditions_card, width=12),
+ ]),
+ ])
+
+
+# Qualitative color palette — high-contrast, colorblind-friendly base
+_PALETTE = [
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
+ "#8c564b", "#e377c2", "#17becf", "#bcbd22", "#393b79",
+ "#637939", "#8c6d31", "#843c39", "#7b4173", "#3182bd",
+ "#e6550d", "#31a354", "#756bb1", "#636363", "#6baed6",
+]
+_NOISE_COLOR = "#c0c0c0"
+
+
+def _uniprot_card(pmid):
+ """Build a UniProt entries card for a given PMID."""
+ entry = RAW.get(str(pmid), {})
+ uniprot_ids = entry.get("Uniprot_IDS", []) or []
+ protein_names = entry.get("Protein_names", []) or []
+ organisms = entry.get("Organisms", []) or []
+ sequences = entry.get("Sequences", []) or []
+
+ if not uniprot_ids:
+ return dbc.Card([
+ dbc.CardHeader(html.Span("UniProt Entries", className="fw-bold")),
+ dbc.CardBody(html.Span("No UniProt entries linked to this PMID.",
+ className="text-muted small")),
+ ], className="mb-3", color="light")
+
+ rows = []
+ for i, uid in enumerate(uniprot_ids):
+ name = protein_names[i] if i < len(protein_names) else "—"
+ org = organisms[i] if i < len(organisms) else "—"
+ seq = sequences[i] if i < len(sequences) else None
+ seq_info = f"{len(seq)} aa" if seq else "—"
+ rows.append(dbc.ListGroupItem([
+ dbc.Row([
+ dbc.Col([
+ html.A(uid, href=f"https://www.uniprot.org/uniprot/{uid}",
+ target="_blank", className="fw-bold small me-2"),
+ html.Span(org, className="text-muted small"),
+ ], width=4),
+ dbc.Col(html.Span(name, className="small"), width=6),
+ dbc.Col(html.Span(seq_info, className="text-muted small"), width=2),
+ ], align="center"),
+ ]))
+
+ return dbc.Card([
+ dbc.CardHeader(html.Span(
+ f"UniProt Entries for PMID {pmid} ({len(uniprot_ids)} entries)",
+ className="fw-bold",
+ )),
+ dbc.CardBody([
+ dbc.Row([
+ dbc.Col(html.Span("UniProt ID / Organism", className="fw-semibold small"), width=4),
+ dbc.Col(html.Span("Protein Name", className="fw-semibold small"), width=6),
+ dbc.Col(html.Span("Sequence", className="fw-semibold small"), width=2),
+ ], className="px-3 mb-1"),
+ dbc.ListGroup(rows, flush=True),
+ ]),
+ ], className="mb-3")
+
+
+def _load_cluster_csv(model, min_val, threshold):
+ """Return the ALL_FIELDS dataframe or None. Supports .csv.gz and .csv."""
+ base = os.path.join(GRID_BASE, f"model={model}", f"min={min_val}",
+ f"t={threshold}_ALL_FIELDS")
+ for ext in (".csv.gz", ".csv"):
+ path = base + ext
+ if os.path.isfile(path):
+ return pd.read_csv(path, compression="gzip" if ext == ".csv.gz" else None)
+ return None
+
+
+def _umap_figure(cdf, field_name, top_n=20):
+ """Build a UMAP scatter figure from a filtered cluster DataFrame."""
+ cdf = cdf.reset_index(drop=True)
+ noise_mask = cdf["cluster_id"] == -1
+ non_noise = cdf[~noise_mask]
+
+ # Rank clusters by size; optionally cap at top_n
+ cluster_sizes = non_noise.groupby("cluster_id").size().sort_values(ascending=False)
+ if top_n and top_n > 0:
+ top_ids = set(cluster_sizes.index[:top_n])
+ else:
+ top_ids = set(cluster_sizes.index)
+
+ fig = go.Figure()
+
+ # NOISE — thin gray, low opacity, drawn first
+ if noise_mask.any():
+ nd = cdf[noise_mask]
+ fig.add_trace(go.Scattergl(
+ x=nd["x"], y=nd["y"],
+ mode="markers",
+ name="Noise",
+ marker=dict(color=_NOISE_COLOR, size=4, opacity=0.25),
+ hovertemplate="Noise
%{customdata[0]}
PMID: %{text}",
+ customdata=list(zip(nd["value"].str[:80].tolist(),
+ nd["protein_index"].astype(str).tolist())),
+ text=nd["key"].astype(str),
+ showlegend=True,
+ ))
+
+ # "Other clusters" bucket — light gray, slightly more visible than noise
+ other_mask = ~noise_mask & ~cdf["cluster_id"].isin(top_ids)
+ if other_mask.any():
+ od = cdf[other_mask]
+ fig.add_trace(go.Scattergl(
+ x=od["x"], y=od["y"],
+ mode="markers",
+ name=f"Other ({len(cluster_sizes) - len(top_ids)} clusters)",
+ marker=dict(color="#adb5bd", size=5, opacity=0.35),
+ hovertemplate="%{customdata[0]}
%{customdata[2]}
PMID: %{text}",
+ customdata=list(zip(
+ ("Cluster " + od["cluster_id"].astype(str)).tolist(),
+ od["protein_index"].astype(str).tolist(),
+ od["value"].str[:80].tolist(),
+ )),
+ text=od["key"].astype(str),
+ showlegend=True,
+ ))
+
+ # Top-N named clusters — distinct colors, larger markers
+ for rank, cid in enumerate(cluster_sizes.index[:len(top_ids)]):
+ cd = non_noise[non_noise["cluster_id"] == cid]
+ label = cd["cluster_label_short"].iloc[0][:35] if len(cd) else f"C{cid}"
+ color = _PALETTE[rank % len(_PALETTE)]
+ fig.add_trace(go.Scattergl(
+ x=cd["x"], y=cd["y"],
+ mode="markers",
+ name=f"[{cid}] {label}",
+ marker=dict(color=color, size=7, opacity=0.80,
+ line=dict(width=0.4, color="rgba(255,255,255,0.6)")),
+ hovertemplate=(
+ "[%{meta}] %{customdata[0]}
"
+ "%{customdata[2]}
"
+ "PMID: %{text}"
+ ),
+ meta=cid,
+ customdata=list(zip(
+ cd["cluster_label_short"].str[:50].tolist(),
+ cd["protein_index"].astype(str).tolist(),
+ cd["value"].str[:100].tolist(),
+ )),
+ text=cd["key"].astype(str),
+ ))
+
+ n_total = len(cluster_sizes)
+ n_shown = len(top_ids)
+ n_noise = noise_mask.sum()
+ title_txt = (
+ f"UMAP — {field_name.replace('_', ' ').title()}"
+ f" · top {n_shown}/{n_total} clusters shown · {n_noise:,} noise pts"
+ )
+ fig.update_layout(
+ title=dict(text=title_txt, font=dict(size=13)),
+ plot_bgcolor="#f9fafc",
+ paper_bgcolor="#ffffff",
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=""),
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=""),
+ legend=dict(
+ title=dict(text="Cluster", font=dict(size=11)),
+ font=dict(size=10),
+ itemsizing="constant",
+ bordercolor="#dee2e6", borderwidth=1,
+ tracegroupgap=1,
+ ),
+ updatemenus=[dict(
+ type="buttons",
+ showactive=False,
+ direction="right",
+ x=1.01, xanchor="left",
+ y=1.06, yanchor="top",
+ pad={"r": 4, "t": 0},
+ bgcolor="#f8f9fa",
+ bordercolor="#ced4da",
+ font=dict(size=11),
+ buttons=[
+ dict(
+ label="Deselect all",
+ method="restyle",
+ args=[{"visible": "legendonly"}],
+ ),
+ dict(
+ label="Select all",
+ method="restyle",
+ args=[{"visible": True}],
+ ),
+ ],
+ )],
+ margin=dict(l=20, r=200, t=50, b=20),
+ hoverlabel=dict(bgcolor="white", font_size=12, namelength=-1),
+ )
+ return fig
+
+
+def _distribution_figure(cdf, field_name, top_n=20):
+ """Build a cluster-size bar chart from a filtered cluster DataFrame."""
+ cdf = cdf.reset_index(drop=True)
+ counts = (
+ cdf[cdf["cluster_id"] != -1]
+ .groupby(["cluster_id", "cluster_label_short"], sort=False)
+ .size()
+ .reset_index(name="count")
+ .sort_values("count", ascending=False)
+ )
+ if top_n and top_n > 0:
+ counts = counts.head(top_n)
+
+ colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(counts))]
+ labels = counts["cluster_label_short"].str[:45]
+
+ fig = go.Figure(go.Bar(
+ x=labels,
+ y=counts["count"],
+ marker_color=colors,
+ marker_line_color="rgba(255,255,255,0.6)",
+ marker_line_width=0.8,
+ opacity=0.88,
+ customdata=counts["cluster_id"].tolist(),
+ hovertemplate="%{x}
Count: %{y:,}
Click to see proteins",
+ ))
+ fig.update_layout(
+ title=dict(
+ text=f"Cluster Sizes — {field_name.replace('_', ' ').title()}"
+ + (f" (top {top_n})" if top_n else ""),
+ font=dict(size=13),
+ ),
+ plot_bgcolor="#f9fafc",
+ paper_bgcolor="#ffffff",
+ xaxis=dict(
+ showgrid=False, zeroline=False,
+ tickangle=-45, tickfont=dict(size=10),
+ ),
+ yaxis=dict(
+ showgrid=True, gridcolor="#e5e7eb", zeroline=False,
+ title="# entries",
+ ),
+ margin=dict(l=50, r=20, t=50, b=160),
+ hoverlabel=dict(bgcolor="white", font_size=12),
+ bargap=0.25,
+ )
+ return fig
+
+
+@app.callback(
+ Output("cluster-graph", "figure"),
+ Output("plot-status", "children"),
+ Input("dd-model", "value"),
+ Input("dd-min", "value"),
+ Input("dd-threshold", "value"),
+ Input("dd-field", "value"),
+ Input("plot-type", "value"),
+ Input("top-n-clusters", "value"),
+ Input("cluster-group-filter", "value"),
+)
+def update_cluster_plot(model, min_val, threshold, field, plot_type, top_n, groups):
+ empty_fig = go.Figure()
+ empty_fig.update_layout(paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc")
+ if not all([model, min_val, threshold, field, plot_type]):
+ return empty_fig, "Select all parameters above."
+
+ cdf = _load_cluster_csv(model, min_val, threshold)
+ if cdf is None:
+ return empty_fig, f"Data file not found for model={model} min={min_val} t={threshold}"
+
+ field_df = cdf[cdf["field"] == field].copy()
+ if field_df.empty:
+ return empty_fig, f"No data for field '{field}' in this parameter combination."
+
+ # Filter by group if selected
+ if groups:
+ sel = set(groups)
+ field_df = field_df[
+ field_df["key"].astype(str).apply(
+ lambda pmid: bool(PMID_GROUPS.get(pmid, set()) & sel)
+ )
+ ]
+ if field_df.empty:
+ return empty_fig, f"No data for the selected group(s) in this field."
+
+ n = top_n or 0
+ suffix = f" — {', '.join(groups)}" if groups else ""
+ if plot_type == "cluster":
+ return _umap_figure(field_df, field + suffix, top_n=n), ""
+ else:
+ return _distribution_figure(field_df, field + suffix, top_n=n), ""
+
+
+_CONDITION_COLS = [
+ "enzyme_name", "organism_source", "strain", "expression_strain",
+ "plasmid", "molecular_weight", "medium_name", "inducer",
+ "induction_temperature", "lysis_buffer", "elution_buffer", "desalting_process",
+]
+
+
+@app.callback(
+ Output("cluster-point-detail", "children"),
+ Output("cluster-table-store", "data"),
+ Input("cluster-graph", "clickData"),
+ State("dd-field", "value"),
+ State("dd-model", "value"),
+ State("dd-min", "value"),
+ State("dd-threshold", "value"),
+ State("plot-type", "value"),
+)
+def show_cluster_point_detail(click_data, field, model, min_val, threshold, plot_type):
+ if not click_data:
+ return html.Div(), None
+
+ point = click_data["points"][0]
+
+ # ── Bar chart click: show all proteins in that cluster ──────────────────
+ if plot_type == "distribution":
+ cluster_id = point.get("customdata")
+ cluster_label = str(point.get("x", ""))
+ count = point.get("y", 0)
+
+ cdf = _load_cluster_csv(model, min_val, threshold)
+ if cdf is None:
+ return html.Div("Could not load cluster data.", className="text-muted small mt-2"), None
+
+ members = cdf[(cdf["field"] == field) & (cdf["cluster_id"] == cluster_id)]
+
+ rows = []
+ for _, r in members.iterrows():
+ pmid = str(r["key"])
+ pidx = int(r["protein_index"])
+ entry = RAW.get(pmid, {})
+ proteins = entry.get("proteins", []) or []
+ protein = proteins[pidx] if pidx < len(proteins) else {}
+ m = META.get(pmid, {})
+ row = {"pmid": pmid}
+ row.update({c: str(protein.get(c) or "") for c in _CONDITION_COLS})
+ row["field_value"] = str(r.get("value", ""))
+ row["journal"] = m.get("source", "")
+ row["pub_date"] = m.get("pub_date", "")
+ rows.append(row)
+
+ # Column order: pmid, enzyme_name, organism_source, [clustered field], rest, journal, pub_date
+ fixed = ["pmid", "enzyme_name", "organism_source"]
+ field_col = "field_value"
+ remaining = [c for c in _CONDITION_COLS if c not in fixed and c != field]
+ table_cols = fixed + [field_col] + remaining + ["journal", "pub_date"]
+ field_label = field.replace("_", " ").title()
+ col_defs = [
+ {"name": (field_label if c == field_col else c.replace("_", " ").title()), "id": c}
+ for c in table_cols
+ ]
+
+ return html.Div([
+ dbc.Card([
+ dbc.CardHeader(
+ dbc.Row([
+ dbc.Col([
+ html.Span("Proteins in cluster — ", className="fw-bold"),
+ html.Span(f'"{cluster_label}"', className="fst-italic"),
+ dbc.Badge(f"{count} proteins", color="primary", className="ms-2"),
+ ], width=10),
+ dbc.Col(
+ dbc.Button("⬇ Download CSV", id="download-cluster-btn",
+ size="sm", color="success", outline=True),
+ width=2, className="text-end",
+ ),
+ ], align="center"),
+ ),
+ dbc.CardBody(
+ dash_table.DataTable(
+ columns=col_defs,
+ data=rows,
+ page_size=15,
+ sort_action="native",
+ style_table={"overflowX": "auto"},
+ style_cell={
+ "fontSize": "12px",
+ "padding": "5px 10px",
+ "textAlign": "left",
+ "maxWidth": "260px",
+ "overflow": "hidden",
+ "textOverflow": "ellipsis",
+ "whiteSpace": "nowrap",
+ },
+ style_cell_conditional=[
+ {"if": {"column_id": field_col},
+ "backgroundColor": "#fff8e1", "fontWeight": "500"},
+ ],
+ style_header={
+ "fontWeight": "700",
+ "backgroundColor": "#1a3a5c",
+ "color": "#ffffff",
+ "fontSize": "11px",
+ "textTransform": "uppercase",
+ "letterSpacing": "0.04em",
+ },
+ style_data_conditional=[
+ {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"},
+ ],
+ tooltip_data=[
+ {c: {"value": str(r.get(c, "")), "type": "markdown"} for c in table_cols}
+ for r in rows
+ ],
+ tooltip_delay=0,
+ tooltip_duration=None,
+ ) if rows else html.Span("No proteins found.", className="text-muted small")
+ ),
+ ], className="mb-3"),
+ ]), rows
+
+ # ── UMAP scatter click: show single protein conditions ──────────────────
+ pmid = str(point.get("text", ""))
+ customdata = point.get("customdata", [])
+
+ # customdata layout: [label_or_value, protein_index, value_text] (noise: [value, protein_index])
+ try:
+ protein_index = int(customdata[1])
+ except (IndexError, ValueError, TypeError):
+ protein_index = 0
+
+ entry = RAW.get(pmid, {})
+ proteins = entry.get("proteins", []) or []
+ if not proteins:
+ return html.Div(f"No protein data for PMID {pmid}.", className="text-muted small mt-2")
+
+ protein_index = min(protein_index, len(proteins) - 1)
+ protein = proteins[protein_index]
+
+ # Field value that was clicked (used for context header)
+ clicked_value = str(customdata[0] if customdata else "")
+
+ # Conditions table: condition → value, skip empty
+ cond_rows = [
+ {"Condition": c.replace("_", " ").title(), "Value": str(protein.get(c) or "")}
+ for c in _CONDITION_COLS
+ if protein.get(c)
+ ]
+
+ m = META.get(pmid, {})
+ pubmed_link = html.A(f"PMID {pmid}", href=f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
+ target="_blank", className="small")
+ paper_info = " · ".join(filter(None, [m.get("source", ""), m.get("pub_date", "")]))
+
+ return html.Div([
+ dbc.Card([
+ dbc.CardHeader([
+ html.Span("Purification Conditions — ", className="fw-bold"),
+ pubmed_link,
+ html.Span(f" · {paper_info}", className="text-muted small") if paper_info else None,
+ html.Span(f" · {field.replace('_', ' ').title()}: ", className="text-muted small ms-2"),
+ html.Span(f'"{clicked_value[:80]}"', className="small fst-italic"),
+ ]),
+ dbc.CardBody(
+ dash_table.DataTable(
+ columns=[{"name": c, "id": c} for c in ["Condition", "Value"]],
+ data=cond_rows,
+ style_table={"overflowX": "auto"},
+ style_cell={
+ "fontSize": "12px",
+ "padding": "5px 10px",
+ "textAlign": "left",
+ },
+ style_cell_conditional=[
+ {"if": {"column_id": "Condition"},
+ "fontWeight": "600", "width": "200px", "minWidth": "200px",
+ "backgroundColor": "#f8f9fa"},
+ {"if": {"column_id": "Value"},
+ "whiteSpace": "normal", "height": "auto"},
+ ],
+ style_header={
+ "fontWeight": "700",
+ "backgroundColor": "#1a3a5c",
+ "color": "#ffffff",
+ "fontSize": "11px",
+ "textTransform": "uppercase",
+ "letterSpacing": "0.04em",
+ },
+ style_data_conditional=[
+ {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"},
+ ],
+ ) if cond_rows else html.Span("No conditions recorded for this protein.", className="text-muted small")
+ ),
+ ], className="mb-3"),
+ _uniprot_card(pmid),
+ ]), None
+
+
+@app.callback(
+ Output("download-cluster-csv", "data"),
+ Input("download-cluster-btn", "n_clicks"),
+ State("cluster-table-store", "data"),
+ prevent_initial_call=True,
+)
+def download_cluster_table(n_clicks, rows):
+ if not n_clicks or not rows:
+ return None
+ return dcc.send_data_frame(pd.DataFrame(rows).to_csv, "cluster_proteins.csv", index=False)
+
+
+@app.callback(
+ Output("metrics-table-container", "children"),
+ Input("dd-model", "value"),
+ Input("dd-min", "value"),
+ Input("dd-threshold", "value"),
+)
+def update_metrics_table(model, min_val, threshold):
+ if not all([model, min_val, threshold]):
+ return html.Div()
+
+ csv_path = os.path.join(
+ GRID_BASE, f"model={model}", f"min={min_val}", f"t={threshold}_FIELD_CLUSTER_METRICS.csv"
+ )
+ if not os.path.isfile(csv_path):
+ return html.Div("Metrics file not found.", className="text-muted small")
+
+ metrics_df = pd.read_csv(csv_path).round(4)
+ return dash_table.DataTable(
+ columns=[{"name": c.replace("_", " ").title(), "id": c} for c in metrics_df.columns],
+ data=metrics_df.to_dict("records"),
+ sort_action="native",
+ style_table={"overflowX": "auto"},
+ style_cell={"fontSize": "12px", "padding": "5px 10px", "textAlign": "left"},
+ style_header={"fontWeight": "bold", "backgroundColor": "#f8f9fa"},
+ style_data_conditional=[
+ {"if": {"row_index": "odd"}, "backgroundColor": "#f8f9fa"},
+ {
+ "if": {"filter_query": "{silhouette_cosine} > 0.6", "column_id": "silhouette_cosine"},
+ "color": "#198754", "fontWeight": "bold",
+ },
+ {
+ "if": {"filter_query": "{silhouette_cosine} < 0.3", "column_id": "silhouette_cosine"},
+ "color": "#dc3545",
+ },
+ ],
+ )
+
+
+@app.callback(
+ Output("metrics-heatmap", "figure"),
+ Output("metrics-model-compare", "figure"),
+ Output("metrics-best-table", "children"),
+ Input("dd-metrics-field", "value"),
+ Input("dd-metrics-type", "value"),
+ Input("dd-metrics-model", "value"),
+)
+def update_grid_metrics(field, metric, model):
+ empty = go.Figure()
+ empty.update_layout(paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc")
+ if METRICS_DF.empty or not field or not metric or not model:
+ return empty, empty, html.Div()
+
+ metric_label = _METRIC_LABELS.get(metric, metric)
+ higher_better = metric == "silhouette_cosine"
+
+ # ── Heatmap: threshold × min_size for selected model + field ─────────────
+ sub = METRICS_DF[(METRICS_DF["model"] == model) & (METRICS_DF["field"] == field)]
+ if not sub.empty:
+ pivot = sub.pivot_table(index="min_community_size", columns="threshold",
+ values=metric, aggfunc="mean")
+ pivot = pivot.sort_index(ascending=False)
+
+ colorscale = "RdYlGn" if higher_better else "RdYlGn_r"
+ heatmap_fig = go.Figure(go.Heatmap(
+ z=pivot.values,
+ x=[str(c) for c in pivot.columns],
+ y=[str(r) for r in pivot.index],
+ colorscale=colorscale,
+ text=[[f"{v:.3f}" for v in row] for row in pivot.values],
+ texttemplate="%{text}",
+ hovertemplate="Threshold: %{x}
Min size: %{y}
" + metric_label + ": %{z:.3f}",
+ colorbar=dict(title=metric_label, thickness=14),
+ ))
+ heatmap_fig.update_layout(
+ title=dict(text=f"{metric_label} — {field.replace('_',' ').title()}
"
+ f"{model}", font=dict(size=13)),
+ xaxis=dict(title="Threshold", type="category"),
+ yaxis=dict(title="Min community size", type="category"),
+ paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc",
+ margin=dict(l=60, r=20, t=70, b=50),
+ )
+ else:
+ heatmap_fig = empty
+
+ # ── Bar chart: compare all models for selected field at best threshold ────
+ field_df = METRICS_DF[METRICS_DF["field"] == field]
+ if not field_df.empty:
+ best = (field_df.groupby("model")[metric]
+ .apply(lambda x: x.max() if higher_better else x.min())
+ .reset_index()
+ .sort_values(metric, ascending=not higher_better))
+ colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(best))]
+ compare_fig = go.Figure(go.Bar(
+ x=best["model"],
+ y=best[metric],
+ marker_color=colors,
+ marker_line_color="rgba(255,255,255,0.6)",
+ marker_line_width=0.8,
+ opacity=0.88,
+ hovertemplate="%{x}
" + metric_label + ": %{y:.3f}",
+ ))
+ compare_fig.update_layout(
+ title=dict(text=f"Best {metric_label} per model — {field.replace('_',' ').title()}",
+ font=dict(size=13)),
+ xaxis=dict(tickangle=-35, tickfont=dict(size=10)),
+ yaxis=dict(title=metric_label, gridcolor="#e5e7eb"),
+ paper_bgcolor="#ffffff", plot_bgcolor="#f9fafc",
+ margin=dict(l=60, r=20, t=60, b=120),
+ showlegend=False,
+ )
+ else:
+ compare_fig = empty
+
+ # ── Best combinations table ────────────────────���──────────────────────────
+ top = (METRICS_DF[METRICS_DF["field"] == field]
+ .sort_values(metric, ascending=not higher_better)
+ .head(10)[["model", "threshold", "min_community_size", "n_clusters",
+ "silhouette_cosine", "davies_bouldin"]]
+ .round(4))
+ best_table = dash_table.DataTable(
+ columns=[{"name": c.replace("_", " ").title(), "id": c} for c in top.columns],
+ data=top.to_dict("records"),
+ sort_action="native",
+ style_table={"overflowX": "auto"},
+ style_cell={"fontSize": "12px", "padding": "5px 10px", "textAlign": "left"},
+ style_header={
+ "fontWeight": "700", "backgroundColor": "#1a3a5c",
+ "color": "#ffffff", "fontSize": "11px",
+ "textTransform": "uppercase", "letterSpacing": "0.04em",
+ },
+ style_data_conditional=[
+ {"if": {"row_index": "odd"}, "backgroundColor": "#eef4fd"},
+ {"if": {"row_index": 0}, "backgroundColor": "#d4edda", "fontWeight": "bold"},
+ {"if": {"filter_query": "{silhouette_cosine} > 0.6", "column_id": "silhouette_cosine"},
+ "color": "#198754", "fontWeight": "bold"},
+ {"if": {"filter_query": "{silhouette_cosine} < 0.3", "column_id": "silhouette_cosine"},
+ "color": "#dc3545"},
+ ],
+ )
+
+ return heatmap_fig, compare_fig, best_table
+
+
+# ---------------------------------------------------------------------------
+@app.callback(
+ Output("search-value", "value"),
+ Output("filter-group", "value"),
+ Input("clear-search", "n_clicks"),
+ prevent_initial_call=True,
+)
+def clear_filters(_):
+ return "", []
+
+
+@app.callback(
+ Output("download-csv", "data"),
+ Input("download-btn", "n_clicks"),
+ State("protein-table", "data"),
+ prevent_initial_call=True,
+)
+def download_csv(_, table_data):
+ filtered_df = pd.DataFrame(table_data)
+ return dcc.send_data_frame(filtered_df.to_csv, "llm_extractor_results.csv", index=False)
+
+
+@app.callback(
+ Output("eval-classif-graph", "figure"),
+ Input("classif-group", "value"),
+ prevent_initial_call=True,
+)
+def update_classif_chart(group):
+ if CLASSIF_DF.empty or not group:
+ return go.Figure()
+ sub = CLASSIF_DF[CLASSIF_DF["group"] == group]
+ if sub.empty:
+ return go.Figure()
+ grp = sub.groupby("metric")["value"]
+ avg = grp.mean().rename("value").reset_index()
+ sem = grp.sem().rename("sem").reset_index()
+ avg = avg.merge(sem, on="metric")
+ avg["label"] = avg["metric"].map(_NLP_METRICS)
+ # Normalise BLEU / METEOR from 0–100 → 0–1
+ avg["scale"] = avg["metric"].map(lambda m: _NLP_SCALE.get(m, 1.0))
+ avg["value"] = avg["value"] / avg["scale"]
+ avg["sem"] = avg["sem"] / avg["scale"]
+ fig = go.Figure(go.Bar(
+ x=avg["label"], y=avg["value"],
+ customdata=avg[["metric"]].values.tolist(),
+ error_y=dict(type="data", array=avg["sem"].fillna(0).tolist(), visible=True),
+ marker_color="#1a6090",
+ hovertemplate="%{x}
mean: %{y:.3f}
SEM: %{error_y.array:.3f}",
+ ))
+ fig.update_layout(
+ yaxis=dict(title="Mean score (0–1)", range=[0, 1], gridcolor="#eeeeee"),
+ xaxis_title="Metric",
+ margin=dict(t=20, b=40, l=60, r=20),
+ plot_bgcolor="white", paper_bgcolor="white",
+ )
+ return fig
+
+
+@app.callback(
+ Output("eval-classif-examples", "children"),
+ Input("eval-classif-graph", "clickData"),
+ State("classif-group", "value"),
+ prevent_initial_call=True,
+)
+def show_classif_examples(click_data, group):
+ if not click_data or CLASSIF_DF.empty:
+ return dash.no_update
+ pt = click_data["points"][0]
+ metric = pt["customdata"][0]
+ pool = CLASSIF_DF[
+ (CLASSIF_DF["group"] == group) &
+ (CLASSIF_DF["metric"] == metric)
+ ]
+ if pool.empty:
+ return html.P("No examples found.", className="text-muted small")
+ metric_label = _NLP_METRICS.get(metric, metric)
+ scale = _NLP_SCALE.get(metric, 1.0)
+ values = (pool["value"] / scale).tolist()
+ dist_fig = _dist_figure(values, x_label=f"{metric_label} (0–1)")
+ sub = _extremes(pool, "value")
+ rows = []
+ prev_rank = None
+ for _, r in sub.iterrows():
+ if r["_rank"] != prev_rank:
+ label_text = "5 Worst" if r["_rank"] == "worst" else "5 Best"
+ color = "danger" if r["_rank"] == "worst" else "success"
+ rows.append(html.Tr([
+ html.Td(dbc.Badge(label_text, color=color, className="me-1"),
+ colSpan=4, className="fw-semibold small py-1 table-active"),
+ ]))
+ prev_rank = r["_rank"]
+ rows.append(html.Tr([
+ html.Td(r["pmid"], className="text-muted small", style={"whiteSpace": "nowrap"}),
+ html.Td(r["gt_text"][:300], className="small"),
+ html.Td(r["llm_text"][:300], className="small"),
+ html.Td(f"{r['value']/scale:.3f}", className="small text-center"),
+ ]))
+ return html.Div([
+ html.H6(f"Examples · {metric_label} · {group.title()}", className="fw-semibold mb-2"),
+ dcc.Graph(figure=dist_fig, config={"displayModeBar": False}),
+ html.P(f"Methods text truncated to 300 chars for display.",
+ className="text-muted small mb-2 mt-3"),
+ dbc.Table([
+ html.Thead(html.Tr([
+ html.Th("PMID"), html.Th("Ground truth (excerpt)"),
+ html.Th("LLM extraction (excerpt)"), html.Th(metric_label),
+ ])),
+ html.Tbody(rows),
+ ], bordered=True, size="sm", hover=True, responsive=True),
+ ])
+
+
+def _eval_bar_figure(stats_df, y_col, err_col, y_label, models):
+ """Grouped bar chart over _PURIF_FIELDS with standard-error bars."""
+ fig = go.Figure()
+ for lbl in models:
+ d = stats_df[stats_df["label"] == lbl].copy()
+ d = d.set_index("field").reindex(_PURIF_FIELDS).reset_index()
+ fig.add_trace(go.Bar(
+ name=lbl,
+ x=d["field"],
+ y=d[y_col],
+ customdata=[[lbl]] * len(d),
+ error_y=dict(type="data", array=d[err_col].fillna(0).tolist(), visible=True),
+ hovertemplate="%{x}
mean: %{y:.3f}
SEM: %{error_y.array:.3f}" + lbl + "",
+ ))
+ fig.update_layout(
+ barmode="group",
+ yaxis_title=y_label,
+ xaxis_title="Field",
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+ margin=dict(t=60, b=110, l=60, r=20),
+ plot_bgcolor="white",
+ paper_bgcolor="white",
+ yaxis=dict(gridcolor="#eeeeee"),
+ )
+ fig.update_xaxes(tickangle=35)
+ return fig
+
+
+@app.callback(
+ Output("eval-llm-graph", "figure"),
+ Input("eval-group", "value"),
+ Input("eval-model-checklist", "value"),
+ prevent_initial_call=True,
+)
+def update_eval_llm(group, models):
+ if EVAL_LLM_DF.empty or not group or not models:
+ return go.Figure()
+ sub = EVAL_LLM_DF[
+ (EVAL_LLM_DF["group"] == group) & (EVAL_LLM_DF["label"].isin(models))
+ ]
+ if sub.empty:
+ return go.Figure()
+ grp = sub.groupby(["label", "field"])["similarity_score"]
+ stats = grp.mean().rename("similarity_score").reset_index()
+ stats["sem"] = grp.sem().values
+ return _eval_bar_figure(stats, "similarity_score", "sem",
+ "Mean similarity score (0–10)", models)
+
+
+@app.callback(
+ Output("eval-nlp-graph", "figure"),
+ Input("eval-group", "value"),
+ Input("eval-model-checklist", "value"),
+ Input("eval-nlp-metric", "value"),
+ prevent_initial_call=True,
+)
+def update_eval_nlp(group, models, metric):
+ if EVAL_NLP_DF.empty or not group or not models or not metric:
+ return go.Figure()
+ sub = EVAL_NLP_DF[
+ (EVAL_NLP_DF["group"] == group) &
+ (EVAL_NLP_DF["label"].isin(models)) &
+ (EVAL_NLP_DF["metric"] == metric)
+ ]
+ if sub.empty:
+ return go.Figure()
+ grp = sub.groupby(["label", "field"])["value"]
+ stats = grp.mean().rename("value").reset_index()
+ stats["sem"] = grp.sem().values
+ scale = _NLP_SCALE.get(metric, 1.0)
+ stats["value"] /= scale
+ stats["sem"] /= scale
+ metric_label = _NLP_METRICS.get(metric, metric)
+ return _eval_bar_figure(stats, "value", "sem",
+ f"Mean {metric_label} (0–1)", models)
+
+
+def _examples_header(field, label):
+ return html.H6(
+ f"Examples · {field.replace('_', ' ').title()} · {label}",
+ className="fw-semibold mb-2",
+ )
+
+
+def _dist_figure(values, x_label, x_range=None, nbinsx=20):
+ """Box plot (top) + histogram (bottom) for a 1-D series of values."""
+ fig = make_subplots(
+ rows=2, cols=1,
+ shared_xaxes=True,
+ row_heights=[0.25, 0.75],
+ vertical_spacing=0.04,
+ )
+ color = "#1a6090"
+ fig.add_trace(go.Box(
+ x=values, orientation="h",
+ marker_color=color, line_color=color,
+ boxmean="sd",
+ hovertemplate=(
+ "min: %{x[0]:.3f}
"
+ "Q1: %{x[1]:.3f}
"
+ "median: %{x[2]:.3f}
"
+ "Q3: %{x[3]:.3f}
"
+ "max: %{x[4]:.3f}"
+ ),
+ ), row=1, col=1)
+ mean_val = float(pd.Series(values).mean())
+ fig.add_trace(go.Histogram(
+ x=values, nbinsx=nbinsx,
+ marker_color=color, opacity=0.8,
+ hovertemplate=f"{x_label}: %{{x:.3f}}
Count: %{{y}}",
+ ), row=2, col=1)
+ fig.add_vline(x=mean_val, line_dash="dash", line_color="crimson",
+ annotation_text=f"mean={mean_val:.3f}",
+ annotation_position="top right")
+ layout = dict(
+ showlegend=False,
+ xaxis2_title=x_label,
+ yaxis2_title="Count",
+ margin=dict(t=20, b=40, l=50, r=20),
+ height=280,
+ plot_bgcolor="white",
+ paper_bgcolor="white",
+ yaxis=dict(showticklabels=False, gridcolor="#eeeeee"),
+ yaxis2=dict(gridcolor="#eeeeee"),
+ )
+ if x_range:
+ layout["xaxis2"] = dict(title=x_label, range=x_range)
+ layout["xaxis"] = dict(range=x_range)
+ fig.update_layout(**layout)
+ return fig
+
+
+def _extremes(df, score_col, n=5):
+ """Return bottom-n and top-n rows by score_col, labelled."""
+ worst = df.nsmallest(n, score_col).copy()
+ best = df.nlargest(n, score_col).copy()
+ worst["_rank"] = "worst"
+ best["_rank"] = "best"
+ return pd.concat([worst, best], ignore_index=True)
+
+
+@app.callback(
+ Output("eval-llm-examples", "children"),
+ Input("eval-llm-graph", "clickData"),
+ State("eval-group", "value"),
+ prevent_initial_call=True,
+)
+def show_eval_llm_examples(click_data, group):
+ if not click_data or EVAL_LLM_DF.empty:
+ return dash.no_update
+ pt = click_data["points"][0]
+ field = pt["x"]
+ label = pt["customdata"][0]
+ pool = EVAL_LLM_DF[
+ (EVAL_LLM_DF["group"] == group) &
+ (EVAL_LLM_DF["label"] == label) &
+ (EVAL_LLM_DF["field"] == field)
+ ]
+ if pool.empty:
+ return html.P("No examples found.", className="text-muted small")
+ # Distribution chart
+ dist_fig = _dist_figure(
+ pool["similarity_score"].tolist(),
+ x_label="Similarity score (0–10)",
+ x_range=[0, 10],
+ nbinsx=10,
+ )
+ # Examples table
+ sub = _extremes(pool, "similarity_score")
+ rows = []
+ prev_rank = None
+ for _, r in sub.iterrows():
+ if r["_rank"] != prev_rank:
+ label_text = "5 Worst" if r["_rank"] == "worst" else "5 Best"
+ color = "danger" if r["_rank"] == "worst" else "success"
+ rows.append(html.Tr([
+ html.Td(dbc.Badge(label_text, color=color, className="me-1"),
+ colSpan=5, className="fw-semibold small py-1 table-active"),
+ ]))
+ prev_rank = r["_rank"]
+ rows.append(html.Tr([
+ html.Td(r["pmid"], className="text-muted small", style={"whiteSpace":"nowrap"}),
+ html.Td(r["gt_text"], className="small"),
+ html.Td(r["llm_text"], className="small"),
+ html.Td(f"{r['similarity_score']:.0f} / 10", className="small text-center"),
+ html.Td(r["explanation"], className="small text-muted"),
+ ]))
+ return html.Div([
+ _examples_header(field, label),
+ dcc.Graph(figure=dist_fig, config={"displayModeBar": False}),
+ dbc.Table([
+ html.Thead(html.Tr([
+ html.Th("PMID"), html.Th("Ground truth"), html.Th("LLM extraction"),
+ html.Th("Score"), html.Th("Explanation"),
+ ])),
+ html.Tbody(rows),
+ ], bordered=True, size="sm", hover=True, responsive=True, className="mt-3"),
+ ])
+
+
+@app.callback(
+ Output("eval-nlp-examples", "children"),
+ Input("eval-nlp-graph", "clickData"),
+ State("eval-group", "value"),
+ State("eval-nlp-metric", "value"),
+ prevent_initial_call=True,
+)
+def show_eval_nlp_examples(click_data, group, metric):
+ if not click_data or EVAL_NLP_DF.empty:
+ return dash.no_update
+ pt = click_data["points"][0]
+ field = pt["x"]
+ label = pt["customdata"][0]
+ pool = EVAL_NLP_DF[
+ (EVAL_NLP_DF["group"] == group) &
+ (EVAL_NLP_DF["label"] == label) &
+ (EVAL_NLP_DF["field"] == field) &
+ (EVAL_NLP_DF["metric"] == metric)
+ ]
+ if pool.empty:
+ return html.P("No examples found.", className="text-muted small")
+ metric_label = _NLP_METRICS.get(metric, metric)
+ scale = _NLP_SCALE.get(metric, 1.0)
+ # Distribution chart
+ dist_fig = _dist_figure(
+ (pool["value"] / scale).tolist(),
+ x_label=f"{metric_label} (0–1)",
+ )
+ # Examples table
+ sub = _extremes(pool, "value")
+ rows = []
+ prev_rank = None
+ for _, r in sub.iterrows():
+ if r["_rank"] != prev_rank:
+ label_text = "5 Worst" if r["_rank"] == "worst" else "5 Best"
+ color = "danger" if r["_rank"] == "worst" else "success"
+ rows.append(html.Tr([
+ html.Td(dbc.Badge(label_text, color=color, className="me-1"),
+ colSpan=4, className="fw-semibold small py-1 table-active"),
+ ]))
+ prev_rank = r["_rank"]
+ rows.append(html.Tr([
+ html.Td(r["pmid"], className="text-muted small", style={"whiteSpace":"nowrap"}),
+ html.Td(r["gt_text"], className="small"),
+ html.Td(r["llm_text"], className="small"),
+ html.Td(f"{r['value']/scale:.3f}", className="small text-center"),
+ ]))
+ return html.Div([
+ _examples_header(field, label),
+ dcc.Graph(figure=dist_fig, config={"displayModeBar": False}),
+ dbc.Table([
+ html.Thead(html.Tr([
+ html.Th("PMID"), html.Th("Ground truth"),
+ html.Th("LLM extraction"), html.Th(metric_label),
+ ])),
+ html.Tbody(rows),
+ ], bordered=True, size="sm", hover=True, responsive=True, className="mt-3"),
+ ])
+
+
+def _metric_card(label, value, color="primary"):
+ body = f"{value:.3f}" if isinstance(value, float) else (str(value) if value is not None else "N/A")
+ return dbc.Col(
+ dbc.Card([
+ dbc.CardBody([
+ html.P(label, className="text-muted small mb-1"),
+ html.H4(body, className=f"text-{color} mb-0 fw-bold"),
+ ], className="text-center p-2"),
+ ], className="shadow-sm"),
+ xs=6, sm=4, md=2,
+ )
+
+
+@app.callback(
+ Output("confmat-cards", "children"),
+ Output("confmat-heatmap", "figure"),
+ Input("confmat-group", "value"),
+ prevent_initial_call=True,
+)
+def update_confmat(group):
+ data = CONF_MATRICES.get(group)
+ if not data:
+ return html.P("No data available.", className="text-muted"), go.Figure()
+
+ tp, fp, fn, tn = data["TP"], data["FP"], data["FN"], data["TN"]
+
+ cards = dbc.Row([
+ _metric_card("Precision", data["Precision"], "success"),
+ _metric_card("Recall", data["Recall"], "primary"),
+ _metric_card("F1 Score", data["F1"], "warning"),
+ _metric_card("Accuracy", data["Accuracy"], "info"),
+ _metric_card("TP", tp, "secondary"),
+ _metric_card("FP", fp, "danger"),
+ _metric_card("FN", fn, "danger"),
+ _metric_card("TN", tn, "secondary"),
+ ], className="g-2 mb-3")
+
+ note = data.get("note")
+ if note:
+ fig = go.Figure()
+ fig.add_annotation(text=note, xref="paper", yref="paper",
+ x=0.5, y=0.5, showarrow=False,
+ font=dict(size=13), align="center")
+ fig.update_layout(plot_bgcolor="white", paper_bgcolor="white",
+ xaxis_visible=False, yaxis_visible=False)
+ else:
+ total = tp + fp + fn + tn
+ z = [[tn, fp], [fn, tp]]
+ cdata = [[[False, False], [False, True]],
+ [[True, False], [True, True]]]
+ text = [
+ [f"TN
{tn} ({tn/total*100:.1f}%)",
+ f"FP
{fp} ({fp/total*100:.1f}%)"],
+ [f"FN
{fn} ({fn/total*100:.1f}%)",
+ f"TP
{tp} ({tp/total*100:.1f}%)"],
+ ]
+ fig = go.Figure(go.Heatmap(
+ z=z,
+ x=["Predicted: Non-Enzymology", "Predicted: Enzymology"],
+ y=["Actual: Non-Enzymology", "Actual: Enzymology"],
+ customdata=cdata,
+ text=text,
+ texttemplate="%{text}",
+ colorscale="Blues",
+ showscale=True,
+ hovertemplate=(
+ "%{y}
%{x}
Count: %{z}
"
+ "Click to see examples"
+ ),
+ ))
+ fig.update_layout(
+ margin=dict(t=20, b=60, l=160, r=20),
+ xaxis=dict(side="bottom"),
+ plot_bgcolor="white", paper_bgcolor="white",
+ )
+
+ return cards, fig
+
+
+@app.callback(
+ Output("confmat-examples", "children"),
+ Input("confmat-heatmap", "clickData"),
+ State("confmat-group", "value"),
+ prevent_initial_call=True,
+)
+def show_confmat_examples(click_data, group):
+ if not click_data:
+ return dash.no_update
+ data = CONF_MATRICES.get(group, {})
+ papers = data.get("papers", {})
+ if not papers:
+ return html.P("No paper-level data available.", className="text-muted small")
+
+ pt = click_data["points"][0]
+ actual = "Non-Enzymology" not in pt.get("y", "")
+ predicted = "Non-Enzymology" not in pt.get("x", "")
+
+ # Label for the cell
+ cell_label = {
+ (True, True): ("TP", "success", "True Positives — correctly classified as enzymology"),
+ (True, False): ("FN", "warning", "False Negatives — enzymology papers missed by classifier"),
+ (False, True): ("FP", "danger", "False Positives — non-enzymology classified as enzymology"),
+ (False, False): ("TN", "secondary","True Negatives — correctly classified as non-enzymology"),
+ }.get((actual, predicted), ("?", "light", ""))
+
+ abbr, color, description = cell_label
+ subset = [(pmid, v) for pmid, v in papers.items()
+ if v["actual"] == actual and v["predicted"] == predicted]
+
+ if not subset:
+ return html.P("No papers in this category.", className="text-muted small")
+
+ # Show up to 10 examples
+ import random
+ sample = random.sample(subset, min(10, len(subset)))
+
+ rows = []
+ for pmid, v in sample:
+ url = v.get("url", "")
+ pmid_cell = html.A(pmid, href=url, target="_blank") if url else pmid
+ rows.append(html.Tr([
+ html.Td(pmid_cell, className="text-muted small", style={"whiteSpace": "nowrap"}),
+ html.Td(v["title"], className="small"),
+ html.Td(v["pub_date"], className="small text-muted", style={"whiteSpace": "nowrap"}),
+ html.Td(v["source"], className="small text-muted"),
+ ]))
+
+ return html.Div([
+ html.H6([
+ dbc.Badge(abbr, color=color, className="me-2"),
+ description,
+ html.Span(f" ({len(subset)} total, showing {len(sample)})",
+ className="text-muted small fw-normal"),
+ ], className="fw-semibold mb-2"),
+ dbc.Table([
+ html.Thead(html.Tr([
+ html.Th("PMID"), html.Th("Title"),
+ html.Th("Date"), html.Th("Journal"),
+ ])),
+ html.Tbody(rows),
+ ], bordered=True, size="sm", hover=True, responsive=True),
+ ])
+
+
+# Run
+# ---------------------------------------------------------------------------
+if __name__ == "__main__":
+ port = int(os.environ.get("PORT", 8050))
+ debug = os.environ.get("DASH_DEBUG", "false").lower() == "true"
+ app.run(debug=debug, host="0.0.0.0", port=port)