diff --git a/allelio/ai/engine.py b/allelio/ai/engine.py index 0fa6692..a944e75 100644 --- a/allelio/ai/engine.py +++ b/allelio/ai/engine.py @@ -20,6 +20,17 @@ 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 + +# Fifty variants, three at a time, at the per-request timeout is over an hour +# of staring at a progress bar. Cap the batch and keep what finished. Thirty +# minutes clears a full run of this project's own default model on an M1 Max +# with room to spare; fifteen did not. +BATCH_DEADLINE = 1800 + class AIEngine: """ @@ -127,7 +138,7 @@ async def explain_variant(self, result) -> str: ], stream=False ), - timeout=60 + timeout=REQUEST_TIMEOUT ) explanation = response['message']['content'] @@ -152,7 +163,8 @@ async def explain_variants_batch( self, results: List, max_concurrent: int = 3, - progress_callback: Optional[Callable[[int, int], None]] = None + progress_callback: Optional[Callable[[int, int], None]] = None, + deadline: float = BATCH_DEADLINE ) -> Dict[str, str]: """ Generate AI explanations for multiple variants with concurrency control. @@ -174,23 +186,52 @@ 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 - explanations = {} - - # Create all tasks - tasks = [explain_with_semaphore(result) for result in results] - - # Execute with progress tracking - for coro in asyncio.as_completed(tasks): - rsid, explanation = await coro - explanations[rsid] = explanation - if progress_callback: - progress_callback(len(explanations), len(results)) + # Seeded, not empty: a variant the deadline cuts off still deserves the + # gene, the ClinVar call and the GWAS traits that _fallback_explanation + # writes. A finished task overwrites its seed. + explanations = { + r.rsid: self._fallback_explanation( + r, reason="Explanation ran past the time limit" + ) + for r in results + } + done = 0 + tasks = [ + asyncio.ensure_future(explain_with_semaphore(result)) + for result in results + ] + + # Whatever is done when the clock runs out is what the user gets. The + # per-request timeout on its own lets 50 variants, three at a time, + # hold the upload open for well over an hour on a slow model. + try: + for coro in asyncio.as_completed(tasks, timeout=deadline): + try: + rsid, explanation = await coro + except asyncio.TimeoutError: + raise + except Exception: + # One variant that fails outside explain_variant's own + # guard used to cost one explanation. It should not cost + # the whole upload, minutes after the analysis is done. + done += 1 + if progress_callback: + progress_callback(done, len(results)) + continue + explanations[rsid] = explanation + done += 1 + if progress_callback: + progress_callback(done, len(results)) + except asyncio.TimeoutError: + pass + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + return explanations async def generate_summary(self, results: List) -> str: @@ -220,16 +261,25 @@ async def generate_summary(self, results: List) -> str: # Classify based on available data if clinvar or (gwas and len(gwas) > 0): - has_clinvar_pathogenic = any( - 'pathogenic' in str(e.get('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', '')) - )): + # 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() + 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 = getattr(e, 'p_value', None) + try: + 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): high_impact.append(result) elif clinvar or gwas: moderate.append(result) @@ -249,6 +299,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, 'mapped_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, " @@ -274,7 +345,7 @@ async def generate_summary(self, results: List) -> str: ], stream=False ), - timeout=60 + timeout=REQUEST_TIMEOUT ) summary = response['message']['content'] diff --git a/allelio/cli.py b/allelio/cli.py index f7ae5c4..9308d0f 100644 --- a/allelio/cli.py +++ b/allelio/cli.py @@ -2,11 +2,13 @@ import asyncio import os +import socket from pathlib import Path from typing import Optional import click from rich.console import Console +from rich.markup import escape from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table @@ -278,13 +280,61 @@ def serve(port: int, host: str): Start an interactive web server for variant analysis and exploration. """ console.print("\n[bold cyan]Allelio Web Interface[/bold cyan]\n") - console.print(f"Starting Allelio web interface on {host}:{port}...\n") - console.print(f"Open [bold cyan]http://{host}:{port}[/bold cyan] in your browser\n") - + # escape: rich reads "[" as the start of a style tag, and --host takes + # whatever the shell hands it. + console.print(f"Starting Allelio web interface on {escape(host)}:{port}...\n") + + # The app rejects Host headers it does not recognise, which is what stops a + # remote page from reaching this server by pointing its own domain at + # 127.0.0.1. Whatever the operator chose to bind belongs on the list; it has + # to be set before the app module is imported. + # An empty --host binds everywhere; there is no URL in it to print. + browse_to = host or "localhost" + if not os.environ.get("ALLELIO_ALLOWED_HOSTS"): + try: + # inet_aton takes every legacy spelling of "all interfaces" — 0, + # 0.0, 0x0 — that comparing against "0.0.0.0" would miss, and it + # does no lookups, so a hostname simply raises. + binds_everywhere = socket.inet_aton(host) == b"\x00\x00\x00\x00" + except OSError: + binds_everywhere = False + + # Starlette strips the port by splitting the Host header on ":", so no + # IPv6 literal on the list could ever match; neither could a bind + # address nobody types into a browser. Lowercased because that is how + # browsers send it and starlette compares exactly. + # An empty --host binds everywhere too: asyncio special-cases it into a + # getaddrinfo with AI_PASSIVE, which answers 0.0.0.0 and ::. + extra = [] if binds_everywhere or not host or ":" in host else [host.lower()] + os.environ["ALLELIO_ALLOWED_HOSTS"] = ",".join( + dict.fromkeys(["localhost", "127.0.0.1"] + extra) + ) + if not extra: + # Printing the bound address here would send them to a URL that + # answers 400. + browse_to = "localhost" + console.print( + f"[bold yellow]⚠[/bold yellow] Bound to {escape(host) or 'every interface'}, but " + "browse to " + "localhost — " + "the host check has no way to match that address.\n" + " That check is what stops a web page you visit from reading your genome " + "off this server, which has no password on it.\n" + " To let another machine reach it, name that machine: " + "ALLELIO_ALLOWED_HOSTS=192.168.1.50 allelio serve --host 0.0.0.0\n", + style="yellow", + ) + + # A bare IPv6 literal needs brackets or the browser reads the last colon as + # the port separator, and rich reads "[::1]" as a style tag. + url = f"http://[{browse_to}]:{port}" if ":" in browse_to else f"http://{browse_to}:{port}" + console.print(f"Open [bold cyan]{escape(url)}[/bold cyan] in your browser\n") + try: import uvicorn + from allelio.web.app import app - + uvicorn.run(app, host=host, port=port, log_level="info") except ImportError: console.print("[bold red]✗[/bold red] Web server dependencies not installed\n", style="red") 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/app.py b/allelio/web/app.py index 8b8a099..5cfe56b 100644 --- a/allelio/web/app.py +++ b/allelio/web/app.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.trustedhost import TrustedHostMiddleware from allelio import __version__, __app_name__ @@ -15,15 +15,58 @@ description="Privacy-first local genomics analysis powered by AI", ) -# Add CORS middleware for local development -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - 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. + +# Binding to 127.0.0.1 is not on its own a boundary: a page on a domain whose +# DNS re-resolves to 127.0.0.1 is same-origin by the browser's reckoning, and +# CORS never enters into it. Checking the Host header is what closes that, and +# it costs nothing when the host is what we bound to. `serve` widens this to +# whatever --host it was given. +# +# Starlette compares against the Host header with the port already stripped, so +# entries carry no port. It splits on ":" to do it, which leaves no spelling of +# a bracketed IPv6 literal that can ever match — "localhost" is how you reach +# this over IPv6. +# Loopback always stays on the list. It is the address the person running this +# actually types, and naming a LAN address should not lock them out of their own +# machine. It costs nothing: a rebound domain arrives in the Host header as its +# own name, never as "localhost", so this is not a way in. +# +# Lowercased because browsers send the host lowercased and starlette compares it +# exactly — an entry with a capital in it could never match. +ALLOWED_HOSTS = list( + dict.fromkeys( + ["localhost", "127.0.0.1"] + + [ + h.strip().lower() + for h in os.environ.get("ALLELIO_ALLOWED_HOSTS", "").split(",") + if h.strip() + ] + ) ) +# TrustedHostMiddleware only checks its patterns when the stack is first built, +# which is on the first request — a typo in the environment would otherwise take +# down a run that had already printed its URL. It checks with an assert, so it +# would also stop checking under -O. Both halves of starlette's rule, restated +# here and raising for real: no "*" past the first character, and a leading one +# has to be the "*." of a subdomain wildcard. "*example.com" fails both its +# check and this one — it is a forgotten dot, not a pattern. +_bad = [ + h + for h in ALLOWED_HOSTS + if "*" in h[1:] or (h.startswith("*") and h != "*" and not h.startswith("*.")) +] +if _bad: + raise ValueError( + f"ALLELIO_ALLOWED_HOSTS: {', '.join(_bad)} — a wildcard host has to look " + "like '*.example.com', or be '*' on its own." + ) + +app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS) + # Template directory TEMPLATE_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) diff --git a/allelio/web/routes.py b/allelio/web/routes.py index 76ca7f4..93d16fe 100644 --- a/allelio/web/routes.py +++ b/allelio/web/routes.py @@ -1,18 +1,22 @@ """API routes for Allelio web interface.""" import asyncio +import json +import os import tempfile +from datetime import datetime +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 -from starlette.concurrency import run_in_executor +from starlette.background import BackgroundTask 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 @@ -24,7 +28,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 +39,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 +55,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), @@ -68,6 +72,45 @@ 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 badges the results list knows how to draw. + + ClinVar has the last word. "Conflicting classifications of pathogenicity" + is 130,833 rsIDs and "Uncertain significance" is 1,236,063 — both sort high + enough to reach the report, and neither is a trait or a benign call. + """ + for entry in (variant.clinvar_entries or []): + significance = (entry.clinical_significance or "").lower() + if "conflicting" in significance: + return "conflicting" + if "uncertain" in significance: + return "uncertain" + if "pathogenic" in significance and "benign" not in significance: + return "pathogenic" + if "benign" in significance or "protective" in significance: + return "benign" + if "risk" in significance: + return "risk" + if variant.gwas_entries: + # A GWAS row on its own is an association and nothing stronger — 37,108 + # of the 62,057 findings on a real genome. Calling them all a risk + # over-states every one; calling them all a trait quietly demotes type 2 + # diabetes and coronary artery disease. Say what the row actually is. + return "association" + return "trait" + + @router.post("/api/analyze") async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: """ @@ -81,21 +124,26 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") - # Save uploaded file to temp location - temp_dir = tempfile.gettempdir() - temp_file_path = Path(temp_dir) / file.filename - content = await file.read() if not content: raise HTTPException(status_code=400, detail="Uploaded file is empty") - - with open(temp_file_path, "wb") as f: + + # The multipart filename is raw header data: "../../../.zshenv" resolves + # out of the temp directory, and multipart is CORS-safelisted, so any + # page could have posted here. mkstemp picks the name and the mode — + # this file is the user's entire genome and the temp directory is shared. + # Only the .gz suffix matters to the parser. + suffix = ".gz" if file.filename.endswith(".gz") else "" + fd, temp_file_path = tempfile.mkstemp(prefix="allelio_upload_", suffix=suffix) + with os.fdopen(fd, "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( - None, parse_genotype_file, str(temp_file_path) + None, parse_genotype_file, temp_file_path ) if not genotypes: @@ -113,6 +161,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 ) @@ -123,55 +172,43 @@ 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 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( - analysis_results, - key=lambda x: x.significance_score if hasattr(x, 'significance_score') else 0, - reverse=True + # 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. + _progress.update( + stage="Writing explanations", done=0, total=len(top_variants) ) - top_variants = sorted_results[:50] - # Generate AI explanations for significant variants - 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" + 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="Summarizing", 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", done=0, total=0) 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, @@ -181,18 +218,23 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: "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) - return { + payload = { "summary": summary, "results": formatted_results, "total_variants": len(analysis_results), "analyzed_at": _get_timestamp(), } + return payload except HTTPException: raise @@ -203,6 +245,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() @@ -210,6 +253,17 @@ async def analyze_file(file: UploadFile = File(...)) -> Dict[str, Any]: pass +# 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 + + @router.post("/api/export") async def export_report(analysis_data: Dict[str, Any]) -> FileResponse: """ @@ -224,17 +278,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: @@ -246,40 +303,179 @@ 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 _unlink(path: str) -> None: + """Delete a file we are done with, where failing to is not worth reporting.""" + try: + os.unlink(path) + except OSError: + pass + + +# A whole-genome run is half an hour, and the results only live in the tab that +# ran it — a reload throws them away. Saving is opt-in and stays on this +# machine: the file is the user's genome and never leaves it. +SAVED_ANALYSIS_PATH = os.path.expanduser("~/.allelio/last_analysis.json") + + +# No lock around any of this, and none needed: every writer gets its own mkstemp +# temp file, os.replace is atomic, and the delete treats "already gone" as +# success. Concurrent saves and deletes can only order differently, never tear. +def _write_saved_analysis(analysis_data: Dict[str, Any]) -> None: + """Write the saved analysis atomically, readable only by its owner.""" + directory = os.path.dirname(SAVED_ANALYSIS_PATH) + # 0700 only bites when this creates the directory; ~/.allelio usually + # already exists for the database, and silently tightening a directory + # this module does not own is not this feature's call to make. + os.makedirs(directory, mode=0o700, exist_ok=True) + + # Same directory so os.replace stays on one filesystem, and mkstemp so a + # crash mid-write leaves the previous save intact rather than a half file. + fd, temp_path = tempfile.mkstemp(prefix=".last_analysis_", dir=directory) + try: + # os.fdopen owns the descriptor once it succeeds; if it raises, nothing + # else is going to close it. + try: + handle = os.fdopen(fd, "w", encoding="utf-8") + except BaseException: + os.close(fd) + raise + with handle as f: + json.dump(analysis_data, f) + # os.replace orders this rename against other renames, not against + # the data blocks. Without the fsync a power cut can land the + # rename and lose the contents — the truncated file this whole + # dance exists to prevent. + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, SAVED_ANALYSIS_PATH) + except BaseException: + # BaseException on purpose: a KeyboardInterrupt here would otherwise + # strand 15 MB of genotypes under a dotfile name nothing cleans up. + _unlink(temp_path) + raise + + +def _read_saved_analysis() -> Optional[Dict[str, Any]]: + """Return the saved analysis, or None if there isn't a usable one.""" + try: + with open(SAVED_ANALYSIS_PATH, encoding="utf-8") as f: + analysis_data = json.load(f) + except (OSError, ValueError): + # Missing, unreadable, or truncated by a crash mid-write. Any of those + # means "nothing to restore" — none of them should wedge the page. + return None + # A file holding a valid JSON list, string or number parses fine and then + # fails serialisation on the way out as a 500. It is not a saved analysis. + return analysis_data if isinstance(analysis_data, dict) else None + + +@router.get("/api/saved") +async def get_saved_analysis_info() -> Dict[str, Any]: + """Whether there is a saved analysis worth offering to restore.""" + try: + saved_at = os.path.getmtime(SAVED_ANALYSIS_PATH) + except OSError: + return {"saved": False, "saved_at": None} + # Reading it to answer costs a parse of 15 MB on every page load, so the + # banner is offered on the strength of the file existing. /api/saved/data + # is the one that can still say no; the page handles that. + return { + "saved": True, + "saved_at": datetime.fromtimestamp(saved_at).isoformat(timespec="seconds"), + } + + +@router.get("/api/saved/data") +async def get_saved_analysis() -> Dict[str, Any]: + """The saved analysis itself, for restoring the results view.""" + analysis_data = await asyncio.to_thread(_read_saved_analysis) + if analysis_data is None: + raise HTTPException(status_code=404, detail="No saved analysis") + return analysis_data + + +@router.post("/api/saved") +async def save_analysis(analysis_data: Dict[str, Any]) -> Dict[str, Any]: + """Save the current analysis to this machine, at the user's request.""" + if not analysis_data: + raise HTTPException(status_code=400, detail="No analysis data provided") + + try: + # A whole genome is 15 MB of JSON and encoding it takes a quarter of a + # second, which on the event loop is a quarter of a second nothing else + # is served. FastAPI has already spent its own on decoding the body; + # this is the half we get to move off. + await asyncio.to_thread(_write_saved_analysis, analysis_data) + except (OSError, TypeError, ValueError): + # The path is under the user's home directory — saying which home is + # not the browser's business. + raise HTTPException(status_code=500, detail="Could not save the analysis") + + return {"saved": True} + + +def _delete_saved_analysis() -> None: + """Remove the saved analysis. Absent already is success, not an error.""" + try: + os.unlink(SAVED_ANALYSIS_PATH) + except FileNotFoundError: + pass + + +@router.delete("/api/saved") +async def delete_saved_analysis() -> Dict[str, Any]: + """Forget the saved analysis, and only say so if it is really gone.""" + try: + await asyncio.to_thread(_delete_saved_analysis) + except OSError: + # The page says "deleted" on any 200. Reporting success over a genome + # file still sitting on the disk is the one lie this feature cannot + # afford. + raise HTTPException( + status_code=500, detail="Could not delete the saved analysis" + ) + return {"saved": False} def _get_timestamp() -> str: """Get current timestamp in ISO format.""" - from datetime import datetime return datetime.now().isoformat().replace(":", "-").split(".")[0] 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") + 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 = "" 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") + + # The safety layer computes these for BRCA1/2, TP53, Lynch and APOE. + # A report that omits them is the one place they matter most. + # explain_variant folds these into the explanation via + # wrap_with_disclaimer — but only on the path where the model answered. + # A fallback explanation carries no warning, so test the text itself. + explanation_text = str(result.get("explanation") or "") + warnings = "".join( + f'
{escape(str(w))}
' + for w in (result.get("warnings") or []) + if str(w) not in explanation_text + ) + results_html += f"""