Skill: manuelcorpas/nutrigx-advisor (version 0.2.0)

# NutriGx Advisor — Personalised Nutrition from Genetic Data **Skill ID**: `nutrigx-advisor` **Version**: 0.1.0 **Status**: MVP **Author**: David de Lorenzo (ClawBio Community) **Requires**: Python 3.11+, pandas, numpy, matplotlib, seaborn, reportlab (optional) --- ## What This Skill Does The NutriGx Advisor generates a **personalised nutrition report** from consumer genetic data (23andMe, AncestryDNA raw files or VCF). It interrogates a curated set of nutritionally-relevant SNPs drawn from GWAS Catalog, ClinVar, and peer-reviewed nutrigenomics literature, then translates genotype calls into actionable dietary and supplementation guidance — all computed locally. **Key outputs** - Markdown nutrition report with risk scores and recommendations - Radar chart of nutrient risk profile - Gene × nutrient heatmap - Reproducibility bundle (`commands.sh`, `environment.yml`, SHA-256 checksums) --- ## Trigger Phrases The Bio Orchestrator should route to this skill when the user says anything like: - "personalised nutrition", "nutrigenomics", "diet genetics" - "what should I eat based on my DNA" - "nutrient metabolism", "vitamin absorption genetics" - "MTHFR", "APOE", "FTO", "BCMO1", "VDR", "FADS1/2" - "folate", "omega-3", "vitamin D", "caffeine metabolism", "lactose", "gluten" - Input files: `.txt` or `.csv` (23andMe), `.csv` (AncestryDNA), `.vcf` --- ## Curated SNP Panel ### Macronutrient Metabolism | Gene | SNP | Nutrient Impact | Evidence | |---------|------------|------------------------------------------|----------| | FTO | rs9939609 | Energy balance, fat mass, carb sensitivity | Strong (GWAS) | | PPARG | rs1801282 | Fat metabolism, insulin sensitivity | Moderate | | APOA5 | rs662799 | Triglyceride response to dietary fat | Strong | | TCF7L2 | rs7903146 | Carbohydrate metabolism, T2D risk | Strong | | ADRB2 | rs1042713 | Fat oxidation, exercise × diet interaction | Moderate | ### Micronutrient Metabolism | Gene | SNP | Nutrient | Effect of risk allele | |---------|------------|-------------------------|----------------------------------| | MTHFR | rs1801133 | Folate / B12 | ↓ 5-MTHF conversion (~70%) | | MTHFR | rs1801131 | Folate / B12 | ↓ enzyme activity (~30%) | | MTR | rs1805087 | B12 / homocysteine | ↑ homocysteine risk | | BCMO1 | rs7501331 | Beta-carotene → Vitamin A | ↓ conversion (~50%) | | BCMO1 | rs12934922 | Beta-carotene → Vitamin A | ↓ conversion (compound het) | | VDR | rs2228570 | Vitamin D absorption | ↓ VDR function | | VDR | rs731236 | Vitamin D | ↓ bone mineral density response | | GC | rs4588 | Vitamin D binding | ↑ deficiency risk | | SLC23A1 | rs33972313 | Vitamin C transport | ↓ renal reabsorption | | ALPL | rs1256335 | Vitamin B6 | ↓ alkaline phosphatase activity | ### Omega-3 / Fatty Acid Metabolism | Gene | SNP | Nutrient | Effect | |---------|------------|----------------------|---------------------------------| | FADS1 | rs174546 | LC-PUFA synthesis | ↑/↓ EPA/DHA from ALA | | FADS2 | rs1535 | LC-PUFA synthesis | Modulates omega-6:omega-3 ratio | | ELOVL2 | rs953413 | DHA synthesis | ↓ elongation of EPA→DHA | | APOE | rs429358 | Saturated fat response | ε4 → ↑ LDL-C on high SFA diet | | APOE | rs7412 | Saturated fat response | Combined with rs429358 for ε typing | ### Caffeine & Alcohol | Gene | SNP | Compound | Effect | |---------|------------|-------------|--------------------------------| | CYP1A2 | rs762551 | Caffeine | Slow/Fast metaboliser | | AHR | rs4410790 | Caffeine | Modulates CYP1A2 induction | | ADH1B | rs1229984 | Alcohol | Acetaldehyde accumulation risk | | ALDH2 | rs671 | Alcohol | Asian flush / toxicity risk | ### Food Sensitivities | Gene | SNP | Sensitivity | Effect | |---------|------------|----------------------|---------------------------------| | MCM6 | rs4988235 | Lactose intolerance | Non-persistence of lactase | | HLA-DQ2 | Proxy SNPs | Coeliac / gluten | HLA-DQA1/DQB1 risk haplotypes | ### Antioxidant & Detoxification | Gene | SNP | Pathway | Effect | |---------|------------|----------------------|---------------------------------| | SOD2 | rs4880 | Manganese SOD | ↓ mitochondrial antioxidant | | GPX1 | rs1050450 | Selenium / GSH-Px | ↓ glutathione peroxidase | | GSTT1 | Deletion | Glutathione-S-trans | Null genotype → ↑ oxidative risk| | NQO1 | rs1800566 | Coenzyme Q10 | ↓ CoQ10 regeneration | | COMT | rs4680 | Catechol / B vitamins | Met/Val → methylation load | --- ## Algorithm ### 1. Input Parsing (`parse_input.py`) Accepts: - 23andMe `.txt` or `.csv` (tab-separated: rsid, chromosome, position, genotype) - AncestryDNA `.csv` - Standard VCF (extracts GT field) Auto-detects format from header lines. Normalises alleles to forward strand using a hard-coded reference table (avoids requiring external databases). ### 2. Genotype Extraction (`extract_genotypes.py`) For each SNP in the panel: 1. Look up rsid in parsed data 2. Return genotype string (e.g. `"AT"`, `"TT"`, `"AA"`) 3. Flag as `"NOT_TESTED"` if absent (common for chip-to-chip variation) ### 3. Risk Scoring (`score_variants.py`) Each SNP is scored on a **0 / 0.5 / 1.0** scale: - `0.0` — homozygous reference (lowest risk) - `0.5` — heterozygous - `1.0` — homozygous risk allele Composite **Nutrient Risk Scores** (0–10) are computed per nutrient domain by summing weighted SNP scores. Weights are derived from reported effect sizes (beta coefficients or OR) in the primary literature. Risk categories: - **0–3**: Low risk — standard dietary advice applies - **3–6**: Moderate risk — dietary optimisation recommended - **6–10**: Elevated risk — consider testing and targeted supplementation > **Important caveat**: These are polygenic risk indicators based on common > variants. They are not diagnostic. Rare pathogenic variants (e.g. MTHFR > compound heterozygosity with high homocysteine) require clinical confirmation. ### 4. Report Generation (`generate_report.py`) Outputs a structured Markdown report with: - Executive summary (top 3 personalised findings) - Per-nutrient sections: genotype table → interpretation → recommendation - Radar chart (matplotlib) of nutrient risk scores - Gene × nutrient heatmap (seaborn) - Supplement interactions table - Disclaimer section - Reproducibility block ### 5. Reproducibility Bundle (`repro_bundle.py`) Exports to the output directory (not committed to the repo): - `commands.sh` — full CLI to reproduce analysis - `environment.yml` — pinned conda environment - `checksums.txt` — SHA-256 checksums of input and output files - `provenance.json` — timestamp and ClawBio version tag --- ## Usage ```bash # From 23andMe raw data openclaw "Generate my personalised nutrition report from genome.csv" # From VCF openclaw "Run NutriGx analysis on variants.vcf and flag any folate pathway risks" # Targeted query openclaw "What does my APOE status mean for my saturated fat intake?" # Generate a random demo patient and run the report python examples/generate_patient.py --run ``` --- ## File Structure ``` skills/nutrigx-advisor/ ├── SKILL.md ← this file (agent instructions) ├── nutrigx_advisor.py ← main entry point ├── parse_input.py ← multi-format parser ├── extract_genotypes.py ← SNP lookup engine ├── score_variants.py ← risk scoring algorithm ├── generate_report.py ← Markdown + figures ├── repro_bundle.py ← reproducibility export ├── .gitignore ├── data/ │ └── snp_panel.json ← curated SNP definitions ├── tests/ │ ├── synthetic_patient.csv ← fixed 23andMe-format test data (for pytest) │ └── test_nutrigx.py ← pytest suite └── examples/ ├── generate_patient.py ← random patient generator (demo use) ├── data/ ← generated patient files land here (gitignored) └── output/ ├── nutrigx_report.md ← pre-rendered demo report ├── nutrigx_radar.png ← demo radar chart (nutrient risk profile) └── nutrigx_heatmap.png ← demo gene × nutrient heatmap ``` > **Note**: Runtime output directories and randomly generated patient files are > excluded from version control via `.gitignore`. Only the pre-rendered demo > report in `examples/output/` is committed. --- ## Privacy All computation runs **locally**. No genetic data is transmitted. Input files are read-only; no raw genotype data appears in any output file (reports contain only gene names, SNP IDs, and risk categories). --- ## Limitations & Disclaimer 1. **Not a medical device.** This skill provides educational, research-oriented nutrigenomics analysis. It does not constitute medical advice. 2. **Common variants only.** The panel covers SNPs with MAF > 1% in at least one major population. Rare pathogenic variants are out of scope. 3. **Population context.** Effect sizes are predominantly derived from European GWAS cohorts. Risk estimates may not generalise equally across all ancestries. 4. **Gene–environment interaction.** Genetic risk scores interact with baseline diet, lifestyle, microbiome, and epigenetic state. A "high risk" score does not mean a nutrient deficiency is present — it means the individual may benefit from monitoring. 5. **Simpson's Paradox note.** Population-level associations used to derive weights may not reflect individual trajectories (see Corpas 2025, *Nutrigenomics and the Ecological Fallacy*). --- ## Roadmap - [ ] **v0.2**: Microbiome × genotype interaction module (16S rRNA input) - [ ] **v0.3**: Longitudinal tracking — compare reports across time - [ ] **v0.4**: HLA typing for immune-mediated food reactions (coeliac, gluten sensitivity) - [ ] **v0.5**: Integration with NeoTree neonatal data for maternal nutrition risk scoring - [ ] **v1.0**: Multi-omics integration (metabolomics + genomics + dietary recall) --- ## References Key literature underpinning the SNP panel and scoring algorithm: - Corbin JM & Ruczinski I (2023). Nutrigenomics: current state and future directions. *Annu Rev Nutr*. - Fenech M et al. (2011). Nutrigenetics and nutrigenomics: viewpoints on the current status. *J Nutrigenet Nutrigenomics*. - Stover PJ (2006). Influence of human genetic variation on nutritional requirements. *Am J Clin Nutr*. - Phillips CM (2013). Nutrigenetics and metabolic disease: current status and implications for personalised nutrition. *Nutrients*. - Minihane AM et al. (2015). APOE genotype, cardiovascular risk and responsiveness to dietary fat manipulation. *Proc Nutr Soc*. - Frayling TM et al. (2007). A common variant in the FTO gene is associated with body mass index. *Science*. - Pare G et al. (2010). MTHFR variants and cardiovascular risk. *Hum Genet*. - Lecerf JM & de Lorgeril M (2011). Dietary cholesterol: from physiology to cardiovascular risk. *Br J Nutr*. - Tanaka T et al. (2009). Genome-wide association study of plasma polyunsaturated fatty acids in the InCHIANTI Study. *PLoS Genet* (FADS1/2). - Cornelis MC et al. (2006). Coffee, CYP1A2 genotype, and risk of myocardial infarction. *JAMA*. --- ## Contributing The SNP panel (`data/snp_panel.json`) is maintained by the skill author. To suggest additions or corrections, contact David de Lorenzo directly via GitHub ([@drdaviddelorenzo](https://github.com/drdaviddelorenzo)) or open an issue tagging him in the main ClawBio repository.

path: data/snp_panel.json
content: rsid: rs1801133
gene: MTHFR
ref_allele: C
risk_allele: T
nutrient_domain: folate
weight: 0.85
effect_direction: decreased_folate_conversion
pmid: 9042914

rsid: rs1801131
gene: MTHFR
ref_allele: A
risk_allele: C
nutrient_domain: folate
weight: 0.5
effect_direction: decreased_folate_conversion
pmid: 9042914

rsid: rs1805087
gene: MTR
ref_allele: A
risk_allele: G
nutrient_domain: folate
weight: 0.4
effect_direction: increased_homocysteine_risk
pmid: 11134235

rsid: rs2228570
gene: VDR
ref_allele: T
risk_allele: C
nutrient_domain: vitamin_d
weight: 0.6
effect_direction: decreased_vitamin_d_response
pmid: 16099820

rsid: rs731236
gene: VDR
ref_allele: T
risk_allele: C
nutrient_domain: vitamin_d
weight: 0.55
effect_direction: decreased_vitamin_d_response
pmid: 16099820

rsid: rs4588
gene: GC
ref_allele: C
risk_allele: A
nutrient_domain: vitamin_d
weight: 0.45
effect_direction: decreased_vitamin_d_binding
pmid: 20427483

rsid: rs174546
gene: FADS1
ref_allele: T
risk_allele: C
nutrient_domain: omega3
weight: 0.8
effect_direction: decreased_lc_pufa_synthesis
pmid: 19524563

rsid: rs1535
gene: FADS2
ref_allele: G
risk_allele: A
nutrient_domain: omega3
weight: 0.7
effect_direction: altered_omega6_omega3_ratio
pmid: 19524563

rsid: rs953413
gene: ELOVL2
ref_allele: A
risk_allele: G
nutrient_domain: omega3
weight: 0.6
effect_direction: decreased_dha_synthesis
pmid: 28270201

rsid: rs429358
gene: APOE
ref_allele: T
risk_allele: C
nutrient_domain: omega3
weight: 0.5
effect_direction: increased_ldl_on_saturated_fat
pmid: 25655249

rsid: rs7501331
gene: BCMO1
ref_allele: C
risk_allele: T
nutrient_domain: vitamin_a
weight: 0.7
effect_direction: decreased_beta_carotene_conversion
pmid: 19103647

rsid: rs12934922
gene: BCMO1
ref_allele: A
risk_allele: T
nutrient_domain: vitamin_a
weight: 0.6
effect_direction: decreased_beta_carotene_conversion
pmid: 19103647

rsid: rs33972313
gene: SLC23A1
ref_allele: C
risk_allele: T
nutrient_domain: vitamin_c
weight: 0.5
effect_direction: decreased_vitamin_c_reabsorption
pmid: 25498559

rsid: rs1256335
gene: ALPL
ref_allele: C
risk_allele: T
nutrient_domain: vitamin_b6
weight: 0.45
effect_direction: decreased_b6_utilisation
pmid: 20937875

rsid: rs9939609
gene: FTO
ref_allele: T
risk_allele: A
nutrient_domain: carbohydrate
weight: 0.7
effect_direction: increased_fat_mass_energy_dysregulation
pmid: 17434869

rsid: rs7903146
gene: TCF7L2
ref_allele: C
risk_allele: T
nutrient_domain: carbohydrate
weight: 0.75
effect_direction: impaired_carbohydrate_metabolism
pmid: 16415884

rsid: rs1801282
gene: PPARG
ref_allele: C
risk_allele: G
nutrient_domain: fat_metabolism
weight: 0.55
effect_direction: reduced_fat_oxidation
pmid: 11773551

rsid: rs662799
gene: APOA5
ref_allele: T
risk_allele: C
nutrient_domain: fat_metabolism
weight: 0.65
effect_direction: elevated_triglycerides_on_fat
pmid: 11726552

rsid: rs7412
gene: APOE
ref_allele: C
risk_allele: T
nutrient_domain: fat_metabolism
weight: 0.55
effect_direction: apoe_epsilon2_lower_ldl
pmid: 25655249

rsid: rs762551
gene: CYP1A2
ref_allele: A
risk_allele: C
nutrient_domain: caffeine
weight: 0.85
effect_direction: slow_caffeine_metabolism
pmid: 16507463

rsid: rs4410790
gene: AHR
ref_allele: C
risk_allele: T
nutrient_domain: caffeine
weight: 0.4
effect_direction: reduced_cyp1a2_induction
pmid: 21270764

rsid: rs1229984
gene: ADH1B
ref_allele: G
risk_allele: A
nutrient_domain: alcohol
weight: 0.8
effect_direction: altered_acetaldehyde_accumulation
pmid: 14685153

rsid: rs671
gene: ALDH2
ref_allele: G
risk_allele: A
nutrient_domain: alcohol
weight: 0.9
effect_direction: impaired_acetaldehyde_clearance
pmid: 12042820

rsid: rs4988235
gene: MCM6
ref_allele: G
risk_allele: A
nutrient_domain: lactose
weight: 0.95
effect_direction: lactase_non_persistence
pmid: 12369657

rsid: rs4880
gene: SOD2
ref_allele: T
risk_allele: C
nutrient_domain: antioxidant
weight: 0.55
effect_direction: decreased_mitochondrial_antioxidant
pmid: 14607993

rsid: rs1050450
gene: GPX1
ref_allele: C
risk_allele: T
nutrient_domain: antioxidant
weight: 0.5
effect_direction: decreased_glutathione_peroxidase
pmid: 11786716

rsid: rs1800566
gene: NQO1
ref_allele: C
risk_allele: T
nutrient_domain: antioxidant
weight: 0.6
effect_direction: decreased_coq10_regeneration
pmid: 9067901

rsid: rs4680
gene: COMT
ref_allele: G
risk_allele: A
nutrient_domain: antioxidant
weight: 0.45
effect_direction: altered_methylation_catechol_metabolism
pmid: 9463411

path: examples/generate_patient.py
content: #!/usr/bin/env python3
"""
generate_patient.py — Random synthetic patient generator for NutriGx Advisor demos.

Generates a realistic 23andMe-format CSV file with randomly assigned genotypes
for all SNPs in the NutriGx panel. Each run produces a different risk profile,
demonstrating the diversity of personalised nutrition reports.

Usage:
    # Generate one random patient
    python examples/generate_patient.py

    # Generate with a specific seed (reproducible)
    python examples/generate_patient.py --seed 42

    # Generate and immediately run the report
    python examples/generate_patient.py --run

    # Generate N patients at once (for batch demos)
    python examples/generate_patient.py --count 5

Output:
    examples/data/patient_<seed>.csv
    (or nutrigx_results_<seed>/ if --run is used)
"""

import argparse
import json
import random
import subprocess
import sys
from datetime import date
from pathlib import Path

# ── Population allele frequencies ─────────────────────────────────────────────
# Approximate minor allele frequencies from gnomAD v4 (global population).
# Used to weight random genotype assignment so the distribution is realistic
# rather than purely uniform.
#
# Format: rsid -> risk_allele_frequency (0–1)
# Where 1.0 means the risk allele is always present (monomorphic in panel),
# and 0.0 means it is never present.
#
# SNPs not listed here default to 0.30 (typical common variant MAF).

RISK_ALLELE_FREQS = {
    "rs1801133":  0.32,   # MTHFR C677T  — very common
    "rs1801131":  0.20,   # MTHFR A1298C
    "rs1805087":  0.17,   # MTR A2756G
    "rs2228570":  0.42,   # VDR FokI
    "rs731236":   0.38,   # VDR TaqI
    "rs4588":     0.28,   # GC Thr436Lys
    "rs174546":   0.47,   # FADS1
    "rs1535":     0.38,   # FADS2
    "rs953413":   0.44,   # ELOVL2
    "rs429358":   0.15,   # APOE ε4 allele
    "rs7412":     0.08,   # APOE ε2 allele
    "rs7501331":  0.24,   # BCMO1
    "rs12934922": 0.42,   # BCMO1
    "rs33972313": 0.05,   # SLC23A1 (rare)
    "rs1256335":  0.35,   # ALPL
    "rs9939609":  0.43,   # FTO
    "rs7903146":  0.29,   # TCF7L2
    "rs1801282":  0.12,   # PPARG Pro12Ala
    "rs662799":   0.09,   # APOA5
    "rs762551":   0.31,   # CYP1A2 slow metaboliser
    "rs4410790":  0.41,   # AHR
    "rs1229984":  0.05,   # ADH1B (mainly East Asian: ~0.70; European: ~0.05)
    "rs671":      0.00,   # ALDH2 (essentially absent in Europeans; ~0.30 East Asian)
    "rs4988235":  0.35,   # MCM6 lactase non-persistence
    "rs4880":     0.47,   # SOD2
    "rs1050450":  0.28,   # GPX1
    "rs1800566":  0.22,   # NQO1
    "rs4680":     0.49,   # COMT Val158Met
}

# Chromosomal positions (GRCh37) — for the 23andMe header format
POSITIONS = {
    "rs1801133":  ("1",  "11854476"),
    "rs1801131":  ("1",  "11856378"),
    "rs1805087":  ("1",  "237048500"),
    "rs2228570":  ("12", "48272895"),
    "rs731236":   ("12", "48239835"),
    "rs4588":     ("4",  "72608790"),
    "rs174546":   ("11", "61327359"),
    "rs1535":     ("11", "61311797"),
    "rs953413":   ("6",  "11044620"),
    "rs429358":   ("19", "45411941"),
    "rs7412":     ("19", "45412079"),
    "rs7501331":  ("16", "81274254"),
    "rs12934922": ("16", "81271111"),
    "rs33972313": ("5",  "110032987"),
    "rs1256335":  ("1",  "21882173"),
    "rs9939609":  ("16", "53820527"),
    "rs7903146":  ("10", "114758349"),
    "rs1801282":  ("3",  "12393125"),
    "rs662799":   ("11", "116700773"),
    "rs762551":   ("15", "75041917"),
    "rs4410790":  ("7",  "17381394"),
    "rs1229984":  ("4",  "100239319"),
    "rs671":      ("12", "111803962"),
    "rs4988235":  ("2",  "136616754"),
    "rs4880":     ("6",  "160113872"),
    "rs1050450":  ("3",  "49394834"),
    "rs1800566":  ("16", "69748869"),
    "rs4680":     ("22", "19951271"),
}

# Reference allele for each rsid (the non-risk base)
REF_ALLELES = {
    "rs1801133":  "C",
    "rs1801131":  "A",
    "rs1805087":  "A",
    "rs2228570":  "T",
    "rs731236":   "T",
    "rs4588":     "C",
    "rs174546":   "T",
    "rs1535":     "G",
    "rs953413":   "A",
    "rs429358":   "T",
    "rs7412":     "C",
    "rs7501331":  "C",
    "rs12934922": "A",
    "rs33972313": "C",
    "rs1256335":  "C",
    "rs9939609":  "T",
    "rs7903146":  "C",
    "rs1801282":  "C",
    "rs662799":   "T",
    "rs762551":   "A",
    "rs4410790":  "C",
    "rs1229984":  "G",
    "rs671":      "G",
    "rs4988235":  "G",
    "rs4880":     "T",
    "rs1050450":  "C",
    "rs1800566":  "C",
    "rs4680":     "G",
}

RISK_ALLELES = {
    "rs1801133":  "T",
    "rs1801131":  "C",
    "rs1805087":  "G",
    "rs2228570":  "C",
    "rs731236":   "C",
    "rs4588":     "A",
    "rs174546":   "C",
    "rs1535":     "A",
    "rs953413":   "G",
    "rs429358":   "C",
    "rs7412":     "T",
    "rs7501331":  "T",
    "rs12934922": "T",
    "rs33972313": "T",
    "rs1256335":  "T",
    "rs9939609":  "A",
    "rs7903146":  "T",
    "rs1801282":  "G",
    "rs662799":   "C",
    "rs762551":   "C",
    "rs4410790":  "T",
    "rs1229984":  "A",
    "rs671":      "A",
    "rs4988235":  "A",
    "rs4880":     "C",
    "rs1050450":  "T",
    "rs1800566":  "T",
    "rs4680":     "A",
}


def assign_genotype(rsid: str, rng: random.Random) -> str:
    """
    Assign a genotype for one SNP using Hardy-Weinberg equilibrium probabilities
    derived from the risk allele frequency.

    Returns a two-character genotype string, e.g. 'CT', 'TT', 'CC'.
    Homozygous risk and homozygous ref are both possible; het is most likely
    at intermediate frequencies.
    """
    q = RISK_ALLELE_FREQS.get(rsid, 0.30)   # risk allele frequency
    p = 1.0 - q                               # ref allele frequency

    # Hardy-Weinberg: p², 2pq, q²
    hom_ref  = p * p
    het      = 2 * p * q
    # hom_risk = q * q  (implicit: the remainder)

    r = rng.random()
    ref = REF_ALLELES.get(rsid, "A")
    risk = RISK_ALLELES.get(rsid, "T")

    if r < hom_ref:
        return ref + ref
    elif r < hom_ref + het:
        # Randomise allele order to mimic real raw data
        alleles = [ref, risk]
        rng.shuffle(alleles)
        return "".join(alleles)
    else:
        return risk + risk


def generate_csv(seed: int, output_path: Path) -> Path:
    """Generate one synthetic patient CSV and write to output_path."""
    rng = random.Random(seed)
    today = date.today().strftime("%a %b %d 00:00:00 %Y")

    rsids = list(POSITIONS.keys())

    lines = [
        f"# This data file generated by 23andMe at {today}",
        "# This file contains raw genotype data, including data that is not used in 23andMe reports.",
        f"# SYNTHETIC DATA — generated by NutriGx generate_patient.py (seed={seed})",
        "# NOT REAL PATIENT DATA.",
        "#",
        "# rsid\tchromosome\tposition\tgenotype",
    ]

    for rsid in rsids:
        chrom, pos = POSITIONS[rsid]
        genotype = assign_genotype(rsid, rng)
        lines.append(f"{rsid}\t{chrom}\t{pos}\t{genotype}")

    output_path.write_text("\n".join(lines) + "\n")
    return output_path


def main():
    parser = argparse.ArgumentParser(
        description="Generate random synthetic patient file(s) for NutriGx demos"
    )
    parser.add_argument(
        "--seed", type=int, default=None,
        help="Random seed for reproducibility (default: random)"
    )
    parser.add_argument(
        "--count", type=int, default=1,
        help="Number of patients to generate (default: 1)"
    )
    parser.add_argument(
        "--run", action="store_true",
        help="Immediately run NutriGx Advisor on each generated file"
    )
    parser.add_argument(
        "--no-figures", action="store_true",
        help="Skip figures when --run is used"
    )
    args = parser.parse_args()

    skill_root = Path(__file__).parent.parent
    examples_data = Path(__file__).parent / "data"
    examples_data.mkdir(exist_ok=True)

    seeds = []
    for i in range(args.count):
        s = (args.seed + i) if args.seed is not None else random.randint(0, 999_999)
        seeds.append(s)

    for seed in seeds:
        out_csv = examples_data / f"patient_{seed}.csv"
        generate_csv(seed, out_csv)
        print(f"[generate_patient] Written: {out_csv}")

        if args.run:
            out_dir = Path(__file__).parent / "output" / f"results_{seed}"
            cmd = [
                sys.executable,
                str(skill_root / "nutrigx_advisor.py"),
                "--input", str(out_csv),
                "--output", str(out_dir),
            ]
            if args.no_figures:
                cmd.append("--no-figures")
            print(f"[generate_patient] Running: {' '.join(cmd)}")
            subprocess.run(cmd, check=True)


if __name__ == "__main__":
    main()


path: examples/output/nutrigx_report.md
content: # NutriGx Personalised Nutrition Report

**Generated**: 2026-02-27 12:25 UTC  
**Tool**: ClawBio NutriGx Advisor v0.1.0  
**Input**: `synthetic_patient.csv` (demo — fixed synthetic data)  

> **Disclaimer**: This report is for research and educational purposes only. It does not constitute medical advice. Consult a registered dietitian or clinical geneticist before making significant dietary changes or starting supplements.

## Executive Summary

Analysed **28** of **28** panel SNPs from your genetic data.

**1 nutrient domain(s) at elevated genetic risk:**
- Vitamin D (score: 6.72/10)

**8 domain(s) at moderate genetic risk:**
- Folate / B-Vitamins (score: 5.0/10)
- Omega-3 / LC-PUFA (score: 4.04/10)
- Vitamin A (Beta-carotene) (score: 5.0/10)
- Vitamin B6 (score: 5.0/10)
- Carbohydrate Metabolism (score: 5.0/10)
- Caffeine Metabolism (score: 5.0/10)
- Lactose Tolerance (score: 5.0/10)
- Antioxidant / Detox (score: 5.0/10)

---

## Nutrient Risk Score Overview

| Nutrient Domain | Score (0–10) | Risk Category |
|-----------------|:------------:|:-------------:|
| Vitamin D | 6.72 | 🔴 Elevated |
| Folate / B-Vitamins | 5.0 | 🟡 Moderate |
| Vitamin A (Beta-carotene) | 5.0 | 🟡 Moderate |
| Vitamin B6 | 5.0 | 🟡 Moderate |
| Carbohydrate Metabolism | 5.0 | 🟡 Moderate |
| Caffeine Metabolism | 5.0 | 🟡 Moderate |
| Lactose Tolerance | 5.0 | 🟡 Moderate |
| Antioxidant / Detox | 5.0 | 🟡 Moderate |
| Omega-3 / LC-PUFA | 4.04 | 🟡 Moderate |
| Alcohol Metabolism | 2.35 | 🟢 Low |
| Fat Metabolism | 1.57 | 🟢 Low |
| Vitamin C | 0.0 | 🟢 Low |

![NutriGx Nutrient Risk Profile](nutrigx_radar.png)

![Gene × Nutrient Risk Heatmap](nutrigx_heatmap.png)

---

## Detailed Findings by Nutrient Domain

### Vitamin D

**Risk Score**: 6.72/10 — **Elevated**  
**SNPs tested**: 3 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| VDR | rs2228570 | `TC` | 1/2 | Decreased Vitamin D Response |
| VDR | rs731236 | `CC` | 2/2 | Decreased Vitamin D Response |
| GC | rs4588 | `CA` | 1/2 | Decreased Vitamin D Binding |

**Recommendation**

> Genetic predisposition to low vitamin D binding efficiency. Test and maintain 25(OH)D >100 nmol/L. D3+K2 co-supplementation advisable.

---

### Folate / B-Vitamins

**Risk Score**: 5.0/10 — **Moderate**  
**SNPs tested**: 3 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| MTHFR | rs1801133 | `CT` | 1/2 | Decreased Folate Conversion |
| MTHFR | rs1801131 | `AC` | 1/2 | Decreased Folate Conversion |
| MTR | rs1805087 | `AG` | 1/2 | Increased Homocysteine Risk |

**Recommendation**

> Consider increasing dietary folate. If homocysteine is elevated, 5-MTHF (methylfolate) supplement preferred over folic acid.

---

### Vitamin A (Beta-carotene)

**Risk Score**: 5.0/10 — **Moderate**  
**SNPs tested**: 2 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| BCMO1 | rs7501331 | `CT` | 1/2 | Decreased Beta Carotene Conversion |
| BCMO1 | rs12934922 | `AT` | 1/2 | Decreased Beta Carotene Conversion |

**Recommendation**

> Reduced BCMO1 activity. Include preformed vitamin A (liver, eggs, dairy) alongside carotenoid-rich vegetables.

---

### Vitamin B6

**Risk Score**: 5.0/10 — **Moderate**  
**SNPs tested**: 1 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| ALPL | rs1256335 | `CT` | 1/2 | Decreased B6 Utilisation |

**Recommendation**

> No specific recommendation available.

---

### Carbohydrate Metabolism

**Risk Score**: 5.0/10 — **Moderate**  
**SNPs tested**: 2 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| FTO | rs9939609 | `AT` | 1/2 | Increased Fat Mass Energy Dysregulation |
| TCF7L2 | rs7903146 | `CT` | 1/2 | Impaired Carbohydrate Metabolism |

**Recommendation**

> Glycaemic index awareness recommended. Favour whole-grain, low-GI carbohydrates.

---

### Caffeine Metabolism

**Risk Score**: 5.0/10 — **Moderate**  
**SNPs tested**: 2 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| CYP1A2 | rs762551 | `CA` | 1/2 | Slow Caffeine Metabolism |
| AHR | rs4410790 | `CT` | 1/2 | Reduced Cyp1A2 Induction |

**Recommendation**

> Intermediate metaboliser. Caffeine ≤200 mg/day recommended.

---

### Lactose Tolerance

**Risk Score**: 5.0/10 — **Moderate**  
**SNPs tested**: 1 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| MCM6 | rs4988235 | `GA` | 1/2 | Lactase Non Persistence |

**Recommendation**

> Possible partial lactase non-persistence. Monitor tolerance to high-lactose foods.

---

### Antioxidant / Detox

**Risk Score**: 5.0/10 — **Moderate**  
**SNPs tested**: 4 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| SOD2 | rs4880 | `CT` | 1/2 | Decreased Mitochondrial Antioxidant |
| GPX1 | rs1050450 | `CT` | 1/2 | Decreased Glutathione Peroxidase |
| NQO1 | rs1800566 | `CT` | 1/2 | Decreased Coq10 Regeneration |
| COMT | rs4680 | `GA` | 1/2 | Altered Methylation Catechol Metabolism |

**Recommendation**

> Some oxidative stress pathway variants. Increase dietary antioxidants; selenium and CoQ10 food sources beneficial.

---

### Omega-3 / LC-PUFA

**Risk Score**: 4.04/10 — **Moderate**  
**SNPs tested**: 4 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| FADS1 | rs174546 | `TC` | 1/2 | Decreased Lc Pufa Synthesis |
| FADS2 | rs1535 | `AG` | 1/2 | Altered Omega6 Omega3 Ratio |
| ELOVL2 | rs953413 | `AG` | 1/2 | Decreased Dha Synthesis |
| APOE | rs429358 | `TT` | 0/2 | Increased Ldl On Saturated Fat |

**Recommendation**

> Reduced LC-PUFA synthesis capacity. Increase direct EPA/DHA sources (oily fish, algae oil). Consider omega-6:omega-3 ratio <4:1.

---

### Alcohol Metabolism

**Risk Score**: 2.35/10 — **Low**  
**SNPs tested**: 2 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| ADH1B | rs1229984 | `AG` | 1/2 | Altered Acetaldehyde Accumulation |
| ALDH2 | rs671 | `GG` | 0/2 | Impaired Acetaldehyde Clearance |

**Recommendation**

> Standard alcohol metabolism. General guidelines apply.

---

### Fat Metabolism

**Risk Score**: 1.57/10 — **Low**  
**SNPs tested**: 3 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| PPARG | rs1801282 | `CG` | 1/2 | Reduced Fat Oxidation |
| APOA5 | rs662799 | `TT` | 0/2 | Elevated Triglycerides On Fat |
| APOE | rs7412 | `CC` | 0/2 | Apoe Epsilon2 Lower Ldl |

**Recommendation**

> No specific fat quality concerns from genetics alone.

---

### Vitamin C

**Risk Score**: 0.0/10 — **Low**  
**SNPs tested**: 1 | **Not on chip**: 0

| Gene | rsID | Genotype | Risk Alleles | Effect |
|------|------|----------|:------------:|--------|
| SLC23A1 | rs33972313 | `CC` | 0/2 | Decreased Vitamin C Reabsorption |

**Recommendation**

> Standard dietary vitamin C (citrus, peppers, kiwi) sufficient.

---

## Supplement Interaction Notes

| Supplement | Relevant Genes | Caution |
|------------|---------------|---------|
| Folic acid (synthetic) | MTHFR | Prefer 5-MTHF if MTHFR variant present |
| Vitamin D3 | VDR, GC | K2 co-supplementation improves utilisation |
| Fish oil / EPA+DHA | FADS1, FADS2, ELOVL2 | Higher dose needed if FADS variants present |
| Vitamin A (retinol) | BCMO1 | Do not exceed UL (3000 μg RAE/day) |
| Iron | HFE (not in panel) | Not assessed; request separate iron panel |
| CoQ10 | NQO1 | Ubiquinol form preferred if NQO1 P187S homozygous |

---

## Reproducibility

This report was generated deterministically. See `commands.sh` and `environment.yml`
in the output directory to reproduce this analysis on any machine.

## References

SNP-nutrient associations sourced from GWAS Catalog, ClinVar, and CPIC guidelines.
Full citations available in `skills/nutrigx-advisor/SKILL.md`.


path: extract_genotypes.py
content: """
extract_genotypes.py — SNP lookup with forward-strand normalisation
For each SNP in the panel, extracts the genotype from the parsed data dict.
Handles strand flipping for ambiguous A/T and C/G SNPs using frequency context.
"""

COMPLEMENT = {"A": "T", "T": "A", "C": "G", "G": "C"}

# SNPs where both alleles are complementary (ambiguous strand)
AMBIGUOUS_PAIRS = {frozenset(["A", "T"]), frozenset(["C", "G"])}


def flip_genotype(genotype: str) -> str:
    """Return the complement strand genotype."""
    return "".join(COMPLEMENT.get(b, b) for b in genotype)


def is_ambiguous(ref: str, alt: str) -> bool:
    return frozenset([ref, alt]) in AMBIGUOUS_PAIRS


def extract_snp_genotypes(genotype_table: dict, snp_panel: list) -> dict:
    """
    For each SNP in the panel, look up the genotype in genotype_table.

    Returns dict keyed by rsid:
    {
      "rsid": "rs1801133",
      "status": "found" | "not_tested",
      "genotype": "CT",           # raw as reported
      "normalised": "CT",         # forward-strand normalised
      "risk_allele": "T",
      "risk_count": 1             # 0, 1, or 2 copies of risk allele
    }
    """
    results = {}

    for snp in snp_panel:
        rsid = snp["rsid"]
        risk_allele = snp["risk_allele"]
        ref_allele = snp.get("ref_allele", "")

        if rsid not in genotype_table:
            results[rsid] = {
                "rsid": rsid,
                "gene": snp["gene"],
                "status": "not_tested",
                "genotype": None,
                "normalised": None,
                "risk_allele": risk_allele,
                "risk_count": None,
                "nutrient_domain": snp["nutrient_domain"],
            }
            continue

        raw_geno = genotype_table[rsid]
        if not raw_geno or len(raw_geno) < 2:
            results[rsid] = {
                "rsid": rsid,
                "gene": snp["gene"],
                "status": "no_call",
                "genotype": raw_geno,
                "normalised": None,
                "risk_allele": risk_allele,
                "risk_count": None,
                "nutrient_domain": snp["nutrient_domain"],
            }
            continue

        # Try direct match first
        norm = raw_geno
        if risk_allele not in raw_geno:
            # Try strand flip
            flipped = flip_genotype(raw_geno)
            if risk_allele in flipped:
                norm = flipped

        risk_count = norm.count(risk_allele)

        results[rsid] = {
            "rsid": rsid,
            "gene": snp["gene"],
            "status": "found",
            "genotype": raw_geno,
            "normalised": norm,
            "risk_allele": risk_allele,
            "risk_count": risk_count,
            "nutrient_domain": snp["nutrient_domain"],
        }

    return results


path: generate_report.py
content: """
generate_report.py — Markdown report + matplotlib figures for NutriGx Advisor
"""

import os
import json
from datetime import datetime, timezone
from pathlib import Path


DOMAIN_LABELS = {
    "folate": "Folate / B-Vitamins",
    "vitamin_d": "Vitamin D",
    "omega3": "Omega-3 / LC-PUFA",
    "vitamin_a": "Vitamin A (Beta-carotene)",
    "vitamin_c": "Vitamin C",
    "vitamin_b6": "Vitamin B6",
    "fat_metabolism": "Fat Metabolism",
    "carbohydrate": "Carbohydrate Metabolism",
    "caffeine": "Caffeine Metabolism",
    "alcohol": "Alcohol Metabolism",
    "lactose": "Lactose Tolerance",
    "gluten": "Gluten Sensitivity",
    "antioxidant": "Antioxidant / Detox",
}

RECOMMENDATIONS = {
    "folate": {
        "Low": "Standard dietary folate (leafy greens, legumes) is sufficient.",
        "Moderate": "Consider increasing dietary folate. If homocysteine is elevated, 5-MTHF (methylfolate) supplement preferred over folic acid.",
        "Elevated": "Clinical assessment of homocysteine and B12 recommended. 5-MTHF + methylcobalamin supplementation often indicated. Avoid high-dose synthetic folic acid.",
    },
    "vitamin_d": {
        "Low": "Standard sun exposure and dietary vitamin D3 are likely adequate.",
        "Moderate": "Serum 25(OH)D testing recommended. Supplement D3 (1000–2000 IU/day) if <75 nmol/L.",
        "Elevated": "Genetic predisposition to low vitamin D binding efficiency. Test and maintain 25(OH)D >100 nmol/L. D3+K2 co-supplementation advisable.",
    },
    "omega3": {
        "Low": "Dietary ALA (flaxseed, walnuts) efficiently converted. Two oily fish servings/week sufficient.",
        "Moderate": "Reduced LC-PUFA synthesis capacity. Increase direct EPA/DHA sources (oily fish, algae oil). Consider omega-6:omega-3 ratio <4:1.",
        "Elevated": "Markedly reduced FADS1/2 or ELOVL2 activity. Direct EPA+DHA supplementation (1–3 g/day algae or fish oil) recommended. Minimise linoleic acid (LA) competition.",
    },
    "vitamin_a": {
        "Low": "Beta-carotene conversion is efficient. Plant-based vitamin A sources adequate.",
        "Moderate": "Reduced BCMO1 activity. Include preformed vitamin A (liver, eggs, dairy) alongside carotenoid-rich vegetables.",
        "Elevated": "Substantially impaired beta-carotene → retinol conversion. Preformed vitamin A (retinol) from animal sources or supplements essential.",
    },
    "vitamin_c": {
        "Low": "Standard dietary vitamin C (citrus, peppers, kiwi) sufficient.",
        "Moderate": "May benefit from higher dietary vitamin C intake (200–500 mg/day from food).",
        "Elevated": "Reduced renal reabsorption of vitamin C. Higher dietary intake or supplementation (500–1000 mg/day) may be warranted.",
    },
    "fat_metabolism": {
        "Low": "No specific fat quality concerns from genetics alone.",
        "Moderate": "Monitor LDL response to saturated fat intake. Mediterranean-style fat profile advisable.",
        "Elevated": "APOE ε4 or PPARG variants detected. Significant LDL-C elevation on high saturated fat diet likely. Prioritise MUFA, PUFA; limit SFA to <7% of energy.",
    },
    "carbohydrate": {
        "Low": "Standard carbohydrate intake appropriate.",
        "Moderate": "Glycaemic index awareness recommended. Favour whole-grain, low-GI carbohydrates.",
        "Elevated": "TCF7L2 / FTO variants suggest elevated metabolic risk on high-GI carbohydrate intake. Restrict refined carbohydrates; increase dietary fibre.",
    },
    "caffeine": {
        "Low": "Fast CYP1A2 metaboliser. Moderate caffeine (≤400 mg/day) poses low cardiovascular risk.",
        "Moderate": "Intermediate metaboliser. Caffeine ≤200 mg/day recommended.",
        "Elevated": "Slow metaboliser. Caffeine intake >200 mg/day associated with increased MI risk in carriers. Consider limiting to 1 cup coffee/day.",
    },
    "alcohol": {
        "Low": "Standard alcohol metabolism. General guidelines apply.",
        "Moderate": "Some acetaldehyde accumulation risk. Limit alcohol consumption.",
        "Elevated": "ADH1B / ALDH2 variant(s) detected. Significantly increased acetaldehyde toxicity risk. Strong recommendation to minimise or avoid alcohol.",
    },
    "lactose": {
        "Low": "Likely lactase-persistent. Can tolerate dairy normally.",
        "Moderate": "Possible partial lactase non-persistence. Monitor tolerance to high-lactose foods.",
        "Elevated": "Predicted lactase non-persistence. Limit fresh milk; fermented dairy (yoghurt, hard cheese) typically well tolerated. Calcium intake from non-dairy sources or supplements.",
    },
    "gluten": {
        "Low": "No HLA-DQ risk haplotypes detected. Gluten restriction not genetically indicated.",
        "Moderate": "HLA-DQ risk haplotype(s) present. Genetic predisposition to coeliac disease. Clinical testing (tTG-IgA) recommended if symptomatic.",
        "Elevated": "High-risk HLA-DQ2/DQ8 haplotype(s). Coeliac disease screening strongly recommended. Do not begin gluten-free diet before testing.",
    },
    "antioxidant": {
        "Low": "Standard antioxidant-rich diet (fruits, vegetables) sufficient.",
        "Moderate": "Some oxidative stress pathway variants. Increase dietary antioxidants; selenium and CoQ10 food sources beneficial.",
        "Elevated": "Multiple antioxidant enzyme variants detected. Consider assessed supplementation: selenium, CoQ10, and magnesium. Limit pro-oxidant exposures (smoking, excess alcohol).",
    },
}


def generate_report(snp_calls, risk_scores, snp_panel, output_dir, figures=True, input_file=""):
    """Generate Markdown report and optional figures. Returns path to report file."""
    output_dir = Path(output_dir)
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")

    lines = []

    # ── Header ────────────────────────────────────────────────────────────────
    lines += [
        "# NutriGx Personalised Nutrition Report",
        "",
        f"**Generated**: {timestamp}  ",
        f"**Tool**: ClawBio NutriGx Advisor v0.1.0  ",
        f"**Input**: `{Path(input_file).name}`  ",
        "",
        "> **Disclaimer**: This report is for research and educational purposes only. "
        "It does not constitute medical advice. Consult a registered dietitian or clinical "
        "geneticist before making significant dietary changes or starting supplements.",
        "",
    ]

    # ── Executive Summary ─────────────────────────────────────────────────────
    elevated = [(d, v) for d, v in risk_scores.items() if v["category"] == "Elevated"]
    moderate = [(d, v) for d, v in risk_scores.items() if v["category"] == "Moderate"]

    lines += ["## Executive Summary", ""]
    total_tested = sum(v["tested_snps"] for v in risk_scores.values())
    total_panel = len(snp_panel)
    lines.append(f"Analysed **{total_tested}** of **{total_panel}** panel SNPs from your genetic data.")
    lines.append("")

    if elevated:
        lines.append(f"**{len(elevated)} nutrient domain(s) at elevated genetic risk:**")
        for d, v in elevated:
            label = DOMAIN_LABELS.get(d, d)
            lines.append(f"- {label} (score: {v['score']}/10)")
        lines.append("")

    if moderate:
        lines.append(f"**{len(moderate)} domain(s) at moderate genetic risk:**")
        for d, v in moderate:
            label = DOMAIN_LABELS.get(d, d)
            lines.append(f"- {label} (score: {v['score']}/10)")
        lines.append("")

    lines += [
        "---",
        "",
        "## Nutrient Risk Score Overview",
        "",
        "| Nutrient Domain | Score (0–10) | Risk Category |",
        "|-----------------|:------------:|:-------------:|",
    ]
    for domain, data in sorted(risk_scores.items(), key=lambda x: -(x[1]["score"] or 0)):
        label = DOMAIN_LABELS.get(domain, domain)
        score = data["score"] if data["score"] is not None else "N/A"
        cat = data["category"]
        emoji = {"Low": "🟢", "Moderate": "🟡", "Elevated": "🔴", "Unknown": "⚪"}.get(cat, "")
        lines.append(f"| {label} | {score} | {emoji} {cat} |")
    lines += ["", "---", ""]

    # ── Per-Domain Sections ───────────────────────────────────────────────────
    lines.append("## Detailed Findings by Nutrient Domain")
    lines.append("")

    for domain, data in sorted(risk_scores.items(), key=lambda x: -(x[1]["score"] or 0)):
        label = DOMAIN_LABELS.get(domain, domain)
        score = data["score"]
        cat = data["category"]
        rec = RECOMMENDATIONS.get(domain, {}).get(cat, "No specific recommendation available.")

        lines += [
            f"### {label}",
            "",
            f"**Risk Score**: {score}/10 — **{cat}**  ",
            f"**SNPs tested**: {data['tested_snps']} | **Not on chip**: {data['missing_snps']}",
            "",
        ]

        if data["contributing_snps"]:
            lines += [
                "| Gene | rsID | Genotype | Risk Alleles | Effect |",
                "|------|------|----------|:------------:|--------|",
            ]
            for s in data["contributing_snps"]:
                effect = s["effect_direction"].replace("_", " ").title()
                lines.append(
                    f"| {s['gene']} | {s['rsid']} | `{s['genotype']}` "
                    f"| {s['risk_count']}/2 | {effect} |"
                )
            lines.append("")

        lines += [
            "**Recommendation**",
            "",
            f"> {rec}",
            "",
            "---",
            "",
        ]

    # ── Supplement Interactions ───────────────────────────────────────────────
    lines += [
        "## Supplement Interaction Notes",
        "",
        "| Supplement | Relevant Genes | Caution |",
        "|------------|---------------|---------|",
        "| Folic acid (synthetic) | MTHFR | Prefer 5-MTHF if MTHFR variant present |",
        "| Vitamin D3 | VDR, GC | K2 co-supplementation improves utilisation |",
        "| Fish oil / EPA+DHA | FADS1, FADS2, ELOVL2 | Higher dose needed if FADS variants present |",
        "| Vitamin A (retinol) | BCMO1 | Do not exceed UL (3000 μg RAE/day) |",
        "| Iron | HFE (not in panel) | Not assessed; request separate iron panel |",
        "| CoQ10 | NQO1 | Ubiquinol form preferred if NQO1 P187S homozygous |",
        "",
        "---",
        "",
    ]

    # ── Footer ────────────────────────────────────────────────────────────────
    lines += [
        "## Reproducibility",
        "",
        "This report was generated deterministically. See `commands.sh` and `environment.yml`",
        "in the output directory to reproduce this analysis on any machine.",
        "",
        "## References",
        "",
        "SNP-nutrient associations sourced from GWAS Catalog, ClinVar, and CPIC guidelines.",
        "Full citations available in `skills/nutrigx-advisor/SKILL.md`.",
        "",
    ]

    report_text = "\n".join(lines)
    report_path = output_dir / "nutrigx_report.md"
    report_path.write_text(report_text)

    if figures:
        _generate_figures(risk_scores, output_dir)

    return str(report_path)


def _generate_figures(risk_scores: dict, output_dir: Path):
    """Generate radar chart and heatmap."""
    try:
        import numpy as np
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
    except ImportError:
        print("[NutriGx] matplotlib/numpy not available — skipping figures")
        return

    # ── Radar Chart ───────────────────────────────────────────────────────────
    scored = {d: v for d, v in risk_scores.items() if v["score"] is not None}
    labels = [DOMAIN_LABELS.get(d, d) for d in scored]
    values = [v["score"] for v in scored.values()]

    if len(labels) < 3:
        return

    N = len(labels)
    angles = [n / float(N) * 2 * np.pi for n in range(N)]
    angles += angles[:1]
    values_plot = values + values[:1]

    fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
    ax.set_theta_offset(np.pi / 2)
    ax.set_theta_direction(-1)
    plt.xticks(angles[:-1], labels, color="grey", size=8)
    ax.set_rlabel_position(0)
    plt.yticks([2, 4, 6, 8, 10], ["2", "4", "6", "8", "10"], color="grey", size=7)
    plt.ylim(0, 10)
    ax.plot(angles, values_plot, linewidth=2, linestyle="solid", color="#2196F3")
    ax.fill(angles, values_plot, alpha=0.25, color="#2196F3")

    # Colour zones
    for r, color, alpha in [(3.5, "green", 0.05), (6.5, "orange", 0.05), (10, "red", 0.05)]:
        ax.fill_between(angles, 0, r, alpha=alpha, color=color)

    plt.title("NutriGx Nutrient Risk Profile", size=14, fontweight="bold", pad=20)
    plt.tight_layout()
    fig.savefig(output_dir / "nutrigx_radar.png", dpi=150, bbox_inches="tight")
    plt.close()

    # ── Heatmap ───────────────────────────────────────────────────────────────
    try:
        import seaborn as sns
        import pandas as pd

        rows = []
        for domain, data in risk_scores.items():
            for snp in data["contributing_snps"]:
                rows.append({
                    "Gene": snp["gene"],
                    "Nutrient": DOMAIN_LABELS.get(domain, domain),
                    "Score": snp["raw_score"],
                })

        if rows:
            df = pd.DataFrame(rows)
            pivot = df.pivot_table(index="Gene", columns="Nutrient", values="Score", aggfunc="mean")
            pivot = pivot.fillna(0)

            fig, ax = plt.subplots(figsize=(max(8, len(pivot.columns)), max(6, len(pivot.index) * 0.6)))
            sns.heatmap(pivot, annot=True, fmt=".1f", cmap="RdYlGn_r",
                        vmin=0, vmax=1, linewidths=0.5, ax=ax,
                        cbar_kws={"label": "Risk Score (0=Ref, 0.5=Het, 1=Hom Risk)"})
            ax.set_title("Gene × Nutrient Risk Heatmap", fontsize=13, fontweight="bold")
            plt.tight_layout()
            fig.savefig(output_dir / "nutrigx_heatmap.png", dpi=150, bbox_inches="tight")
            plt.close()
    except ImportError:
        pass


path: nutrigx_advisor.py
content: #!/usr/bin/env python3
"""
nutrigx_advisor.py — NutriGx Advisor: Personalised Nutrition from Genetic Data
ClawBio Skill v0.1.0

Usage:
    python nutrigx_advisor.py --input genome.csv --output results/
    python nutrigx_advisor.py --input variants.vcf --output results/ --format vcf
"""

import argparse
import json
import sys
from pathlib import Path

from parse_input import parse_genetic_file
from extract_genotypes import extract_snp_genotypes
from score_variants import compute_nutrient_risk_scores
from generate_report import generate_report
from repro_bundle import create_reproducibility_bundle


def main():
    parser = argparse.ArgumentParser(
        description="NutriGx Advisor — personalised nutrigenomics report from genetic data"
    )
    parser.add_argument(
        "--input", required=True,
        help="Path to genetic data file (23andMe .txt/.csv, AncestryDNA .csv, or .vcf)"
    )
    parser.add_argument(
        "--output", default="nutrigx_results",
        help="Output directory (created if absent)"
    )
    parser.add_argument(
        "--format", choices=["auto", "23andme", "ancestry", "vcf"], default="auto",
        help="Input file format (default: auto-detect)"
    )
    parser.add_argument(
        "--panel", default=None,
        help="Path to custom SNP panel JSON (default: data/snp_panel.json)"
    )
    parser.add_argument(
        "--no-figures", action="store_true",
        help="Skip figure generation (useful in headless environments)"
    )
    args = parser.parse_args()

    output_dir = Path(args.output)
    output_dir.mkdir(parents=True, exist_ok=True)

    # Resolve SNP panel
    panel_path = (
        Path(args.panel) if args.panel
        else Path(__file__).parent / "data" / "snp_panel.json"
    )
    if not panel_path.exists():
        print(f"[ERROR] SNP panel not found at {panel_path}", file=sys.stderr)
        sys.exit(1)

    with open(panel_path) as f:
        snp_panel = json.load(f)

    input_path = Path(args.input)
    if not input_path.exists():
        print(f"[ERROR] Input file not found: {input_path}", file=sys.stderr)
        sys.exit(1)

    print(f"[NutriGx] Parsing input: {input_path}")
    genotype_table = parse_genetic_file(str(input_path), fmt=args.format)
    print(f"[NutriGx] Loaded {len(genotype_table):,} variants")

    print("[NutriGx] Extracting SNP genotypes from panel ...")
    snp_calls = extract_snp_genotypes(genotype_table, snp_panel)

    present = sum(1 for v in snp_calls.values() if v["status"] == "found")
    print(f"[NutriGx] Panel coverage: {present}/{len(snp_panel)} SNPs found")

    print("[NutriGx] Computing nutrient risk scores ...")
    risk_scores = compute_nutrient_risk_scores(snp_calls, snp_panel)

    print("[NutriGx] Generating report ...")
    report_path = generate_report(
        snp_calls=snp_calls,
        risk_scores=risk_scores,
        snp_panel=snp_panel,
        output_dir=str(output_dir),
        figures=not args.no_figures,
        input_file=str(input_path)
    )

    print("[NutriGx] Creating reproducibility bundle ...")
    create_reproducibility_bundle(
        input_file=str(input_path),
        output_dir=str(output_dir),
        panel_path=str(panel_path),
        args=vars(args)
    )

    print(f"\n[NutriGx] Done. Report: {report_path}")
    print(f"[NutriGx] Results in: {output_dir}/")


if __name__ == "__main__":
    main()


path: parse_input.py
content: """
parse_input.py — Multi-format genetic data parser
Supports: 23andMe .txt, AncestryDNA .csv, standard VCF
Returns: dict mapping rsid -> genotype string (e.g. "AT", "TT")
"""

import csv
import re
from pathlib import Path


def detect_format(filepath: str) -> str:
    """Auto-detect genetic file format from header."""
    with open(filepath, encoding="utf-8", errors="replace") as f:
        for line in f:
            if line.startswith("##fileformat=VCF"):
                return "vcf"
            if "rsid" in line.lower() and "chromosome" in line.lower() and "genotype" in line.lower():
                return "23andme"
            if "rsid" in line.lower() and "allele1" in line.lower():
                return "ancestry"
            if not line.startswith("#"):
                break
    # Fallback: infer from extension
    ext = Path(filepath).suffix.lower()
    if ext == ".vcf":
        return "vcf"
    if ext == ".csv":
        return "ancestry"
    return "23andme"


def parse_23andme(filepath: str) -> dict:
    """Parse 23andMe raw data file. Returns {rsid: genotype}."""
    genotypes = {}
    with open(filepath, encoding="utf-8", errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            parts = line.split("\t")
            if len(parts) < 4:
                continue
            rsid, chrom, pos, genotype = parts[0], parts[1], parts[2], parts[3]
            if rsid.startswith("rs"):
                genotypes[rsid] = genotype.replace("-", "")
    return genotypes


def parse_ancestry(filepath: str) -> dict:
    """Parse AncestryDNA raw data file. Returns {rsid: genotype}."""
    genotypes = {}
    with open(filepath, encoding="utf-8", errors="replace") as f:
        reader = csv.DictReader(f, delimiter="\t")
        # Handle comment lines
        raw = f.read() if not hasattr(reader, 'fieldnames') else ""
    
    # Re-read skipping comments
    lines = []
    with open(filepath, encoding="utf-8", errors="replace") as f:
        for line in f:
            if not line.startswith("#"):
                lines.append(line)
    
    reader = csv.DictReader(lines, delimiter="\t")
    for row in reader:
        rsid = row.get("rsid", "").strip()
        allele1 = row.get("allele1", "").strip()
        allele2 = row.get("allele2", "").strip()
        if rsid.startswith("rs"):
            genotypes[rsid] = allele1 + allele2
    return genotypes


def parse_vcf(filepath: str) -> dict:
    """Parse VCF file, extracting GT field. Returns {rsid: genotype_bases}."""
    genotypes = {}
    chrom_col, pos_col, id_col, ref_col, alt_col, gt_col = 0, 1, 2, 3, 4, 9

    with open(filepath, encoding="utf-8", errors="replace") as f:
        for line in f:
            line = line.strip()
            if line.startswith("##"):
                continue
            if line.startswith("#CHROM"):
                continue
            parts = line.split("\t")
            if len(parts) < 10:
                continue
            rsid = parts[id_col]
            if not rsid.startswith("rs"):
                continue
            ref = parts[ref_col]
            alts = parts[alt_col].split(",")
            alleles = [ref] + alts

            fmt = parts[8].split(":")
            gt_idx = fmt.index("GT") if "GT" in fmt else 0
            sample = parts[gt_col].split(":")[gt_idx]
            # Handle phased (|) or unphased (/)
            indices = re.split(r"[|/]", sample)
            try:
                called = "".join(alleles[int(i)] for i in indices if i != ".")
                genotypes[rsid] = called
            except (IndexError, ValueError):
                pass
    return genotypes


def parse_genetic_file(filepath: str, fmt: str = "auto") -> dict:
    """Parse genetic data file in any supported format."""
    if fmt == "auto":
        fmt = detect_format(filepath)
    
    parsers = {
        "23andme": parse_23andme,
        "ancestry": parse_ancestry,
        "vcf": parse_vcf,
    }
    
    if fmt not in parsers:
        raise ValueError(f"Unknown format: {fmt}. Choose from: {list(parsers.keys())}")
    
    return parsers[fmt](filepath)


path: repro_bundle.py
content: """
repro_bundle.py — Creates reproducibility artefacts for NutriGx Advisor
Outputs: commands.sh, environment.yml, checksums.txt
"""

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path


CONDA_ENV = """name: nutrigx-advisor
channels:
  - conda-forge
  - defaults
dependencies:
  - python=3.11
  - numpy>=1.26
  - pandas>=2.2
  - matplotlib>=3.8
  - seaborn>=0.13
  - pip
  - pip:
    - clawbio==0.1.0
"""


def sha256_file(filepath: str) -> str:
    h = hashlib.sha256()
    try:
        with open(filepath, "rb") as f:
            for chunk in iter(lambda: f.read(8192), b""):
                h.update(chunk)
        return h.hexdigest()
    except FileNotFoundError:
        return "FILE_NOT_FOUND"


def create_reproducibility_bundle(input_file: str, output_dir: str, panel_path: str, args: dict):
    output_dir = Path(output_dir)
    timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

    # commands.sh
    cmd_args = " ".join(f"--{k.replace('_', '-')} {v}" for k, v in args.items() if v and k != "synthetic")
    commands = f"""#!/usr/bin/env bash
# NutriGx Advisor — Reproducibility Script
# Generated: {timestamp}
# ClawBio NutriGx Advisor v0.1.0

set -euo pipefail

# 1. Create conda environment
conda env create -f environment.yml
conda activate nutrigx-advisor

# 2. Run analysis
python nutrigx_advisor.py {cmd_args}

# 3. Verify checksums
sha256sum -c checksums.txt
"""
    (output_dir / "commands.sh").write_text(commands)

    # environment.yml
    (output_dir / "environment.yml").write_text(CONDA_ENV)

    # checksums.txt
    files_to_checksum = [
        input_file,
        panel_path,
        str(output_dir / "nutrigx_report.md"),
    ]
    checksum_lines = [f"# NutriGx Advisor checksums — {timestamp}"]
    for fp in files_to_checksum:
        chk = sha256_file(fp)
        checksum_lines.append(f"{chk}  {Path(fp).name}")

    (output_dir / "checksums.txt").write_text("\n".join(checksum_lines) + "\n")

    # provenance.json
    provenance = {
        "tool": "ClawBio NutriGx Advisor",
        "version": "0.1.0",
        "timestamp": timestamp,
        "input_file": Path(input_file).name,
        "args": args,
    }
    (output_dir / "provenance.json").write_text(json.dumps(provenance, indent=2))


path: score_variants.py
content: """
score_variants.py — Compute per-nutrient risk scores from SNP calls
Each SNP contributes a weighted score; composite scores are 0–10.
"""

from collections import defaultdict


def snp_raw_score(risk_count: int) -> float:
    """
    Convert risk allele count to a 0–1 dosage score.
    0 copies → 0.0 (homozygous reference)
    1 copy   → 0.5 (heterozygous)
    2 copies → 1.0 (homozygous risk)
    """
    if risk_count is None:
        return None
    return {0: 0.0, 1: 0.5, 2: 1.0}.get(risk_count, 0.0)


def compute_nutrient_risk_scores(snp_calls: dict, snp_panel: list) -> dict:
    """
    Returns a dict of nutrient_domain → {
        'score': float (0–10),
        'category': str ('Low' | 'Moderate' | 'Elevated'),
        'contributing_snps': list,
        'tested_snps': int,
        'missing_snps': int,
    }
    """
    # Index panel by rsid
    panel_index = {s["rsid"]: s for s in snp_panel}

    # Group by nutrient domain
    domain_snps = defaultdict(list)
    for snp in snp_panel:
        domain_snps[snp["nutrient_domain"]].append(snp["rsid"])

    results = {}

    for domain, rsids in domain_snps.items():
        weighted_sum = 0.0
        max_possible = 0.0
        contributing = []
        tested = 0
        missing = 0

        for rsid in rsids:
            call = snp_calls.get(rsid)
            panel_entry = panel_index[rsid]
            weight = panel_entry.get("weight", 0.5)

            if call is None or call["status"] not in ("found",):
                missing += 1
                continue

            raw = snp_raw_score(call["risk_count"])
            if raw is None:
                missing += 1
                continue

            tested += 1
            weighted_sum += raw * weight
            max_possible += weight
            contributing.append({
                "rsid": rsid,
                "gene": panel_entry["gene"],
                "genotype": call["normalised"],
                "risk_count": call["risk_count"],
                "raw_score": raw,
                "weight": weight,
                "effect_direction": panel_entry.get("effect_direction", ""),
            })

        # Normalise to 0–10; handle all-missing
        if max_possible > 0:
            score = round((weighted_sum / max_possible) * 10, 2)
        elif tested == 0 and missing > 0:
            score = None  # no data for this domain
        else:
            score = 0.0

        if score is None:
            category = "Unknown"
        elif score < 3.5:
            category = "Low"
        elif score < 6.5:
            category = "Moderate"
        else:
            category = "Elevated"

        results[domain] = {
            "score": score,
            "category": category,
            "contributing_snps": contributing,
            "tested_snps": tested,
            "missing_snps": missing,
        }

    return results


path: tests/synthetic_patient.csv
content: # This data file generated by 23andMe at Thu Feb 26 00:00:00 2026
# This file contains raw genotype data, including data that is not used in 23andMe reports.
# THIS DATA HAS BEEN GENERATED FOR TESTING PURPOSES ONLY — NOT REAL PATIENT DATA
# Refer to your 23andMe Personal Genome Service® Agreement for more information.
#
# Fields are tab-separated; each line corresponds to a single SNP.
# For each SNP, we provide its identifier, its location on the human reference
# genome, and the genotype call oriented with respect to the plus strand on the
# human reference genome.
#
# rsid	chromosome	position	genotype
rs1801133	1	11854476	CT
rs1801131	1	11856378	AC
rs1805087	1	237048500	AG
rs2228570	12	48272895	TC
rs731236	12	48239835	CC
rs4588	4	72608790	CA
rs174546	11	61327359	TC
rs1535	11	61311797	AG
rs953413	6	11044620	AG
rs429358	19	45411941	TT
rs7412	19	45412079	CC
rs7501331	16	81274254	CT
rs12934922	16	81271111	AT
rs33972313	5	110032987	CC
rs1256335	1	21882173	CT
rs9939609	16	53820527	AT
rs7903146	10	114758349	CT
rs1801282	3	12393125	CG
rs662799	11	116700773	TT
rs762551	15	75041917	CA
rs4410790	7	17381394	CT
rs1229984	4	100239319	AG
rs671	12	111803962	GG
rs4988235	2	136616754	GA
rs4880	6	160113872	CT
rs1050450	3	49394834	CT
rs1800566	16	69748869	CT
rs4680	22	19951271	GA


path: tests/test_nutrigx.py
content: """
test_nutrigx.py — Automated test suite for NutriGx Advisor
Run with: pytest tests/test_nutrigx.py -v

Uses a FIXED synthetic patient (synthetic_patient.csv) with known genotypes
so that all assertions are deterministic and reproducible. This file is NOT
meant to showcase the skill — use examples/generate_patient.py for varied demos.
"""

import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from parse_input import parse_genetic_file
from extract_genotypes import extract_snp_genotypes
from score_variants import compute_nutrient_risk_scores


SYNTHETIC = Path(__file__).parent / "synthetic_patient.csv"
PANEL     = Path(__file__).parent.parent / "data" / "snp_panel.json"


def load_panel():
    with open(PANEL) as f:
        return json.load(f)


# ── Parsing ────────────────────────────────────────────────────────────────────

def test_parse_23andme():
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    assert len(table) >= 20
    assert "rs1801133" in table
    assert table["rs1801133"] in ("CT", "TC")


def test_all_panel_snps_present():
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    found = sum(1 for v in calls.values() if v["status"] == "found")
    assert found == len(panel), f"Expected all {len(panel)} SNPs found, got {found}"


# ── Extraction ─────────────────────────────────────────────────────────────────

def test_mthfr_heterozygous():
    """Fixed patient has MTHFR C677T = CT (1 risk allele)."""
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    mthfr = calls["rs1801133"]
    assert mthfr["status"] == "found"
    assert mthfr["risk_count"] == 1


def test_vdr_homozygous_risk():
    """Fixed patient has VDR TaqI = CC (2 risk alleles) → drives Elevated vitamin D score."""
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    vdr = calls["rs731236"]
    assert vdr["status"] == "found"
    assert vdr["risk_count"] == 2


def test_aldh2_ref_homozygous():
    """Fixed patient has ALDH2 = GG (0 risk alleles) → Low alcohol risk."""
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    aldh2 = calls["rs671"]
    assert aldh2["status"] == "found"
    assert aldh2["risk_count"] == 0


# ── Scoring ────────────────────────────────────────────────────────────────────

def test_scores_structure():
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    scores = compute_nutrient_risk_scores(calls, panel)
    assert "folate" in scores
    assert "vitamin_d" in scores
    assert "omega3" in scores
    assert "alcohol" in scores
    for domain, data in scores.items():
        if data["score"] is not None:
            assert 0.0 <= data["score"] <= 10.0
        assert data["category"] in ("Low", "Moderate", "Elevated", "Unknown")


def test_vitamin_d_elevated():
    """VDR TaqI hom risk → Vitamin D expected Elevated."""
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    scores = compute_nutrient_risk_scores(calls, panel)
    assert scores["vitamin_d"]["category"] == "Elevated"


def test_alcohol_low():
    """ALDH2 GG ref hom → Alcohol expected Low."""
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    scores = compute_nutrient_risk_scores(calls, panel)
    assert scores["alcohol"]["category"] == "Low"


def test_folate_not_low():
    """MTHFR C677T het → Folate should be Moderate or Elevated, not Low."""
    panel = load_panel()
    table = parse_genetic_file(str(SYNTHETIC), fmt="23andme")
    calls = extract_snp_genotypes(table, panel)
    scores = compute_nutrient_risk_scores(calls, panel)
    assert scores["folate"]["category"] in ("Moderate", "Elevated")


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)