366 lines
13 KiB
Python
366 lines
13 KiB
Python
|
|
# -*- 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", ""),
|
||
|
|
},
|
||
|
|
}
|