Files
aza/AzA march 2026/aza_doku_vorlagen.py
2026-06-10 22:55:03 +02:00

981 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
aza_doku_vorlagen.py — Doku-Prompt-Fenster (AzA-Stil)
Zentrale Prompt-Verwaltung fuer medizinische Dokumente (UI: Doku-Prompt).
Interne Datei/IDs bleiben aus Kompatibilitaetsgruenden unveraendert.
Kernprinzipien:
- AzA-Grundvorlage ist Systemvorlage und wird nicht direkt ueberschrieben.
- Benutzer darf den Inhalt im Editor bearbeiten und als neue Vorlage speichern.
- Immer genau eine aktive Vorlage pro Dokumenttyp.
- Alle Daten lokal in aza_document_templates.json.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import time
import tkinter as tk
from datetime import datetime, timezone
from tkinter import messagebox, simpledialog, ttk
from typing import Any
SCHEMA_VERSION = 2
MAX_PROMPT_CHARS = 32000
# ── AzA-Designkonstanten (identisch mit KI-Guthaben-Fenster) ──────────────────
_BG = "#E8F4FA"
_HDR_BG = "#1A4D6D"
_HDR_FG = "#FFFFFF"
_SUB_BG = "#D0E8F5"
_CARD_BG = "#FFFFFF"
_CARD_BD = "#C8D8E8"
_ACCENT = "#5B8DB3"
_TEXT = "#1A3D55"
_TEXT_SUB = "#607890"
_BTN_BLUE = "#5B8DB3"
_BTN_ACT = "#5B8DB3" # Einheitlich AzA-Blau (wie Speichern/Als neue Vorlage)
_BTN_RESET = "#C04040" # Rot nur fuer destruktive Aktion "Zurücksetzen"
_BTN_CLOSE = "#5B8DB3" # Einheitlich AzA-Blau
_FF = "Segoe UI"
# ── Dokumenttypen (Reihenfolge exakt laut Spezifikation) ─────────────────────
DOC_TYPES = [
("kg", "Krankengeschichte"),
("verlauf", "Verlauf"),
("brief", "Brief"),
("rezept", "Rezept"),
("op_bericht", "OP-Bericht"),
("kogu", "KOGU"),
("arztzeugnis", "Arztzeugnis"),
("eigenes_dokument", "Eigenes Dokument"),
]
DOC_TYPE_KEYS = [dt for dt, _ in DOC_TYPES]
# Status-Label pro Dokumenttyp (für dynamische "Erstelle …"-Statusmeldung).
DOC_TYPE_STATUS_LABELS = {
"kg": "Krankengeschichte",
"verlauf": "Verlauf",
"brief": "Brief",
"rezept": "Rezept",
"op_bericht": "OP-Bericht",
"kogu": "KOGU",
"arztzeugnis": "Arztzeugnis",
"eigenes_dokument": "Eigenes Dokument",
}
def doc_type_status_label(doc_type: str) -> str:
"""Reines Mapping doc_type → Anzeigename für Statusmeldungen."""
return DOC_TYPE_STATUS_LABELS.get(doc_type or "kg", "Krankengeschichte")
def resolve_doc_generation_route(doc_type: str) -> str:
"""Reines Routing: welcher Generierungspfad gehört zum Dokumenttyp.
Rückgabe:
"kg" → Krankengeschichte-Generator (summarize_text)
"form" → Formularpfad (Arztzeugnis)
"generate" → generate_document_to_main_field(doc_type)
"""
dt = doc_type or "kg"
if dt == "kg":
return "kg"
if dt == "arztzeugnis":
return "form"
if dt in ("verlauf", "brief", "rezept", "op_bericht", "kogu", "eigenes_dokument"):
return "generate"
return "kg"
DOC_TYPE_CREATE_LABELS = {
"kg": "KG erstellen",
"verlauf": "Verlauf erstellen",
"brief": "Brief erstellen",
"rezept": "Rezept erstellen",
"op_bericht": "OP-Bericht erstellen",
"kogu": "KOGU erstellen",
"arztzeugnis": "Arztzeugnis erstellen",
"eigenes_dokument": "Dokument erstellen",
}
DOC_TYPE_COPY_LABELS = {
"kg": "KG kopieren",
"verlauf": "Verlauf kopieren",
"brief": "Brief kopieren",
"rezept": "Rezept kopieren",
"op_bericht": "OP-Bericht kopieren",
"kogu": "KOGU kopieren",
"arztzeugnis": "Arztzeugnis kopieren",
"eigenes_dokument": "Dokument kopieren",
}
VERLAUF_DEFAULT_PROMPT = (
"Erstelle aus dem Transkript einen präzisen medizinischen Verlaufsbericht. "
"Beschreibe die Entwicklung seit der letzten Konsultation, den aktuellen subjektiven Zustand, "
"relevante neue oder persistierende Beschwerden, objektive Befunde, relevante Resultate, "
"die Wirkung und Verträglichkeit der bisherigen Therapie, erfolgte Therapieanpassungen "
"sowie das weitere Vorgehen. Schreibe chronologisch, sachlich und knapp. "
"Wiederhole die vollständige Anamnese nicht unnötig. "
"Verwende ausschliesslich Angaben aus dem Transkript und dem vorhandenen Kontext. "
"Erfinde keine Diagnosen, Befunde, Medikamente oder Massnahmen. "
"Gliedere sinnvoll in Verlauf, Befunde, Beurteilung und weiteres Vorgehen, "
"sofern genügend Informationen vorhanden sind."
)
EIGENES_DOKUMENT_DEFAULT_PROMPT = (
"Erstelle aus dem Transkript ein medizinisches Dokument gemäss den untenstehenden "
"Anweisungen. Verwende ausschliesslich Informationen aus dem Transkript und dem "
"vorhandenen Kontext. Erfinde keine Diagnosen, Befunde, Medikamente oder Massnahmen. "
"Schreibe sachlich, professionell und in Schweizer medizinischer Schreibweise."
)
# ── AzA-Grundvorlagen (verständlich formuliert, für Ärzte) ────────────────────
AZA_DEFAULT_TEMPLATES: dict[str, str] = {
"kg": (
"Reihenfolge: 1. Diagnose, 2. Anamnese, 3. Befund, 4. Therapie, 5. Procedere\n\n"
"Schreibregeln:\n"
"- Aus der Transkription eine klare medizinische Krankengeschichte erstellen.\n"
"- Diagnosen nummerieren (1. 2. 3. ...). KEIN Bulletpoint vor der Nummer.\n"
" Richtig: 1. Diagnose Falsch: • 1. Diagnose\n"
"- Wenn 'Diagnosen gliedern' aktiv ist: Therapien möglichst direkt unter die\n"
" passende Diagnose schreiben, wenn eindeutig zuordenbar.\n"
"- Unklare Therapien separat unter 'Therapie' aufführen.\n"
"- Verlauf, Kontrollen und Wiedervorstellung unter 'Procedere', nicht unter 'Therapie'.\n"
"- Medikamente mit Wirkstoff in Klammern, wenn sicher bekannt:\n"
" Dermovate Creme (Clobetasol), Dafalgan (Paracetamol), Fucidin (Fusidinsäure).\n"
"- 'Status nach' als Standard verwenden, nicht 'Zustand nach'.\n"
"- ICD-Codes aus dem Input übernehmen, wenn vorhanden. Keine ICD-Codes erfinden.\n"
"- Keine Diagnosen, Therapien oder Daten erfinden.\n"
"- Keine genannten Diagnosen weglassen.\n"
"- Unterpunkte unter Diagnosen einrücken (Bindestrich).\n"
"- Schreibstil: medizinisch, professionell, kompakt.\n\n"
"Hinweis: Die Diagnose-/Therapie-Gliederung (Therapie direkt unter Diagnose,\n"
"Procedere getrennt) wird zusätzlich durch das Häkchen 'Diagnosen gliedern' gesteuert."
),
"brief": (
"Reihenfolge: 1. Diagnose, 2. Anlass, 3. Befunde, 4. Therapie, 5. Procedere/Empfehlung\n\n"
"Schreibregeln:\n"
"- Diagnosen nummerieren (1. 2. 3. ...).\n"
"- Wenn 'Diagnosen gliedern' aktiv ist: Therapien möglichst direkt\n"
" unter die passende Diagnose schreiben.\n"
"- Verlauf, Kontrollen und Wiedervorstellung unter Procedere,\n"
" nicht unter Therapie.\n"
"- Medikamente mit Wirkstoff in Klammern angeben, wenn sicher bekannt.\n"
" Beispiel: Dermovate Creme (Clobetasol), Dafalgan (Paracetamol).\n"
"- 'Status nach' als Standard verwenden, nicht 'Zustand nach'.\n"
"- ICD-Codes aus dem Input übernehmen, wenn vorhanden.\n"
"- Nichts erfinden. Medizinisch, professionell und kompakt schreiben.\n\n"
"Hinweis: Die Diagnose-/Therapie-Gliederung (Therapie direkt unter\n"
"Diagnose, Procedere getrennt) wird zusätzlich durch das Häkchen\n"
"'Diagnosen gliedern' gesteuert."
),
"rezept": (
"Rezeptvorlage Schreibregeln:\n\n"
"- Nur Medikamente aufführen, die im Transkript genannt wurden.\n"
"- Dosierung nur aus dem Transkript übernehmen, nichts erfinden.\n"
"- Markenname mit Wirkstoff in Klammern angeben, wenn sicher bekannt.\n"
" Beispiel: Dermovate Creme (Clobetasol) 2x täglich.\n"
"- Generikum bevorzugen, wenn nur der Wirkstoff genannt wurde.\n"
"- Keine ICD-Codes erfinden, wenn nicht vorhanden."
),
"op_bericht": (
"OP-Bericht / Eingriffsbericht Reihenfolge:\n\n"
"1. Diagnose (mit ICD-Code, falls vorhanden)\n"
"2. Indikation\n"
"3. Eingriff / Prozedur\n"
"4. Befund / Histologie\n"
"5. Verlauf / Procedere\n\n"
"Schreibregeln:\n"
"- 'Status nach' als Standard, nicht 'Zustand nach'.\n"
"- Keine Befunde oder Histologien erfinden.\n"
"- ICD-Codes nur übernehmen, wenn im Transkript genannt.\n"
"- Medizinisch korrekter Fachstil."
),
"kogu": (
"Kostengutsprache Reihenfolge:\n\n"
"1. Patient / Versicherung\n"
"2. Diagnose (ICD-Code falls vorhanden)\n"
"3. Klinische Begründung / Notwendigkeit\n"
"4. Beantragte Massnahme / Behandlung\n"
"5. Kosten / Tarifpositionen (nur wenn im Transkript genannt)\n"
"6. Datum / Unterschrift\n\n"
"Schreibregeln:\n"
"- Kosten und Tarifpositionen nur aufnehmen, wenn genannt.\n"
"- Medizinische Begründung sachlich und klinisch formulieren.\n"
"- Keine Diagnosen oder Befunde erfinden."
),
"arztzeugnis": (
"Arztzeugnis Reihenfolge:\n\n"
"1. Angaben Patient/in\n"
"2. Diagnose (ICD-Code falls vorhanden)\n"
"3. Behandlungszeitraum\n"
"4. Arbeitsfähigkeit / Arbeitsunfähigkeit\n"
"5. Besondere Anmerkungen (nur aus Transkript)\n"
"6. Datum, Arzt/Ärztin\n\n"
"Schreibregeln:\n"
"- Zeiträume nur aus Transkript übernehmen, nichts erfinden.\n"
"- Diagnose medizinisch korrekt, ICD-Code wenn vorhanden.\n"
"- Sachlicher, formeller Schreibstil."
),
"verlauf": VERLAUF_DEFAULT_PROMPT,
"eigenes_dokument": EIGENES_DOKUMENT_DEFAULT_PROMPT,
}
# ── Persistenz ────────────────────────────────────────────────────────────────
def _templates_json_path() -> str:
try:
from aza_config import get_writable_data_dir
return os.path.join(get_writable_data_dir(), "aza_document_templates.json")
except Exception:
return os.path.join(os.path.expanduser("~"), "AppData", "Roaming",
"AZA Desktop", "aza_document_templates.json")
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sanitize_prompt_text(text: str) -> str:
"""Plaintext only — keine HTML/Script-Injection."""
if not text:
return ""
s = str(text)
s = re.sub(r"(?is)<script[^>]*>.*?</script>", "", s)
s = re.sub(r"(?is)<style[^>]*>.*?</style>", "", s)
s = re.sub(r"<[^>]+>", "", s)
s = re.sub(r"(?i)javascript\s*:", "", s)
s = re.sub(r"(?i)data\s*:", "", s)
s = s.replace("\x00", "")
if len(s) > MAX_PROMPT_CHARS:
s = s[:MAX_PROMPT_CHARS]
return s.strip()
def _new_template_entry(doc_type: str, *, now: str | None = None) -> dict[str, Any]:
ts = now or _utc_now()
return {
"id": "aza_default",
"name": "AzA-Grundvorlage",
"is_system": True,
"content": AZA_DEFAULT_TEMPLATES.get(doc_type, ""),
"description": "",
"created_at": ts,
"updated_at": ts,
"revision": 1,
"user_id": "",
"practice_id": "",
"server_id": "",
"published": False,
"sync_updated_at": "",
"source_author": "",
"source_server_id": "",
}
def _ensure_template_fields(t: dict[str, Any]) -> dict[str, Any]:
if not isinstance(t, dict):
return _new_template_entry("kg")
t.setdefault("description", "")
t.setdefault("revision", 1)
t.setdefault("user_id", "")
t.setdefault("practice_id", "")
t.setdefault("server_id", "")
t.setdefault("published", False)
t.setdefault("sync_updated_at", "")
t.setdefault("source_author", "")
t.setdefault("source_server_id", "")
if "content" in t:
t["content"] = sanitize_prompt_text(t.get("content") or "")
return t
def _default_structure() -> dict[str, Any]:
now = _utc_now()
return {
"schema_version": SCHEMA_VERSION,
"active": {dt: "aza_default" for dt, _ in DOC_TYPES},
"templates": {
dt: [_new_template_entry(dt, now=now)]
for dt, _ in DOC_TYPES
},
"sync_meta": {"last_sync_at": "", "conflicts": []},
}
def _backup_json_before_migration(path: str) -> None:
if not os.path.isfile(path):
return
try:
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
shutil.copy2(path, path + f".pre_migrate_{stamp}.bak")
except Exception:
pass
def _migrate_data(d: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Migration schema v1→v2: fehlende Typen ergänzen, Metadaten anreichern."""
changed = False
now = _utc_now()
sv = int(d.get("schema_version") or 1)
for dt, _ in DOC_TYPES:
if dt not in d.get("templates", {}):
d.setdefault("templates", {})[dt] = [_new_template_entry(dt, now=now)]
changed = True
if dt not in d.get("active", {}):
d.setdefault("active", {})[dt] = "aza_default"
changed = True
tpls = d.get("templates", {}).get(dt, [])
for i, t in enumerate(tpls):
if isinstance(t, dict):
before = json.dumps(t, sort_keys=True)
tpls[i] = _ensure_template_fields(t)
if json.dumps(tpls[i], sort_keys=True) != before:
changed = True
if "sync_meta" not in d:
d["sync_meta"] = {"last_sync_at": "", "conflicts": []}
changed = True
if sv < SCHEMA_VERSION:
d["schema_version"] = SCHEMA_VERSION
changed = True
return d, changed
def _load() -> dict[str, Any]:
path = _templates_json_path()
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as f:
d = json.load(f)
if isinstance(d, dict) and d.get("schema_version") in (1, 2):
d, changed = _migrate_data(d)
if changed:
try:
_backup_json_before_migration(path)
_save(d)
except Exception:
pass
return d
except Exception:
pass
return _default_structure()
def _load_all_templates() -> dict[str, Any]:
"""Alias fuer externe Module (z. B. aza_text_windows_mixin)."""
return _load()
def _save(data: dict[str, Any]) -> None:
path = _templates_json_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp, path)
def get_active_template_content(doc_type: str) -> str:
try:
data = _load()
aid = data.get("active", {}).get(doc_type, "aza_default")
for t in data.get("templates", {}).get(doc_type, []):
if isinstance(t, dict) and t.get("id") == aid:
return sanitize_prompt_text(t.get("content") or "")
except Exception:
pass
return AZA_DEFAULT_TEMPLATES.get(doc_type, "")
def get_active_template_id(doc_type: str) -> str:
try:
data = _load()
return str(data.get("active", {}).get(doc_type, "aza_default"))
except Exception:
return "aza_default"
def doc_type_label(doc_type: str) -> str:
for dt, label in DOC_TYPES:
if dt == doc_type:
return label
return doc_type
def copy_public_template_as_own(
doc_type: str,
*,
content: str,
title: str,
source_author: str = "",
source_server_id: str = "",
) -> str | None:
"""Übernimmt eine öffentliche Vorlage als neue eigene Kopie (neue ID)."""
data = _load()
name = (title or "Übernommene Vorlage").strip()
new_id = f"user_{int(time.time())}_{doc_type}"
now = _utc_now()
new_tpl = {
"id": new_id,
"name": name,
"is_system": False,
"content": sanitize_prompt_text(content),
"description": "",
"created_at": now,
"updated_at": now,
"revision": 1,
"user_id": "",
"practice_id": "",
"server_id": "",
"published": False,
"sync_updated_at": "",
"source_author": (source_author or "").strip(),
"source_server_id": (source_server_id or "").strip(),
}
data.setdefault("templates", {}).setdefault(doc_type, []).append(new_tpl)
_save(data)
return new_id
# ── Sync / Veröffentlichung (Client, kein Deploy in diesem Block) ─────────────
def _app_user_practice_ids(app: Any) -> tuple[str, str]:
user_id = ""
practice_id = ""
try:
practice_id = (app.get_practice_id() or "").strip()
except Exception:
pass
try:
user_id = (getattr(app, "_empfang_self_user_id", lambda: "")() or "").strip()
except Exception:
user_id = ""
return user_id, practice_id
def sync_private_doku_prompts(app: Any) -> bool:
"""Synchronisiert private Benutzer-Prompts mit dem Server (falls erreichbar)."""
try:
from aza_doku_prompt_sync import sync_doku_prompts_with_server
return sync_doku_prompts_with_server(app)
except ImportError:
return False
except Exception:
return False
def publish_template_to_server(app: Any, doc_type: str, tpl: dict[str, Any],
title: str, description: str = "") -> tuple[bool, str]:
"""Veröffentlicht aktuelle Vorlage auf dem Server (nur wenn angemeldet)."""
try:
from aza_doku_prompt_sync import publish_doku_prompt
return publish_doku_prompt(app, doc_type, tpl, title, description)
except ImportError:
return False, "Sync-Modul nicht verfügbar."
except Exception as e:
return False, str(e)
def fetch_public_prompt_library(app: Any) -> list[dict[str, Any]]:
try:
from aza_doku_prompt_sync import fetch_public_doku_prompts
return fetch_public_doku_prompts(app)
except ImportError:
return []
except Exception:
return []
# ── Fenster ───────────────────────────────────────────────────────────────────
def open_doku_vorlage_fenster(app: Any) -> None:
"""Oeffnet das Doku-Prompt-Fenster. Singleton."""
existing = getattr(app, "_doku_vorlage_win", None)
if existing is not None:
try:
if existing.winfo_exists():
existing.lift(); existing.focus_force(); return
except Exception:
pass
W, H = 820, 600
win = tk.Toplevel(app)
app._doku_vorlage_win = win
win.title("Doku-Prompt")
win.configure(bg=_BG)
win.resizable(True, True)
win.minsize(700, 520)
try:
win.transient(app)
except Exception:
pass
try:
win.update_idletasks()
sw = win.winfo_screenwidth()
x = max(0, (sw - W) // 2)
win.geometry(f"{W}x{H}+{x}+60")
except Exception:
win.geometry(f"{W}x{H}")
try:
win.lift(); win.focus_force()
except Exception:
pass
if hasattr(app, "_register_window"):
try: app._register_window(win)
except Exception: pass
try:
import threading
threading.Thread(target=lambda: sync_private_doku_prompts(app), daemon=True).start()
except Exception:
pass
# ── Header ────────────────────────────────────────────────────────────────
hdr = tk.Frame(win, bg=_HDR_BG)
hdr.pack(fill="x")
tk.Label(hdr, text="📄 Doku-Prompt", font=(_FF, 13, "bold"),
bg=_HDR_BG, fg=_HDR_FG, padx=18, pady=10).pack(side="left")
close_x = tk.Label(hdr, text="", font=(_FF, 11), bg=_HDR_BG, fg="#A0C4D8",
cursor="hand2", padx=14)
close_x.pack(side="right")
sub = tk.Frame(win, bg=_SUB_BG)
sub.pack(fill="x")
tk.Label(sub,
text="Prompts für medizinische Dokumente anpassen. "
"Diagnose-/Therapie-Gliederung wird über das Häkchen 'Diagnosen gliedern' gesteuert.",
font=(_FF, 8), bg=_SUB_BG, fg=_TEXT_SUB,
padx=18, pady=5, wraplength=780, justify="left").pack(anchor="w")
# ── Hauptbereich ──────────────────────────────────────────────────────────
body = tk.Frame(win, bg=_BG)
body.pack(fill="both", expand=True, padx=10, pady=8)
body.columnconfigure(1, weight=1)
body.rowconfigure(0, weight=1)
# Linke Spalte — Dokumenttypen
left = tk.Frame(body, bg=_CARD_BG, bd=0,
highlightbackground=_CARD_BD, highlightthickness=1, width=140)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
left.grid_propagate(False)
tk.Label(left, text="Dokumenttyp", font=(_FF, 9, "bold"),
bg="#E0EEF7", fg=_TEXT, pady=6).pack(fill="x")
# Rechte Karte — Bearbeitungsfläche
right = tk.Frame(body, bg=_CARD_BG, bd=0,
highlightbackground=_CARD_BD, highlightthickness=1)
right.grid(row=0, column=1, sticky="nsew")
right.rowconfigure(2, weight=1)
right.columnconfigure(0, weight=1)
# ── State-Variablen ───────────────────────────────────────────────────────
_data: list[dict] = [_load()]
_cur_dt: list[str] = ["kg"]
_cur_tpl: list[dict] = [{}]
_dirty: list[bool] = [False]
combo_var = [None]
combo_widget = [None]
txt_content = [None]
lbl_doctype = [None]
lbl_active_info = [None]
lbl_mode = [None]
btn_save = [None]
btn_activate = [None]
library_items: list[list[dict]] = [[]]
library_listbox: list[tk.Listbox | None] = [None]
def _tpls(dt): return _data[0].get("templates", {}).get(dt, [])
def _aid(dt): return _data[0].get("active", {}).get(dt, "aza_default")
def _by_id(dt, tid):
for t in _tpls(dt):
if isinstance(t, dict) and t.get("id") == tid: return t
return None
def _dname(t):
n = (t.get("name") or "Unbenannt").strip()
return f"{n}" if t.get("is_system") else n
def _refresh(dt: str, tpl_id: str | None = None):
templates = _tpls(dt)
active_id = _aid(dt)
target_id = tpl_id or active_id
tpl = _by_id(dt, target_id)
if tpl is None and templates:
tpl = templates[0]
if tpl is None:
tpl = {"id": "aza_default", "name": "AzA-Grundvorlage",
"is_system": True, "content": AZA_DEFAULT_TEMPLATES.get(dt, "")}
_cur_tpl[0] = tpl
# Combo
names = [_dname(t) for t in templates if isinstance(t, dict)]
if combo_widget[0]:
combo_widget[0]["values"] = names
combo_var[0].set(_dname(tpl))
# Label Dokumenttyp
_, dlabel = next((d for d in DOC_TYPES if d[0] == dt), (dt, dt))
if lbl_doctype[0]:
lbl_doctype[0].config(text=dlabel)
# Textfeld — IMMER beschreibbar, unabhaengig von Systemvorlage
if txt_content[0]:
txt_content[0].config(state="normal")
txt_content[0].delete("1.0", "end")
txt_content[0].insert("1.0", tpl.get("content") or "")
txt_content[0].config(state="normal", bg="#FFFFFF", fg=_TEXT)
# Aktive-Info
at = _by_id(dt, active_id)
aname = _dname(at) if at else active_id
if lbl_active_info[0]:
lbl_active_info[0].config(text=f"Aktive Vorlage: {aname}")
is_system = bool(tpl.get("is_system"))
is_active = (tpl.get("id") == active_id)
# Modus-Hinweis
if lbl_mode[0]:
if is_system:
lbl_mode[0].config(
text="Systemvorlage 'Speichern' nicht direkt möglich → 'Als neue Vorlage speichern'",
fg="#B06000")
else:
lbl_mode[0].config(
text="Benutzervorlage direkt bearbeitbar und speicherbar.",
fg="#2A7A4A")
# Buttons
if btn_activate[0]:
btn_activate[0].config(
text="Aktiv" if is_active else "Als aktiv setzen",
bg="#8AAFC8" if is_active else _BTN_ACT,
state="disabled" if is_active else "normal",
)
if btn_save[0]:
# Speichern bei Systemvorlage deaktivieren — klare Führung
btn_save[0].config(
state="disabled" if is_system else "normal",
bg="#8AAFC8" if is_system else _BTN_BLUE,
)
_dirty[0] = False
def _on_combo(evt=None):
dt = _cur_dt[0]
sel = combo_var[0].get() if combo_var[0] else ""
for t in _tpls(dt):
if isinstance(t, dict) and _dname(t) == sel:
_refresh(dt, t.get("id")); return
def _do_save():
tpl = _cur_tpl[0]
if tpl.get("is_system"):
messagebox.showinfo(
"Systemvorlage",
"Die AzA-Grundvorlage kann nicht direkt überschrieben werden.\n\n"
"Bitte verwenden Sie 'Als neue Vorlage speichern', um eine\n"
"eigene Variante zu erstellen.",
parent=win)
return
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
tpl["content"] = content
tpl["updated_at"] = _utc_now()
tpl["revision"] = int(tpl.get("revision") or 0) + 1
try:
_save(_data[0])
except Exception as e:
messagebox.showerror("Fehler", str(e), parent=win); return
_dirty[0] = False
messagebox.showinfo("Gespeichert", "Vorlage gespeichert.", parent=win)
def _do_save_new():
dt = _cur_dt[0]
name = simpledialog.askstring("Neue Vorlage", "Name der neuen Vorlage:", parent=win)
if not name or not name.strip(): return
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
new_id = f"user_{int(time.time())}_{dt}"
uid, pid = _app_user_practice_ids(app)
new_tpl = {
"id": new_id, "name": name.strip(), "is_system": False,
"content": content, "description": "",
"created_at": _utc_now(), "updated_at": _utc_now(),
"revision": 1, "user_id": uid, "practice_id": pid,
"server_id": "", "published": False, "sync_updated_at": "",
"source_author": "", "source_server_id": "",
}
_data[0].setdefault("templates", {}).setdefault(dt, []).append(new_tpl)
activate = messagebox.askyesno("Aktivieren?",
f"Neue Vorlage '{name.strip()}' gespeichert.\nJetzt aktivieren?", parent=win)
if activate:
_data[0].setdefault("active", {})[dt] = new_id
try:
_save(_data[0])
except Exception as e:
messagebox.showerror("Fehler", str(e), parent=win); return
_refresh(dt, new_id if activate else _aid(dt))
def _do_activate():
dt = _cur_dt[0]; tpl = _cur_tpl[0]; tid = tpl.get("id", "")
if not tid: return
_data[0].setdefault("active", {})[dt] = tid
try: _save(_data[0])
except Exception as e:
messagebox.showerror("Fehler", str(e), parent=win); return
_refresh(dt, tid)
def _do_reset():
dt = _cur_dt[0]
_, dlabel = next((d for d in DOC_TYPES if d[0] == dt), (dt, dt))
if not messagebox.askyesno(
"Zurücksetzen?",
f"'{dlabel}' auf AzA-Grundvorlage zurücksetzen?\n\n"
"Die aktive Vorlage wird auf AzA-Grundvorlage gesetzt.\n"
"Ihre eigenen Varianten werden NICHT gelöscht.", parent=win):
return
templates = _tpls(dt)
has_default = any(isinstance(t, dict) and t.get("id") == "aza_default"
for t in templates)
if not has_default:
now = _utc_now()
_data[0].setdefault("templates", {}).setdefault(dt, []).insert(0, {
"id": "aza_default", "name": "AzA-Grundvorlage", "is_system": True,
"content": AZA_DEFAULT_TEMPLATES.get(dt, ""),
"created_at": now, "updated_at": now})
_data[0].setdefault("active", {})[dt] = "aza_default"
try: _save(_data[0])
except Exception as e:
messagebox.showerror("Fehler", str(e), parent=win); return
_refresh(dt, "aza_default")
def _do_delete():
dt = _cur_dt[0]
tpl = _cur_tpl[0]
if tpl.get("is_system"):
messagebox.showinfo("Systemvorlage", "AzA-Grundvorlage kann nicht gelöscht werden.", parent=win)
return
tid = tpl.get("id", "")
if not tid:
return
name = (tpl.get("name") or "Unbenannt").strip()
if not messagebox.askyesno("Löschen?", f"Vorlage '{name}' wirklich löschen?", parent=win):
return
templates = _tpls(dt)
_data[0]["templates"][dt] = [t for t in templates if not (isinstance(t, dict) and t.get("id") == tid)]
if _aid(dt) == tid:
_data[0].setdefault("active", {})[dt] = "aza_default"
try:
_save(_data[0])
except Exception as e:
messagebox.showerror("Fehler", str(e), parent=win)
return
_refresh(dt, _aid(dt))
def _do_publish():
dt = _cur_dt[0]
tpl = dict(_cur_tpl[0])
content = sanitize_prompt_text(txt_content[0].get("1.0", "end") if txt_content[0] else "")
if not content:
messagebox.showinfo("Hinweis", "Bitte zuerst einen Prompt-Inhalt eingeben.", parent=win)
return
tpl["content"] = content
default_title = (tpl.get("name") or doc_type_label(dt)).strip()
title = simpledialog.askstring("Titel", "Titel für Veröffentlichung:", initialvalue=default_title, parent=win)
if not title or not title.strip():
return
desc = simpledialog.askstring("Beschreibung", "Kurze Beschreibung (optional):", parent=win) or ""
preview = content[:600] + ("" if len(content) > 600 else "")
if not messagebox.askyesno(
"Veröffentlichen?",
f"Dokumenttyp: {doc_type_label(dt)}\nTitel: {title.strip()}\n\nVorschau:\n{preview}\n\n"
"Bitte prüfen Sie, dass diese Vorlage keine Patientendaten oder "
"vertraulichen Praxisdaten enthält.\n\n"
"Für andere angemeldete Ärzte veröffentlichen?",
parent=win,
):
return
ok, msg = publish_template_to_server(app, dt, tpl, title.strip(), desc.strip())
if ok:
tpl["published"] = True
tpl["updated_at"] = _utc_now()
_save(_data[0])
messagebox.showinfo("Veröffentlicht", "Prompt wurde veröffentlicht.", parent=win)
_refresh_library()
else:
messagebox.showwarning("Veröffentlichung", msg or "Server nicht erreichbar — lokal gespeichert.", parent=win)
def _refresh_library():
if not library_listbox[0]:
return
library_listbox[0].delete(0, "end")
library_items[0].clear()
for item in fetch_public_prompt_library(app):
if not isinstance(item, dict):
continue
dt = item.get("doc_type") or ""
title = item.get("title") or "Ohne Titel"
author = item.get("author_display") or item.get("author") or "Unbekannt"
label = f"[{doc_type_label(dt)}] {title}{author}"
library_listbox[0].insert("end", label)
library_items[0].append(item)
def _adopt_library_item(as_new: bool = True):
sel = library_listbox[0].curselection() if library_listbox[0] else ()
if not sel:
messagebox.showinfo("Hinweis", "Bitte eine Vorlage aus der Bibliothek wählen.", parent=win)
return
item = library_items[0][sel[0]]
dt = item.get("doc_type") or _cur_dt[0]
content = item.get("content") or ""
title = item.get("title") or "Übernommene Vorlage"
author = item.get("author_display") or item.get("author") or ""
sid = item.get("id") or item.get("server_id") or ""
new_id = copy_public_template_as_own(
dt, content=content, title=title,
source_author=author, source_server_id=sid,
)
if new_id:
_sel_dt(dt)
_refresh(dt, new_id)
messagebox.showinfo("Übernommen", "Vorlage als eigene Kopie gespeichert.", parent=win)
def _close():
try: app._doku_vorlage_win = None
except Exception: pass
try: win.destroy()
except Exception: pass
close_x.bind("<Button-1>", lambda e: _close())
win.protocol("WM_DELETE_WINDOW", _close)
# ── Linke Buttons (Dokumenttypen) ─────────────────────────────────────────
_dt_btns: dict[str, tk.Label] = {}
def _sel_dt(dt: str):
_cur_dt[0] = dt
for k, b in _dt_btns.items():
b.config(bg=_ACCENT if k == dt else _CARD_BG,
fg="#FFFFFF" if k == dt else _TEXT)
_refresh(dt)
for dt_key, dt_label in DOC_TYPES:
b = tk.Label(left, text=dt_label, font=(_FF, 9),
bg=_CARD_BG, fg=_TEXT, cursor="hand2",
pady=9, anchor="w", padx=12)
b.pack(fill="x")
b.bind("<Button-1>", lambda e, dk=dt_key: _sel_dt(dk))
_dt_btns[dt_key] = b
# ── Rechte Karte ──────────────────────────────────────────────────────────
# Zeile 0: Header (Dokumenttyp + aktive Vorlage)
top_bar = tk.Frame(right, bg="#E0EEF7")
top_bar.grid(row=0, column=0, sticky="ew")
lbl_doctype[0] = tk.Label(top_bar, text="Krankengeschichte", font=(_FF, 10, "bold"),
bg="#E0EEF7", fg=_TEXT, padx=12, pady=6)
lbl_doctype[0].pack(side="left")
lbl_active_info[0] = tk.Label(top_bar, text="Aktive Vorlage: AzA-Grundvorlage",
font=(_FF, 8), bg="#E0EEF7", fg=_TEXT_SUB, padx=8)
lbl_active_info[0].pack(side="left")
# Zeile 1: Vorlage-Auswahl + Modus-Hinweis
sel_bar = tk.Frame(right, bg=_CARD_BG)
sel_bar.grid(row=1, column=0, sticky="ew", padx=10, pady=(6, 2))
tk.Label(sel_bar, text="Vorlage:", font=(_FF, 8),
bg=_CARD_BG, fg=_TEXT_SUB).pack(side="left")
combo_var[0] = tk.StringVar()
combo_widget[0] = ttk.Combobox(sel_bar, textvariable=combo_var[0],
state="readonly", width=32, font=(_FF, 9))
combo_widget[0].pack(side="left", padx=(4, 12))
combo_widget[0].bind("<<ComboboxSelected>>", _on_combo)
lbl_mode[0] = tk.Label(sel_bar, text="", font=(_FF, 7),
bg=_CARD_BG, fg="#B06000", padx=4)
lbl_mode[0].pack(side="left")
# Zeile 2: Textfeld (expandierend)
txt_frame = tk.Frame(right, bg=_CARD_BG)
txt_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(2, 4))
txt_frame.rowconfigure(0, weight=1)
txt_frame.columnconfigure(0, weight=1)
vsb = tk.Scrollbar(txt_frame, orient="vertical")
vsb.grid(row=0, column=1, sticky="ns")
txt_content[0] = tk.Text(txt_frame, font=(_FF, 9),
bg="#FFFFFF", fg=_TEXT,
relief="flat", bd=0, wrap="word",
yscrollcommand=vsb.set,
highlightbackground=_CARD_BD, highlightthickness=1,
padx=8, pady=8)
txt_content[0].grid(row=0, column=0, sticky="nsew")
vsb.config(command=txt_content[0].yview)
txt_content[0].bind("<KeyRelease>", lambda e: _dirty.__setitem__(0, True))
# Zeile 3: Button-Leiste (feste Höhe, nie abgeschnitten)
btn_row = tk.Frame(right, bg="#E8F0F5", pady=8)
btn_row.grid(row=3, column=0, sticky="ew", padx=0)
def _mk(parent, text, cmd, bg, fg="#FFFFFF", width=16):
return tk.Button(parent, text=text, command=cmd,
font=(_FF, 8, "bold"), bg=bg, fg=fg,
relief="flat", bd=0, padx=10, pady=5,
cursor="hand2", width=width,
activebackground=bg)
btn_save[0] = _mk(btn_row, "Speichern", _do_save, _BTN_BLUE, width=12)
btn_save[0].pack(side="left", padx=(10, 4))
_mk(btn_row, "Als neue Vorlage", _do_save_new, _BTN_BLUE, width=16).pack(side="left", padx=4)
btn_activate[0] = _mk(btn_row, "Als aktiv setzen", _do_activate, _BTN_ACT, width=15)
btn_activate[0].pack(side="left", padx=4)
_mk(btn_row, "Löschen", _do_delete, _BTN_RESET, width=10).pack(side="left", padx=4)
btn_publish = _mk(
btn_row, "Für Ärzte veröffentlichen", _do_publish, _BTN_BLUE, width=24,
)
btn_publish.pack(side="left", padx=4)
try:
from aza_ui_helpers import add_tooltip
add_tooltip(btn_publish, "Aktuellen Prompt für andere angemeldete Ärzte freigeben.")
except Exception:
pass
_mk(btn_row, "Schließen", _close, _BTN_CLOSE, width=10).pack(side="right", padx=(4, 10))
_mk(btn_row, "↩ Auf Grundvorlage", _do_reset, _BTN_RESET, width=18).pack(side="right", padx=4)
# Öffentliche Prompt-Bibliothek
lib_frame = tk.Frame(win, bg=_BG)
lib_frame.pack(fill="x", padx=10, pady=(0, 8))
tk.Label(lib_frame, text="Öffentliche Prompt-Bibliothek", font=(_FF, 9, "bold"),
bg=_BG, fg=_TEXT).pack(anchor="w")
lib_inner = tk.Frame(lib_frame, bg=_CARD_BG,
highlightbackground=_CARD_BD, highlightthickness=1)
lib_inner.pack(fill="x", pady=(4, 0))
lb = tk.Listbox(lib_inner, font=(_FF, 8), height=4, bg="#FFFFFF", fg=_TEXT,
selectbackground=_ACCENT, activestyle="none")
lb.pack(side="left", fill="both", expand=True, padx=6, pady=6)
library_listbox[0] = lb
lib_btns = tk.Frame(lib_inner, bg=_CARD_BG)
lib_btns.pack(side="right", fill="y", padx=6, pady=6)
tk.Button(lib_btns, text="Aktualisieren", command=_refresh_library,
font=(_FF, 8), bg=_BTN_BLUE, fg="#FFFFFF", relief="flat",
cursor="hand2", padx=8, pady=4).pack(fill="x", pady=(0, 4))
tk.Button(lib_btns, text="Als eigene Vorlage\nübernehmen", command=lambda: _adopt_library_item(True),
font=(_FF, 8), bg=_BTN_BLUE, fg="#FFFFFF", relief="flat",
cursor="hand2", padx=8, pady=4).pack(fill="x")
# Initialer Zustand — immer Krankengeschichte
_sel_dt("kg")
_refresh_library()