Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)

**OpenDNA** parses a 23andMe / AncestryDNA / MyHeritage raw DNA file against 8 curated SNP panels (cardiovascular, methylation, pharmacogenomics, athletic, dietary, HFE, cognition, stimulant sensitivity), joins findings with ClinVar + PharmGKB/CPIC annotations, and renders a self-contained HTML report. Optional BYOK LLM synthesis (Anthropic Claude or OpenAI GPT) adds a prose interpretation layer.
**OpenDNA** parses a 23andMe / AncestryDNA / MyHeritage raw DNA file against 8 curated SNP panels (cardiovascular, methylation, pharmacogenomics, athletic, dietary, HFE, cognition, stimulant sensitivity), joins findings with ClinVar + PharmGKB/CPIC annotations, and renders a self-contained HTML report. It now also scores match confidence, shows panel coverage / blind spots, and rolls multi-marker genes like APOE, HFE, MTHFR, CYP2C19, and warfarin PGx into composite calls. Optional BYOK LLM synthesis (Anthropic Claude or OpenAI GPT) adds a prose interpretation layer.

**What leaves your machine:** nothing, by default. No account, no upload, no telemetry. The raw DNA file is parsed, analyzed, and rendered entirely offline. If — and only if — you paste an API key and opt in to AI synthesis, OpenDNA sends the filtered *findings* (rsid + gene + genotype + tier + short note; roughly 40–50 lines of structured text) to the provider you chose. Your raw DNA file is never transmitted. See the [Privacy](#privacy--what-leaves-your-machine) section for the full rules.

Expand Down Expand Up @@ -86,6 +86,8 @@ opendna --version
pytest -v # (only if you installed [dev])
```

Current contributor baseline: `51` tests passing.

---

## Quickstart — the web UI
Expand Down Expand Up @@ -135,13 +137,13 @@ The JSON schema is stable and suitable for ingestion by another pipeline stage.

## What OpenDNA interprets

Every finding is tier-scored (`risk` / `warning` / `normal` / `unknown`) and, where available, cross-referenced with ClinVar clinical significance and PharmGKB/CPIC dosing guidelines.
Every finding is tier-scored (`risk` / `warning` / `normal` / `unknown`), annotated with match confidence, and, where available, cross-referenced with ClinVar clinical significance and PharmGKB/CPIC dosing guidelines. The report also separates `not on chip`, `no-call`, and `ambiguous` markers so missing data is not misread as a negative result.

| Panel | Focus | Representative genes |
|---|---|---|
| **Cardiovascular & Longevity** | CAD risk, lifespan | APOE, 9p21, FOXO3, LPA |
| **Cardiovascular & Longevity** | CAD risk, thrombosis, lifespan | APOE, 9p21, FOXO3, LPA, F5, F2 |
| **Methylation & Detox** | Folate/B12 cycle | MTHFR, COMT, MTR/MTRR, FUT2, CBS, VDR |
| **Pharmacogenomics (PGx)** | Drug metabolism | CYP2C19, CYP2C9, VKORC1, SLCO1B1, TPMT, CYP3A5 |
| **Pharmacogenomics (PGx)** | Drug metabolism | CYP2C19, CYP2C9, VKORC1, CYP4F2, SLCO1B1, TPMT, CYP3A5 |
| **Athletic Performance & Recovery** | Fiber type, recovery | ACTN3, PPARA, PPARGC1A, COL5A1, IL6 |
| **Dietary Sensitivity** | Lactose, caffeine, alcohol, omega-3 | LCT, CYP1A2, ALDH2, FADS1, TAS2R38 |
| **Iron Metabolism (HFE)** | Hemochromatosis | C282Y, H63D, S65C |
Expand All @@ -150,6 +152,12 @@ Every finding is tier-scored (`risk` / `warning` / `normal` / `unknown`) and, wh

The analyzer automatically handles both **allele-order variations** (`AG` == `GA`) and **reverse-strand reports** (panel `CC` matches file `GG` for C/T SNPs), so genotypes from any major consumer-testing vendor should resolve correctly.

### Source-file caveats, now surfaced in the report

- `Not on chip` means the marker was not present in the source file. It does **not** mean the user has a reassuring genotype there.
- `No-call` means the vendor included the marker but did not make a confident genotype call.
- Composite calls are only made where the source file type can support them. OpenDNA still does **not** infer rare variants, structural variants, HLA types, CYP2D6 star alleles, or methylation from array data.

---

## Troubleshooting
Expand Down
9 changes: 6 additions & 3 deletions docs/superpowers/handover-2026-04-17.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ Local-first web utility that parses a raw 23andMe-style DNA file, matches agains

### Shipped features

- 8 curated SNP panels (cardiovascular, methylation, pharmacogenomics, athletic, dietary, HFE, cognition, stimulant sensitivity) — ~50 SNPs, each with ≥2 genotype interpretations.
- ClinVar (14 rsids) + PharmGKB (7 rsids, CPIC drug recs for TPMT / CYP2C19 / CYP2C9 / VKORC1 / SLCO1B1) shipped subsets.
- 8 curated SNP panels (cardiovascular, methylation, pharmacogenomics, athletic, dietary, HFE, cognition, stimulant sensitivity) — now expanded with F5 / F2 thrombophilia markers and CYP4F2 warfarin PGx support.
- ClinVar (16 rsids) + PharmGKB (8 rsids, CPIC drug recs for TPMT / CYP2C19 / CYP2C9 / VKORC1 / CYP4F2 / SLCO1B1) shipped subsets.
- FastAPI server with five endpoints: `/`, `/api/panels`, `/api/analyze`, `/api/analyze-stream` (SSE for live progress), `/api/update-db`.
- Vanilla-JS SPA: DOM APIs only (no `innerHTML` on dynamic data), report rendered into sandboxed iframe. Live progress bar with per-stage messages. Optional browser `localStorage` persistence of form values behind a *Remember* checkbox + *Clear saved values* button.
- CLI: `opendna serve`, `opendna scan <file>`, `opendna update-db`.
- Jinja2 HTML report (autoescape on) styled after the original sandbox synthesis — stat tiles, color-coded card grid per panel, critical-findings alert box for risk tier, AI synthesis block when LLM is configured.
- Report now includes source-file QC, vendor/build heuristics, panel completeness, confidence labels, gene caveats, and composite summaries for APOE, HFE, MTHFR, CYP2C19, and warfarin PGx.
- LLM provider layer: pluggable ABC, Anthropic default (`claude-sonnet-4-6` + prompt caching), OpenAI (`gpt-4o` using `max_completion_tokens`). API keys held per-request in memory only.
- Analyzer normalizes allele order (`AG == GA`) AND reverse complement (panel `CC` matches file `GG` for non-palindromic C/T SNPs).
- 44 tests passing (parser, panels, analyzer, annotations, report, LLM providers, server, CLI, SSE stream, e2e). `ruff check` clean. CI runs on Python 3.11 / 3.12 / 3.13.
- 51 tests passing (parser, panels, analyzer, summaries, annotations, report, LLM providers, server, CLI, SSE stream, e2e). `ruff check` clean. CI runs on Python 3.11 / 3.12 / 3.13.
- Apache 2.0 license.
- README with full setup guide, per-provider raw-DNA download instructions, sample report + screenshot, troubleshooting, author links (`@corbett3000`, `stillrush.co/bio`).
- Sample report: `examples/sample-report.html` (served live via raw.githack; preview image at `docs/assets/sample-report.png`).
Expand Down Expand Up @@ -58,6 +59,8 @@ pytest -v # 44 tests should pass
opendna serve # localhost:8787
```

The current baseline is `51` passing tests.

## Key design decisions (for context)

| Decision | Choice | Why |
Expand Down
104 changes: 96 additions & 8 deletions src/opendna/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
from opendna.models import Finding, Panel, SnpDef

_COMPLEMENT = {"A": "T", "T": "A", "C": "G", "G": "C"}
_NO_CALL_GENOTYPES = {"--", "-", "00", "NN", "NC"}
_CONFIDENCE_SCORES = {
"exact": 1.0,
"normalized": 0.97,
"reverse_complement": 0.9,
"unmatched": 0.35,
"not_tested": 0.0,
"no_call": 0.0,
"ambiguous": 0.15,
}

# A/T and C/G are palindromic sites: forward and reverse complements collide,
# so RC inference is unreliable. Panels whose allele set is exactly one of
Expand Down Expand Up @@ -50,7 +60,30 @@ def _is_palindromic_site(snp: SnpDef) -> bool:
return _panel_alleles(snp) in _PALINDROMIC_PAIRS


def _match_interpretation(snp: SnpDef, genotype: str) -> tuple[str, str]:
def _classify_call_status(genotype: str | None) -> str:
if genotype is None:
return "not_tested"
genotype = genotype.upper()
if genotype in _NO_CALL_GENOTYPES:
return "no_call"
if not genotype or len(genotype) not in {1, 2}:
return "ambiguous"
if any(base not in "ACGT" for base in genotype):
return "ambiguous"
return "called"


def _confidence_label(score: float) -> str:
if score >= 0.9:
return "high"
if score >= 0.6:
return "medium"
if score > 0:
return "low"
return "none"


def _match_interpretation(snp: SnpDef, genotype: str) -> tuple[str, str, str, str | None]:
"""Match a genotype to a panel interpretation.

Fallback chain:
Expand All @@ -63,21 +96,21 @@ def _match_interpretation(snp: SnpDef, genotype: str) -> tuple[str, str]:
# 1. exact
if genotype in snp.interpretations:
i = snp.interpretations[genotype]
return i.tier, i.note
return i.tier, i.note, "exact", genotype
# 2. allele-order normalization
norm = _normalize_genotype(genotype)
for key, interp in snp.interpretations.items():
if _normalize_genotype(key) == norm:
return interp.tier, interp.note
return interp.tier, interp.note, "normalized", key
# 3. reverse-complement (only for non-palindromic sites)
if not _is_palindromic_site(snp):
rc = _reverse_complement(genotype)
if rc is not None:
rc_norm = _normalize_genotype(rc)
for key, interp in snp.interpretations.items():
if _normalize_genotype(key) == rc_norm:
return interp.tier, interp.note
return "unknown", f"Genotype {genotype} not interpreted in panel."
return interp.tier, interp.note, "reverse_complement", key
return "unknown", f"Genotype {genotype} not interpreted in panel.", "unmatched", None


def analyze(
Expand All @@ -92,29 +125,84 @@ def analyze(
continue
for snp in panel.snps:
genotype = parsed.get(snp.rsid)
if genotype is None:
call_status = _classify_call_status(genotype)
if call_status == "not_tested":
findings.append(
Finding(
panel_id=panel.id,
rsid=snp.rsid,
gene=snp.gene,
genotype=None,
interpreted_genotype=None,
tier="unknown",
note="SNP not present in raw DNA file.",
note="Marker not present in this source DNA file.",
description=snp.description,
call_status="not_tested",
match_method="not_tested",
confidence_score=0.0,
confidence_label="none",
)
)
continue
tier, note = _match_interpretation(snp, genotype)
if call_status == "no_call":
findings.append(
Finding(
panel_id=panel.id,
rsid=snp.rsid,
gene=snp.gene,
genotype=genotype,
interpreted_genotype=None,
tier="unknown",
note=(
"Marker is present in the file, but the source genotype "
"was a no-call."
),
description=snp.description,
call_status="no_call",
match_method="no_call",
confidence_score=0.0,
confidence_label="none",
)
)
continue
if call_status == "ambiguous":
findings.append(
Finding(
panel_id=panel.id,
rsid=snp.rsid,
gene=snp.gene,
genotype=genotype,
interpreted_genotype=None,
tier="unknown",
note=(
"Marker is present, but the source genotype format is "
"ambiguous for this panel."
),
description=snp.description,
call_status="ambiguous",
match_method="ambiguous",
confidence_score=_CONFIDENCE_SCORES["ambiguous"],
confidence_label=_confidence_label(_CONFIDENCE_SCORES["ambiguous"]),
)
)
continue

tier, note, match_method, interpreted_genotype = _match_interpretation(snp, genotype)
confidence_score = _CONFIDENCE_SCORES[match_method]
findings.append(
Finding(
panel_id=panel.id,
rsid=snp.rsid,
gene=snp.gene,
genotype=genotype,
interpreted_genotype=interpreted_genotype,
tier=tier,
note=note,
description=snp.description,
call_status="called",
match_method=match_method,
confidence_score=confidence_score,
confidence_label=_confidence_label(confidence_score),
)
)
return findings
12 changes: 12 additions & 0 deletions src/opendna/annotations/clinvar.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,17 @@
"condition": "Simvastatin-induced myopathy",
"review_status": "reviewed by expert panel (PharmGKB)",
"source": "ClinVar 2026-03 snapshot"
},
"rs6025": {
"clinical_significance": "pathogenic / risk factor",
"condition": "Factor V Leiden thrombophilia; venous thromboembolism",
"review_status": "multiple submitters",
"source": "ClinVar 2026-04 snapshot"
},
"rs1799963": {
"clinical_significance": "pathogenic (low penetrance)",
"condition": "Prothrombin-related thrombophilia; venous thromboembolism",
"review_status": "multiple submitters",
"source": "ClinVar 2026-04 snapshot"
}
}
12 changes: 12 additions & 0 deletions src/opendna/annotations/pharmgkb.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@
}
]
},
"rs2108622": {
"gene": "CYP4F2",
"recommendations": [
{
"drug": "warfarin",
"diplotype": "*3 carrier",
"recommendation": "Use a CYP2C9/VKORC1-guided dosing algorithm and recognize that CYP4F2 carriers can need a modestly higher dose.",
"evidence_level": "2A",
"source": "CPIC 2017"
}
]
},
"rs4149056": {
"gene": "SLCO1B1",
"recommendations": [
Expand Down
13 changes: 9 additions & 4 deletions src/opendna/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
from opendna.annotations import annotate, load_clinvar, load_pharmgkb
from opendna.annotations.updater import refresh
from opendna.panels import load_panels
from opendna.parser import parse_23andme
from opendna.parser import parse_source_file
from opendna.report import render_report
from opendna.summaries import build_analysis_summary


def build_parser() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -56,12 +57,16 @@ def cmd_scan(args: argparse.Namespace) -> int:
if not input_path.exists():
print(f"error: file not found: {input_path}", file=sys.stderr)
return 1
parsed = parse_23andme(input_path)
parsed = parse_source_file(input_path)
panels = load_panels()
selected = set(args.panels) if args.panels else None
findings = analyze(parsed, panels, selected_panel_ids=selected)
findings = analyze(parsed.genotypes, panels, selected_panel_ids=selected)
findings = annotate(findings, load_clinvar(), load_pharmgkb())
bundle = render_report(findings)
bundle = render_report(
findings,
source_file=parsed.source,
analysis_summary=build_analysis_summary(findings, panels),
)

html_out = input_path.with_name(input_path.stem + ".opendna.html")
json_out = input_path.with_name(input_path.stem + ".opendna.json")
Expand Down
5 changes: 4 additions & 1 deletion src/opendna/llm/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ def findings_to_prompt(findings: list[Finding]) -> str:
"""Render findings as a compact structured block for the LLM."""
lines = []
for f in findings:
line = f"[{f.tier}] {f.gene} {f.rsid} = {f.genotype or '--'} ({f.panel_id}) — {f.note}"
if f.call_status != "called":
continue
display_genotype = f.interpreted_genotype or f.genotype or "--"
line = f"[{f.tier}] {f.gene} {f.rsid} = {display_genotype} ({f.panel_id}) — {f.note}"
if f.clinvar:
line += f" | ClinVar: {f.clinvar['clinical_significance']}"
if f.pharmgkb:
Expand Down
Loading
Loading