From 446b9b9a10be7d63235b9ac027da07ed987c0fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 19:20:43 -0300 Subject: [PATCH 01/16] fix(web): repair the web interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web interface could not start, and would not have served a request if it had. Six defects, all on the `allelio serve` path — the CLI is unaffected. - `starlette.concurrency.run_in_executor` does not exist and never has. The import aborted `allelio serve` before uvicorn bound a port. The name is unused in the module; the code calls `loop.run_in_executor`. - `TemplateResponse(name, {"request": request})` is the removed positional form. `/` returned 500. - `AIEngine.check_connection` is async and was called without `await` in two places. `/api/status` then tried to serialize a coroutine and returned 500; the upload path treated a truthy coroutine as a live connection. - `AllelioDB.get_statistics` is not a method — it is `get_stats`. The AttributeError was swallowed, so the UI reported 0 ClinVar and 0 GWAS entries against a fully populated database. - `get_variant_warnings` takes one argument and was passed two, failing every upload after the analysis had already run. - The sqlite connection was opened on the event loop thread and then used from `run_in_executor`, which sqlite refuses by default. The template also read `database_ready` and `ollama_connected`, while /api/status returns `db_ready` and `ollama_available`, so the status panel read "Database Not Set Up" whatever the real state was. Verified against a 630,774-variant 23andMe export: `allelio serve` starts, `/` renders, `/api/status` reports 2,645,423 ClinVar and 1,191,572 GWAS entries, and `POST /api/analyze` returns 62,057 results. --- allelio/database/store.py | 2 +- allelio/web/routes.py | 14 +++++--------- allelio/web/templates/index.html | 4 ++-- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/allelio/database/store.py b/allelio/database/store.py index 763d378..37b0584 100644 --- a/allelio/database/store.py +++ b/allelio/database/store.py @@ -27,7 +27,7 @@ def __init__(self, db_path: Optional[str] = None): def _connect(self) -> None: """Establish database connection and enable WAL mode.""" - self.conn = sqlite3.connect(str(self.db_path)) + self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) self.conn.row_factory = sqlite3.Row self.cursor = self.conn.cursor() # Enable WAL mode for better concurrent read performance diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 76ca7f4..14fd5e1 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, UploadFile, File, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse -from starlette.concurrency import run_in_executor from allelio import __version__ from allelio.parsers import parse_genotype_file @@ -24,7 +23,7 @@ async def read_root(request: Request) -> str: """Serve the main HTML page.""" try: - return templates.TemplateResponse("index.html", {"request": request}) + return templates.TemplateResponse(request, "index.html") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to load index page: {str(e)}") @@ -35,7 +34,7 @@ async def get_status() -> Dict[str, Any]: try: # Check ollama availability ai_engine = AIEngine() - ollama_available = ai_engine.check_connection() + ollama_available = await ai_engine.check_connection() except Exception: ollama_available = False @@ -51,7 +50,7 @@ async def get_status() -> Dict[str, Any]: db = AllelioDB() db_ready = db.is_initialized() if db_ready: - stats = db.get_statistics() + stats = db.get_stats() db_stats = { "clinvar_entries": stats.get("clinvar_entries", 0), "gwas_entries": stats.get("gwas_entries", 0), @@ -125,7 +124,7 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: # Create AI engine and check connection ai_engine = AIEngine() - if not ai_engine.check_connection(): + if not await ai_engine.check_connection(): raise HTTPException( status_code=503, detail="AI service (Ollama) is not available" @@ -168,10 +167,7 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: # Format results formatted_results = [] for i, variant in enumerate(analysis_results): - warnings = get_variant_warnings( - variant.rsid, - variant.genotype if hasattr(variant, 'genotype') else None, - ) + warnings = get_variant_warnings(variant) result_dict = { "rsid": variant.rsid, diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 9d30b43..90193fb 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -925,7 +925,7 @@

Upload Your Genetic Data

// Update Database Status const dbIndicator = document.getElementById('dbIndicator'); const dbStatus = document.getElementById('dbStatus'); - if (data.database_ready) { + if (data.db_ready) { dbIndicator.classList.add('connected'); dbStatus.textContent = 'Database Ready'; } else { @@ -936,7 +936,7 @@

Upload Your Genetic Data

// Update Ollama Status const ollamaIndicator = document.getElementById('ollamaIndicator'); const ollamaStatus = document.getElementById('ollamaStatus'); - if (data.ollama_connected) { + if (data.ollama_available) { ollamaIndicator.classList.add('connected'); ollamaStatus.textContent = 'Ollama Connected'; } else { From d326eec23fc756054273496a5a72d65fedaea75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 21:43:27 -0300 Subject: [PATCH 02/16] fix(web): real progress, working AI explanations, honest summary Uploading a genome through the web UI looked like it hung. The progress bar animated to 90% in fifteen seconds and then stopped, while the run itself takes about ten minutes on a whole genome. Nothing told the user where it had got to. - Add /api/progress and have the page poll it, so the bar reports the actual stage and count ("Writing explanations - 27 of 50"). - Run the explanations through explain_variants_batch, which was already in the codebase but unused by the web route. 718s -> 569s. - Call explain_variant, the method that exists; the route called generate_explanation and every explanation came back as a failure string. - generate_summary treated ClinVar/GWAS entries as dicts, and sent the model counts without the variants, so it replied that the list "was not included in your message". Pass the findings. - Cap the results list at 200 rows. Drawing all 62,057 locks the browser. - Keep the last run in ~/.allelio/last_analysis.json and restore it on load, with a button back to the upload panel. --- allelio/ai/engine.py | 41 ++++++++++++--- allelio/web/routes.py | 89 ++++++++++++++++++++++---------- allelio/web/templates/index.html | 74 +++++++++++++++++++++++--- 3 files changed, 162 insertions(+), 42 deletions(-) diff --git a/allelio/ai/engine.py b/allelio/ai/engine.py index 0fa6692..db094b9 100644 --- a/allelio/ai/engine.py +++ b/allelio/ai/engine.py @@ -220,16 +220,22 @@ async def generate_summary(self, results: List) -> str: # Classify based on available data if clinvar or (gwas and len(gwas) > 0): + # clinvar/gwas hold ClinVarEntry and GWASEntry objects, not dicts. has_clinvar_pathogenic = any( - 'pathogenic' in str(e.get('clinical_significance', '')).lower() + 'pathogenic' in str(getattr(e, 'clinical_significance', '')).lower() for e in clinvar ) - - if has_clinvar_pathogenic or (gwas and any( - float(e.get('p_value', '1.0').split('e-')[1]) > 5 - for e in gwas - if 'e-' in str(e.get('p_value', '')) - )): + + def _strong_gwas(e) -> bool: + p = str(getattr(e, 'p_value', '')) + if 'e-' not in p: + return False + try: + return float(p.split('e-')[1]) > 5 + except ValueError: + return False + + if has_clinvar_pathogenic or any(_strong_gwas(e) for e in gwas): high_impact.append(result) elif clinvar or gwas: moderate.append(result) @@ -249,6 +255,27 @@ async def generate_summary(self, results: List) -> str: summary_parts.append(f"- {len(moderate)} variant(s) with moderate research associations") if low: summary_parts.append(f"- {len(low)} variant(s) with limited available data") + + # The model was previously handed counts alone and asked to summarise + # findings it had never been shown, so it answered by saying so. List them. + listed = (high_impact + moderate)[:25] + if listed: + summary_parts.append("\nThe findings:") + for r in listed: + gene = "" + for e in (r.clinvar_entries or []): + gene = getattr(e, 'gene', '') or gene + for e in (r.gwas_entries or []): + gene = gene or getattr(e, 'gene', '') + sig = "" + for e in (r.clinvar_entries or []): + sig = getattr(e, 'clinical_significance', '') or sig + traits = [getattr(e, 'trait', '') for e in (r.gwas_entries or [])] + traits = [t for t in traits if t][:2] + bits = [b for b in (gene, sig, "; ".join(traits)) if b] + summary_parts.append( + f"- {r.rsid} ({r.genotype}): " + (" — ".join(bits) if bits else "no annotation") + ) summary_parts.append( "\nPlease provide a brief 2-3 paragraph executive summary of these findings, " diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 14fd5e1..6d3bf75 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -1,6 +1,8 @@ """API routes for Allelio web interface.""" import asyncio +import json +import os import tempfile from pathlib import Path from typing import List, Dict, Any, Optional @@ -91,6 +93,8 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: with open(temp_file_path, "wb") as f: f.write(content) + _progress.update(stage="Reading your file", done=0, total=0) + # Parse genotype file loop = asyncio.get_event_loop() genotypes = await loop.run_in_executor( @@ -112,6 +116,7 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: ) # Analyze variants + _progress.update(stage=f"Matching {len(genotypes):,} variants against ClinVar and GWAS") analysis_results = await loop.run_in_executor( None, analyze_variants, genotypes, db ) @@ -122,13 +127,10 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: detail="No variants found in database" ) - # Create AI engine and check connection + # Create AI engine. Ollama is optional — the README promises the tool + # still works without it, minus the plain-English explanations. ai_engine = AIEngine() - if not await ai_engine.check_connection(): - raise HTTPException( - status_code=503, - detail="AI service (Ollama) is not available" - ) + ai_available = await ai_engine.check_connection() # Get top 50 significant variants sorted_results = sorted( @@ -138,33 +140,33 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: ) top_variants = sorted_results[:50] - # Generate AI explanations for significant variants + # Generate AI explanations for significant variants. One call per + # variant, run a few at a time — sequentially this took 12 minutes. explanations = {} - for i, variant in enumerate(top_variants): - try: - explanation = await ai_engine.generate_explanation( - variant.rsid, - variant.chromosome, - variant.position, - variant.genotype, - variant.clinvar_data if hasattr(variant, 'clinvar_data') else None, - variant.gwas_data if hasattr(variant, 'gwas_data') else None, - ) - explanations[variant.rsid] = explanation - except Exception: - explanations[variant.rsid] = "Explanation generation failed" + if ai_available: + _progress.update( + stage="Writing explanations", done=0, total=len(top_variants) + ) + + def on_explained(done: int, total: int) -> None: + _progress.update(done=done, total=total) + + explanations = await ai_engine.explain_variants_batch( + top_variants, progress_callback=on_explained + ) # Generate executive summary + _progress.update(stage="Summarising", done=0, total=0) try: - summary = await ai_engine.generate_summary( - total_variants=len(analysis_results), - significant_variants=len(top_variants), - top_categories=_get_top_categories(analysis_results), - ) + if not ai_available: + raise RuntimeError("ollama unavailable") + summary = await ai_engine.generate_summary(top_variants) except Exception: - summary = "Unable to generate summary at this time" + summary = ("AI summary unavailable. Variant findings below come " + "straight from ClinVar and the GWAS Catalog.") # Format results + _progress.update(stage="Building your report") formatted_results = [] for i, variant in enumerate(analysis_results): warnings = get_variant_warnings(variant) @@ -183,12 +185,14 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: } formatted_results.append(result_dict) - return { + payload = { "summary": summary, "results": formatted_results, "total_variants": len(analysis_results), "analyzed_at": _get_timestamp(), } + _save_last_analysis(payload) + return payload except HTTPException: raise @@ -199,6 +203,7 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: ) finally: # Clean up temp file + _progress.update(stage="idle", done=0, total=0) if temp_file_path and Path(temp_file_path).exists(): try: Path(temp_file_path).unlink() @@ -206,6 +211,36 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: pass +LAST_ANALYSIS_PATH = Path(os.path.expanduser("~/.allelio/last_analysis.json")) + +# A whole-genome run takes minutes. Without real numbers the page looks hung, +# so the analyse route publishes its stage here and the browser polls it. +_progress: Dict[str, Any] = {"stage": "idle", "done": 0, "total": 0} + + +@router.get("/api/progress") +async def get_progress() -> Dict[str, Any]: + """Where the current analysis has got to.""" + return _progress + + +def _save_last_analysis(payload: Dict[str, Any]) -> None: + """Keep the most recent run so a page reload does not mean a re-upload.""" + try: + LAST_ANALYSIS_PATH.parent.mkdir(parents=True, exist_ok=True) + LAST_ANALYSIS_PATH.write_text(json.dumps(payload)) + except Exception: + pass + + +@router.get("/api/last") +async def get_last_analysis() -> Dict[str, Any]: + """Return the most recent analysis, or 404 if there has not been one.""" + if not LAST_ANALYSIS_PATH.exists(): + raise HTTPException(status_code=404, detail="No previous analysis") + return json.loads(LAST_ANALYSIS_PATH.read_text()) + + @router.post("/api/export") async def export_report(analysis_data: Dict[str, Any]) -> FileResponse: """ diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 90193fb..26070c0 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -867,8 +867,40 @@

Upload Your Genetic Data

document.addEventListener('DOMContentLoaded', () => { setupEventListeners(); fetchStatus(); + loadLastAnalysis(); }); + // Restore the previous run so a reload does not require re-uploading. + async function loadLastAnalysis() { + try { + const response = await fetch('/api/last'); + if (!response.ok) return; + analysisResults = await response.json(); + displayResults(); + document.getElementById('resultsSection').classList.remove('hidden'); + // A restored run is finished: no progress bar, no upload prompt. + document.getElementById('progressSection').classList.add('hidden'); + // Hide the upload panel so a finished run does not look like a + // prompt, but leave one way back to it. + const upload = document.getElementById('uploadSection'); + if (upload) { + upload.classList.add('hidden'); + const again = document.createElement('button'); + again.className = 'btn btn-secondary'; + again.textContent = 'Analyze another file'; + again.style.cssText = 'display:block;margin:0 auto 1.5rem;'; + again.onclick = () => { + upload.classList.remove('hidden'); + again.remove(); + }; + const results = document.getElementById('resultsSection'); + results.parentNode.insertBefore(again, results); + } + } catch (e) { + // No previous run — the upload panel stands on its own. + } + } + // Setup Event Listeners function setupEventListeners() { // Dark Mode Toggle @@ -959,14 +991,28 @@

Upload Your Genetic Data

document.getElementById('progressSection').classList.remove('hidden'); document.getElementById('resultsSection').classList.add('hidden'); - // Animate progress bar - let progress = 0; + // Poll the server for real progress. A whole genome takes minutes; + // a bar that fakes its way to 90% and stops looks like a hang. const progressBar = document.getElementById('progressBar'); - const progressInterval = setInterval(() => { - progress += Math.random() * 30; - if (progress > 90) progress = 90; - progressBar.style.width = progress + '%'; - }, 500); + const progressText = document.getElementById('progressText'); + progressBar.style.width = '2%'; + progressText.textContent = 'Uploading...'; + const progressInterval = setInterval(async () => { + try { + const r = await fetch('/api/progress'); + if (!r.ok) return; + const p = await r.json(); + if (p.stage === 'idle') return; + progressText.textContent = p.total + ? `${p.stage} — ${p.done} of ${p.total}` + : `${p.stage}...`; + if (p.total) { + progressBar.style.width = (10 + 80 * p.done / p.total) + '%'; + } + } catch (e) { + // Server busy or restarting — the next tick tries again. + } + }, 1000); try { const response = await fetch('/api/analyze', { @@ -1067,7 +1113,19 @@

Upload Your Genetic Data

return; } - results.forEach((result, idx) => { + // Cap the DOM: a full genome yields tens of thousands of results and + // rendering them all locks the browser. Results are already ordered + // by significance, so the cap keeps the ones that matter. + const RENDER_LIMIT = 200; + const shown = results.slice(0, RENDER_LIMIT); + if (results.length > RENDER_LIMIT) { + const note = document.createElement('p'); + note.style.cssText = 'text-align:center;color:var(--gray-600);padding:1rem;'; + note.textContent = `Showing the top ${RENDER_LIMIT} of ${results.length.toLocaleString()} findings. Export the report for the full set.`; + container.appendChild(note); + } + + shown.forEach((result, idx) => { const card = createResultCard(result); container.appendChild(card); }); From ea3a92653d8652b5a7a6fbf3964750453539357d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 21:49:57 -0300 Subject: [PATCH 03/16] fix(web): stop serving CORS to the whole web, cover the routes with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allow_origins=["*"] with allow_credentials meant any page the user happened to have open could POST their genotype file to localhost and read the analysis back. The UI is served from this same app; only a dev server on another loopback port needs CORS at all. Adds tests/test_web.py: the routes answer, a foreign origin gets no access-control-allow-origin header, and the two AI entry points are exercised against a stubbed client — including the assertion that the summary prompt actually carries the variants. --- allelio/web/app.py | 6 ++- tests/test_web.py | 107 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/test_web.py diff --git a/allelio/web/app.py b/allelio/web/app.py index 8b8a099..e0b7f9d 100644 --- a/allelio/web/app.py +++ b/allelio/web/app.py @@ -15,10 +15,12 @@ description="Privacy-first local genomics analysis powered by AI", ) -# Add CORS middleware for local development +# The UI is served from this same app, so CORS only needs to cover a dev +# server on another loopback port. A wildcard with credentials let any page +# the user visited POST their genome to localhost and read the result back. app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..9565391 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,107 @@ +"""Tests for the web interface. + +The web module went unexercised long enough to accumulate a startup crash, two +missing awaits and three wrong method names, so these cover the wiring: the +routes answer, CORS is not open to the world, and the AI engine's two entry +points survive a round trip against a stubbed client. +""" + +import pytest +from fastapi.testclient import TestClient + +from allelio.ai.engine import AIEngine +from allelio.analysis.lookup import ClinVarEntry, VariantResult +from allelio.web.app import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +class StubClient: + """Stands in for ollama.AsyncClient, recording what it was asked.""" + + def __init__(self, reply: str = "A plain-English explanation."): + self.reply = reply + self.prompts = [] + + async def list(self): + return {"models": [{"name": "llama3.1:8b"}]} + + async def chat(self, model, messages, stream=False, **kwargs): + self.prompts.append(messages[-1]["content"]) + return {"message": {"content": self.reply}} + + +def _variant(rsid: str = "rs429358") -> VariantResult: + return VariantResult( + rsid=rsid, + genotype="CT", + chromosome="19", + position=44908684, + clinvar_entries=[ + ClinVarEntry( + rsid=rsid, + gene="APOE", + clinical_significance="Pathogenic", + conditions="Alzheimer disease", + review_status="reviewed by expert panel", + ) + ], + gwas_entries=[], + ) + + +def test_index_page_renders(client: TestClient) -> None: + response = client.get("/") + assert response.status_code == 200 + assert "Allelio" in response.text + + +def test_status_reports_database_and_ai(client: TestClient) -> None: + response = client.get("/api/status") + assert response.status_code == 200 + body = response.json() + assert "db_ready" in body + assert "ollama_available" in body + + +def test_progress_starts_idle(client: TestClient) -> None: + assert client.get("/api/progress").json()["stage"] == "idle" + + +def test_cors_rejects_foreign_origins(client: TestClient) -> None: + """A page on the open web must not be able to read a genome off localhost.""" + allowed = client.get("/api/status", headers={"Origin": "http://localhost:3000"}) + assert allowed.headers.get("access-control-allow-origin") == "http://localhost:3000" + + blocked = client.get("/api/status", headers={"Origin": "https://example.com"}) + assert "access-control-allow-origin" not in blocked.headers + + +@pytest.mark.asyncio +async def test_explain_variant_uses_the_model() -> None: + engine = AIEngine() + engine.client = StubClient() + engine.available = True + + explanation = await engine.explain_variant(_variant()) + + assert "plain-English explanation" in explanation + assert "rs429358" in engine.client.prompts[0] + + +@pytest.mark.asyncio +async def test_summary_prompt_lists_the_variants() -> None: + """The prompt used to carry counts alone, so the model had nothing to summarise.""" + engine = AIEngine() + engine.client = StubClient(reply="Two findings of note.") + engine.available = True + + summary = await engine.generate_summary([_variant("rs1"), _variant("rs2")]) + + assert "Two findings of note." in summary + prompt = engine.client.prompts[0] + assert "rs1" in prompt and "rs2" in prompt + assert "APOE" in prompt From cab1594d402f5e0a28891d071e594b7dfdee6a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 22:00:53 -0300 Subject: [PATCH 04/16] fix(web): style the restore-upload button like the app's other buttons --- allelio/web/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 26070c0..d81b743 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -886,7 +886,7 @@

Upload Your Genetic Data

if (upload) { upload.classList.add('hidden'); const again = document.createElement('button'); - again.className = 'btn btn-secondary'; + again.className = 'export-button'; again.textContent = 'Analyze another file'; again.style.cssText = 'display:block;margin:0 auto 1.5rem;'; again.onclick = () => { From 7aee0cc7116a51e79e4b71c295b434fc22a93a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 23:05:36 -0300 Subject: [PATCH 05/16] fix(web): give result cards a gene and a real significance, raise the AI timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The results list reads result.gene, result.significance and result.pubmed_id. The route sent clinvar_data and gwas_data, which are not fields on VariantResult, so every card in a 62,000-variant report rendered as "Gene: Unknown" with a BENIGN badge — including the pathogenic ones. Sixty seconds also turned out to be short. On llama3.1:8b, the model this project defaults to, 28 of 50 explanations came back as "Request timed out". At 300s none do. Verified end to end in a browser against Ollama: first card now reads rs80224560 / CFTR / PATHOGENIC, 200 of 200 cards name a gene, 50 of 50 explanations complete. --- allelio/ai/engine.py | 9 +++++++-- allelio/web/routes.py | 35 +++++++++++++++++++++++++++++++++-- tests/test_web.py | 17 +++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/allelio/ai/engine.py b/allelio/ai/engine.py index db094b9..1306b43 100644 --- a/allelio/ai/engine.py +++ b/allelio/ai/engine.py @@ -20,6 +20,11 @@ DEFAULT_MODEL = "llama3.1:8b" DEFAULT_HOST = "http://localhost:11434" +# Sixty seconds is not enough for the default 8B model on a warm machine +# once a few explanations run at once; every other one came back as a +# timeout fallback. +REQUEST_TIMEOUT = 300 + class AIEngine: """ @@ -127,7 +132,7 @@ async def explain_variant(self, result) -> str: ], stream=False ), - timeout=60 + timeout=REQUEST_TIMEOUT ) explanation = response['message']['content'] @@ -301,7 +306,7 @@ def _strong_gwas(e) -> bool: ], stream=False ), - timeout=60 + timeout=REQUEST_TIMEOUT ) summary = response['message']['content'] diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 6d3bf75..443efbd 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -69,6 +69,33 @@ async def get_status() -> Dict[str, Any]: } +def _gene_of(variant) -> Optional[str]: + """Gene symbol for a result, from ClinVar first and GWAS as a fallback.""" + for entry in (variant.clinvar_entries or []): + if entry.gene: + return entry.gene + for entry in (variant.gwas_entries or []): + if entry.mapped_gene: + return entry.mapped_gene + return None + + +def _significance_of(variant) -> str: + """Bucket a result into the four badges the results list knows how to draw.""" + for entry in (variant.clinvar_entries or []): + significance = (entry.clinical_significance or "").lower() + if "pathogenic" in significance and "benign" not in significance: + return "pathogenic" + if "risk" in significance: + return "risk" + if variant.gwas_entries: + return "risk" + for entry in (variant.clinvar_entries or []): + if "benign" in (entry.clinical_significance or "").lower(): + return "benign" + return "trait" + + @router.post("/api/analyze") async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: """ @@ -179,8 +206,12 @@ def on_explained(done: int, total: int) -> None: "category": variant.category if hasattr(variant, 'category') else "Unknown", "significance_rank": i + 1, "explanation": explanations.get(variant.rsid, ""), - "clinvar_data": variant.clinvar_data if hasattr(variant, 'clinvar_data') else None, - "gwas_data": variant.gwas_data if hasattr(variant, 'gwas_data') else None, + "gene": _gene_of(variant), + "significance": _significance_of(variant), + "pubmed_id": next( + (e.pubmed_id for e in (variant.gwas_entries or []) if e.pubmed_id), + None, + ), "warnings": warnings, } formatted_results.append(result_dict) diff --git a/tests/test_web.py b/tests/test_web.py index 9565391..c792f44 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -105,3 +105,20 @@ async def test_summary_prompt_lists_the_variants() -> None: prompt = engine.client.prompts[0] assert "rs1" in prompt and "rs2" in prompt assert "APOE" in prompt + + +def test_result_cards_get_a_gene_and_a_significance() -> None: + """The list drew every variant as "Gene: Unknown" and BENIGN, including the + pathogenic ones, because the route sent fields the page does not read.""" + from allelio.web.routes import _gene_of, _significance_of + + variant = _variant() + assert _gene_of(variant) == "APOE" + assert _significance_of(variant) == "pathogenic" + + benign = VariantResult( + rsid="rs1", + clinvar_entries=[ClinVarEntry(rsid="rs1", clinical_significance="Benign")], + ) + assert _significance_of(benign) == "benign" + assert _gene_of(benign) is None From d797c81ed8bc3066314f46c28053b70bb5dc2fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 23:14:12 -0300 Subject: [PATCH 06/16] fix(web): make the category tabs match the categories the analyser emits The tabs filtered on slugs (health_conditions), the analyser labels results "Health Conditions". Four of the five tabs showed "No results found for this category" on every report. --- allelio/web/templates/index.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index d81b743..76e9667 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -1046,6 +1046,11 @@

Upload Your Genetic Data

} // Display Results + // "Health Conditions" -> "health_conditions" + function slugify(category) { + return (category || '').toLowerCase().replace(/ /g, '_'); + } + function displayResults() { if (!analysisResults) return; @@ -1105,7 +1110,9 @@

Upload Your Genetic Data

// Filter by category if (currentCategory !== 'all') { - results = results.filter(r => r.category === currentCategory); + // The tabs are keyed on slugs; the analyser emits labels like + // "Health Conditions", so every tab but All matched nothing. + results = results.filter(r => slugify(r.category) === currentCategory); } if (results.length === 0) { From 770b2871669d75832aaa2f10e7cc6d04cebc8ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 23:19:25 -0300 Subject: [PATCH 07/16] fix(web): render every finding Dropping the 200-row slice added while chasing the browser freeze. It does not reproduce: all 62,057 findings render in under four seconds. Paging a list this size is worth doing, but it is not part of this fix. --- allelio/web/templates/index.html | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 76e9667..4464072 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -1120,19 +1120,7 @@

Upload Your Genetic Data

return; } - // Cap the DOM: a full genome yields tens of thousands of results and - // rendering them all locks the browser. Results are already ordered - // by significance, so the cap keeps the ones that matter. - const RENDER_LIMIT = 200; - const shown = results.slice(0, RENDER_LIMIT); - if (results.length > RENDER_LIMIT) { - const note = document.createElement('p'); - note.style.cssText = 'text-align:center;color:var(--gray-600);padding:1rem;'; - note.textContent = `Showing the top ${RENDER_LIMIT} of ${results.length.toLocaleString()} findings. Export the report for the full set.`; - container.appendChild(note); - } - - shown.forEach((result, idx) => { + results.forEach((result, idx) => { const card = createResultCard(result); container.appendChild(card); }); From c4823d0f5817f27e876e311b550e4e34800c27c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 23:33:37 -0300 Subject: [PATCH 08/16] fix(web): escape untrusted fields, drop dead code, keep the PR to fixes Follow-up on review of this branch. Security: - Result cards built the DOM by string interpolation from the uploaded file and the model. Nothing validates a genotype except a "--" check, so a crafted file executed script in the page. Escape every field, and only accept digits for the ClinVar and PubMed IDs that go into a URL. - The exported HTML report had the same hole, server side. - Removed the CORS middleware outright. The UI is same-origin with the API, so no origin needs to be granted anything; a loopback allowlist still let any local process read a genome off the API. Correctness: - "Conflicting interpretations of pathogenicity" was badged PATHOGENIC and pushed into the summary prompt as high impact, on a substring match. - explain_variants_batch reported progress twice per variant, once with a stale count, so the bar counted backwards. - The summary prompt read .gene on a GWAS entry, which names it mapped_gene. - The top-50 sort keyed on significance_score, an attribute no result has; analyze_variants already orders them. - The category tabs omitted carrier_status, which the analyser emits. - pyproject allowed fastapi 0.104, whose starlette predates the TemplateResponse(request, name) signature this branch now uses. Scope: - Dropped the last-analysis file and its endpoint. Writing a whole genome's findings to disk and replaying them for whoever next opens the page is a feature with a privacy cost, not a crash fix; it belongs in its own PR. --- allelio/ai/engine.py | 13 +++---- allelio/web/app.py | 14 ++----- allelio/web/routes.py | 54 +++++++++---------------- allelio/web/templates/index.html | 67 +++++++++++++------------------- pyproject.toml | 2 +- tests/test_web.py | 66 +++++++++++++++++++++++++++---- 6 files changed, 113 insertions(+), 103 deletions(-) diff --git a/allelio/ai/engine.py b/allelio/ai/engine.py index 1306b43..bf3cc88 100644 --- a/allelio/ai/engine.py +++ b/allelio/ai/engine.py @@ -179,8 +179,6 @@ async def explain_variants_batch( async def explain_with_semaphore(result): async with semaphore: explanation = await self.explain_variant(result) - if progress_callback: - progress_callback(len(explanations), len(results)) return result.rsid, explanation # Track completions for callback @@ -226,10 +224,11 @@ async def generate_summary(self, results: List) -> str: # Classify based on available data if clinvar or (gwas and len(gwas) > 0): # clinvar/gwas hold ClinVarEntry and GWASEntry objects, not dicts. - has_clinvar_pathogenic = any( - 'pathogenic' in str(getattr(e, 'clinical_significance', '')).lower() - for e in clinvar - ) + def _pathogenic(e) -> bool: + sig = str(getattr(e, 'clinical_significance', '')).lower() + return 'pathogenic' in sig and 'conflicting' not in sig + + has_clinvar_pathogenic = any(_pathogenic(e) for e in clinvar) def _strong_gwas(e) -> bool: p = str(getattr(e, 'p_value', '')) @@ -271,7 +270,7 @@ def _strong_gwas(e) -> bool: for e in (r.clinvar_entries or []): gene = getattr(e, 'gene', '') or gene for e in (r.gwas_entries or []): - gene = gene or getattr(e, 'gene', '') + gene = gene or getattr(e, 'mapped_gene', '') sig = "" for e in (r.clinvar_entries or []): sig = getattr(e, 'clinical_significance', '') or sig diff --git a/allelio/web/app.py b/allelio/web/app.py index e0b7f9d..7c88b40 100644 --- a/allelio/web/app.py +++ b/allelio/web/app.py @@ -5,7 +5,6 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.middleware.cors import CORSMiddleware from allelio import __version__, __app_name__ @@ -15,16 +14,9 @@ description="Privacy-first local genomics analysis powered by AI", ) -# The UI is served from this same app, so CORS only needs to cover a dev -# server on another loopback port. A wildcard with credentials let any page -# the user visited POST their genome to localhost and read the result back. -app.add_middleware( - CORSMiddleware, - allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +# No CORS middleware: the UI is served from this same app, so nothing here is +# cross-origin. The wildcard that used to sit here, with credentials allowed, +# let any page the user happened to visit read their genome off localhost. # Template directory TEMPLATE_DIR = Path(__file__).parent / "templates" diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 443efbd..8a46ed4 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -1,9 +1,8 @@ """API routes for Allelio web interface.""" import asyncio -import json -import os import tempfile +from html import escape from pathlib import Path from typing import List, Dict, Any, Optional @@ -84,6 +83,8 @@ def _significance_of(variant) -> str: """Bucket a result into the four badges the results list knows how to draw.""" for entry in (variant.clinvar_entries or []): significance = (entry.clinical_significance or "").lower() + if "conflicting" in significance: + continue if "pathogenic" in significance and "benign" not in significance: return "pathogenic" if "risk" in significance: @@ -159,13 +160,9 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: ai_engine = AIEngine() ai_available = await ai_engine.check_connection() - # Get top 50 significant variants - sorted_results = sorted( - analysis_results, - key=lambda x: x.significance_score if hasattr(x, 'significance_score') else 0, - reverse=True - ) - top_variants = sorted_results[:50] + # analyze_variants already returns these most-significant-first, so the + # top 50 are the 50 worth spending an AI call on. + top_variants = analysis_results[:50] # Generate AI explanations for significant variants. One call per # variant, run a few at a time — sequentially this took 12 minutes. @@ -222,7 +219,6 @@ def on_explained(done: int, total: int) -> None: "total_variants": len(analysis_results), "analyzed_at": _get_timestamp(), } - _save_last_analysis(payload) return payload except HTTPException: @@ -242,8 +238,6 @@ def on_explained(done: int, total: int) -> None: pass -LAST_ANALYSIS_PATH = Path(os.path.expanduser("~/.allelio/last_analysis.json")) - # A whole-genome run takes minutes. Without real numbers the page looks hung, # so the analyse route publishes its stage here and the browser polls it. _progress: Dict[str, Any] = {"stage": "idle", "done": 0, "total": 0} @@ -255,23 +249,6 @@ async def get_progress() -> Dict[str, Any]: return _progress -def _save_last_analysis(payload: Dict[str, Any]) -> None: - """Keep the most recent run so a page reload does not mean a re-upload.""" - try: - LAST_ANALYSIS_PATH.parent.mkdir(parents=True, exist_ok=True) - LAST_ANALYSIS_PATH.write_text(json.dumps(payload)) - except Exception: - pass - - -@router.get("/api/last") -async def get_last_analysis() -> Dict[str, Any]: - """Return the most recent analysis, or 404 if there has not been one.""" - if not LAST_ANALYSIS_PATH.exists(): - raise HTTPException(status_code=404, detail="No previous analysis") - return json.loads(LAST_ANALYSIS_PATH.read_text()) - - @router.post("/api/export") async def export_report(analysis_data: Dict[str, Any]) -> FileResponse: """ @@ -327,7 +304,7 @@ def _get_timestamp() -> str: def _generate_html_report(analysis_data: Dict[str, Any]) -> str: """Generate HTML report from analysis data.""" - summary = analysis_data.get("summary", "No summary available") + summary = escape(str(analysis_data.get("summary") or "No summary available")) results = analysis_data.get("results", []) total_variants = analysis_data.get("total_variants", 0) analyzed_at = analysis_data.get("analyzed_at", "Unknown") @@ -335,12 +312,17 @@ def _generate_html_report(analysis_data: Dict[str, Any]) -> str: # Build results table HTML results_html = "" for result in results[:100]: # Limit to first 100 for report - rsid = result.get("rsid", "N/A") - chrom = result.get("chromosome", "N/A") - pos = result.get("position", "N/A") - genotype = result.get("genotype", "N/A") - category = result.get("category", "N/A") - explanation = result.get("explanation", "N/A") + # These come from the uploaded file and the model, and the report is + # opened in a browser — none of it is trusted markup. + def field(name): + return escape(str(result.get(name) or "N/A")) + + rsid = field("rsid") + chrom = field("chromosome") + pos = field("position") + genotype = field("genotype") + category = field("category") + explanation = field("explanation") results_html += f""" diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 4464072..9503340 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -867,40 +867,8 @@

Upload Your Genetic Data

document.addEventListener('DOMContentLoaded', () => { setupEventListeners(); fetchStatus(); - loadLastAnalysis(); }); - // Restore the previous run so a reload does not require re-uploading. - async function loadLastAnalysis() { - try { - const response = await fetch('/api/last'); - if (!response.ok) return; - analysisResults = await response.json(); - displayResults(); - document.getElementById('resultsSection').classList.remove('hidden'); - // A restored run is finished: no progress bar, no upload prompt. - document.getElementById('progressSection').classList.add('hidden'); - // Hide the upload panel so a finished run does not look like a - // prompt, but leave one way back to it. - const upload = document.getElementById('uploadSection'); - if (upload) { - upload.classList.add('hidden'); - const again = document.createElement('button'); - again.className = 'export-button'; - again.textContent = 'Analyze another file'; - again.style.cssText = 'display:block;margin:0 auto 1.5rem;'; - again.onclick = () => { - upload.classList.remove('hidden'); - again.remove(); - }; - const results = document.getElementById('resultsSection'); - results.parentNode.insertBefore(again, results); - } - } catch (e) { - // No previous run — the upload panel stands on its own. - } - } - // Setup Event Listeners function setupEventListeners() { // Dark Mode Toggle @@ -1046,6 +1014,21 @@

Upload Your Genetic Data

} // Display Results + // Result fields are read out of the uploaded file or written by the + // model; neither is trusted markup. + function esc(value) { + if (value === null || value === undefined) return ''; + const div = document.createElement('div'); + div.textContent = value; + return div.innerHTML; + } + + // ClinVar and PubMed IDs land inside a URL, where escaping HTML is not + // enough — anything but digits is not an ID. + function idFor(value) { + return /^\d+$/.test(String(value ?? '')) ? String(value) : ''; + } + // "Health Conditions" -> "health_conditions" function slugify(category) { return (category || '').toLowerCase().replace(/ /g, '_'); @@ -1063,10 +1046,11 @@

Upload Your Genetic Data

} // Create Category Tabs - const categories = ['all', 'health_conditions', 'risk_factors', 'pharmacogenomics', 'traits']; + const categories = ['all', 'health_conditions', 'carrier_status', 'risk_factors', 'pharmacogenomics', 'traits']; const categoryLabels = { 'all': 'All', 'health_conditions': 'Health Conditions', + 'carrier_status': 'Carrier Status', 'risk_factors': 'Risk Factors', 'pharmacogenomics': 'Pharmacogenomics', 'traits': 'Traits' @@ -1141,6 +1125,7 @@

Upload Your Genetic Data

const categoryBadgeClass = { 'health_conditions': 'badge-category', + 'carrier_status': 'badge-category', 'risk_factors': 'badge-category', 'pharmacogenomics': 'badge-category', 'traits': 'badge-category' @@ -1149,20 +1134,20 @@

Upload Your Genetic Data

card.innerHTML = `
-
${result.rsid}
-
${result.gene || 'Gene: Unknown'}
+
${esc(result.rsid)}
+
${esc(result.gene) || 'Gene: Unknown'}
- ${result.category.replace('_', ' ')} - ${significance} + ${esc(result.category).replace('_', ' ')} + ${esc(significance)}
-
${result.genotype}
+
${esc(result.genotype)}
-
${result.explanation || 'No explanation available.'}
+
${esc(result.explanation) || 'No explanation available.'}
- ${result.clinvar_id ? `ClinVar` : ''} - ${result.pubmed_id ? `PubMed` : ''} + ${idFor(result.clinvar_id) ? `ClinVar` : ''} + ${idFor(result.pubmed_id) ? `PubMed` : ''}
`; diff --git a/pyproject.toml b/pyproject.toml index 0fe2a98..521cd18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Bio-Informatics", ] dependencies = [ - "fastapi>=0.104.0", + "fastapi>=0.108.0", # TemplateResponse(request, name) needs starlette >= 0.29 "uvicorn[standard]>=0.24.0", "click>=8.1.0", "ollama>=0.4.0", diff --git a/tests/test_web.py b/tests/test_web.py index c792f44..cbe6be2 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -10,7 +10,7 @@ from fastapi.testclient import TestClient from allelio.ai.engine import AIEngine -from allelio.analysis.lookup import ClinVarEntry, VariantResult +from allelio.analysis.lookup import ClinVarEntry, GWASEntry, VariantResult from allelio.web.app import app @@ -71,13 +71,14 @@ def test_progress_starts_idle(client: TestClient) -> None: assert client.get("/api/progress").json()["stage"] == "idle" -def test_cors_rejects_foreign_origins(client: TestClient) -> None: - """A page on the open web must not be able to read a genome off localhost.""" - allowed = client.get("/api/status", headers={"Origin": "http://localhost:3000"}) - assert allowed.headers.get("access-control-allow-origin") == "http://localhost:3000" +def test_no_cross_origin_reads(client: TestClient) -> None: + """A page on the open web must not be able to read a genome off localhost. - blocked = client.get("/api/status", headers={"Origin": "https://example.com"}) - assert "access-control-allow-origin" not in blocked.headers + The UI is same-origin with the API, so the app grants no origin anything. + """ + for origin in ("https://example.com", "http://localhost:3000"): + response = client.get("/api/status", headers={"Origin": origin}) + assert "access-control-allow-origin" not in response.headers @pytest.mark.asyncio @@ -122,3 +123,54 @@ def test_result_cards_get_a_gene_and_a_significance() -> None: ) assert _significance_of(benign) == "benign" assert _gene_of(benign) is None + + +def test_conflicting_interpretations_are_not_pathogenic() -> None: + """ClinVar's commonest ambiguous term contains the word "pathogenic"; a + substring match painted those variants with the red badge.""" + from allelio.web.routes import _significance_of + + conflicting = VariantResult( + rsid="rs1", + clinvar_entries=[ + ClinVarEntry( + rsid="rs1", + clinical_significance="Conflicting interpretations of pathogenicity", + ) + ], + ) + assert _significance_of(conflicting) != "pathogenic" + + +@pytest.mark.asyncio +async def test_summary_prompt_names_the_gwas_gene() -> None: + """GWASEntry calls it mapped_gene, so reading .gene left GWAS-only variants + reaching the model with no gene at all.""" + engine = AIEngine() + engine.client = StubClient(reply="Noted.") + engine.available = True + + variant = VariantResult( + rsid="rs2", + gwas_entries=[GWASEntry(rsid="rs2", trait="Height", mapped_gene="HMGA2")], + ) + await engine.generate_summary([variant]) + + assert "HMGA2" in engine.client.prompts[0] + + +def test_exported_report_escapes_the_uploaded_file() -> None: + """Genotypes are copied verbatim out of the user's file and the report is + opened in a browser.""" + from allelio.web.routes import _generate_html_report + + html = _generate_html_report( + { + "summary": "", + "results": [{"rsid": "rs1", "genotype": ""}], + } + ) + assert "" not in html + assert " Date: Thu, 27 Aug 2026 23:39:20 -0300 Subject: [PATCH 09/16] fix(web): give every category a tab, and stop the tab switch throwing Picking a tab looked the button up by its position in a second, hardcoded category list. Anything missing from that list indexed to -1 and threw before the results were re-rendered, so the tab appeared to do nothing. The tabs now carry their own category. That list was also missing two of the six categories the analyser emits: carrier status and uncategorized. On a real genome that is 21% of the findings, reachable only under All. --- allelio/web/routes.py | 2 +- allelio/web/templates/index.html | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 8a46ed4..3ab1c4e 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -180,7 +180,7 @@ def on_explained(done: int, total: int) -> None: ) # Generate executive summary - _progress.update(stage="Summarising", done=0, total=0) + _progress.update(stage="Summarizing", done=0, total=0) try: if not ai_available: raise RuntimeError("ollama unavailable") diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 9503340..81ff665 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -1046,14 +1046,15 @@

Upload Your Genetic Data

} // Create Category Tabs - const categories = ['all', 'health_conditions', 'carrier_status', 'risk_factors', 'pharmacogenomics', 'traits']; + const categories = ['all', 'health_conditions', 'carrier_status', 'risk_factors', 'pharmacogenomics', 'traits', 'unknown']; const categoryLabels = { 'all': 'All', 'health_conditions': 'Health Conditions', 'carrier_status': 'Carrier Status', 'risk_factors': 'Risk Factors', 'pharmacogenomics': 'Pharmacogenomics', - 'traits': 'Traits' + 'traits': 'Traits', + 'unknown': 'Uncategorized' }; const tabsContainer = document.getElementById('resultsTabs'); @@ -1062,6 +1063,7 @@

Upload Your Genetic Data

categories.forEach(cat => { const button = document.createElement('button'); button.className = 'tab-button' + (cat === 'all' ? ' active' : ''); + button.dataset.category = cat; button.textContent = categoryLabels[cat]; button.addEventListener('click', () => selectCategory(cat)); tabsContainer.appendChild(button); @@ -1075,9 +1077,11 @@

Upload Your Genetic Data

function selectCategory(category) { currentCategory = category; - // Update active tab - document.querySelectorAll('.tab-button').forEach((btn, idx) => { - btn.classList.toggle('active', btn.textContent === document.querySelectorAll('.tab-button')[['all', 'health_conditions', 'risk_factors', 'pharmacogenomics', 'traits'].indexOf(category)].textContent); + // Update active tab. This used to look the tab up by position in a + // second, hardcoded category list; anything missing from that list + // indexed to -1 and threw. + document.querySelectorAll('.tab-button').forEach(btn => { + btn.classList.toggle('active', btn.dataset.category === category); }); renderResultCards(); @@ -1126,6 +1130,7 @@

Upload Your Genetic Data

const categoryBadgeClass = { 'health_conditions': 'badge-category', 'carrier_status': 'badge-category', + 'unknown': 'badge-category', 'risk_factors': 'badge-category', 'pharmacogenomics': 'badge-category', 'traits': 'badge-category' From 45d965c6b5ce70565cb07b73e20f47e640e04caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 23:43:06 -0300 Subject: [PATCH 10/16] fix(web): finish the escaping, fix two classifiers, drop a dead helper Second pass over the same ground, after review. - The exported report escaped its rows but not its own two header fields, and /api/export takes whatever dict it is handed. - The summary's pathogenic test disagreed with the badge on the card: it had no benign guard, so a combined value could be badged benign in the UI and sent to the model as high impact. - _strong_gwas compared p-values by slicing the exponent out of the string form. p_value is a float column and 6,271 GWAS rows hold 0.0, which has no exponent at all, so the strongest associations in the catalogue were read as the weakest. Compare the number. - The ClinVar source link keyed on a clinvar_id that is in no payload and on no entry, so it rendered for no variant. Point it at the rsID. - _get_top_categories lost its only caller when the summary prompt changed; it was the last thing importing List and VariantResult here. --- allelio/ai/engine.py | 14 +++++++----- allelio/web/routes.py | 19 ++++------------ allelio/web/templates/index.html | 10 ++++++--- tests/test_web.py | 38 ++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/allelio/ai/engine.py b/allelio/ai/engine.py index bf3cc88..e37687d 100644 --- a/allelio/ai/engine.py +++ b/allelio/ai/engine.py @@ -225,18 +225,20 @@ async def generate_summary(self, results: List) -> str: if clinvar or (gwas and len(gwas) > 0): # clinvar/gwas hold ClinVarEntry and GWASEntry objects, not dicts. def _pathogenic(e) -> bool: + # Same test the results list badges on, so the summary and + # the cards cannot disagree about one variant. sig = str(getattr(e, 'clinical_significance', '')).lower() - return 'pathogenic' in sig and 'conflicting' not in sig + if 'conflicting' in sig or 'benign' in sig: + return False + return 'pathogenic' in sig has_clinvar_pathogenic = any(_pathogenic(e) for e in clinvar) def _strong_gwas(e) -> bool: - p = str(getattr(e, 'p_value', '')) - if 'e-' not in p: - return False + p = getattr(e, 'p_value', None) try: - return float(p.split('e-')[1]) > 5 - except ValueError: + return p is not None and float(p) < 1e-5 + except (TypeError, ValueError): return False if has_clinvar_pathogenic or any(_strong_gwas(e) for e in gwas): diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 3ab1c4e..1c7197d 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -4,7 +4,7 @@ import tempfile from html import escape from pathlib import Path -from typing import List, Dict, Any, Optional +from typing import Dict, Any, Optional from fastapi import APIRouter, UploadFile, File, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse @@ -12,7 +12,7 @@ from allelio import __version__ from allelio.parsers import parse_genotype_file from allelio.database.store import AllelioDB -from allelio.analysis.lookup import analyze_variants, VariantResult +from allelio.analysis.lookup import analyze_variants from allelio.ai.engine import AIEngine from allelio.ai.safety import get_variant_warnings from allelio.web.app import templates @@ -285,17 +285,6 @@ async def export_report(analysis_data: Dict[str, Any]) -> FileResponse: ) -def _get_top_categories(results: List[VariantResult]) -> List[str]: - """Extract top categories from analysis results.""" - categories = {} - for result in results: - category = result.category if hasattr(result, 'category') else "Unknown" - categories[category] = categories.get(category, 0) + 1 - - sorted_cats = sorted(categories.items(), key=lambda x: x[1], reverse=True) - return [cat[0] for cat in sorted_cats[:5]] - - def _get_timestamp() -> str: """Get current timestamp in ISO format.""" from datetime import datetime @@ -306,8 +295,8 @@ def _generate_html_report(analysis_data: Dict[str, Any]) -> str: """Generate HTML report from analysis data.""" summary = escape(str(analysis_data.get("summary") or "No summary available")) results = analysis_data.get("results", []) - total_variants = analysis_data.get("total_variants", 0) - analyzed_at = analysis_data.get("analyzed_at", "Unknown") + total_variants = escape(str(analysis_data.get("total_variants", 0))) + analyzed_at = escape(str(analysis_data.get("analyzed_at") or "Unknown")) # Build results table HTML results_html = "" diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 81ff665..7779676 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -1023,12 +1023,16 @@

Upload Your Genetic Data

return div.innerHTML; } - // ClinVar and PubMed IDs land inside a URL, where escaping HTML is not - // enough — anything but digits is not an ID. + // These land inside a URL, where escaping HTML is not enough. A PubMed + // ID is digits; an rsID is rs or i followed by digits. function idFor(value) { return /^\d+$/.test(String(value ?? '')) ? String(value) : ''; } + function rsidFor(value) { + return /^(rs|i)\d+$/.test(String(value ?? '')) ? String(value) : ''; + } + // "Health Conditions" -> "health_conditions" function slugify(category) { return (category || '').toLowerCase().replace(/ /g, '_'); @@ -1151,7 +1155,7 @@

Upload Your Genetic Data

${esc(result.explanation) || 'No explanation available.'}
- ${idFor(result.clinvar_id) ? `ClinVar` : ''} + ${rsidFor(result.rsid) ? `ClinVar` : ''} ${idFor(result.pubmed_id) ? `PubMed` : ''}
diff --git a/tests/test_web.py b/tests/test_web.py index cbe6be2..97cac02 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -174,3 +174,41 @@ def test_exported_report_escapes_the_uploaded_file() -> None: assert " None: + """/api/export takes an arbitrary dict, so the report's own two fields are + no more trusted than the rows.""" + from allelio.web.routes import _generate_html_report + + html = _generate_html_report( + {"analyzed_at": "", "total_variants": "x"} + ) + assert "" not in html + assert "x" not in html + + +def test_strong_gwas_reads_the_smallest_p_values() -> None: + """p_value is a float column; 6,271 rows hold 0.0, which has no exponent to + string-slice, so the strongest associations were read as the weakest.""" + from allelio.ai.engine import AIEngine + + engine = AIEngine() + engine.client = StubClient(reply="Noted.") + engine.available = True + + import asyncio + + strong = VariantResult( + rsid="rs3", + gwas_entries=[GWASEntry(rsid="rs3", trait="Height", p_value=0.0)], + ) + weak = VariantResult( + rsid="rs4", + gwas_entries=[GWASEntry(rsid="rs4", trait="Height", p_value=0.5)], + ) + # Weak one first: only a working p-value test reorders them. + asyncio.run(engine.generate_summary([weak, strong])) + prompt = engine.client.prompts[0] + + assert prompt.index("rs3") < prompt.index("rs4") From 950309c1e5f2a3dd70b186e150bb3886fff71bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Thu, 27 Aug 2026 23:53:08 -0300 Subject: [PATCH 11/16] fix(web): keep the model's paragraph breaks, and close the outbound links The summary and the per-variant explanations are the one thing the AI is here for, and both arrived as a single run-on block: the model writes paragraphs, HTML collapses the newlines. pre-wrap on the two elements that hold prose. Also rel="noopener noreferrer" on the ClinVar and PubMed links. --- allelio/web/templates/index.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/allelio/web/templates/index.html b/allelio/web/templates/index.html index 7779676..cce2e29 100644 --- a/allelio/web/templates/index.html +++ b/allelio/web/templates/index.html @@ -431,6 +431,7 @@ .summary-text { font-size: 0.95rem; line-height: 1.6; + white-space: pre-wrap; } /* Result Card */ @@ -593,6 +594,7 @@ margin-bottom: 1rem; color: var(--gray-700); line-height: 1.7; + white-space: pre-wrap; } body.dark-mode .result-explanation { @@ -1155,8 +1157,8 @@

Upload Your Genetic Data

${esc(result.explanation) || 'No explanation available.'}
- ${rsidFor(result.rsid) ? `ClinVar` : ''} - ${idFor(result.pubmed_id) ? `PubMed` : ''} + ${rsidFor(result.rsid) ? `ClinVar` : ''} + ${idFor(result.pubmed_id) ? `PubMed` : ''}
`; From f622b8a12d727907f69af5a9e48896d6e00f4bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Fri, 28 Aug 2026 00:39:42 -0300 Subject: [PATCH 12/16] fix(web): name the conflicting variants, show the safety warnings, clean up the export ClinVar's "Conflicting classifications of pathogenicity" is 130,833 of the rsIDs in the shipped dump and sorts to the top of every report. It was drawn in the same green as a benign call. It now has its own badge, and so does a trait, which was also borrowing benign's green. A ClinVar benign call was unreachable for any variant that also had a GWAS row, because the GWAS check ran first. The safety layer computes a genetic-counselling warning for BRCA1/2, TP53, Lynch and APOE. Nothing rendered it. The cards and the exported report do now. Explanations were gated on the model being reachable, so a machine without Ollama got empty cards instead of the ClinVar/GWAS fallback that explain_variant already writes. The exported report was left in the shared temp directory at 0644 forever with the user's genotypes in it. It is now 0600 and deleted once sent. --- allelio/web/routes.py | 75 ++++++++++++++++++++++---------- allelio/web/templates/index.html | 54 ++++++++++++++++++----- tests/test_web.py | 59 ++++++++++++++++++++++--- 3 files changed, 149 insertions(+), 39 deletions(-) diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 1c7197d..23cd7a4 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -1,6 +1,7 @@ """API routes for Allelio web interface.""" import asyncio +import os import tempfile from html import escape from pathlib import Path @@ -8,6 +9,7 @@ from fastapi import APIRouter, UploadFile, File, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse +from starlette.background import BackgroundTask from allelio import __version__ from allelio.parsers import parse_genotype_file @@ -80,20 +82,24 @@ def _gene_of(variant) -> Optional[str]: def _significance_of(variant) -> str: - """Bucket a result into the four badges the results list knows how to draw.""" + """Bucket a result into the badges the results list knows how to draw. + + ClinVar has the last word. "Conflicting classifications of pathogenicity" + is 130,833 rsIDs and sorts to the very top of the report, so it needs to + say so rather than borrow either neighbour's colour. + """ for entry in (variant.clinvar_entries or []): significance = (entry.clinical_significance or "").lower() if "conflicting" in significance: - continue + return "conflicting" if "pathogenic" in significance and "benign" not in significance: return "pathogenic" + if "benign" in significance: + return "benign" if "risk" in significance: return "risk" if variant.gwas_entries: return "risk" - for entry in (variant.clinvar_entries or []): - if "benign" in (entry.clinical_significance or "").lower(): - return "benign" return "trait" @@ -166,18 +172,16 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: # Generate AI explanations for significant variants. One call per # variant, run a few at a time — sequentially this took 12 minutes. - explanations = {} - if ai_available: - _progress.update( - stage="Writing explanations", done=0, total=len(top_variants) - ) + _progress.update( + stage="Writing explanations", done=0, total=len(top_variants) + ) - def on_explained(done: int, total: int) -> None: - _progress.update(done=done, total=total) + def on_explained(done: int, total: int) -> None: + _progress.update(done=done, total=total) - explanations = await ai_engine.explain_variants_batch( - top_variants, progress_callback=on_explained - ) + explanations = await ai_engine.explain_variants_batch( + top_variants, progress_callback=on_explained + ) # Generate executive summary _progress.update(stage="Summarizing", done=0, total=0) @@ -263,17 +267,20 @@ async def export_report(analysis_data: Dict[str, Any]) -> FileResponse: # Generate HTML report (using report generator when available) html_content = _generate_html_report(analysis_data) - # Create temp file for report - temp_dir = tempfile.gettempdir() - temp_report_path = Path(temp_dir) / f"allelio_report_{_get_timestamp()}.html" - - with open(temp_report_path, "w") as f: + # mkstemp gives the file 0600, and the report holds the user's + # genotypes. Explicit encoding because the report declares UTF-8 and + # the model writes em dashes. + fd, temp_report_path = tempfile.mkstemp( + prefix="allelio_report_", suffix=".html" + ) + with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(html_content) return FileResponse( - path=str(temp_report_path), + path=temp_report_path, filename=f"allelio_report_{_get_timestamp()}.html", media_type="text/html", + background=BackgroundTask(_unlink, temp_report_path), ) except HTTPException: @@ -285,6 +292,14 @@ async def export_report(analysis_data: Dict[str, Any]) -> FileResponse: ) +def _unlink(path: str) -> None: + """Remove the exported report once it has been sent.""" + try: + os.unlink(path) + except OSError: + pass + + def _get_timestamp() -> str: """Get current timestamp in ISO format.""" from datetime import datetime @@ -312,7 +327,14 @@ def field(name): genotype = field("genotype") category = field("category") explanation = field("explanation") - + + # The safety layer computes these for BRCA1/2, TP53, Lynch and APOE. + # A report that omits them is the one place they matter most. + warnings = "".join( + f'

{escape(str(w))}

' + for w in (result.get("warnings") or []) + ) + results_html += f""" {rsid} @@ -320,7 +342,7 @@ def field(name): {pos} {genotype} {category} - {explanation} + {explanation}{warnings} """ @@ -331,6 +353,13 @@ def field(name): Allelio Analysis Report