Skip to content

Commit dc32b62

Browse files
committed
Merge branch 'feat/risk-demographic-selector'
# Conflicts: # backend/app/main.py # frontend/src/App.tsx
2 parents a3179a0 + 9885833 commit dc32b62

95 files changed

Lines changed: 11636 additions & 596 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,7 @@ relay.db-wal
6060
.claude/settings.local.json
6161
.claude/projects/
6262
.claude/worktrees/
63+
.claude/*.lock
64+
65+
# Local test fixtures / scratch images (not for version control)
66+
test-images/

backend/app/agent/tools.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ async def query_snps(args: dict[str, Any]) -> dict[str, Any]:
6565
annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False),
6666
)
6767
async def get_snp_detail(args: dict[str, Any]) -> dict[str, Any]:
68-
snp = await _genome_db.get_snp(args["rsid"])
68+
snp = await _genome_db.get_snp(args["rsid"], profile_id="default")
6969
if not snp:
7070
text = f"Variant {args['rsid']} not found in your data."
7171
else:

backend/app/db/genome.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,13 @@ async def query_snps(
9393
# Need myvariant join when gene filter is used
9494
mv_join = "LEFT JOIN enrichments e_mv ON s.rsid = e_mv.rsid AND e_mv.source = 'myvariant'"
9595

96+
gw_join = "LEFT JOIN enrichments e_gw ON s.rsid = e_gw.rsid AND e_gw.source = 'gwas_catalog'"
97+
9698
count_sql = f"""
9799
SELECT COUNT(DISTINCT s.rsid) FROM snps s
98100
LEFT JOIN enrichments e_cv ON s.rsid = e_cv.rsid AND e_cv.source = 'clinvar'
99101
{mv_join}
102+
{gw_join}
100103
{where}
101104
"""
102105
async with self._conn.execute(count_sql, params) as cursor:
@@ -108,10 +111,15 @@ async def query_snps(
108111
s.source, s.r2_quality,
109112
json_extract(e_cv.data, '$.clinical_significance') as significance,
110113
json_extract(e_cv.data, '$.disease_name') as disease,
111-
json_extract(e_mv.data, '$.gene_symbol') as gene_symbol
114+
json_extract(e_mv.data, '$.gene_symbol') as gene_symbol,
115+
json_extract(e_cv.data, '$.review_status') as review_status,
116+
json_extract(e_mv.data, '$.gnomad_genome_af') as gnomad_af,
117+
json_extract(e_gw.data, '$.associations[0].beta') as effect_size,
118+
json_extract(e_gw.data, '$.associations[0].traits[0]') as effect_trait
112119
FROM snps s
113120
LEFT JOIN enrichments e_cv ON s.rsid = e_cv.rsid AND e_cv.source = 'clinvar'
114121
{mv_join}
122+
{gw_join}
115123
{where}
116124
GROUP BY s.rsid
117125
ORDER BY CASE
@@ -172,8 +180,32 @@ async def list_genes(self, vault_path: str | None = None) -> list[dict]:
172180
key=lambda x: (-x["count"], x["gene"]),
173181
)
174182

175-
async def get_snp(self, rsid: str) -> dict | None:
176-
sql = """
183+
async def _snps_has_profile_column(self) -> bool:
184+
"""Whether the snps table carries a profile_id column.
185+
186+
Older single-profile databases (and some test fixtures) predate the
187+
multi-profile schema; in that case we must not reference profile_id in
188+
SQL or the query errors with "no such column".
189+
"""
190+
async with self._conn.execute("PRAGMA table_info(snps)") as cursor:
191+
cols = await cursor.fetchall()
192+
return any(col[1] == "profile_id" for col in cols)
193+
194+
async def get_snp(self, rsid: str, profile_id: str = "default") -> dict | None:
195+
# Scope by profile so a multi-profile DB never returns another
196+
# person's genotype for the same rsID. Degrade safely on legacy
197+
# single-profile schemas that lack the column.
198+
#
199+
# TODO(audit-17 follow-up): list_snps/query_snps/count/get_stats and
200+
# other aggregates remain profile-blind and can mix profiles in a
201+
# multi-profile DB. Thread profile_id through them in a follow-up.
202+
if await self._snps_has_profile_column():
203+
profile_filter = "AND COALESCE(s.profile_id, 'default') = ?"
204+
params: list = [rsid, profile_id]
205+
else:
206+
profile_filter = ""
207+
params = [rsid]
208+
sql = f"""
177209
SELECT s.rsid, s.chromosome, s.position, s.genotype, s.is_rsid,
178210
s.source, s.r2_quality,
179211
json_extract(e_cv.data, '$.clinical_significance') as significance,
@@ -194,15 +226,15 @@ async def get_snp(self, rsid: str) -> dict | None:
194226
LEFT JOIN enrichments e_cv ON s.rsid = e_cv.rsid AND e_cv.source = 'clinvar'
195227
LEFT JOIN enrichments e_mv ON s.rsid = e_mv.rsid AND e_mv.source = 'myvariant'
196228
LEFT JOIN gene_snp_map gsm ON s.rsid = gsm.rsid
197-
WHERE s.rsid = ?
229+
WHERE s.rsid = ? {profile_filter}
198230
"""
199-
async with self._conn.execute(sql, [rsid]) as cursor:
231+
async with self._conn.execute(sql, params) as cursor:
200232
row = await cursor.fetchone()
201233
return dict(row) if row else None
202234

203-
async def get_variant_guidance(self, rsid: str) -> dict:
235+
async def get_variant_guidance(self, rsid: str, profile_id: str = "default") -> dict:
204236
"""Generate guidance data for a variant based on its enrichments."""
205-
snp = await self.get_snp(rsid)
237+
snp = await self.get_snp(rsid, profile_id=profile_id)
206238
if not snp:
207239
return {}
208240

backend/app/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async def lifespan(app: FastAPI):
9090
from backend.app.routes.starter_prompts import router as starter_prompts_router
9191
from backend.app.routes.export import router as export_router
9292
from backend.app.routes.imports_route import router as imports_router
93+
from backend.app.routes.life_map import router as life_map_router
9394

9495
app.include_router(snps_router)
9596
app.include_router(sessions_router)
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
103104
app.include_router(starter_prompts_router)
104105
app.include_router(export_router)
105106
app.include_router(imports_router)
107+
app.include_router(life_map_router)
106108

107109

108110
@app.get("/api/health")
@@ -111,7 +113,7 @@ async def health():
111113
return {"status": "ok", "variants": stats["total"]}
112114

113115

114-
ALL_VIEWS = ["snps", "mental-health", "pgx", "addiction", "risk"]
116+
ALL_VIEWS = ["snps", "mental-health", "pgx", "addiction", "risk", "life-map"]
115117

116118
@app.get("/api/settings/views")
117119
async def get_visible_views():

backend/app/routes/gwas.py

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,27 @@
1919
GWAS_CONFIG_DIR = REPO_ROOT / "config" / "gwas"
2020

2121

22-
def _count_effect_alleles(genotype: str | None, effect_allele: str | None) -> int | None:
22+
_COMPLEMENT = str.maketrans("ACGT", "TGCA")
23+
_PALINDROMIC = {frozenset(("A", "T")), frozenset(("C", "G"))}
24+
25+
26+
def _count_effect_alleles(
27+
genotype: str | None,
28+
effect_allele: str | None,
29+
other_allele: str | None = None,
30+
) -> int | None:
2331
"""Count copies of the effect allele in a diploid genotype like 'AG' or 'A/G'.
2432
25-
Returns None if we can't determine (missing data, indel, etc).
33+
When ``other_allele`` is supplied, the genotype is validated against the
34+
GWAS {effect, other} allele pair so we can orient it to the reference strand:
35+
36+
* If the genotype alleles already fall within {effect, other}, count directly.
37+
* Otherwise try the complement strand; count only if it then matches.
38+
* Palindromic SNPs (A/T, C/G) are their own complement and therefore
39+
un-orientable from genotype alone — return None.
40+
* Genotypes matching neither orientation are unresolvable — return None.
41+
42+
Returns None if we can't determine (missing data, indel, strand-ambiguous, etc).
2643
"""
2744
if not genotype or not effect_allele:
2845
return None
@@ -34,9 +51,52 @@ def _count_effect_alleles(genotype: str | None, effect_allele: str | None) -> in
3451
return None
3552
if len(ea) != 1:
3653
return None
54+
55+
oa = other_allele.upper() if other_allele else None
56+
if oa and len(oa) != 1:
57+
return None
58+
59+
if oa:
60+
expected = {ea, oa}
61+
# Palindromic SNPs are un-orientable from genotype alone.
62+
if frozenset(expected) in _PALINDROMIC:
63+
return None
64+
65+
if set(g) <= expected:
66+
return sum(1 for base in g if base == ea)
67+
68+
comp = g.translate(_COMPLEMENT)
69+
if set(comp) <= expected:
70+
return sum(1 for base in comp if base == ea)
71+
72+
# Matches neither orientation — unresolvable.
73+
return None
74+
3775
return sum(1 for base in g if base == ea)
3876

3977

78+
def _count_traits_per_entry(
79+
user_genotype: str | None, traits_list: list[dict]
80+
) -> list[dict]:
81+
"""Attach a per-trait ``effect_allele_count`` to each pleiotropic trait entry.
82+
83+
Each trait may carry a different effect/other allele for the same SNP, so the
84+
dosage is computed independently per entry rather than reusing one trait's
85+
effect allele for all (audit finding #12).
86+
"""
87+
return [
88+
{
89+
**t,
90+
"effect_allele_count": _count_effect_alleles(
91+
user_genotype,
92+
t.get("effect_allele"),
93+
t.get("other_allele"),
94+
),
95+
}
96+
for t in traits_list
97+
]
98+
99+
40100
@router.get("/traits")
41101
async def list_traits():
42102
"""List traits that have pre-computed GWAS hits available."""
@@ -108,12 +168,11 @@ async def get_gwas_overlap():
108168

109169
results: list[dict] = []
110170
for rsid, traits_list in pleiotropic.items():
111-
snp = await genome_db.get_snp(rsid)
112-
# Use the first trait's effect allele for counting (they may differ across studies)
113-
ea = traits_list[0].get("effect_allele")
114-
ea_count = _count_effect_alleles(
115-
snp.get("genotype") if snp else None, ea
116-
)
171+
snp = await genome_db.get_snp(rsid, profile_id="default")
172+
user_genotype = snp.get("genotype") if snp else None
173+
# Count the effect-allele dosage PER trait — a pleiotropic SNP can carry
174+
# different effect/other alleles across studies (audit finding #12).
175+
traits_with_counts = _count_traits_per_entry(user_genotype, traits_list)
117176

118177
avg_p = sum(t["p_value"] for t in traits_list if t["p_value"]) / max(
119178
sum(1 for t in traits_list if t["p_value"]), 1
@@ -125,9 +184,8 @@ async def get_gwas_overlap():
125184
"pos": rsid_info[rsid]["pos"],
126185
"n_traits": len(traits_list),
127186
"avg_p_value": avg_p,
128-
"user_genotype": snp.get("genotype") if snp else None,
129-
"effect_allele_count": ea_count,
130-
"traits": traits_list,
187+
"user_genotype": user_genotype,
188+
"traits": traits_with_counts,
131189
})
132190

133191
# Sort: most traits first, then lowest average p-value
@@ -175,10 +233,10 @@ async def get_gwas_summary():
175233
rsid = hit.get("rsid")
176234
if not rsid:
177235
continue
178-
snp = await genome_db.get_snp(rsid)
236+
snp = await genome_db.get_snp(rsid, profile_id="default")
179237
if not snp:
180238
continue
181-
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"))
239+
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"), hit.get("other_allele"))
182240
if ea_count is None:
183241
continue
184242

@@ -250,10 +308,10 @@ async def _compute_trait_summary(trait: str) -> dict | None:
250308
rsid = hit.get("rsid")
251309
if not rsid:
252310
continue
253-
snp = await genome_db.get_snp(rsid)
311+
snp = await genome_db.get_snp(rsid, profile_id="default")
254312
if not snp:
255313
continue
256-
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"))
314+
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"), hit.get("other_allele"))
257315
if ea_count is None:
258316
continue
259317

@@ -358,11 +416,11 @@ async def get_addiction_gwas_summary():
358416
if not rsid:
359417
continue
360418

361-
snp = await genome_db.get_snp(rsid)
419+
snp = await genome_db.get_snp(rsid, profile_id="default")
362420
if not snp:
363421
continue
364422

365-
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"))
423+
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"), hit.get("other_allele"))
366424
if ea_count is None:
367425
continue
368426

@@ -456,11 +514,11 @@ async def get_gwas_matches(trait: str, clumped: bool = Query(False)):
456514
if not rsid:
457515
continue
458516

459-
snp = await genome_db.get_snp(rsid)
517+
snp = await genome_db.get_snp(rsid, profile_id="default")
460518
if not snp:
461519
continue
462520

463-
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"))
521+
ea_count = _count_effect_alleles(snp.get("genotype"), hit.get("effect_allele"), hit.get("other_allele"))
464522
if ea_count is None:
465523
continue
466524

backend/app/routes/gwas_analytics.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ async def get_gene_map():
7878
if not rsid:
7979
continue
8080

81-
snp = await genome_db.get_snp(rsid)
81+
snp = await genome_db.get_snp(rsid, profile_id="default")
8282
if not snp:
8383
continue
8484

@@ -190,7 +190,7 @@ async def get_trait_prs(trait: str):
190190
if not rsid:
191191
continue
192192

193-
snp = await genome_db.get_snp(rsid)
193+
snp = await genome_db.get_snp(rsid, profile_id="default")
194194
if not snp:
195195
continue
196196

backend/app/routes/life_map.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Migrant Life-Map routes (#27).
2+
3+
Serves the committed, version-stamped life-table data produced by
4+
``scripts/fetch_life_expectancy.py``. Dumb data server — all blend logic lives in
5+
the frontend (``lib/lifeBlend.ts``). The modifier catalogue is served via the
6+
existing generic ``/api/config/{name}`` endpoint (``config/life-modifiers.yaml``).
7+
"""
8+
from __future__ import annotations
9+
10+
import json
11+
from pathlib import Path
12+
13+
from fastapi import APIRouter, HTTPException
14+
15+
router = APIRouter(prefix="/api/life-map", tags=["life-map"])
16+
17+
_DATA_FILE = Path(__file__).resolve().parents[3] / "config" / "life_tables.json"
18+
19+
20+
@router.get("/life-tables")
21+
async def get_life_tables() -> dict:
22+
"""Return the committed life-table dataset (countries x sex x age -> ex)."""
23+
if not _DATA_FILE.exists():
24+
raise HTTPException(status_code=404, detail="Life tables not generated")
25+
try:
26+
return json.loads(_DATA_FILE.read_text())
27+
except Exception as exc: # noqa: BLE001
28+
raise HTTPException(status_code=500, detail=f"Failed to read life tables: {exc}")

backend/app/routes/snps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ async def list_genes():
3737

3838
@router.get("/snps/{rsid}")
3939
async def get_snp(rsid: str):
40-
snp = await genome_db.get_snp(rsid)
40+
snp = await genome_db.get_snp(rsid, profile_id="default")
4141
if not snp:
4242
raise HTTPException(status_code=404, detail="Variant not found")
4343
return snp
4444

4545

4646
@router.get("/snps/{rsid}/guidance")
4747
async def get_variant_guidance(rsid: str):
48-
guidance = await genome_db.get_variant_guidance(rsid)
48+
guidance = await genome_db.get_variant_guidance(rsid, profile_id="default")
4949
if not guidance:
5050
raise HTTPException(status_code=404, detail="Variant not found")
5151
return guidance

0 commit comments

Comments
 (0)