Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
446b9b9
fix(web): repair the web interface
42piratas Aug 27, 2026
d326eec
fix(web): real progress, working AI explanations, honest summary
42piratas Aug 28, 2026
ea3a926
fix(web): stop serving CORS to the whole web, cover the routes with t…
42piratas Aug 28, 2026
cab1594
fix(web): style the restore-upload button like the app's other buttons
42piratas Aug 28, 2026
7aee0cc
fix(web): give result cards a gene and a real significance, raise the…
42piratas Aug 28, 2026
d797c81
fix(web): make the category tabs match the categories the analyser emits
42piratas Aug 28, 2026
770b287
fix(web): render every finding
42piratas Aug 28, 2026
c4823d0
fix(web): escape untrusted fields, drop dead code, keep the PR to fixes
42piratas Aug 28, 2026
d6d68c2
fix(web): give every category a tab, and stop the tab switch throwing
42piratas Aug 28, 2026
45d965c
fix(web): finish the escaping, fix two classifiers, drop a dead helper
42piratas Aug 28, 2026
950309c
fix(web): keep the model's paragraph breaks, and close the outbound l…
42piratas Aug 28, 2026
f622b8a
fix(web): name the conflicting variants, show the safety warnings, cl…
42piratas Aug 28, 2026
1f0101f
fix(web): let the upload not choose where it lands, and stop over-bad…
42piratas Aug 28, 2026
0d657bb
fix(web): call a GWAS hit what it is, and stop dropping the safety wa…
42piratas Aug 28, 2026
1be6f1b
fix(web): keep the fallback when the clock runs out, and put the warn…
42piratas Aug 28, 2026
95c982c
feat(web): let the user keep an analysis instead of re-running it
42piratas Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 98 additions & 27 deletions allelio/ai/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -127,7 +138,7 @@ async def explain_variant(self, result) -> str:
],
stream=False
),
timeout=60
timeout=REQUEST_TIMEOUT
)

explanation = response['message']['content']
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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, "
Expand All @@ -274,7 +345,7 @@ async def generate_summary(self, results: List) -> str:
],
stream=False
),
timeout=60
timeout=REQUEST_TIMEOUT
)

summary = response['message']['content']
Expand Down
58 changes: 54 additions & 4 deletions allelio/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion allelio/database/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 51 additions & 8 deletions allelio/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__

Expand All @@ -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))
Expand Down
Loading