""" Parser for YourEpi DNA-Analysis PDFs. Extracts GPS scores, genotypes and section results. """ import re import fitz # PyMuPDF # ───────────────────────────────────────────────────────── # GPS result labels → internal keys # ───────────────────────────────────────────────────────── RESULT_MAP = { # ── Nutrition ── "gute kontrolle": ("sattigung_hunger", "Gute Kontrolle"), "schlechte kontrolle": ("sattigung_hunger", "Schlechte Kontrolle"), "geringes risiko": { # context-dependent — handled below "milchprodukte": ("milchprodukte", "Geringes Risiko"), }, "vermindertes risiko": ("glutensensitivitaet", "Vermindertes Risiko"), "erhöhtes risiko": ("getreidesensitivitaet", "Erhöhtes Risiko"), "normale sensitivität": ("histamin", "Normale Sensitivität"), "apo-ε3/ε3": ("apoe", "Apo-ε3/ε3"), "apo-ε3/ε4": ("apoe", "Apo-ε3/ε4"), "apo-ε4/ε4": ("apoe", "Apo-ε4/ε4"), "normale reaktion": ("gesaettigte_fette", "Normale Reaktion"), "gute reaktion": ("eufs", "Gute Reaktion"), "geringeres entzündungsrisiko": ("mufs", "Geringeres Entzündungsrisiko"), "höheres entzündungsrisiko": ("mufs", "Höheres Entzündungsrisiko"), "normaler bedarf": None, # generic — handled by section context "erhöhter bedarf": None, "verminderter bedarf": None, "ungünstige reaktion": ("kohlenhydrate", "Ungünstige Reaktion"), "sensitiver": ("insulin", "Sensitiver Insulinstoffwechsel"), "geringerer proteinbedarf": ("protein", "Geringerer Bedarf"), # ── Lifestyle ── "hoher stoffwechsel": ("stoffwechsel", "Hoher Stoffwechsel"), "langsamer stoffwechsel": ("stoffwechsel", "Langsamer Stoffwechsel"), "schneller koffeinstoffwechsel": ("koffein", "Schneller Stoffwechsel"), "langsamer koffeinstoffwechsel": ("koffein", "Langsamer Stoffwechsel"), "abendtyp": ("chronotyp", "Abendtyp"), "morgentyp": ("chronotyp", "Morgentyp"), "kürzere schlafdauer": ("schlafdauer", "Kürzere Schlafdauer"), "längere schlafdauer": ("schlafdauer", "Längere Schlafdauer"), "geringe schlafqualität": ("schlafqualitaet", "Geringe Schlafqualität"), "gute schlafqualität": ("schlafqualitaet", "Gute Schlafqualität"), # ── Detox ── "übermethylierer": ("methylierung", "Übermethylierer"), "untermethylierer": ("methylierung", "Untermethylierer"), "normale entgiftungseffizienz": ("phase1_detox", "Normale Effizienz"), "normale acetylierung": ("acetylierung", "Normale Acetylierung"), "normales glutathionprofil": ("glutathion", "Normales Profil"), "höhere mitochondrieneffizienz": ("mitochondrien", "Höhere Effizienz"), "hohe sensitivität": ("umweltgifte", "Hohe Sensitivität"), "effiziente verarbeitung von schwermetallen": ("schwermetalle", "Effiziente Verarbeitung"), # ── Hormones ── "normale schilddrüsenfunktion": ("schilddruese", "Normale Funktion"), "geringere produktion": ("cortisol", "Geringere Produktion"), "geschützter östrogenstoffwechsel": ("oestrogen", "Geschützter Stoffwechsel"), "normaler testosteronspiegel": ("testosteron", "Normaler Spiegel"), # ── Athletics ── "optimales vo2max-potenzial": ("vo2max", "Optimales Potenzial"), "normale muskelkraft": ("kraft", "Normale Muskelkraft"), "normales potenzial": ("hypertrophie", "Normales Potenzial"), "normale veranlagung": ("ausdauer", "Normale Veranlagung"), "höhere veranlagung für schnellkraft": ("schnellkraft", "Höhere Schnellkraft-Veranlagung"), "normale erholung": ("erholung", "Normale Erholung"), } # Section-specific nutrient results NUTRIENT_SECTIONS = { "vitamin b1": "vitamin_b1", "vitamin b4": "vitamin_b4", "vitamin b6": "vitamin_b6", "vitamin b9": "vitamin_b9", "vitamin b12": "vitamin_b12", "vitamin c": "vitamin_c", "vitamin a": "vitamin_a", "vitamin e": "vitamin_e", "vitamin d": "vitamin_d", "vitamin k": "vitamin_k", "selen": "selen", "magnesium": "magnesium", "kupfer": "kupfer", "zink": "zink", "eisen": "eisen", "stickstoffmonoxid": "stickstoffmonoxid", "natrium": "natrium", } NUTRIENT_RESULT_WORDS = { "erhöhter bedarf": "erhoehter_bedarf", "verminderter bedarf": "verminderter_bedarf", "normaler bedarf": "normaler_bedarf", "weniger effiziente umwandlung": "verminderte_umwandlung", "geringe umwandlung": "verminderte_umwandlung", "höherer spiegel": "hoeherer_spiegel", "erhöhter spiegel": "hoeherer_spiegel", } def extract_text(pdf_path: str) -> list[str]: """Return list of page texts from the PDF (PyMuPDF, with fallbacks).""" doc = fitz.open(pdf_path) print(f"[extract_text] {len(doc)} Seiten, encrypted={doc.is_encrypted}", flush=True) texts = [] image_pages = 0 # Seiten ohne Text aber mit Bildern for i, page in enumerate(doc): t = "" # Methode 1: Standard mit sort try: t = page.get_text("text", sort=True) except Exception: pass # Methode 2: Ohne sort if not t.strip(): try: t = page.get_text("text") except Exception: pass # Methode 3: Über rawdict alle Spans sammeln (kein "rawtext" — ungültig!) if not t.strip(): try: d = page.get_text("rawdict") parts = [] for block in d.get("blocks", []): for line in block.get("lines", []): for span in line.get("spans", []): txt = span.get("text", "") if txt.strip(): parts.append(txt) t = " ".join(parts) except Exception: pass # Methode 4: Über Blocks iterieren if not t.strip(): try: blocks = page.get_text("blocks") t = "\n".join( b[4] for b in blocks if len(b) > 4 and isinstance(b[4], str) and b[4].strip() ) except Exception: pass imgs = len(page.get_images()) if not t.strip() and imgs > 0: image_pages += 1 texts.append(t or "") print( f"[extract_text] Seite {i}: {len(t.strip())} Zeichen, {imgs} Bilder" f" | {t[:80]!r}", flush=True, ) doc.close() # Methode 5: pdfplumber — nur bei Kodierungsproblemen (leere Seiten OHNE Bilder) if not any(t.strip() for t in texts): empty_pages = sum(1 for t in texts if not t.strip()) if image_pages >= empty_pages * 0.5: print( f"[extract_text] {image_pages}/{empty_pages} leere Seiten mit Bildern" f" — bildbasiertes PDF erkannt, versuche OCR", flush=True, ) # Methode 6: OCR — wird von app_web.py per ocr_pages_batched() gehandelt, # damit Fortschrittsupdates via Gradio-Generator möglich sind. # Marker damit parse_genetic_test weiß: OCR nötig, aber noch nicht gemacht. texts = ["__OCR_NEEDED__"] * len(texts) else: try: import pdfplumber print("[extract_text] fitz fand keinen Text — versuche pdfplumber", flush=True) texts = [] with pdfplumber.open(pdf_path) as plumb: for i, page in enumerate(plumb.pages): t = page.extract_text() or "" texts.append(t) if t.strip(): print(f"[extract_text] pdfplumber Seite {i}: {len(t)} Zeichen", flush=True) if any(t.strip() for t in texts): print("[extract_text] pdfplumber erfolgreich", flush=True) else: print("[extract_text] pdfplumber: ebenfalls kein Text", flush=True) except ImportError: print("[extract_text] pdfplumber nicht installiert", flush=True) except Exception as e: print(f"[extract_text] pdfplumber Fehler: {e}", flush=True) return texts def ocr_pages_batched(pdf_path: str, page_count: int, batch_size: int = 5): """Generator: OCR in Batches. Yieldet (pages_done, texts) nach jedem Batch. Ermöglicht Fortschrittsanzeige in der Gradio-Generator-Funktion ohne Timeout. """ try: import pytesseract from PIL import Image except ImportError: print("[ocr] pytesseract oder Pillow nicht installiert", flush=True) yield page_count, [""] * page_count return # --oem 3: LSTM (Standard, funktioniert mit allen apt-Paketen) # --psm 3: automatische Seitensegmentierung (besser für mehrspaltige Formulare) # timeout=30: genug Zeit für komplexe Seiten bei 200 DPI _cfg = "--oem 3 --psm 3" # Verfügbare Sprachen prüfen — deu+eng wenn vorhanden, sonst nur eng try: _available = pytesseract.get_languages() _lang = "deu+eng" if "deu" in _available else "eng" except Exception: _lang = "deu+eng" print(f"[ocr] Sprache: {_lang}, Config: {_cfg}", flush=True) doc = fitz.open(pdf_path) texts = [""] * page_count _errors = 0 for batch_start in range(0, page_count, batch_size): batch_end = min(batch_start + batch_size, page_count) for i in range(batch_start, batch_end): try: pix = doc[i].get_pixmap(dpi=200) img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) t = pytesseract.image_to_string(img, lang=_lang, config=_cfg, timeout=30) texts[i] = t if t.strip(): print(f"[ocr] Seite {i}: {len(t.strip())} Zeichen", flush=True) else: print(f"[ocr] Seite {i}: leer", flush=True) except Exception as e: _errors += 1 print(f"[ocr] Seite {i} Fehler: {e}", flush=True) texts[i] = "" yield batch_end, texts doc.close() ocr_chars = sum(len(t.strip()) for t in texts) print(f"[ocr] Fertig: {ocr_chars} Zeichen aus {page_count} Seiten, {_errors} Fehler", flush=True) def clean(text: str) -> str: """Remove font encoding artifacts and normalise whitespace.""" # Remove (cid:NN) sequences text = re.sub(r'\(cid:\d+\)', '', text) # Normalise whitespace text = re.sub(r'[ \t]+', ' ', text) return text.strip() def _page_header(page_text: str, n_paragraphs: int = 3) -> str: """ Return the first N paragraphs of a page (separated by blank lines). This covers the breadcrumb + section title area and avoids matching keywords that appear only in the body description text. """ paras = [p.strip() for p in re.split(r'\n{2,}', page_text) if p.strip()] return clean(" ".join(paras[:n_paragraphs])).lower() def parse_genetic_test(pdf_path: str) -> dict: """ Parse a YourEpi PDF and return a structured dict. Wenn die PDF bildbasiert ist, enthält das Result _ocr_needed=True — der Aufrufer soll dann ocr_pages_batched() verwenden und parse_pages() aufrufen. """ pages = extract_text(pdf_path) # OCR-Marker gesetzt → sofort zurück, Aufrufer macht OCR if pages and pages[0] == "__OCR_NEEDED__": return { "client_name": "Kunde/Kundin", "results": {}, "nutrients": {}, "genotypes": [], "raw_pages": len(pages), "diag": ["Bildbasierte PDF — OCR erforderlich"], "_ocr_needed": True, } return parse_pages(pages) def parse_pages(pages: list) -> dict: """Parst bereits extrahierte Seiten-Texte (nach fitz oder OCR).""" result = { "client_name": "Kunde/Kundin", "results": {}, "nutrients": {}, "genotypes": [], "raw_pages": len(pages), "diag": [], } # ── Diagnostics: how many pages have real text? ── pages_with_text = sum(1 for t in pages if len(clean(t)) > 30) result["diag"].append(f"{pages_with_text}/{len(pages)} Seiten mit Text") print(f"[parse] {pages_with_text}/{len(pages)} Seiten mit verwertbarem Text", flush=True) # ── Extract client name ── # Pattern: "Liebe(r) Vorname" on first text page (single line, no newlines in name) name_word = r'[A-ZÄÖÜ][a-zäöüß\-]+' name_full_inline = rf'{name_word}(?:[ ]+{name_word})*' # no newlines! NON_NAME = { 'seite', 'page', 'score', 'genomic', 'yourepi', 'epigenetik', 'analyse', 'ergebnis', 'potential', 'dna', 'test', 'ihren', 'mehr', 'wir', 'hier', 'bitte', 'alle', 'diese', } for i, page_text in enumerate(pages): if not page_text.strip(): continue # Anrede-Muster: "Liebe(r) Vorname" — name muss auf derselben Zeile sein name_match = ( re.search(rf'Liebe\(r\)\s+({name_full_inline})', page_text) or re.search(rf'Liebe[rs]?\s+({name_full_inline})', page_text) ) if name_match: candidate = name_match.group(1).strip() # Reject if any word is in NON_NAME words = candidate.split() if not any(w.lower() in NON_NAME for w in words): result["client_name"] = candidate print(f"[parse] Name (Anrede) Seite {i}: {candidate!r}", flush=True) break # Footer-Muster: letzte 6 Zeilen nach "Vorname Nachname" durchsuchen footer_lines = [l.strip() for l in page_text.strip().split('\n')[-6:] if l.strip()] for line in reversed(footer_lines): m = re.match(rf'^({name_word}(?:[ ]+{name_word}){{1,2}})$', line) if m: candidate = m.group(1) if not any(w.lower() in NON_NAME for w in candidate.split()): result["client_name"] = candidate print(f"[parse] Name (Footer) Seite {i}: {candidate!r}", flush=True) break if result["client_name"] != "Kunde/Kundin": break # ── Per-page GPS result extraction ── # Strategy: match section keyword against the PAGE HEADER (first 3 paragraphs) # to avoid false matches where keywords appear in body text of other sections. # Then look for the GPS result anywhere on the same page. PAGE_SECTION_MAP = [ # (keyword_in_header, result_key, [possible result labels]) # ── Ernährung ── ("wahrnehmung von sättigung", "sattigung_hunger", ["Gute Kontrolle", "Schlechte Kontrolle"]), ("milchprodukte", "milchprodukte", ["Geringes Risiko", "Erhöhtes Risiko"]), ("glutensensitivität", "glutensensitivitaet", ["Vermindertes Risiko", "Geringes Risiko", "Erhöhtes Risiko"]), ("getreidesensitivität", "getreidesensitivitaet", ["Erhöhtes Risiko", "Geringes Risiko", "Vermindertes Risiko"]), ("histamin", "histamin", ["Normale Sensitivität", "Erhöhte Sensitivität"]), ("apoe status", "apoe", ["Apo-ε3/ε3 Typ", "Apo-ε3/ε4 Typ", "Apo-ε4/ε4 Typ", "apo-ε3/ε3", "apo-ε3/ε4", "apo-ε4/ε4"]), ("gesättigte fette", "gesaettigte_fette", ["Normale Reaktion", "Erhöhte Reaktion"]), ("einfach ungesättigte fettsäuren", "eufs", ["Gute Reaktion", "Normale Reaktion"]), ("mehrfach ungesättigte fettsäuren", "mufs", ["Geringeres Entzündungsrisiko", "Höheres Entzündungsrisiko"]), ("omega-3", "omega3", ["Normaler Bedarf", "Erhöhter Bedarf"]), ("kohlenhydrate", "kohlenhydrate", ["Ungünstige Reaktion", "Normale Reaktion"]), ("insulin", "insulin", ["Sensitiver Insulinstoffwechsel", "Sensitiver", "Normaler Insulinstoffwechsel"]), ("proteinzufuhr", "protein", ["Geringerer Bedarf", "Normaler Bedarf", "Erhöhter Bedarf"]), # ── Mikronährstoffe (per-section detail pages) ── ("vitamin b1", "vit_b1_page", ["Erhöhter Bedarf", "Normaler Bedarf", "Verminderter Bedarf"]), ("vitamin b4", "vit_b4_page", ["Verminderter Bedarf", "Normaler Bedarf", "Erhöhter Bedarf"]), ("vitamin b6", "vit_b6_page", ["Normaler Bedarf", "Erhöhter Bedarf", "Verminderter Bedarf"]), ("vitamin b9", "vit_b9_page", ["Verminderter Bedarf", "Normaler Bedarf", "Erhöhter Bedarf"]), ("vitamin b12", "vit_b12_page", ["Normaler Bedarf", "Erhöhter Bedarf", "Verminderter Bedarf"]), # ── Lifestyle ── ("chronotypen", "chronotyp", ["Abendtyp", "Morgentyp"]), ("schlafdauer", "schlafdauer", ["Kürzere Schlafdauer", "Längere Schlafdauer", "Normale Schlafdauer"]), ("schlafstörungen", "schlafqualitaet", ["Geringe Schlafqualität", "Gute Schlafqualität"]), ("stoffwechsel", "stoffwechsel", ["Hoher Stoffwechsel", "Langsamer Stoffwechsel", "Normaler Stoffwechsel"]), # ── Detox ── ("methylierung", "methylierung", ["Übermethylierer", "Untermethylierer", "Normale Methylierung"]), ("glutathion", "glutathion", ["Normales Glutathionprofil", "Normales Profil", "Vermindertes Profil"]), ("mitochondrien", "mitochondrien", ["Höhere Mitochondrieneffizienz", "Höhere Effizienz", "Normale Effizienz"]), ("umweltgifte", "umweltgifte", ["Hohe Sensitivität", "Normale Sensitivität", "Niedrige Sensitivität"]), ("schwermetalle", "schwermetalle", ["Effiziente Verarbeitung von Schwermetallen", "Effiziente Verarbeitung", "Eingeschränkte Verarbeitung"]), ("phase-1-detox", "phase1_detox", ["Normale Entgiftungseffizienz", "Normale Effizienz", "Verringerte Entgiftungseffizienz"]), ("acetylierung", "acetylierung", ["Normale Acetylierung", "Schnelle Acetylierung", "Langsame Acetylierung"]), # ── Hormone ── ("schilddrüsenhormon", "schilddruese", ["Normale Schilddrüsenfunktion", "Normale Funktion", "Eingeschränkte Schilddrüsenfunktion"]), ("cortisol", "cortisol", ["Geringere Produktion", "Normale Produktion", "Erhöhte Produktion"]), ("östrogen", "oestrogen", ["Geschützter Östrogenstoffwechsel", "Geschützter Stoffwechsel", "Ungünstiger Stoffwechsel"]), ("testosteron", "testosteron", ["Normaler Testosteronspiegel", "Normaler Spiegel", "Niedriger Testosteronspiegel"]), # ── Sport / Athletics ── ("vo2max", "vo2max", ["Optimales VO2max-Potenzial", "Optimales Potenzial", "Normales Potenzial"]), ("muskelkraft", "kraft", ["Normale Muskelkraft", "Höhere Muskelkraft"]), ("muskelhypertrophie", "hypertrophie", ["Normales Potenzial", "Höheres Potenzial"]), ("ausdauer", "ausdauer", ["Normale Veranlagung", "Höhere Veranlagung"]), ("schnellkraft", "schnellkraft", ["Höhere Veranlagung für Schnellkraft", "Normale Veranlagung"]), ("erholung", "erholung", ["Normale Erholung", "Verbesserte Erholung"]), ] full_text = "\n".join(pages) full_lower = clean(full_text).lower() for page_text in pages: if not page_text.strip(): continue page_lower = clean(page_text).lower() header_lower = _page_header(page_text, n_paragraphs=2) # breadcrumb + section title only for (keyword, result_key, possible_results) in PAGE_SECTION_MAP: kw_lower = keyword.lower() # The section keyword must appear in the page HEADER (title area), # not just anywhere in the body description text. if kw_lower not in header_lower: continue # Now search for the GPS result label anywhere on this page for label in possible_results: if label.lower() in page_lower: # Only set if not already found (detail pages run first → correct value wins) if result_key not in result["results"]: _set_result(result, result_key, label) print( f"[parse] GPS '{result_key}' = '{label}' " f"(kw: {keyword!r})", flush=True, ) break # ── Normalise multi-word / variant result strings ── _normalise = { "glutathion": {"Normales Glutathionprofil": "Normales Profil"}, "mitochondrien": {"Höhere Mitochondrieneffizienz": "Höhere Effizienz"}, "schwermetalle": {"Effiziente Verarbeitung von Schwermetallen": "Effiziente Verarbeitung"}, "schilddruese": {"Normale Schilddrüsenfunktion": "Normale Funktion"}, "oestrogen": {"Geschützter Östrogenstoffwechsel": "Geschützter Stoffwechsel"}, "testosteron": {"Normaler Testosteronspiegel": "Normaler Spiegel"}, "apoe": {"Apo-ε3/ε3 Typ": "Apo-ε3/ε3", "Apo-ε3/ε4 Typ": "Apo-ε3/ε4", "Apo-ε4/ε4 Typ": "Apo-ε4/ε4"}, "vo2max": {"Optimales VO2max-Potenzial": "Optimales Potenzial"}, } for key, replacements in _normalise.items(): if key in result["results"] and result["results"][key] in replacements: result["results"][key] = replacements[result["results"][key]] # ── Global pattern matches (for results spread across overview pages) ── global_patterns = [ # These are reliable because they use the specific section keyword + result # combined in full_lower (which covers ALL pages) (r'apo-ε3/ε3', "apoe", "Apo-ε3/ε3"), (r'apo-ε3/ε4', "apoe", "Apo-ε3/ε4"), (r'apo-ε4/ε4', "apoe", "Apo-ε4/ε4"), ] for pattern, key, fixed_val in global_patterns: if key not in result["results"]: # only if not already set by page-level match m = re.search(pattern, full_lower, re.IGNORECASE | re.DOTALL) if m: val = fixed_val if fixed_val else m.group(0) _set_result(result, key, val.strip()) # ── Promote per-page vitamin GPS hits → nutrients dict ── # These were set from individual detail pages (via PAGE_SECTION_MAP) # and are more accurate than the full-text regex below. _gps_to_nut = { "vit_b1_page": "vitamin_b1", "vit_b4_page": "vitamin_b4", "vit_b6_page": "vitamin_b6", "vit_b9_page": "vitamin_b9", "vit_b12_page": "vitamin_b12", } for gps_key, nut_key in _gps_to_nut.items(): if gps_key in result["results"]: label = result["results"].pop(gps_key) # remove temp key mapped = next( (v for k, v in NUTRIENT_RESULT_WORDS.items() if k in label.lower()), "normaler_bedarf", ) result["nutrients"].setdefault(nut_key, mapped) # ── Parse nutrient section results via full-text regex ── # Use \s*\n after nutrient name to require it starts on its own line # (detail pages), avoiding overview-page column contamination. for nutrient, key in NUTRIENT_SECTIONS.items(): if key in result["nutrients"]: continue # already set from per-page GPS pattern = ( rf'{re.escape(nutrient)}\s*\n' # nutrient on its own line rf'.*?' rf'(erhöhter bedarf|verminderter bedarf|normaler bedarf' rf'|weniger eff.{{0,6}}iziente umwandlung' rf'|geringe umwandlung' rf'|höherer spiegel|erhöhter spiegel)' ) m = re.search(pattern, full_lower, re.IGNORECASE | re.DOTALL) if m: result_text = m.group(1).strip() mapped = next( (v for k, v in NUTRIENT_RESULT_WORDS.items() if k in result_text), "normaler_bedarf", ) result["nutrients"][key] = mapped # ── Parse genotype table ── # Pattern: GENE rs12345 WILDTYPE VARIANT GENOTYPE geno_pattern = r'([A-Z][A-Z0-9]+(?:\d+)?)\s+(rs\d+)\s+([ACGT]+)\s+([ACGT]+)\s+([ACGT]{2})' for m in re.finditer(geno_pattern, full_text): result["genotypes"].append({ "gene": m.group(1), "snp": m.group(2), "wildtype": m.group(3), "variant": m.group(4), "genotype": m.group(5), }) # ── Check for CAT gene variant ── cat_entries = [g for g in result["genotypes"] if g["gene"] == "CAT"] for cat in cat_entries: if cat["genotype"] != cat["wildtype"] * 2: result["results"]["cat_variant"] = True # Diagnostics summary result["diag"].append(f"GPS gefunden: {len(result['results'])}") result["diag"].append(f"Nährstoffe: {len(result['nutrients'])}") result["diag"].append(f"Genotypen: {len(result['genotypes'])}") print(f"[parse] Fertig: {len(result['results'])} GPS, " f"{len(result['nutrients'])} Nährstoffe, " f"{len(result['genotypes'])} Genotypen", flush=True) return result def _set_result(result_dict: dict, key: str, value: str): result_dict["results"][key] = value def build_recommendations(parsed: dict) -> dict: """ Erstellt Empfehlungen aus dem geparsten Gentest. Primär SNP-basiert (genetic_analyzer), ergänzt durch GPS-Befunde. Gibt dict mit keys: supplements, lab_tests, genetic_analysis, parsed zurück. """ from supplement_db import SUPPLEMENTS, LAB_TESTS, BASIS_SUPPLEMENTS from genetic_analyzer import analyze as snp_analyze # ── 1. SNP-basierte Analyse (Kern der neuen Logik) ── genetic_analysis = snp_analyze(parsed.get("genotypes", [])) snp_supplement_keys = set(genetic_analysis["supplements"]) # ── 2. GPS-basierte Ergänzungen (Fallback für Bereiche ohne SNP-Abdeckung) ── from supplement_db import GPS_TO_SUPPLEMENTS results = parsed["results"] nutrients = parsed["nutrients"] gps_supplement_map = { "getreidesensitivitaet:Erhöhtes Risiko": "getreidesensitivitaet_erhoehtes_risiko", "kohlenhydrate:Ungünstige Reaktion": "kohlenhydrate_ungueinstige_reaktion", "methylierung:Übermethylierer": "uebermethylierung", "umweltgifte:Hohe Sensitivität": "umweltgifte_hohe_sensitivitaet", "mitochondrien:Höhere Effizienz": "mitochondrien_hoehere_effizienz", "schlafqualitaet:Geringe Schlafqualität": "schlafqualitaet_gering", "chronotyp:Abendtyp": "abendtyp", "cortisol:Geringere Produktion": "cortisol_geringere_produktion", } for section, result_val in results.items(): lookup = f"{section}:{result_val}" if lookup in gps_supplement_map: snp_supplement_keys.update( GPS_TO_SUPPLEMENTS.get(gps_supplement_map[lookup], [])) nutrient_supplement_map = { "selen:erhoehter_bedarf": "selen_erhoehter_bedarf", "eisen:erhoehter_bedarf": "eisen_erhoehter_bedarf", "vitamin_b1:erhoehter_bedarf": "vitamin_b1_erhoehter_bedarf", "vitamin_b9:erhoehter_bedarf": "vitamin_b9_erhoehter_bedarf", "vitamin_a:verminderte_umwandlung": "vitamin_a_umwandlung_vermindert", } for nut_key, nut_result in nutrients.items(): lookup = f"{nut_key}:{nut_result}" if lookup in nutrient_supplement_map: snp_supplement_keys.update( GPS_TO_SUPPLEMENTS.get(nutrient_supplement_map[lookup], [])) if results.get("cat_variant"): snp_supplement_keys.add("catalase") # ── 3. Deduplizierung: Einzel-Supplemente entfernen, deren Inhaltsstoffe # bereits von einem anderen empfohlenen Produkt vollständig abgedeckt sind. # Gilt für BEIDE Listen (snp UND basis) — z.B. Methyl-Modulator → B-Komplex ── to_remove: set = set() all_active = snp_supplement_keys | set(BASIS_SUPPLEMENTS) # Vorwärts: Produkt A "supersedes" Produkt B → B aus allen Listen entfernen for key in all_active: supp = SUPPLEMENTS.get(key, {}) for covered_key in supp.get("supersedes", []): if covered_key in all_active: print(f"[recs] '{covered_key}' entfernt — Inhalt bereits in '{key}'", flush=True) to_remove.add(covered_key) # Rückwärts: Produkt B sagt selbst "bin redundant wenn X vorhanden" for key in all_active: supp = SUPPLEMENTS.get(key, {}) for sb in supp.get("superseded_by", []): if sb in all_active: print(f"[recs] '{key}' entfernt — superseded_by '{sb}'", flush=True) to_remove.add(key) snp_supplement_keys -= to_remove # Basis-Liste ebenfalls filtern (z.B. b_komplex raus wenn methyl_modulator aktiv) basis_supplements_active = [k for k in BASIS_SUPPLEMENTS if k not in to_remove] # ── 4. Supplement-Liste zusammenstellen ── priority_order = ["SEHR HOCH", "HOCH", "MITTEL", "STANDARD", "NIEDRIG", "SITUATIV"] basis_list = [] for key in basis_supplements_active: if key in SUPPLEMENTS: s = dict(SUPPLEMENTS[key]) s["key"] = key basis_list.append(s) gezielt_list = [] for key in snp_supplement_keys: if key in SUPPLEMENTS and key not in BASIS_SUPPLEMENTS: s = dict(SUPPLEMENTS[key]) s["key"] = key gezielt_list.append(s) gezielt_list.sort(key=lambda x: priority_order.index(x.get("priority", "STANDARD"))) supplements = basis_list + gezielt_list # ── 4. Labor-Abschnitte ── lab_sections = ["basis", "mikro", "detox_oxidativer_stress"] categories_with_risk = {f["category"] for f in genetic_analysis["findings"] if f["risk"] in ("hoch", "moderat")} if "stress" in categories_with_risk or results.get("cortisol"): lab_sections.append("hormone") if "schlaf" in categories_with_risk or results.get("schlafqualitaet") == "Geringe Schlafqualität": lab_sections.append("schlaf_neuro") if results.get("getreidesensitivitaet") == "Erhöhtes Risiko": lab_sections.append("entzuendung_darm") selected_labs = {k: LAB_TESTS[k] for k in lab_sections if k in LAB_TESTS} return { "supplements": supplements, "lab_tests": selected_labs, "genetic_analysis": genetic_analysis, "parsed": parsed, }