# -*- coding: utf-8 -*- """AzA Bibliothek — eigene vs. öffentliche Begriffe/Medikamente (getrennt von Doku-Prompts). Doku-Prompts: aza_doku_vorlagen.py / aza_document_templates.json Wörter/Medikamente: korrekturen.json + öffentliche Starterliste """ from __future__ import annotations import copy import json import os import re import time import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from aza_config import get_writable_data_dir LIBRARY_SCHEMA_VERSION = 1 # UI-Kategorie → Persistenz-Kategorie (korrekturen.json) CATEGORY_TO_STORE = { "medication": "medikamente", "substance": "wirkstoffe", "diagnosis": "diagnosen", "medical_term": "begriffe", "person": "personen", "correction": "begriffe", "practice_term": "begriffe", } STORE_TO_CATEGORY = {v: k for k, v in CATEGORY_TO_STORE.items()} CATEGORY_LABELS = { "medication": "Medikamente", "substance": "Wirkstoffe", "diagnosis": "Diagnosen", "medical_term": "Medizinische Begriffe", "person": "Personen / Signaturen", "correction": "Korrekturen", "practice_term": "Praxisbegriffe", "doku_prompt": "Doku-Prompts", } PRIVATE_UI_CATEGORIES = ( "medication", "medical_term", "person", "correction", ) PUBLIC_UI_CATEGORIES = ("medication", "medical_term", "correction") def _utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _public_cache_path() -> str: return os.path.join(get_writable_data_dir(), "bibliothek_public_cache.json") def merge_public_korrekturen(private: dict) -> dict: """Private Korrekturen + öffentliche Fallbacks. Private Einträge haben Vorrang.""" merged = copy.deepcopy(private) if isinstance(private, dict) else {} try: from aza_public_medication_terms import get_public_korrektur_mappings public = get_public_korrektur_mappings() except Exception: public = {} inactive = merged.get("_inactive") if not isinstance(inactive, dict): inactive = {} merged["_inactive"] = inactive for cat, mapping in public.items(): if not isinstance(mapping, dict): continue tgt = merged.setdefault(cat, {}) if not isinstance(tgt, dict): tgt = {} merged[cat] = tgt inact = inactive.get(cat) inact_set = set(inact) if isinstance(inact, (list, set, tuple)) else set() for falsch, richtig in mapping.items(): if falsch in tgt or falsch in inact_set: continue tgt[falsch] = richtig return merged def load_korrekturen_for_apply() -> dict: from aza_persistence import load_korrekturen return merge_public_korrekturen(load_korrekturen()) def list_private_correction_rows(korrekturen: dict) -> List[Dict[str, Any]]: """Flache Liste privater Korrekturzeilen für UI.""" rows: List[Dict[str, Any]] = [] inactive = korrekturen.get("_inactive") if isinstance(korrekturen.get("_inactive"), dict) else {} meta = korrekturen.get("_metadata") if isinstance(korrekturen.get("_metadata"), dict) else {} adopted = meta.get("adopted_sources") if isinstance(meta.get("adopted_sources"), dict) else {} for store_cat, mapping in (korrekturen or {}).items(): if not store_cat or str(store_cat).startswith("_"): continue if not isinstance(mapping, dict): continue ui_cat = STORE_TO_CATEGORY.get(store_cat, "medical_term") inact = set(inactive.get(store_cat) or []) for falsch, richtig in mapping.items(): src = adopted.get(f"{store_cat}:{falsch}", "Eigene Bibliothek") rows.append({ "item_id": f"priv_{store_cat}_{falsch}", "scope": "private", "category": ui_cat, "store_category": store_cat, "term": falsch, "preferred_spelling": richtig, "variants": [falsch], "active": falsch not in inact, "source": src, "updated_at": meta.get("updated_at", ""), }) rows.sort(key=lambda r: (r.get("category", ""), r.get("term", "").lower())) return rows def list_public_entries(*, category: Optional[str] = None) -> List[Dict[str, Any]]: try: from aza_public_medication_terms import get_public_library_entries items = list(get_public_library_entries()) except Exception: items = [] cached = _load_public_cache() for it in cached: if isinstance(it, dict) and it.get("scope") == "public": items.append(it) if category: items = [i for i in items if i.get("category") == category] items.sort(key=lambda r: (r.get("category", ""), r.get("term", "").lower())) return items def _load_public_cache() -> List[Dict[str, Any]]: try: path = _public_cache_path() if os.path.isfile(path): with open(path, encoding="utf-8") as fh: data = json.load(fh) if isinstance(data, list): return data if isinstance(data, dict) and isinstance(data.get("items"), list): return data["items"] except Exception: pass return [] def save_public_cache(items: List[Dict[str, Any]]) -> None: path = _public_cache_path() tmp = path + ".tmp" try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(tmp, "w", encoding="utf-8") as fh: json.dump({"schema_version": LIBRARY_SCHEMA_VERSION, "items": items}, fh, ensure_ascii=False, indent=2) os.replace(tmp, path) except Exception: try: if os.path.isfile(tmp): os.remove(tmp) except Exception: pass def adopt_public_entry(entry: dict) -> Tuple[bool, str]: """Übernimmt öffentlichen Eintrag als private Kopie in korrekturen.json.""" from aza_persistence import load_korrekturen, save_korrekturen, add_korrektur_to_bibliothek if not isinstance(entry, dict): return False, "Ungültiger Eintrag" variants = entry.get("variants") or [] preferred = (entry.get("preferred_spelling") or entry.get("term") or "").strip() cat = entry.get("category") or "medication" store_cat = CATEGORY_TO_STORE.get(cat, "begriffe") data = load_korrekturen() meta = data.setdefault("_metadata", {}) if not isinstance(meta, dict): meta = {} data["_metadata"] = meta adopted = meta.setdefault("adopted_sources", {}) if not isinstance(adopted, dict): adopted = {} meta["adopted_sources"] = adopted added = 0 if variants and preferred: for v in variants: v = str(v).strip() if v and v.lower() != preferred.lower(): add_korrektur_to_bibliothek(data, store_cat, v, preferred) adopted[f"{store_cat}:{v}"] = entry.get("source") or "Öffentliche Bibliothek" added += 1 elif entry.get("term") and preferred and str(entry["term"]).lower() != preferred.lower(): add_korrektur_to_bibliothek(data, store_cat, str(entry["term"]), preferred) adopted[f"{store_cat}:{entry['term']}"] = entry.get("source") or "Öffentliche Bibliothek" added += 1 elif preferred and cat == "medication": note_key = f"_ref_{preferred.lower()}" refs = meta.setdefault("medication_refs", {}) if not isinstance(refs, dict): refs = {} meta["medication_refs"] = refs if note_key not in refs: refs[note_key] = { "brand_name": preferred, "active_substance": entry.get("active_substance"), "source": entry.get("source") or "Öffentliche Bibliothek", "adopted_at": _utc_now(), } added = 1 meta["updated_at"] = _utc_now() if added: save_korrekturen(data) return True, "Als eigene Kopie übernommen." return False, "Nichts zu übernehmen (bereits vorhanden oder keine Korrekturvariante)." def save_private_correction( store_category: str, falsch: str, richtig: str, *, active: bool = True, ) -> None: from aza_persistence import load_korrekturen, save_korrekturen, add_korrektur_to_bibliothek data = load_korrekturen() add_korrektur_to_bibliothek(data, store_category, falsch, richtig) inact = data.setdefault("_inactive", {}) if not isinstance(inact, dict): inact = {} data["_inactive"] = inact cat_inact = set(inact.get(store_category) or []) if active: cat_inact.discard(falsch) else: cat_inact.add(falsch) inact[store_category] = sorted(cat_inact) meta = data.setdefault("_metadata", {}) if isinstance(meta, dict): meta["updated_at"] = _utc_now() save_korrekturen(data) def delete_private_correction(store_category: str, falsch: str) -> None: from aza_persistence import load_korrekturen, save_korrekturen data = load_korrekturen() mp = data.get(store_category) if isinstance(mp, dict) and falsch in mp: del mp[falsch] inact = data.setdefault("_inactive", {}) if isinstance(inact, dict) and isinstance(inact.get(store_category), list): inact[store_category] = [x for x in inact[store_category] if x != falsch] meta = data.setdefault("_metadata", {}) if isinstance(meta, dict): meta["updated_at"] = _utc_now() save_korrekturen(data) def toggle_private_correction_active(store_category: str, falsch: str) -> bool: from aza_persistence import load_korrekturen, save_korrekturen data = load_korrekturen() mp = data.get(store_category) if not isinstance(mp, dict) or falsch not in mp: return False inact = data.setdefault("_inactive", {}) if not isinstance(inact, dict): inact = {} data["_inactive"] = inact cat_inact = set(inact.get(store_category) or []) if falsch in cat_inact: cat_inact.discard(falsch) new_active = True else: cat_inact.add(falsch) new_active = False inact[store_category] = sorted(cat_inact) save_korrekturen(data) return new_active def list_private_doku_prompts() -> List[Dict[str, Any]]: """Eigene Doku-Prompt-Vorlagen (nur Metadaten für Bibliothek-UI).""" try: from aza_doku_vorlagen import DOC_TYPES, _templates_json_path path = _templates_json_path() if not os.path.isfile(path): return [] with open(path, encoding="utf-8") as fh: data = json.load(fh) if not isinstance(data, dict): return [] rows = [] templates = data.get("templates") if isinstance(data.get("templates"), dict) else {} active = data.get("active") if isinstance(data.get("active"), dict) else {} for dt_key, dt_label in DOC_TYPES: for tpl in templates.get(dt_key) or []: if not isinstance(tpl, dict): continue if tpl.get("is_system"): continue tid = tpl.get("id") or "" rows.append({ "item_id": tid, "scope": "private", "category": "doku_prompt", "doc_type": dt_key, "doc_type_label": dt_label, "term": tpl.get("name") or "Unbenannt", "preferred_spelling": tpl.get("name") or "", "active": active.get(dt_key) == tid, "published": bool(tpl.get("published")), "source": "Eigene Bibliothek", "updated_at": tpl.get("updated_at") or "", }) return rows except Exception: return [] def publish_payload_sanitize(entry: dict) -> Tuple[bool, str]: """Prüft Publish-Payload grob auf Patientendaten-Muster.""" blob = json.dumps(entry, ensure_ascii=False).lower() forbidden = ( "patient_name", "geburtsdatum", "sozialversicherung", "ahv", "versicherten", "transcript", ) for f in forbidden: if f in blob: return False, f"Verdächtiges Feld: {f}" for word in ("herr ", "frau ", "geb.", "geb "): if word in blob and "beispiel" not in blob: pass # nur Heuristik, kein harter Block return True, "" def serialize_term_for_sync(entry: dict) -> dict: """Sync-Serialisierung für private Begriffseinträge.""" return { "id": entry.get("item_id") or str(uuid.uuid4()), "item_type": "library_term", "title": entry.get("category") or "medical_term", "trigger": entry.get("term") or "", "content": entry.get("preferred_spelling") or "", "metadata": { "scope": entry.get("scope", "private"), "active": bool(entry.get("active", True)), "language": entry.get("language", "de"), "market_region": entry.get("market_region", "de-CH"), "revision": int(entry.get("revision") or 1), "source": entry.get("source", ""), }, }