natugena / pdf_parser.py
Health Buddy Thorsten
fix: OCR-Engine auf LSTM (--oem 3) umstellen, 200 DPI, Sprachprüfung
8a9871b
Raw
History Blame Contribute Delete
31.3 kB
"""
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,
}