update
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
# -*- 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 merge_server_items_into_public_cache(server_items: List[Dict[str, Any]]) -> bool:
|
||||
"""Fügt/aktualisiert serverseitige Public-Einträge im lokalen Cache (ohne kuratierte AzA-Terms)."""
|
||||
if not server_items:
|
||||
return False
|
||||
cached = _load_public_cache()
|
||||
by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for it in cached:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
iid = str(it.get("item_id") or it.get("id") or "").strip()
|
||||
if iid:
|
||||
by_id[iid] = it
|
||||
changed = False
|
||||
for it in server_items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
iid = str(it.get("item_id") or it.get("id") or "").strip()
|
||||
if not iid:
|
||||
continue
|
||||
entry = dict(it)
|
||||
entry.setdefault("scope", "public")
|
||||
entry.setdefault("source", "doctor_published")
|
||||
prev = by_id.get(iid)
|
||||
if prev and int(prev.get("revision") or 0) > int(entry.get("revision") or 0):
|
||||
continue
|
||||
by_id[iid] = entry
|
||||
changed = True
|
||||
if changed:
|
||||
save_public_cache(list(by_id.values()))
|
||||
return changed
|
||||
|
||||
|
||||
def sync_public_library_from_server(app) -> Optional[str]:
|
||||
"""Lädt Public-Bibliothek vom Server in den lokalen Cache. Fehler-Tags wie Doku-Sync."""
|
||||
try:
|
||||
from aza_bibliothek_sync import fetch_public_library_from_server_result
|
||||
items, err = fetch_public_library_from_server_result(app)
|
||||
if err:
|
||||
return err
|
||||
if items:
|
||||
merge_server_items_into_public_cache(items)
|
||||
return None
|
||||
except Exception:
|
||||
return "__CONN_ERROR__"
|
||||
|
||||
|
||||
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", ""),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user