Files
aza/AzA march 2026 - Kopie (7)/basis14.py
2026-04-16 13:32:32 +02:00

8921 lines
394 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 -*-
"""
KG-Diktat Desktop (Aufnahme -> Transkription -> Krankengeschichte)
===============================================================
Modulare Architektur Hauptdatei mit Imports aus:
aza_config, aza_prompts, aza_ui_helpers, aza_persistence, aza_audio,
aza_todo_mixin, aza_text_windows_mixin, aza_diktat_mixin,
aza_settings_mixin, aza_ordner_mixin, aza_arbeitsplan_mixin
"""
import os
import re
import json
import sys
import time
import uuid
import webbrowser
import subprocess
import traceback
import hashlib
import platform
import bcrypt
import pyotp
import qrcode
from PIL import ImageTk
from aza_totp import (
is_2fa_enabled, is_2fa_required, generate_totp_secret,
generate_backup_codes, hash_backup_code, get_provisioning_uri,
verify_totp, verify_backup_code, encrypt_secret, decrypt_secret,
is_rate_limited,
)
from aza_consent import (
has_valid_consent, record_consent, record_revoke,
get_consent_status, export_consent_log,
)
from aza_audit_log import log_event
from aza_activation import check_app_access, validate_key, save_activation_key, load_activation_key
from security_vault import store_api_key, has_vault_key, get_masked_key
from difflib import SequenceMatcher
import threading
import tempfile
import wave
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Tuple
from urllib.parse import urlparse
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk, messagebox, simpledialog
from tkinter.scrolledtext import ScrolledText
from dotenv import load_dotenv
from openai import OpenAI
import requests
# Windows: für Einfügen in externes Fenster (Word, Notepad etc.)
import ctypes
from ctypes import wintypes
if sys.platform == "win32":
try:
_user32 = ctypes.windll.user32
except Exception:
_user32 = None
else:
_user32 = None
# Globaler Autotext in Windows (Word, Editor, überall): pynput für Tastatur-Hook
try:
from pynput.keyboard import Controller as KbdController, Key, KeyCode, Listener as KbdListener
_HAS_PYNPUT = True
except ImportError:
_HAS_PYNPUT = False
try:
from pynput.mouse import Button as MouseButton, Listener as MouseListener
_HAS_PYNPUT_MOUSE = True
except ImportError:
_HAS_PYNPUT_MOUSE = False
# Modul-Imports
from aza_config import *
from aza_config import (_ALL_WINDOWS, _SOAP_SECTIONS, _SOAP_LABELS,
NUM_SOAP_PRESETS, NUM_BRIEF_PRESETS)
from aza_prompts import *
from aza_persistence import *
from aza_persistence import _win_clipboard_set, _win_clipboard_get
from aza_ui_helpers import *
from aza_audio import AudioRecorder, split_audio_into_chunks
from aza_todo_mixin import TodoMixin
from aza_text_windows_mixin import TextWindowsMixin
from aza_diktat_mixin import AzaDiktatMixin
from aza_settings_mixin import AzaSettingsMixin
from aza_med_validator import validate_medication_name, suggest_medication_candidate, get_medication_facts
from aza_ordner_mixin import AzaOrdnerMixin
from aza_arbeitsplan_mixin import AzaArbeitsplanMixin
from aza_notizen_mixin import AzaNotizenMixin
from desktop_backend_autostart import start_backend, get_backend_error
from desktop_update_check import (
check_for_updates,
download_update_installer,
launch_update_installer,
)
from openai_runtime_config import (
get_openai_api_key,
has_openai_api_key,
ensure_runtime_config_template_exists,
get_runtime_env_file_path,
open_runtime_config_in_editor,
)
from aza_launcher import AzaLauncher, should_skip_launcher
def resolve_license_url() -> str:
env_url = (os.getenv("AZA_LICENSE_URL") or "").strip()
if env_url:
return env_url
try:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "license_url.txt")
with open(path, "r", encoding="utf-8-sig") as f:
first_line = (f.readline() or "").strip()
if first_line:
return first_line
except Exception:
pass
return "http://127.0.0.1:9000"
AZA_LICENSE_URL = resolve_license_url()
LICENSE_TOKEN_FILE = "license_token.txt"
DEMO_USAGE_FILE = "demo_usage.json"
DEMO_MAX_DICTATIONS = 9999
def load_license_token():
try:
path = os.path.join(get_writable_data_dir(), LICENSE_TOKEN_FILE)
with open(path, "r", encoding="utf-8-sig") as f:
token = (f.read() or "").strip()
return token if token else None
except Exception:
return None
def save_license_token(token: str):
path = os.path.join(get_writable_data_dir(), LICENSE_TOKEN_FILE)
with open(path, "w", encoding="utf-8") as f:
f.write((token or "").strip())
def _license_cache_path() -> Path:
return Path(get_writable_data_dir()) / "license_status_cache.json"
def _load_license_cache() -> dict | None:
p = _license_cache_path()
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return None
return data
except Exception:
return None
def _save_license_cache(payload: dict) -> None:
p = _license_cache_path()
try:
p.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except Exception:
# Cache failure must never break app
pass
def _cache_is_fresh(cache: dict, max_age_seconds: int = 24 * 60 * 60) -> bool:
ts = cache.get("cached_at")
if not isinstance(ts, (int, float)):
return False
return (time.time() - float(ts)) <= max_age_seconds
def _cache_license_valid(cache: dict) -> bool:
# Cache payload expects: {"valid": bool, "valid_until": int|None, "cached_at": epoch}
if cache.get("valid") is not True:
return False
vu = cache.get("valid_until")
if not isinstance(vu, (int, float)):
return False
return int(vu) > int(time.time())
def _format_valid_until_ts(ts: Optional[int]) -> str:
if not isinstance(ts, (int, float)):
return "n/a"
try:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts)))
except Exception:
return str(ts)
def _device_id_path() -> Path:
return Path(get_writable_data_dir()) / "device_id.txt"
def _get_or_create_device_id() -> str:
"""
Creates a stable per-installation device id.
Stored locally so it survives reboots.
"""
p = _device_id_path()
if p.exists():
try:
v = p.read_text(encoding="utf-8").strip()
if v:
return v
except Exception:
pass
# best effort: include some system context + random uuid (avoid PII)
v = f"aza-{platform.system()}-{platform.machine()}-{uuid.uuid4()}"
try:
p.write_text(v, encoding="utf-8")
except Exception:
# If we can't persist, still return a value (but it won't be stable)
pass
return v
def open_billing_portal(backend_url: str, api_token: str) -> bool:
"""
Fetches a Stripe Billing Portal URL from backend and opens it in the default browser.
Returns True on success, False otherwise.
"""
if not backend_url or not api_token:
print("[BILLING] missing backend_url or api_token")
return False
url = backend_url.rstrip("/") + "/stripe/billing_portal_url"
# Add required headers
headers = {"X-API-Token": api_token}
# If you have device id helper, include it (safe)
try:
device_id = _get_or_create_device_id()
headers["X-Device-Id"] = device_id
except Exception:
pass
try:
resp = requests.get(url, headers=headers, timeout=10)
if resp.status_code in (401, 403):
print("[BILLING] unauthorized")
return False
resp.raise_for_status()
data = resp.json()
portal_url = data.get("url")
if not portal_url:
print("[BILLING] no url in response")
return False
webbrowser.open(portal_url)
print("[BILLING] opened portal in browser")
return True
except Exception:
print("[BILLING] failed to open portal")
return False
class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettingsMixin, AzaOrdnerMixin, AzaArbeitsplanMixin, AzaNotizenMixin):
def __init__(self, start_module: str = "kg"):
super().__init__()
self._start_module = start_module
self._return_to_launcher = False
self.check_license_status()
if self.license_mode == "demo":
_has_remote = False
try:
_burl = self.get_backend_url()
if _burl and not any(h in _burl for h in ("127.0.0.1", "localhost", "0.0.0.0")):
_has_remote = True
except Exception:
pass
if _has_remote:
print("[LICENSE] Remote-Backend konfiguriert Aktivierungsschluessel-Fallback NICHT angewendet. Backend-Lizenzstatus ist fuehrend.")
else:
try:
from aza_activation import load_activation_key, validate_key
_ak = load_activation_key()
if _ak:
_v, _exp, _ = validate_key(_ak)
if _v:
self.license_mode = "active"
print(f"[LICENSE] mode=ACTIVE reason=activation_key_fallback valid_until={_exp}")
except Exception:
pass
print(f"Lizenzmodus: {'VOLL' if self.license_mode == 'active' else 'DEMO'}")
self.title("KI Assistent PRAXIS LINDENGUT AG")
# Wassertropfen-Icon fuer Titelleiste (logo.ico)
try:
base_dir = os.path.dirname(os.path.abspath(__file__))
ico_path = os.path.join(base_dir, "logo.ico")
if os.path.exists(ico_path):
self.iconbitmap(ico_path)
except Exception as e:
print(f"Icon konnte nicht geladen werden: {e}")
MAIN_MIN_W, MAIN_MIN_H = 750, 650 # Mindestgröe: alle Buttons sichtbar (angepasst für unterschiedliche DPI)
self.minsize(MAIN_MIN_W, MAIN_MIN_H)
saved = load_window_geometry()
self._saved_sash = None
self._saved_sash_transcript = None
if saved:
w = max(MAIN_MIN_W, saved[0])
h = max(MAIN_MIN_H, saved[1])
if len(saved) >= 5 and saved[4] is not None:
self._saved_sash = saved[4]
if len(saved) >= 6 and saved[5] is not None:
self._saved_sash_transcript = saved[5]
else:
w = DEFAULT_WINDOW_WIDTH
h = DEFAULT_WINDOW_HEIGHT
self.geometry(f"{w}x{h}")
self._initial_w = w
self._initial_h = h
self._center_done = False
# Fenster immer im Vordergrund
self.attributes("-topmost", True)
self._last_external_hwnd = None
self.bind("<FocusOut>", self._on_focus_out_for_external_paste)
self._geometry_save_after_id = None
self._tiling_active = False
self.bind("<Configure>", self._on_configure)
self.protocol("WM_DELETE_WINDOW", self._on_close)
# Benutzerprofil laden und Login (wöchentlich statt bei jedem Start)
self._user_profile = load_user_profile()
if not self._user_profile.get("name"):
self.withdraw()
self._show_login_dialog()
self.deiconify()
elif self._user_profile.get("password_hash") and self._login_needed():
self.withdraw()
self._show_login_dialog()
self.deiconify()
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.isfile(_env_path):
load_dotenv(dotenv_path=_env_path, override=True)
else:
load_dotenv(override=True)
api_key = get_openai_api_key() or ""
self.client = OpenAI(api_key=api_key) if api_key else None
self.api_key_present = bool(api_key)
self.recorder = AudioRecorder()
self.is_recording = False
self._recording_mode = "new"
self.last_wav_path = None
self._last_brief_text = ""
self._last_rezept_text = ""
self._last_kogu_text = ""
self._timer_sec = 0
self._timer_running = False
self._phase = ""
self._autotext_data = load_autotext()
self._autotext_data.setdefault("autoOpenNews", False)
self._autotext_data.setdefault("autoOpenEvents", True)
self._autotext_data.setdefault("newsTemplate", "all")
self._autotext_data.setdefault("newsSelectedSpecialties", [])
self._autotext_data.setdefault("newsSelectedRegions", ["CH", "EU"])
self._autotext_data.setdefault("newsSort", "newest")
self._autotext_data.setdefault("eventsSelectedSpecialties", ["general-medicine"])
self._autotext_data.setdefault("eventsSelectedRegions", ["CH", "EU"])
self._autotext_data.setdefault("eventsTemplate", "general_ch_eu")
self._autotext_data.setdefault("eventsSort", "soonest")
self._autotext_data.setdefault("eventsMonthsAhead", 13)
self._autotext_data.setdefault("selectedLanguage", "system")
self._autotext_data.setdefault("user_specialty_default", "dermatology")
self._autotext_data.setdefault("user_specialties_selected", [])
self._autotext_data.setdefault("ui_font_delta", -1)
self._autotext_data.setdefault("notizen_open_on_start", True)
self._autotext_data.setdefault("audio_notiz_autostart", True)
self._autotext_data.setdefault("kommentare_auto_open", False)
self._ensure_user_specialty_preferences()
uid = self._user_profile.get("name", "default")
if not has_valid_consent(uid):
self._show_consent_dialog()
if not self._autotext_data.get("eventsSelectedSpecialties"):
self._autotext_data["eventsSelectedSpecialties"] = [self._autotext_data.get("user_specialty_default", "dermatology")]
if not self._autotext_data.get("newsSelectedSpecialties"):
self._autotext_data["newsSelectedSpecialties"] = [self._autotext_data.get("user_specialty_default", "dermatology")]
self._autotext_global_buffer = []
self._autotext_injecting = [False]
self._autotext_focus_in_app = [False]
self._one_click_paste_until = 0.0
if _HAS_PYNPUT and sys.platform == "win32":
threading.Thread(target=self._run_global_autotext_listener, daemon=True).start()
if sys.platform == "win32":
threading.Thread(target=self._run_global_right_click_paste_listener, daemon=True).start()
self.configure(bg="#B9ECFA")
style = ttk.Style(self)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure("TFrame", background="#B9ECFA")
style.configure("TPanedwindow", background="#B9ECFA")
try:
style.configure("TPanedwindow.Sash", background="#B9ECFA", width=8)
except tk.TclError:
pass
style.configure("TLabel", background="#B9ECFA", foreground="#1a4d6d")
style.configure("TopBar.TFrame", background="#B9ECFA")
style.configure("StatusBar.TFrame", background="#B9ECFA")
style.configure("TranscriptBar.TFrame", background="#B9ECFA")
style.configure(
"TButton",
background="#7EC8E3",
foreground="#1a4d6d",
padding=(10, 6),
borderwidth=0,
)
style.map("TButton", background=[("active", "#5AB9E8"), ("pressed", "#4AA5D4")])
style.configure(
"Primary.TButton",
background="#2196F3",
foreground="white",
padding=(12, 16),
)
style.map("Primary.TButton", background=[("active", "#1E88D4"), ("pressed", "#1976D2")])
self._build_ui()
self._backend_autostart_attempted = False
try:
self.attributes("-alpha", load_opacity())
except Exception:
pass
self.after(50, self._ensure_centered)
self.after(100, self._restore_sash)
self.after(1500, self._check_old_kg_entries)
self.after(2500, self._auto_start_backend_if_needed)
# Automatische Aktualisierung der Token-Nutzung beim Start (nach 3 Sekunden)
self.after(3000, lambda: threading.Thread(target=self._fetch_and_update_usage, daemon=True).start())
self._audio_notiz_autostart_attempted = False
self.after(1200, self._open_dev_status_window)
self._window_registry.add(self)
self.after(500, self._dispatch_start_module)
if self._autotext_data.get("kommentare_auto_open", False):
self.after(1000, self._open_kommentare_fenster)
if not self.api_key_present:
try:
_show_openai_key_setup_dialog()
if has_openai_api_key():
api_key = get_openai_api_key()
self.client = OpenAI(api_key=api_key) if api_key else None
self.api_key_present = True
except Exception:
pass
def _dispatch_start_module(self):
"""Öffnet das per Launcher gewählte Modul."""
mod = self._start_module
if not mod or mod == "kg":
return
try:
dispatch = {
"ki": lambda: None,
"notizen": lambda: self._start_audio_notiz_addon(silent=False),
"translator": self._open_uebersetzer,
"medwork_chat": self._open_docapp,
"praxis_chat": self._open_docapp,
}
handler = dispatch.get(mod)
if handler is None:
messagebox.showinfo(
"Modul nicht verfügbar",
f"Das Modul '{mod}' ist noch nicht vollständig integriert.\n"
"Sie können es manuell über die Seitenleiste starten.",
)
return
if mod in ("notizen", "translator", "medwork_chat", "praxis_chat"):
if not self._check_ai_consent():
return
if mod in ("translator", "notizen", "medwork_chat", "praxis_chat"):
self.iconify()
handler()
except Exception as e:
messagebox.showerror("Modul-Start", f"Fehler beim Öffnen von '{mod}':\n{e}")
_LOGIN_INTERVAL_DAYS = 7
def _login_needed(self) -> bool:
"""True wenn der letzte Login länger als _LOGIN_INTERVAL_DAYS her ist."""
last_ts = self._user_profile.get("last_login_ts")
if not isinstance(last_ts, (int, float)):
return True
elapsed = time.time() - float(last_ts)
return elapsed > (self._LOGIN_INTERVAL_DAYS * 86400)
def _record_login(self):
self._user_profile["last_login_ts"] = int(time.time())
save_user_profile(self._user_profile)
def check_license_status(self):
self.license_mode = "demo"
license_mode = "DEMO"
license_reason = "unknown"
valid_until = None
cache = _load_license_cache()
if cache and _cache_is_fresh(cache) and _cache_license_valid(cache):
license_mode = "ACTIVE"
license_reason = "offline_cache"
valid_until = cache.get("valid_until")
self.license_mode = "active"
print(f"[LICENSE] mode={license_mode} reason={license_reason} valid_until={_format_valid_until_ts(valid_until)}")
return
try:
backend_url = self.get_backend_url()
api_token = self.get_backend_token()
print(f"[LICENSE] status_url={backend_url}/license/status")
response = None
status_code = None
last_exc = None
device_id = _get_or_create_device_id()
headers = {"X-API-Token": api_token, "X-Device-Id": device_id}
for attempt in range(1, 7):
try:
response = requests.get(
f"{backend_url}/license/status",
headers=headers,
timeout=5,
)
status_code = response.status_code
response.raise_for_status()
print(f"[LICENSE] status_code={status_code} attempt={attempt}/6")
break
except requests.HTTPError as http_exc:
last_exc = http_exc
print(f"[LICENSE] status_code={status_code} attempt={attempt}/6")
break
except requests.RequestException as req_exc:
last_exc = req_exc
print(f"[LICENSE] retry={attempt}/6 reason={req_exc}")
if attempt < 6:
time.sleep(1.0)
if isinstance(last_exc, requests.HTTPError):
if status_code in (401, 403):
license_mode = "DEMO"
license_reason = "unauthorized"
else:
if cache and _cache_license_valid(cache):
license_mode = "ACTIVE"
license_reason = "offline_cache"
valid_until = cache.get("valid_until")
else:
license_mode = "DEMO"
license_reason = "no_backend"
valid_until = cache.get("valid_until") if isinstance(cache, dict) else None
self.license_mode = "active" if license_mode == "ACTIVE" else "demo"
print(f"[LICENSE] mode={license_mode} reason={license_reason} valid_until={_format_valid_until_ts(valid_until)}")
return
if response is None:
raise RuntimeError(last_exc or "license status request failed")
try:
data = response.json()
except Exception:
data = {}
resp_valid = bool(data.get("valid")) if isinstance(data, dict) else False
resp_valid_until = data.get("valid_until") if isinstance(data, dict) else None
payload = {"valid": resp_valid, "valid_until": resp_valid_until, "cached_at": time.time()}
_save_license_cache(payload)
now = int(time.time())
valid_until = resp_valid_until if isinstance(resp_valid_until, (int, float)) else None
if resp_valid and isinstance(resp_valid_until, (int, float)) and int(resp_valid_until) > now:
license_mode = "ACTIVE"
license_reason = "online"
valid_until = int(resp_valid_until)
elif isinstance(resp_valid_until, (int, float)) and int(resp_valid_until) <= now:
license_mode = "DEMO"
license_reason = "expired"
valid_until = int(resp_valid_until)
else:
license_mode = "DEMO"
license_reason = "not_valid"
valid_until = resp_valid_until if isinstance(resp_valid_until, (int, float)) else None
except Exception as e:
print(f"[LICENSE] exception={e}")
if cache and _cache_license_valid(cache):
license_mode = "ACTIVE"
license_reason = "offline_cache"
valid_until = cache.get("valid_until")
else:
license_mode = "DEMO"
license_reason = "no_backend"
valid_until = cache.get("valid_until") if isinstance(cache, dict) else None
self.license_mode = "active" if license_mode == "ACTIVE" else "demo"
print(f"[LICENSE] mode={license_mode} reason={license_reason} valid_until={_format_valid_until_ts(valid_until)}")
def _build_ui(self):
try:
def_font = tkfont.nametofont("TkDefaultFont")
font_size = max(10, def_font.actual()["size"]) # Mindestens Gröe 10 für bessere Lesbarkeit
self._text_font = (def_font.actual()["family"], font_size)
except Exception:
self._text_font = ("Segoe UI", 10)
# Liste aller skalierbaren Widgets (Buttons, Labels, Text-Widgets)
self._scalable_widgets = []
self._scalable_text_widgets = []
top = ttk.Frame(self, padding=10, style="TopBar.TFrame")
top.pack(fill="x")
self.model_var = tk.StringVar(value=load_saved_model())
self._btn_row_left = ttk.Frame(top)
self._btn_row_left.pack(side="left")
self.btn_record = RoundedButton(
self._btn_row_left, "⏺ Start", command=self.toggle_record,
bg="#5B8DB3", fg="white", active_bg="#4A7A9E", width=100, height=40,
canvas_bg="#B9ECFA",
)
self.btn_record.lock_color = True
self.btn_record.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self.btn_record)
add_tooltip(self.btn_record, "Aufnahme starten / stoppen")
self.btn_record_append = RoundedButton(
self._btn_row_left, "⏺ Korrigieren", command=self._toggle_record_append,
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
width=90, height=28, canvas_bg="#B9ECFA",
)
self.btn_record_append.lock_color = True
self.btn_record_append.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self.btn_record_append)
add_tooltip(self.btn_record_append, "Zusätzliches Diktat anhängen")
self._btn_diktat_top = RoundedButton(
self._btn_row_left, "Diktat", command=self.open_diktat_window,
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0",
width=60, height=28, canvas_bg="#95D6ED",
)
self._btn_diktat_top.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self._btn_diktat_top)
add_tooltip(self._btn_diktat_top, "Diktatfenster öffnen")
self._btn_notizen_top = RoundedButton(
self._btn_row_left, "Notizen", command=self.open_notizen_window,
bg="#A8D8B9", fg="#1a4d3d", active_bg="#8CC8A5",
width=65, height=28, canvas_bg="#A8D8B9",
)
self._btn_notizen_top.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self._btn_notizen_top)
add_tooltip(self._btn_notizen_top, "Projekt-Notizen anzeigen")
self.btn_new = RoundedButton(
self._btn_row_left, "Neu", command=self._new_session,
width=50, height=28, canvas_bg="#B9ECFA",
)
self.btn_new.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self.btn_new)
add_tooltip(self.btn_new, "Neue Sitzung beginnen")
self._btn_minimize = RoundedButton(
self._btn_row_left, "", command=self._toggle_minimize,
width=28, height=28, canvas_bg="#B9ECFA",
)
self._btn_minimize.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self._btn_minimize)
add_tooltip(self._btn_minimize, "Kompaktansicht")
self._btn_tile_windows = RoundedButton(
self._btn_row_left, "", command=self.arrange_windows_top,
width=28, height=28, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
)
self._btn_tile_windows.pack(side="left", padx=(3, 0), anchor="n")
self._scalable_widgets.append(self._btn_tile_windows)
add_tooltip(self._btn_tile_windows, "Fenster anordnen")
self._btn_reset_pos = RoundedButton(
self._btn_row_left, "", command=self._reset_window_positions,
width=28, height=28, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
)
self._btn_reset_pos.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self._btn_reset_pos)
add_tooltip(self._btn_reset_pos, "Fensterpositionen zurücksetzen")
self._btn_profile = RoundedButton(
self._btn_row_left, "👤", command=self._show_profile_editor,
width=28, height=28, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
)
self._btn_profile.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self._btn_profile)
add_tooltip(self._btn_profile, "Profil bearbeiten")
self._btn_activation = RoundedButton(
self._btn_row_left, "🔑", command=self._show_activation_dialog,
width=28, height=28, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
)
self._btn_activation.pack(side="left", padx=(6, 0), anchor="n")
self._scalable_widgets.append(self._btn_activation)
add_tooltip(self._btn_activation, "Aktivierung verwalten")
self._top_right = ttk.Frame(top, style="TopBar.TFrame")
self._top_right.pack(side="right", anchor="n")
# Token-Anzeige (zeigt echten OpenAI-Verbrauch)
token_data = load_token_usage()
used_dollars = token_data.get("used_dollars", 0)
budget_dollars = token_data.get("budget_dollars", 50) # Standard: $50
if budget_dollars > 0:
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
remaining_percent = max(0, min(100, remaining_percent))
else:
remaining_percent = 100
self._token_label = ttk.Label(
self._top_right,
text=f" {remaining_percent}%",
font=("Segoe UI", 9),
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d"),
cursor="hand2"
)
self._token_label.pack(side="left", padx=(0, 8))
remaining = max(0, budget_dollars - used_dollars)
tooltip_text = f"Guthaben: {remaining_percent}% (${remaining:.2f} von ${budget_dollars:.2f})\n\n"
tooltip_text += f"Verbraucht: ${used_dollars:.2f}\n\n"
tooltip_text += "100% = Volles Guthaben\n0% = Guthaben aufgebraucht\n\n"
tooltip_text += "Klick: Aktuellen Stand laden\nRechtsklick: Guthaben einstellen"
add_tooltip(self._token_label, tooltip_text)
# Token-Label anklickbar machen
def refresh_usage(e=None):
self.set_status("Guthaben wird aktualisiert …")
threading.Thread(target=self._fetch_and_update_usage, daemon=True).start()
def open_budget(e=None):
self._open_budget_settings()
self._token_label.bind("<Button-1>", refresh_usage)
self._token_label.bind("<Button-3>", open_budget)
self._backend_status_var = tk.StringVar(value="Verbindung wird geprüft …")
self._backend_status_label = tk.Label(
self._top_right,
textvariable=self._backend_status_var,
bg="#B9ECFA",
fg="#BD4500",
font=("Segoe UI", 9, "bold"),
)
self._backend_status_label.pack(side="left", padx=(0, 6))
self._btn_backend_start = RoundedButton(
self._top_right, "Verbinden", command=self._start_backend_from_ui,
width=78, height=24, canvas_bg="#B9ECFA",
bg="#E1F6FC", fg="#1a4d6d", active_bg="#B8E8F4",
)
self._btn_backend_start.pack(side="left", padx=(0, 8), anchor="center")
self._scalable_widgets.append(self._btn_backend_start)
add_tooltip(
self._btn_backend_start,
"Lokale Serververbindung manuell starten",
)
# KEINE REGLER MEHR - Fixierte optimale Gröen (Schrift: 60%, Buttons: 140%)
# Wende fixierte Werte direkt an
self._apply_font_scale_global(FIXED_FONT_SCALE)
self._apply_button_scale_global(FIXED_BUTTON_SCALE)
# Transparenz-Regler
self._opacity_var_main = tk.DoubleVar(value=round(load_opacity() * 100))
def on_opacity_main(val):
try:
alpha = float(val) / 100.0
alpha = max(MIN_OPACITY, min(1.0, alpha))
self.attributes("-alpha", alpha)
save_opacity(alpha)
except Exception:
pass
opacity_lbl_half = tk.Label(self._top_right, text="", font=("Segoe UI Symbol", 14),
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
opacity_lbl_half.pack(side="left", padx=(0, 1))
opacity_lbl_half.bind("<Button-1>", lambda e: (self._opacity_var_main.set(round(MIN_OPACITY * 100)),
on_opacity_main(str(MIN_OPACITY * 100))))
opacity_lbl_half.bind("<Enter>", lambda e: opacity_lbl_half.configure(fg="#1a4d6d"))
opacity_lbl_half.bind("<Leave>", lambda e: opacity_lbl_half.configure(fg="#7AAFC8"))
add_tooltip(opacity_lbl_half, f"Fenster durchsichtig ({int(MIN_OPACITY*100)}%)")
try:
s = ttk.Style(self)
s.configure("MainOpacity.Horizontal.TScale", troughcolor="#c8ecf8", background="#5B8DB3")
except Exception:
pass
self._opacity_scale_main = ttk.Scale(
self._top_right,
from_=40, to=100,
variable=self._opacity_var_main,
orient="horizontal",
length=50,
command=on_opacity_main,
style="MainOpacity.Horizontal.TScale",
)
self._opacity_scale_main.pack(side="left")
add_tooltip(self._opacity_scale_main, "Fenster-Transparenz\nLinks = durchsichtig, Rechts = undurchsichtig")
opacity_lbl_sun = tk.Label(self._top_right, text="", font=("Segoe UI Symbol", 14),
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
opacity_lbl_sun.pack(side="left", padx=(1, 8))
opacity_lbl_sun.bind("<Button-1>", lambda e: (self._opacity_var_main.set(100),
on_opacity_main("100")))
opacity_lbl_sun.bind("<Enter>", lambda e: opacity_lbl_sun.configure(fg="#1a4d6d"))
opacity_lbl_sun.bind("<Leave>", lambda e: opacity_lbl_sun.configure(fg="#7AAFC8"))
add_tooltip(opacity_lbl_sun, "Fenster undurchsichtig (100%)")
# Transparenz-Elemente in der rechten Leiste ganz links platzieren
try:
self._token_label.pack_forget()
self._token_label.pack(side="left", padx=(8, 8))
except Exception:
pass
self._btn_launcher = RoundedButton(
self._top_right, "\u2190 Startseite", command=self._go_to_launcher,
bg="#EBEDF0", fg="#6B7280", active_bg="#D1D5DB", width=90, height=26, canvas_bg="#B9ECFA",
)
self._btn_launcher.pack(side="left", anchor="center", padx=(0, 6), pady=(0, 0))
self._scalable_widgets.append(self._btn_launcher)
add_tooltip(self._btn_launcher, "Zur\u00fcck zur Modulauswahl (Startseite)")
self._settings_photo = None
settings_icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "zahnrad.png")
try:
if os.path.isfile(settings_icon_path):
from PIL import Image
try:
resample = Image.Resampling.LANCZOS
except Exception:
resample = Image.LANCZOS
img = Image.open(settings_icon_path).convert("RGBA")
# Icon visuell an UI angleichen: Hintergrund = Fensterblau, Zahnrad = dunkelblau.
bg_rgb = (0xB9, 0xEC, 0xFA)
gear_rgb = (0x1A, 0x4D, 0x6D)
px = img.load()
w, h = img.size
for yy in range(h):
for xx in range(w):
r, g, b, a = px[xx, yy]
if a == 0:
continue
lum = (r + g + b) / 3.0
spread = max(r, g, b) - min(r, g, b)
if lum >= 170 and spread <= 45:
px[xx, yy] = (bg_rgb[0], bg_rgb[1], bg_rgb[2], a)
elif lum <= 130:
px[xx, yy] = (gear_rgb[0], gear_rgb[1], gear_rgb[2], a)
else:
t = (lum - 130.0) / 40.0
t = max(0.0, min(1.0, t))
rr = int(gear_rgb[0] + (bg_rgb[0] - gear_rgb[0]) * t)
gg = int(gear_rgb[1] + (bg_rgb[1] - gear_rgb[1]) * t)
bb = int(gear_rgb[2] + (bg_rgb[2] - gear_rgb[2]) * t)
px[xx, yy] = (rr, gg, bb, a)
# +20% gegenüber bisheriger Darstellung (22 -> 26)
img = img.resize((26, 26), resample)
self._settings_photo = ImageTk.PhotoImage(img)
except Exception as e:
try:
print(f"[SETTINGS_ICON] Ladefehler: {e}")
except Exception:
pass
if self._settings_photo is not None:
self.btn_settings = tk.Label(
self._top_right,
image=self._settings_photo,
bg="#B9ECFA",
bd=0,
highlightthickness=0,
cursor="hand2",
)
self.btn_settings.bind("<Button-1>", lambda e: self._open_settings())
self.btn_settings.bind("<Enter>", lambda e: self.btn_settings.configure(bg="#B9ECFA"))
self.btn_settings.bind("<Leave>", lambda e: self.btn_settings.configure(bg="#B9ECFA"))
self.btn_settings.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
else:
self.btn_settings = RoundedButton(
self._top_right, "", command=self._open_settings, width=36, height=28, canvas_bg="#B9ECFA",
)
self.btn_settings.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
self._scalable_widgets.append(self.btn_settings)
add_tooltip(self.btn_settings, "Einstellungen öffnen")
self._btn_systemstatus = RoundedButton(
self._top_right, "Status", command=self._open_systemstatus,
bg="#EBEDF0", fg="#6B7280", active_bg="#D1D5DB", width=52, height=28, canvas_bg="#B9ECFA",
)
self._btn_systemstatus.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
self._scalable_widgets.append(self._btn_systemstatus)
add_tooltip(self._btn_systemstatus, "Systemstatus und Diagnose")
# Kommentare-Button: gepackt in right_top (neben KG erstellen) weiter unten
self.btn_billing = RoundedButton(
self._top_right, "Abonnement", command=self._open_billing_portal_from_ui,
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=100, height=28, canvas_bg="#B9ECFA",
)
self.btn_billing.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
self._scalable_widgets.append(self.btn_billing)
add_tooltip(self.btn_billing, "Abonnement und Rechnungen verwalten")
self._status_row = ttk.Frame(self, padding=(16, 4), style="StatusBar.TFrame")
self._status_row.pack(fill="x")
self.status_var = tk.StringVar(value="Bereit.")
self.lbl_status = tk.Label(
self._status_row, textvariable=self.status_var, fg="#BD4500", bg="#B9ECFA", font=self._text_font
)
self.lbl_status.pack(side="left")
self._apply_status_color()
self.paned = ttk.PanedWindow(self, orient="horizontal")
self.paned.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self.paned.bind("<Configure>", self._on_paned_configure)
self._minimized = False
self._aza_minimize = self._toggle_minimize
self._aza_is_minimized = lambda: self._minimized
self._aza_windows = set()
self._window_registry = set()
self._btn_brief_mini = None
left = ttk.Frame(self.paned, padding=10)
right = ttk.Frame(self.paned, padding=10)
self.paned.add(left, weight=1)
self.paned.add(right, weight=1)
left_top = ttk.Frame(left)
left_top.pack(fill="x", pady=(0, 4))
self.btn_copy_transcript = RoundedButton(
left_top, "Transkript kopieren", command=self.copy_transcript,
width=160, height=28, canvas_bg="#B9ECFA",
)
self.btn_copy_transcript.pack(side="left", padx=(4, 0))
self._rclick_paste_var = tk.BooleanVar(
value=bool((self._autotext_data or {}).get("global_right_click_paste", True))
)
self._rclick_cb = tk.Checkbutton(
left_top, text="Rechtsklick = Einfügen",
variable=self._rclick_paste_var,
command=self._toggle_rclick_paste,
bg="#B9ECFA", activebackground="#B9ECFA",
font=("Segoe UI", 8),
anchor="w",
)
self._rclick_cb.pack(side="left", padx=(10, 0))
# Vertikales PanedWindow für Transkript (verstellbare Höhe)
self.paned_transcript = ttk.PanedWindow(left, orient="vertical")
self.paned_transcript.pack(fill="both", expand=True)
self.paned_transcript.bind("<Configure>", self._on_paned_configure)
# Sash (Trennbalken) sichtbar machen
style = ttk.Style()
style.configure("Sash", sashthickness=8, background="#7EC8E3")
top_left = ttk.Frame(self.paned_transcript)
bottom_left = ttk.Frame(self.paned_transcript, style="TranscriptBar.TFrame")
self.paned_transcript.add(top_left, weight=1)
self.paned_transcript.add(bottom_left, weight=0)
# Frame für Label + Schriftgröen-Spinbox
transcript_header = ttk.Frame(top_left)
transcript_header.pack(fill="x", anchor="w")
self._transcript_collapsed = False
self._transcript_toggle_label = tk.Label(
transcript_header, text="\u25BC Transkript:",
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d", cursor="hand2")
self._transcript_toggle_label.pack(side="left")
self._transcript_toggle_label.bind("<Button-1>", self._toggle_transcript_collapse)
self._transcript_frame = ttk.Frame(top_left)
self._transcript_frame.pack(fill="both", expand=True)
self.txt_transcript = ScrolledText(
self._transcript_frame, wrap="word", font=self._text_font, bg="#e1f6fc"
)
self.txt_transcript.pack(fill="both", expand=True)
self._bind_textblock_pending(self.txt_transcript)
# Schriftgröen-Spinbox für Transkript
add_text_font_size_control(transcript_header, self.txt_transcript, initial_size=10, bg_color="#B9ECFA", save_key="main_transcript")
# --- Buttons in bottom_left (unterhalb des Trennbalkens) ---
trans_btn_row = ttk.Frame(bottom_left, padding=(0, 4, 0, 0))
trans_btn_row.pack(fill="x")
RoundedButton(
trans_btn_row, "Ordner", command=self.open_ordner_window,
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=180, height=38, canvas_bg="#B9ECFA",
radius=0,
).pack(anchor="center")
# Anchor für Add-on (immer vorhanden, bleibt links)
self._addon_anchor = ttk.Frame(bottom_left)
self._addon_anchor.pack(fill="x")
# Add-on-Container (Übersetzer etc.) ein-/ausblendbar über Einstellungen
self._addon_container = ttk.Frame(bottom_left)
addon_inner = ttk.Frame(self._addon_container)
addon_inner.pack(fill="x")
# Add-on Header mit Toggle-Pfeil
addon_header_frame = ttk.Frame(addon_inner)
addon_header_frame.pack(fill="x", pady=(0, 4))
# Toggle-Pfeil und Label
self._addon_collapsed = False
self._addon_toggle_label = tk.Label(addon_header_frame, text="\u25BC Weitere Module",
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d",
cursor="hand2")
self._addon_toggle_label.pack(anchor="center")
self._addon_toggle_label.bind("<Button-1>", self._toggle_addon_collapse)
# Container für die Buttons (zum Ein-/Ausblenden)
self._addon_buttons_container = ttk.Frame(addon_inner)
self._addon_buttons_container.pack(fill="x")
# Speichere Button-Rows für späteres Ein-/Ausblenden
self._addon_button_rows = {}
uebersetzer_row = ttk.Frame(self._addon_buttons_container, padding=(0, 0, 0, 0))
self._addon_button_rows["uebersetzer"] = uebersetzer_row
uebersetzer_row.pack(fill="x")
RoundedButton(
uebersetzer_row, "Übersetzen", command=self._open_uebersetzer,
bg="#A8B8C0", fg="#1a4d6d", active_bg="#98A8B0", width=180, height=38, canvas_bg="#A8B8C0",
radius=0,
).pack(anchor="center")
# E-Mail Button
email_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["email"] = email_row
email_row.pack(fill="x")
RoundedButton(
email_row, "E-Mail", command=self._open_email,
bg="#B8C8D0", fg="#1a4d6d", active_bg="#A8B8C0", width=180, height=38, canvas_bg="#B8C8D0",
radius=0,
).pack(anchor="center")
# Autotext Button
autotext_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["autotext"] = autotext_row
autotext_row.pack(fill="x")
RoundedButton(
autotext_row, "Autotext", command=self._open_autotext_dialog,
bg="#CADFE8", fg="#1a4d6d", active_bg="#BAD0E0", width=180, height=38, canvas_bg="#CADFE8",
radius=0,
).pack(anchor="center")
# WhatsApp Button
whatsapp_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["whatsapp"] = whatsapp_row
whatsapp_row.pack(fill="x")
RoundedButton(
whatsapp_row, "WhatsApp", command=self._open_whatsapp,
bg="#D8E8F0", fg="#1a4d6d", active_bg="#C8D8E8", width=180, height=38, canvas_bg="#D8E8F0",
radius=0,
).pack(anchor="center")
# DocApp Button
docapp_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["docapp"] = docapp_row
docapp_row.pack(fill="x")
RoundedButton(
docapp_row, "MedWork", command=self._open_docapp,
bg="#E8F4F8", fg="#1a4d6d", active_bg="#D8E8F0", width=180, height=38, canvas_bg="#E8F4F8",
radius=0,
).pack(anchor="center")
# To-do Button
todo_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["todo"] = todo_row
todo_row.pack(fill="x")
RoundedButton(
todo_row, "To-do", command=self._open_todo_window,
bg="#F0F8FB", fg="#1a4d6d", active_bg="#E0F0F5", width=180, height=38, canvas_bg="#F0F8FB",
radius=0,
).pack(anchor="center")
# Arbeitsplan Button
arbeitsplan_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["arbeitsplan"] = arbeitsplan_row
arbeitsplan_row.pack(fill="x")
RoundedButton(
arbeitsplan_row, "Arbeitsplan", command=self._open_arbeitsplan_window,
bg="#F8FCFE", fg="#1a4d6d", active_bg="#EAF4F8", width=180, height=38, canvas_bg="#F8FCFE",
radius=0,
).pack(anchor="center")
# Macro Button
macro_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["macro"] = macro_row
macro_row.pack(fill="x")
RoundedButton(
macro_row, "Makro starten", command=self._open_macro,
bg="#EEF7FB", fg="#1a4d6d", active_bg="#DDECF4", width=180, height=38, canvas_bg="#EEF7FB",
radius=0,
).pack(anchor="center")
kongress_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["kongresse"] = kongress_row
kongress_row.pack(fill="x")
RoundedButton(
kongress_row, "Kongresse", command=self._open_kongress_window,
bg="#e3ecf4", fg="#1e4060", active_bg="#d4e0ee", width=180, height=38, canvas_bg="#EEF7FB",
radius=0,
).pack(anchor="center")
news_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
self._addon_button_rows["news"] = news_row
news_row.pack(fill="x")
RoundedButton(
news_row, "News", command=self._open_news_window,
bg="#e6f0e8", fg="#1a5a3a", active_bg="#d6e8da", width=180, height=38, canvas_bg="#EEF7FB",
radius=0,
).pack(anchor="center")
# Initiales Ein-/Ausblenden der Buttons basierend auf Einstellungen
self._update_addon_buttons_visibility()
self._addon_container.pack(fill="x", before=self._addon_anchor)
if not self._autotext_data.get("addon_visible", True):
self._addon_container.pack_forget()
self._addon_spacer_frame = self._addon_anchor # Referenz für Textblöcke (pack before)
right_top = ttk.Frame(right)
right_top.pack(fill="x", pady=(0, 4))
self.btn_make_kg = RoundedButton(
right_top, "KG erstellen", command=self.make_kg_from_text,
width=90, height=28, canvas_bg="#B9ECFA",
)
self.btn_make_kg.pack(side="left", padx=(4, 0))
self.btn_copy = RoundedButton(
right_top, "KG kopieren", command=self.copy_output,
width=80, height=28, canvas_bg="#B9ECFA",
)
self.btn_copy.pack(side="left", padx=(4, 0))
self.btn_macro1 = RoundedButton(
right_top, "Macro 1", command=self._run_macro1,
width=78, height=28, canvas_bg="#B9ECFA",
)
self.btn_macro1.pack(side="left", padx=(4, 0))
self.btn_macro1.bind("<Button-3>", lambda e: self._record_macro1())
self._btn_kommentare = RoundedButton(
right_top, "Kommentare", command=self._open_kommentare_fenster,
bg="#27AE60", fg="white", active_bg="#1E8449", width=95, height=28, canvas_bg="#B9ECFA",
)
self._btn_kommentare.pack(side="left", padx=(12, 0))
self._scalable_widgets.append(self._btn_kommentare)
add_tooltip(self._btn_kommentare, "Medizinische Kurzkommentare zum KG-Inhalt")
self._kommentare_auto_var = tk.BooleanVar(value=self._autotext_data.get("kommentare_auto_open", False))
self._chk_kommentare_auto = tk.Checkbutton(
right_top, text="Kommentare anzeigen", variable=self._kommentare_auto_var,
font=("Segoe UI", 8), bg="#B9ECFA", fg="#333", activebackground="#B9ECFA",
selectcolor="#B9ECFA", command=self._toggle_kommentare_auto,
)
self._chk_kommentare_auto.pack(side="left", padx=(2, 0))
add_tooltip(self._chk_kommentare_auto, "Kommentare-Fenster nach KG-Erstellung automatisch anzeigen")
# Vertikales PanedWindow für KG (verstellbare Höhe)
self.paned_kg = ttk.PanedWindow(right, orient="vertical")
self.paned_kg.pack(fill="both", expand=True)
self.paned_kg.bind("<Configure>", self._on_paned_configure)
top_right = ttk.Frame(self.paned_kg)
bottom_right = ttk.Frame(self.paned_kg)
self.paned_kg.add(top_right, weight=1)
self.paned_kg.add(bottom_right, weight=0)
# Frame für Label + Schriftgröen-Spinbox
kg_header = ttk.Frame(top_right)
kg_header.pack(fill="x", anchor="w")
self._kg_collapsed = False
self._kg_toggle_label = tk.Label(
kg_header, text="\u25BC Krankengeschichte:",
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d", cursor="hand2")
self._kg_toggle_label.pack(side="left")
self._kg_toggle_label.bind("<Button-1>", self._toggle_kg_collapse)
self._kg_frame = ttk.Frame(top_right)
self._kg_frame.pack(fill="both", expand=True)
self.txt_output = ScrolledText(
self._kg_frame, wrap="word", height=22, font=self._text_font, bg="#F5FCFF"
)
self.txt_output.pack(fill="both", expand=True)
self._bind_textblock_pending(self.txt_output)
self._bind_kg_section_copy(self.txt_output)
# Schriftgröen-Spinbox für KG
add_text_font_size_control(kg_header, self.txt_output, initial_size=10, bg_color="#B9ECFA", save_key="main_kg")
# SOAP-Abschnitts-Steuerung (A±, S±, O±, B±, D±, T±, P±) einklappbar
_SOAP_BG = "#B9ECFA"
soap_toggle_frame = tk.Frame(bottom_right, bg=_SOAP_BG)
soap_toggle_frame.pack(fill="x")
self._soap_collapsed = self._autotext_data.get("soap_collapsed", False)
self._soap_toggle_label = tk.Label(
soap_toggle_frame,
text="\u25B6 SOAP:" if self._soap_collapsed else "\u25BC SOAP:",
font=("Segoe UI", 10), bg=_SOAP_BG, fg="#1a4d6d", cursor="hand2")
self._soap_toggle_label.pack(anchor="w")
self._soap_toggle_label.bind("<Button-1>", self._toggle_soap_collapse)
soap_ctrl_bar = tk.Frame(bottom_right, bg=_SOAP_BG, padx=4, pady=4)
self._soap_container = soap_ctrl_bar
if not self._soap_collapsed:
soap_ctrl_bar.pack(fill="x")
self._soap_anchor = tk.Frame(bottom_right, bg=_SOAP_BG, height=0)
self._soap_anchor.pack(fill="x")
self._soap_section_labels = {}
self._soap_section_levels = load_soap_section_levels()
self._soap_inner = tk.Frame(soap_ctrl_bar, bg=_SOAP_BG)
self._soap_inner.pack(anchor="center")
self._soap_bg = _SOAP_BG
self._rebuild_soap_section_controls()
# KG-Bearbeitungs-Buttons (Kürzer / Ausführlicher / Vorlage)
# Zeile 1: gleiche Farbe wie KG erstellen (#7EC8E3)
_row1_bg, _row1_active = "#7EC8E3", "#6CB8D3"
kg_edit_bar = tk.Frame(soap_ctrl_bar, bg="#B9ECFA", pady=4)
kg_edit_bar.pack(fill="x")
self.btn_kg_kuerzer = RoundedButton(
kg_edit_bar, "Kürzer", command=self._kg_kuerzer,
width=120, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
)
self.btn_kg_kuerzer.pack(side="left")
self.btn_kg_ausfuehrlicher = RoundedButton(
kg_edit_bar, "Ausführlicher", command=self._kg_ausfuehrlicher,
width=140, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
)
self.btn_kg_ausfuehrlicher.pack(side="left", padx=(8, 0))
self.btn_kg_vorlage = RoundedButton(
kg_edit_bar, "Vorlage", command=self._open_kg_vorlage,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
)
self.btn_kg_vorlage.pack(side="left", padx=(8, 0))
self._update_kg_detail_display()
# Dokumente-Gruppe (Brief, Rezept, Korrektur) einklappbar
dokumente_header_frame = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
dokumente_header_frame.pack(fill="x", pady=(6, 0))
self._dokumente_collapsed = self._autotext_data.get("dokumente_collapsed", False)
self._dokumente_toggle_label = tk.Label(
dokumente_header_frame,
text="\u25B6 Dokumente:" if self._dokumente_collapsed else "\u25BC Dokumente:",
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d", cursor="hand2")
self._dokumente_toggle_label.pack(anchor="w")
self._dokumente_toggle_label.bind("<Button-1>", self._toggle_dokumente_collapse)
self._dokumente_container = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
self._dokumente_container.pack(fill="x")
self._dokumente_anchor = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
self._dokumente_anchor.pack(fill="x")
# Zeile 2 (+15% heller): Brief, Rezept, OP-Bericht
_row2_bg, _row2_active = "#97D3E9", "#87C3D9"
letter_bar = ttk.Frame(self._dokumente_container, padding=(0, 4), style="TranscriptBar.TFrame")
letter_bar.pack(fill="x")
self.btn_letter = RoundedButton(
letter_bar, "Brief", command=self.open_brief_window,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
)
self.btn_letter.pack(side="left")
self.btn_recipe = RoundedButton(
letter_bar, "Rezept", command=self.open_rezept_window,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
)
self.btn_recipe.pack(side="left", padx=(8, 0))
self.btn_op_bericht = RoundedButton(
letter_bar, "OP-Bericht", command=self.open_op_bericht_window,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
)
self.btn_op_bericht.pack(side="left", padx=(8, 0))
# Zeile 3 (+30% heller): KOGU, Diskussion mit KI, Arztzeugnis
_row3_bg, _row3_active = "#B0DEEF", "#A0CEDF"
kogu_bar = ttk.Frame(self._dokumente_container, padding=(0, 2), style="TranscriptBar.TFrame")
kogu_bar.pack(fill="x")
self.btn_kogu = RoundedButton(
kogu_bar, "KOGU", command=self.open_kogu_window,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
)
self.btn_kogu.pack(side="left")
self.btn_diskussion = RoundedButton(
kogu_bar, "Diskussion mit KI", command=self.open_diskussion_window,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
)
self.btn_diskussion.pack(side="left", padx=(8, 0))
self.btn_arztzeugnis = RoundedButton(
kogu_bar, "Arztzeugnis", command=self._open_arztzeugnis,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
)
self.btn_arztzeugnis.pack(side="left", padx=(8, 0))
# Zeile 4 (+45% heller): KI-Kontrolle, Korrektur
_row4_bg, _row4_active = "#C9E9F5", "#B9D9E5"
korrektur_bar = ttk.Frame(self._dokumente_container, padding=(0, 2), style="TranscriptBar.TFrame")
korrektur_bar.pack(fill="x")
self.btn_ki_pruefen = RoundedButton(
korrektur_bar, "KI-Kontrolle", command=self.open_ki_pruefen,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row4_bg, fg="#1a4d6d", active_bg=_row4_active,
)
self.btn_ki_pruefen.pack(side="left")
self.btn_korrektur = RoundedButton(
korrektur_bar, "Korrektur", command=self.open_pruefen_window,
width=100, height=28, canvas_bg="#B9ECFA", bg=_row4_bg, fg="#1a4d6d", active_bg=_row4_active,
)
self.btn_korrektur.pack(side="left", padx=(8, 0))
if self._dokumente_collapsed:
self._dokumente_container.pack_forget()
# Textblöcke unterhalb der Button-Reihen (rechte Seite unten)
textbloecke_header_frame = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
textbloecke_header_frame.pack(fill="x", pady=(6, 0))
self._textbloecke_collapsed = self._autotext_data.get("textbloecke_collapsed", False)
self._textbloecke_toggle_label = tk.Label(textbloecke_header_frame,
text="▶ Textblöcke:" if self._textbloecke_collapsed else "▼ Textblöcke:",
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d",
cursor="hand2")
self._textbloecke_toggle_label.pack(anchor="w")
self._textbloecke_toggle_label.bind("<Button-1>", self._toggle_textbloecke_collapse)
self._textbloecke_container = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
self._textbloecke_container.pack(fill="x")
self._textbloecke_anchor = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
self._textbloecke_anchor.pack(fill="x")
self._textbloecke_data = load_textbloecke()
self._removed_textbloecke = []
self._last_focused_text_widget = None
self._last_insert_index = "1.0"
self._textblock_drag_data = {"text": None, "active": False}
self._just_dropped_on_textblock = False
self._textbloecke_buttons = []
self._textbloecke_rows_frame = ttk.Frame(self._textbloecke_container)
self._textbloecke_rows_frame.pack(fill="x")
self._rebuild_textblock_buttons()
plus_minus_row = ttk.Frame(self._textbloecke_container, padding=(0, 4, 0, 0))
plus_minus_row.pack(fill="x")
pm_inner = ttk.Frame(plus_minus_row)
pm_inner.pack(anchor="center")
RoundedButton(pm_inner, "+", command=self._add_textblock, width=28, height=24, canvas_bg="#B9ECFA",
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", radius=0).pack(side="left", padx=(0, 4))
RoundedButton(pm_inner, "", command=self._remove_textblock, width=28, height=24, canvas_bg="#B9ECFA",
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", radius=0).pack(side="left")
if self._textbloecke_collapsed:
self._textbloecke_container.pack_forget()
self.bind_all("<B1-Motion>", self._textblock_on_drag_motion)
self.bind_all("<ButtonRelease-1>", self._on_global_drag_release)
if not self._autotext_data.get("textbloecke_visible", True):
self._textbloecke_container.pack_forget()
self._bottom_frame = ttk.Frame(self, padding=(10, 0, 10, 10), style="StatusBar.TFrame")
self._bottom_frame.pack(fill="x")
ttk.Label(
self._bottom_frame,
text="Ablauf: ⏺ Start ⏹ Stopp automatische Transkription automatische KG. "
"⏺ Korrigieren ergänzt bestehende KG. KG erstellen erzeugt KG aus Text."
).pack(side="left", anchor="w")
add_resize_grip(self)
# GANZ LINKS & WEITER OBEN: "AzA" Text + Logo (über dem Resize-Grip!)
self._logo_frame = tk.Frame(self, bg="#B9ECFA")
self._logo_frame.place(relx=0.01, rely=0.97, anchor="sw")
# Internes Branding-Logo (logo.png)
self._logo_label = None
try:
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logo.png")
if os.path.exists(logo_path):
from PIL import Image
img = Image.open(logo_path).convert("RGBA")
img_small = img.resize((82, 82), Image.Resampling.LANCZOS)
self.bottom_logo_photo = ImageTk.PhotoImage(img_small)
self._logo_label = tk.Label(
self._logo_frame, image=self.bottom_logo_photo,
bg="#B9ECFA", cursor="hand2")
self._logo_label.pack(side="left", padx=(0, 10))
self._logo_label.bind("<Button-1>", self._on_logo_click)
add_tooltip(self._logo_label, "Klick: Aufnahme starten / stoppen")
except Exception as e:
print(f"Bottom-Logo konnte nicht geladen werden: {e}")
# Text-Teil DANACH (rechts vom Logo)
text_frame = tk.Frame(self._logo_frame, bg="#B9ECFA")
text_frame.pack(side="left")
aza_label1 = tk.Label(text_frame, text="AzA von Arzt zu Arzt",
bg="#B9ECFA", fg="#1a4d6d",
font=("Segoe UI", 19, "bold"))
aza_label1.pack(anchor="w")
aza_label2 = tk.Label(text_frame, text="Informatik zu fairen Preisen",
bg="#B9ECFA", fg="#1a4d6d",
font=("Segoe UI", 11))
aza_label2.pack(anchor="w")
if not self._autotext_data.get("logo_visible", True):
self._logo_frame.place_forget()
# Initiale Schriftgröen- und Button-Gröen-Anwendung beim Start
self.after(100, lambda: self._apply_font_scale(load_font_scale()))
self.after(150, lambda: self._apply_button_scale(load_button_scale()))
# To-do wird nicht mehr automatisch beim Start geöffnet;
# Öffnung nur noch über den To-do-Button.
self._backend_status_after_id = None
self.after(800, self._refresh_backend_status)
def _on_configure(self, event):
"""Nach Verschieben/Resize: Gröe und Position mit Verzögerung speichern (nur Hauptfenster)."""
# KRITISCH: Supprimiere Speichern waehrend Docking!
if getattr(self, "_tiling_active", False):
return
if event.widget is not self:
return
if self._geometry_save_after_id:
self.after_cancel(self._geometry_save_after_id)
self._geometry_save_after_id = self.after(400, self._save_window_geometry)
def _on_paned_configure(self, event):
"""Nach Verschieben eines Teilers (Breite oder Transkript/KG-Höhe): mit Verzögerung speichern."""
# KRITISCH: Supprimiere waehrend Docking!
if getattr(self, "_tiling_active", False):
print(f"[_on_paned_configure] BLOCKED: tiling_active={self._tiling_active}")
return
if event.widget is not self.paned and event.widget is not self.paned_transcript and event.widget is not self.paned_kg:
return
if self._geometry_save_after_id:
self.after_cancel(self._geometry_save_after_id)
self._geometry_save_after_id = self.after(400, self._save_window_geometry)
def _restore_sash(self):
"""Stellt gespeicherte Sash-Positionen (Breite Transkript/KG, Höhe Transkript und KG) wieder her. Standard: Transkript 1/3 der Breite."""
# Gespeicherte PanedWindow-Positionen laden
paned_positions = load_paned_positions()
try:
w = self.winfo_width()
if w < 200:
w = 800
sash = self._saved_sash if self._saved_sash is not None else (w // 3)
sash = max(150, min(sash, w - 150))
self.paned.sashpos(0, sash)
except Exception:
pass
# Transkript vertikale Position wiederherstellen
try:
left_h = self.paned_transcript.winfo_height()
if left_h < 100:
left_h = 400
# Versuche gespeicherte Position zu laden
saved_transcript_pos = paned_positions.get("transcript_vertical")
if saved_transcript_pos is not None:
sash_v = max(80, min(saved_transcript_pos, max(80, left_h - 80)))
elif self._saved_sash_transcript is not None:
sash_v = max(80, min(self._saved_sash_transcript, max(80, left_h - 80)))
else:
sash_v = min(150, max(80, left_h - 80))
self.paned_transcript.sashpos(0, sash_v)
except Exception:
pass
# Krankengeschichte vertikale Position wiederherstellen
try:
right_h = self.paned_kg.winfo_height()
if right_h < 100:
right_h = 400
# Versuche gespeicherte Position zu laden
saved_kg_pos = paned_positions.get("kg_vertical")
if saved_kg_pos is not None:
sash_v = max(120, min(saved_kg_pos, max(120, right_h - 80)))
else:
# Standard: ca. 70% für KG Text, 30% für Buttons
sash_v = max(200, int(right_h * 0.70))
self.paned_kg.sashpos(0, sash_v)
except Exception:
pass
def _ensure_centered(self):
"""Zentriert das Hauptfenster robust nach vollstaendigem Widget-Aufbau."""
if self._center_done:
return
self._center_done = True
try:
self.update_idletasks()
w = self.winfo_width()
h = self.winfo_height()
if w < 400 or h < 300:
w = self._initial_w
h = self._initial_h
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
x = max(0, (sw - w) // 2)
y = max(0, (sh - h) // 2)
self.geometry(f"{w}x{h}+{x}+{y}")
except Exception:
pass
def _save_window_geometry(self):
self._geometry_save_after_id = None
if getattr(self, "_tiling_active", False):
return
if getattr(self, "_minimized", False):
return
try:
w, h = self.winfo_width(), self.winfo_height()
x, y = self.winfo_x(), self.winfo_y()
if w >= 400 and h >= 300:
sash = None
sash_transcript = None
try:
sash = self.paned.sashpos(0)
except Exception:
pass
try:
sash_transcript = self.paned_transcript.sashpos(0)
except Exception:
pass
save_window_geometry(w, h, x, y, sash, sash_transcript)
# Auch die vertikalen PanedWindow-Positionen speichern
paned_positions = {}
try:
paned_positions["transcript_vertical"] = self.paned_transcript.sashpos(0)
except Exception:
pass
try:
paned_positions["kg_vertical"] = self.paned_kg.sashpos(0)
except Exception:
pass
if paned_positions:
save_paned_positions(paned_positions)
except Exception:
pass
def _apply_font_scale(self, scale: float):
"""Wendet nur den Schriftgröen-Skalierungsfaktor auf Text-Widgets an."""
try:
# Aktualisiere Text-Widgets (ScrolledText, Text, Label)
# Basis-Gröe 16, damit bei Scale 0.3-0.8 die Schrift lesbar bleibt
base_size = 16
new_size = max(5, int(base_size * scale))
new_font = (self._text_font[0], new_size)
# Aktualisiere Status-Label
if hasattr(self, 'lbl_status'):
self.lbl_status.configure(font=new_font)
# Aktualisiere Text-Widgets
for txt_widget in [self.txt_transcript, self.txt_output]:
if txt_widget and txt_widget.winfo_exists():
txt_widget.configure(font=new_font)
# Skaliere Schrift in allen RoundedButtons
for widget in self.winfo_children():
self._scale_font_recursive(widget, scale)
except Exception as e:
pass
def _apply_font_scale_global(self, scale: float):
"""Wendet Schriftgröen-Skalierung auf ALLE offenen Fenster an."""
self._apply_font_scale(scale)
for win in _ALL_WINDOWS:
try:
if win and win.winfo_exists():
scale_window_fonts(win, scale)
except Exception:
pass
def _apply_button_scale(self, scale: float):
"""Wendet nur den Button-Gröen-Skalierungsfaktor an."""
try:
for widget in self.winfo_children():
self._scale_button_size_recursive(widget, scale)
except Exception:
pass
def _apply_button_scale_global(self, scale: float):
"""Wendet Button-Gröen-Skalierung auf ALLE offenen Fenster an."""
self._apply_button_scale(scale)
for win in _ALL_WINDOWS:
try:
if win and win.winfo_exists():
scale_window_buttons(win, scale)
except Exception:
pass
def update_token_display(self):
"""Aktualisiert die Token-Anzeige (Budget-basiert)."""
try:
token_data = load_token_usage()
used_dollars = token_data.get("used_dollars", 0)
budget_dollars = token_data.get("budget_dollars", 50)
if budget_dollars > 0:
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
remaining_percent = max(0, min(100, remaining_percent))
else:
remaining_percent = 100
self._token_label.configure(
text=f" {remaining_percent}%",
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d")
)
except Exception:
pass
def _fetch_and_update_usage(self):
"""Ruft echte Verbrauchs-Daten von OpenAI ab (läuft in Thread) - OHNE Statusmeldungen."""
try:
usage_data = fetch_openai_usage(self.client)
if usage_data and usage_data.get("success"):
used_dollars = usage_data.get("used_dollars", 0)
save_token_usage(used_dollars=used_dollars)
# KEINE Statusmeldung mehr - stört den Benutzer
self.after(0, self.update_token_display)
# KEINE Fehlermeldungen mehr bei API-Problemen
except Exception:
pass # Fehler still ignorieren
def _open_budget_settings(self):
"""Dialog zum Einstellen des monatlichen Budgets."""
token_data = load_token_usage()
current_budget = token_data.get("budget_dollars", 50)
new_budget = simpledialog.askfloat(
"Monatliches Budget",
"Ihr monatliches OpenAI-Budget in Dollar:\n(z.B. 50 für $50/Monat)",
initialvalue=current_budget,
minvalue=1,
maxvalue=10000
)
if new_budget:
save_token_usage(budget_dollars=new_budget)
self.update_token_display()
self.set_status(f"Budget auf ${new_budget:.2f} gesetzt.")
def _scale_font_recursive(self, widget, scale: float):
"""Rekursive Funktion zum Skalieren der Schriftgröe in RoundedButtons."""
try:
if isinstance(widget, RoundedButton):
widget.set_font_size_scale(scale)
for child in widget.winfo_children():
self._scale_font_recursive(child, scale)
except Exception:
pass
def _scale_button_size_recursive(self, widget, scale: float):
"""Rekursive Funktion zum Skalieren der Button-Gröe in RoundedButtons."""
try:
if isinstance(widget, RoundedButton):
widget.set_button_size_scale(scale)
for child in widget.winfo_children():
self._scale_button_size_recursive(child, scale)
except Exception:
pass
def _scale_widget_recursive(self, widget, scale: float):
"""Legacy-Methode für Rückwärtskompatibilität."""
try:
if isinstance(widget, RoundedButton):
widget.set_font_scale(scale)
for child in widget.winfo_children():
self._scale_widget_recursive(child, scale)
except Exception:
pass
def _check_old_kg_entries(self):
"""Prüft KG-Einträge älter als 2 Wochen: bei Bestätigung löschen (oder automatisch, wenn Einstellung aktiv)."""
try:
old = get_old_kg_entries(14)
if not old:
return
auto = getattr(self, "_autotext_data", {}).get("kg_auto_delete_old", False)
if auto:
n = delete_kg_entries_older_than(14)
if n > 0:
self.set_status(f"{n} KG-Einträge älter als 2 Wochen automatisch gelöscht.")
return
if messagebox.askyesno(
"KG-Aufräumen",
f"Es gibt {len(old)} KG-Einträge älter als 2 Wochen.\n\nSollen diese gelöscht werden, um Speicher zu schonen?",
):
n = delete_kg_entries_older_than(14)
if n > 0:
self.set_status(f"{n} KG-Einträge gelöscht.")
except Exception:
pass
def _on_close(self):
"""Beim Schließen: KG/Brief/Rezept/KOGU lesen und in Ablage speichern (JSON + .txt), dann schließen."""
def _str(w):
if w is None:
return ""
if hasattr(w, "get"):
try:
s = w.get("1.0", "end")
return (s if isinstance(s, str) else str(s)).strip()
except Exception:
return ""
return (str(w)).strip()
kg_text = _str(self.txt_output)
brief_text = _str(getattr(self, "_last_brief_text", None))
rezept_text = _str(getattr(self, "_last_rezept_text", None))
kogu_text = _str(getattr(self, "_last_kogu_text", None))
try:
if kg_text:
save_to_ablage("KG", kg_text)
if brief_text:
save_to_ablage("Briefe", brief_text)
if rezept_text:
save_to_ablage("Rezepte", rezept_text)
if kogu_text:
save_to_ablage("Kostengutsprachen", kogu_text)
except Exception:
pass
self._save_window_geometry()
try:
save_button_heat()
except Exception:
pass
diktat_win = getattr(self, "_diktat_window", None)
if diktat_win is not None and diktat_win.winfo_exists():
self._main_hidden = True
self.withdraw()
return
self._return_to_launcher = True
self.destroy()
def _go_to_launcher(self):
"""Schliesst die App und kehrt zur AZA-Startseite zurück."""
save_launcher_prefs("", False)
self._return_to_launcher = True
self._on_close()
try:
self.destroy()
except Exception:
pass
# _open_settings -> ausgelagert in Mixin-Modul
def _open_systemstatus(self):
"""Oeffnet das Systemstatus-Fenster."""
try:
from aza_systemstatus import show_systemstatus
show_systemstatus(self)
except Exception as e:
messagebox.showerror("Systemstatus", f"Fehler:\n{e}")
def _open_autotext_dialog(self, parent=None):
"""Dialog zum Verwalten der Autotext-Abkürzungen (Abkürzung Ersetzung, mehrzeilig möglich)."""
aw = tk.Toplevel(self)
aw.title("Autotext verwalten")
aw.transient(parent or self)
aw.configure(bg="#B9ECFA")
aw.minsize(500, 400)
aw.attributes("-topmost", True)
self._register_window(aw)
# Fensterposition: gespeichert laden oder zentrieren
setup_window_geometry_saving(aw, "autotext", 660, 500)
add_resize_grip(aw, 500, 400)
add_font_scale_control(aw)
af = ttk.Frame(aw, padding=12)
af.pack(fill="both", expand=True)
ttk.Label(af, text="Tippen Sie eine Abkürzung + Leerzeichen → der gespeicherte Text wird eingefügt.").pack(anchor="w")
edit_f = ttk.Frame(af)
edit_f.pack(fill="x", pady=(8, 4))
ttk.Label(edit_f, text="Text (mehrzeilig möglich):").grid(row=0, column=0, sticky="nw", padx=(0, 8))
repl_text = ScrolledText(edit_f, wrap="word", height=4, width=42, font=self._text_font, bg="#F5FCFF")
repl_text.grid(row=0, column=1, sticky="ew")
ttk.Label(edit_f, text="Abkürzung:").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(4, 0))
abbrev_var = tk.StringVar()
ttk.Entry(edit_f, textvariable=abbrev_var, width=18).grid(row=1, column=1, sticky="w", padx=(0, 16), pady=(4, 0))
edit_f.columnconfigure(1, weight=1)
entries = dict(self._autotext_data.get("entries") or {})
ttk.Label(af, text="Gespeicherte Einträge:", font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(6, 0))
list_f = ttk.Frame(af)
list_f.pack(fill="both", expand=True, pady=(4, 8))
listbox = tk.Listbox(list_f, height=8, font=("Segoe UI", 11))
listbox.pack(side="left", fill="both", expand=True)
scroll = ttk.Scrollbar(list_f, orient="vertical", command=listbox.yview)
scroll.pack(side="right", fill="y")
listbox.configure(yscrollcommand=scroll.set)
def refresh_list():
listbox.delete(0, "end")
for k in sorted(entries.keys()):
listbox.insert("end", f"«{k}» {((entries[k] or '').replace(chr(10), ' ')[:40])}" if len((entries[k] or "").replace("\n", " ")) > 40 else f"«{k}» {((entries[k] or '').replace(chr(10), ' '))}")
refresh_list()
def on_select(evt):
sel = listbox.curselection()
if not sel:
return
idx = sel[0]
keys = sorted(entries.keys())
if idx >= len(keys):
return
k = keys[idx]
abbrev_var.set(k)
repl_text.delete("1.0", "end")
repl_text.insert("1.0", entries.get(k, ""))
listbox.bind("<<ListboxSelect>>", on_select)
def do_add():
ab = abbrev_var.get().strip()
if not ab:
messagebox.showinfo("Hinweis", "Bitte eine Abkürzung eingeben.")
return
entries[ab] = repl_text.get("1.0", "end").rstrip("\n")
self._autotext_data["entries"] = entries
save_autotext(self._autotext_data)
refresh_list()
abbrev_var.set("")
repl_text.delete("1.0", "end")
self.set_status("Autotext-Eintrag hinzugefügt/aktualisiert.")
def do_delete():
sel = listbox.curselection()
if not sel:
messagebox.showinfo("Hinweis", "Bitte einen Eintrag in der Liste auswählen.")
return
keys = sorted(entries.keys())
idx = sel[0]
if idx >= len(keys):
return
k = keys[idx]
del entries[k]
self._autotext_data["entries"] = entries
save_autotext(self._autotext_data)
refresh_list()
abbrev_var.set("")
repl_text.delete("1.0", "end")
self.set_status("Autotext-Eintrag gelöscht.")
def do_diktieren():
if not self.ensure_ready():
messagebox.showinfo("Diktieren", "Die KI-Verbindung ist noch nicht eingerichtet.\nBitte zuerst über das Startmenü einrichten.")
return
rec_win = tk.Toplevel(aw)
rec_win.title("Autotext Diktieren")
rec_win.transient(aw)
rec_win.geometry("420x130")
rec_win.configure(bg="#B9ECFA")
rec_win.attributes("-topmost", True)
self._register_window(rec_win)
lbl = ttk.Label(rec_win, text="Aufnahme läuft Klicken Sie Stoppen, wenn Sie fertig sind.")
lbl.pack(pady=(16, 8))
try:
self.recorder.start()
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
rec_win.destroy()
return
def stop_and_transcribe():
try:
wav_path = self.recorder.stop_and_save_wav()
except Exception:
rec_win.destroy()
return
rec_win.destroy()
def worker():
try:
text = self.transcribe_wav(wav_path)
try:
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
def insert_text():
if aw.winfo_exists() and repl_text.winfo_exists():
repl_text.insert(tk.INSERT, text)
self.set_status("Ersetzungstext diktiert und eingefügt.")
self.after(0, insert_text)
except Exception as e:
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
threading.Thread(target=worker, daemon=True).start()
ttk.Button(rec_win, text="Stoppen", command=stop_and_transcribe).pack(pady=(0, 16))
btn_row = ttk.Frame(af)
btn_row.pack(fill="x")
ttk.Button(btn_row, text="Hinzufügen / Aktualisieren", command=do_add).pack(side="left", padx=(0, 8))
ttk.Button(btn_row, text="Diktieren", command=do_diktieren).pack(side="left", padx=(0, 8))
ttk.Button(btn_row, text="Löschen", command=do_delete).pack(side="left", padx=(0, 8))
ttk.Button(btn_row, text="Schließen", command=aw.destroy).pack(side="left")
@staticmethod
def _hash_password(pw: str) -> str:
"""Erzeugt einen bcrypt-Hash (cost=12, Salt eingebettet)."""
return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
@staticmethod
def _verify_password(pw: str, stored_hash: str) -> bool:
"""Prüft Passwort gegen gespeicherten Hash (bcrypt oder Legacy-SHA-256)."""
if stored_hash.startswith("$2b$") or stored_hash.startswith("$2a$"):
return bcrypt.checkpw(pw.encode("utf-8"), stored_hash.encode("utf-8"))
legacy = hashlib.sha256(pw.encode("utf-8")).hexdigest()
return legacy == stored_hash
@staticmethod
def _is_legacy_hash(stored_hash: str) -> bool:
"""Erkennt alte SHA-256-Hashes (64 Hex-Zeichen, kein bcrypt-Prefix)."""
if not stored_hash:
return False
return not stored_hash.startswith("$2") and len(stored_hash) == 64
def _show_login_dialog(self):
"""Zeigt beim ersten Start einen Registrierungs-Dialog, danach einen Passwort-Login."""
has_profile = bool(self._user_profile.get("name"))
if has_profile and self._user_profile.get("password_hash"):
self._show_password_login()
else:
self._show_registration_dialog()
def _show_password_login(self):
"""Passwort-Abfrage für bestehende Benutzer."""
dlg = tk.Toplevel(self)
dlg.title("🔒 Anmeldung")
dlg.configure(bg="#E8F4FA")
dlg.resizable(False, False)
dlg.geometry("380x260")
self._register_window(dlg)
dlg.attributes("-topmost", True)
dlg.grab_set()
center_window(dlg, 380, 260)
tk.Label(dlg, text="🔒 Anmeldung", font=("Segoe UI", 16, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=12)
user_name = self._user_profile.get("name", "Benutzer")
tk.Label(dlg, text=f"Willkommen zurück, {user_name}!",
font=("Segoe UI", 10), bg="#E8F4FA", fg="#4a8aaa").pack(fill="x", padx=16, pady=(10, 2))
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
form.pack(fill="x")
tk.Label(form, text="Passwort:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
pw_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0, show="")
pw_entry.pack(fill="x", ipady=4, pady=(0, 6))
err_label = tk.Label(form, text="", font=("Segoe UI", 9), bg="#E8F4FA", fg="#E05050")
err_label.pack(fill="x")
def do_login(event=None):
pw = pw_entry.get()
if not pw:
err_label.configure(text="⚠ Bitte Passwort eingeben.")
return
stored = self._user_profile.get("password_hash", "")
if self._verify_password(pw, stored):
uid = self._user_profile.get("name", "default")
if self._is_legacy_hash(stored):
self._user_profile["password_hash"] = self._hash_password(pw)
save_user_profile(self._user_profile)
log_event("PASSWORD_REHASH", uid, detail="legacy SHA-256 -> bcrypt")
if self._user_profile.get("totp_active") and is_2fa_enabled():
dlg.destroy()
self._show_totp_login(pw)
elif is_2fa_required() and is_2fa_enabled() and not self._user_profile.get("totp_active"):
dlg.destroy()
messagebox.showinfo("2FA erforderlich",
"Zwei-Faktor-Authentifizierung ist Pflicht.\n"
"Bitte aktivieren Sie 2FA in Ihrem Profil.")
self._show_2fa_setup(pw)
else:
log_event("LOGIN_OK", uid)
self._record_login()
dlg.destroy()
else:
log_event("LOGIN_FAIL", self._user_profile.get("name", "unknown"), success=False)
err_label.configure(text="❌ Falsches Passwort. Bitte erneut versuchen.")
pw_entry.delete(0, "end")
pw_entry.focus_set()
pw_entry.bind("<Return>", do_login)
btn_frame = tk.Frame(dlg, bg="#E8F4FA")
btn_frame.pack(pady=8)
tk.Button(btn_frame, text="🔑 Anmelden", font=("Segoe UI", 11, "bold"),
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
command=do_login).pack()
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
pw_entry.focus_set()
self.wait_window(dlg)
def _show_totp_login(self, password: str):
"""TOTP-Code-Abfrage nach erfolgreichem Passwort."""
dlg = tk.Toplevel(self)
dlg.title("2FA Verifizierung")
dlg.configure(bg="#E8F4FA")
dlg.resizable(False, False)
dlg.geometry("380x280")
self._register_window(dlg)
dlg.attributes("-topmost", True)
dlg.grab_set()
center_window(dlg, 380, 280)
tk.Label(dlg, text="🔐 Zwei-Faktor-Authentifizierung",
font=("Segoe UI", 14, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=10)
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
form.pack(fill="x")
tk.Label(form, text="Bitte geben Sie den 6-stelligen Code\n"
"aus Ihrer Authenticator-App ein:",
font=("Segoe UI", 9), bg="#E8F4FA", fg="#4a8aaa",
justify="left").pack(anchor="w", pady=(4, 8))
tk.Label(form, text="Code:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w")
code_entry = tk.Entry(form, font=("Segoe UI", 16), bg="white",
fg="#1a4d6d", relief="flat", bd=0,
justify="center")
code_entry.pack(fill="x", ipady=6, pady=(0, 6))
err_label = tk.Label(form, text="", font=("Segoe UI", 9),
bg="#E8F4FA", fg="#E05050")
err_label.pack(fill="x")
user_id = self._user_profile.get("name", "user")
def do_verify(event=None):
code = code_entry.get().strip()
if not code:
err_label.configure(text="⚠ Bitte Code eingeben.")
return
if is_rate_limited(user_id):
err_label.configure(text="Zu viele Versuche. Bitte warten.")
return
enc_secret = self._user_profile.get("totp_secret_enc", "")
if not enc_secret:
err_label.configure(text="2FA-Konfiguration fehlerhaft.")
return
secret = decrypt_secret(enc_secret, password)
if verify_totp(secret, code, user_id):
log_event("LOGIN_OK", user_id, detail="2FA TOTP")
log_event("2FA_OK", user_id)
self._record_login()
dlg.destroy()
return
backup_hashes = self._user_profile.get("backup_codes", [])
idx = verify_backup_code(code, backup_hashes)
if idx is not None:
self._user_profile["backup_codes"][idx] = ""
save_user_profile(self._user_profile)
remaining = sum(1 for c in self._user_profile["backup_codes"] if c)
log_event("LOGIN_OK", user_id, detail="2FA backup-code")
log_event("2FA_OK", user_id, detail=f"backup-code, verbleibend={remaining}")
self._record_login()
messagebox.showinfo("Backup-Code verwendet",
f"Backup-Code akzeptiert.\n"
f"Verbleibende Backup-Codes: {remaining}",
parent=dlg)
dlg.destroy()
return
log_event("2FA_FAIL", user_id, success=False)
err_label.configure(text="Falscher Code. Bitte erneut versuchen.")
code_entry.delete(0, "end")
code_entry.focus_set()
code_entry.bind("<Return>", do_verify)
btn_frame = tk.Frame(dlg, bg="#E8F4FA")
btn_frame.pack(pady=6)
tk.Button(btn_frame, text="Verifizieren",
font=("Segoe UI", 11, "bold"),
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
command=do_verify).pack()
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
code_entry.focus_set()
self.wait_window(dlg)
def _show_2fa_setup(self, password: str):
"""2FA-Aktivierungsdialog mit QR-Code und Erst-Validierung."""
secret = generate_totp_secret()
user_name = self._user_profile.get("name", "Benutzer")
uri = get_provisioning_uri(secret, user_name)
dlg = tk.Toplevel(self)
dlg.title("2FA einrichten")
dlg.configure(bg="#E8F4FA")
dlg.resizable(False, False)
dlg.geometry("420x620")
self._register_window(dlg)
dlg.attributes("-topmost", True)
dlg.grab_set()
center_window(dlg, 420, 620)
tk.Label(dlg, text="🔐 2FA einrichten",
font=("Segoe UI", 14, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=10)
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
form.pack(fill="x")
tk.Label(form, text="1. Scannen Sie den QR-Code mit Ihrer\n"
" Authenticator-App (z.B. Google Authenticator):",
font=("Segoe UI", 9), bg="#E8F4FA", fg="#4a8aaa",
justify="left").pack(anchor="w", pady=(4, 8))
qr_img = qrcode.make(uri, box_size=5, border=2)
qr_photo = ImageTk.PhotoImage(qr_img)
qr_label = tk.Label(form, image=qr_photo, bg="#E8F4FA")
qr_label.image = qr_photo
qr_label.pack(pady=(0, 8))
tk.Label(form, text="Manueller Schlüssel:",
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w")
secret_display = tk.Entry(form, font=("Consolas", 9), bg="#F0F8FF",
fg="#1a4d6d", relief="flat", bd=0,
readonlybackground="#F0F8FF", state="readonly")
secret_display.pack(fill="x", ipady=2, pady=(0, 8))
secret_display.configure(state="normal")
secret_display.insert(0, secret)
secret_display.configure(state="readonly")
sep = tk.Frame(form, bg="#B9ECFA", height=1)
sep.pack(fill="x", pady=(4, 8))
tk.Label(form, text="2. Geben Sie den aktuellen 6-stelligen Code ein:",
font=("Segoe UI", 9, "bold"), bg="#E8F4FA",
fg="#1a4d6d").pack(anchor="w")
code_entry = tk.Entry(form, font=("Segoe UI", 16), bg="white",
fg="#1a4d6d", relief="flat", bd=0,
justify="center")
code_entry.pack(fill="x", ipady=6, pady=(4, 4))
err_label = tk.Label(form, text="", font=("Segoe UI", 9),
bg="#E8F4FA", fg="#E05050")
err_label.pack(fill="x")
def do_activate(event=None):
code = code_entry.get().strip()
if not code:
err_label.configure(text="⚠ Bitte Code eingeben.")
return
totp = pyotp.TOTP(secret)
if not totp.verify(code, valid_window=1):
err_label.configure(text="Falscher Code. Bitte erneut versuchen.")
code_entry.delete(0, "end")
code_entry.focus_set()
return
backup_codes = generate_backup_codes()
backup_hashes = [hash_backup_code(c) for c in backup_codes]
self._user_profile["totp_secret_enc"] = encrypt_secret(secret, password)
self._user_profile["totp_active"] = True
self._user_profile["backup_codes"] = backup_hashes
save_user_profile(self._user_profile)
dlg.destroy()
self._show_backup_codes(backup_codes)
code_entry.bind("<Return>", do_activate)
btn = tk.Button(form, text="2FA aktivieren",
font=("Segoe UI", 11, "bold"),
bg="#27AE60", fg="white", activebackground="#219A52",
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
command=do_activate)
btn.pack(pady=(8, 0))
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0) if is_2fa_required() else dlg.destroy())
code_entry.focus_set()
self.wait_window(dlg)
def _show_backup_codes(self, codes: list[str]):
"""Zeigt die Backup-Codes an (einmalig nach Aktivierung)."""
dlg = tk.Toplevel(self)
dlg.title("Backup-Codes")
dlg.configure(bg="#E8F4FA")
dlg.resizable(False, False)
dlg.geometry("380x420")
self._register_window(dlg)
dlg.attributes("-topmost", True)
dlg.grab_set()
center_window(dlg, 380, 420)
tk.Label(dlg, text="Backup-Codes sichern!",
font=("Segoe UI", 14, "bold"),
bg="#E74C3C", fg="white").pack(fill="x", ipady=10)
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
form.pack(fill="x")
tk.Label(form, text="Diese Codes werden NUR EINMAL angezeigt.\n"
"Bewahren Sie sie sicher auf (z.B. ausdrucken).\n"
"Jeder Code kann nur einmal verwendet werden.",
font=("Segoe UI", 9), bg="#E8F4FA", fg="#E74C3C",
justify="left").pack(anchor="w", pady=(4, 12))
codes_text = tk.Text(form, font=("Consolas", 14), bg="#F0F8FF",
fg="#1a4d6d", relief="flat", bd=1,
height=len(codes), width=20)
codes_text.pack(pady=(0, 12))
for i, code in enumerate(codes, 1):
codes_text.insert("end", f" {i}. {code}\n")
codes_text.configure(state="disabled")
def do_copy():
dlg.clipboard_clear()
dlg.clipboard_append("\n".join(codes))
messagebox.showinfo("Kopiert",
"Backup-Codes in Zwischenablage kopiert.", parent=dlg)
btn_row = tk.Frame(form, bg="#E8F4FA")
btn_row.pack()
tk.Button(btn_row, text="Kopieren", font=("Segoe UI", 10),
bg="#C8DDE6", fg="#1a4d6d", relief="flat", padx=12,
pady=4, cursor="hand2", command=do_copy).pack(side="left", padx=4)
tk.Button(btn_row, text="Ich habe die Codes gesichert",
font=("Segoe UI", 10, "bold"),
bg="#27AE60", fg="white", relief="flat", padx=12,
pady=4, cursor="hand2",
command=dlg.destroy).pack(side="left", padx=4)
dlg.protocol("WM_DELETE_WINDOW", dlg.destroy)
self.wait_window(dlg)
def _show_registration_dialog(self):
"""Erstregistrierung: Profil + Passwort festlegen."""
dlg = tk.Toplevel(self)
dlg.title("Registrierung AzA Profil")
dlg.configure(bg="#E8F4FA")
dlg.resizable(True, True)
dlg.geometry("420x640")
dlg.minsize(380, 520)
add_resize_grip(dlg, 380, 520)
self._register_window(dlg)
dlg.attributes("-topmost", True)
dlg.grab_set()
center_window(dlg, 420, 640)
tk.Label(dlg, text="👤 Willkommen bei AzA", font=("Segoe UI", 16, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=12)
tk.Label(dlg, text="Bitte erfassen Sie Ihr Profil und legen Sie ein Passwort fest:",
font=("Segoe UI", 9), bg="#E8F4FA", fg="#4a8aaa").pack(fill="x", padx=16, pady=(8, 4))
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
form.pack(fill="x")
tk.Label(form, text="Name / Titel:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
name_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0)
name_entry.pack(fill="x", ipady=4, pady=(0, 6))
name_entry.insert(0, self._user_profile.get("name", ""))
tk.Label(form, text="Fachrichtung:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
spec_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0)
spec_entry.pack(fill="x", ipady=4, pady=(0, 6))
spec_entry.insert(0, self._user_profile.get("specialty", ""))
tk.Label(form, text="Praxis / Klinik:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
clinic_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0)
clinic_entry.pack(fill="x", ipady=4, pady=(0, 6))
clinic_entry.insert(0, self._user_profile.get("clinic", ""))
tk.Label(form, text="Code (ZSR/GLN, optional):", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
code_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0)
code_entry.pack(fill="x", ipady=4, pady=(0, 6))
code_entry.insert(0, self._user_profile.get("code", ""))
sep = tk.Frame(form, bg="#B9ECFA", height=1)
sep.pack(fill="x", pady=(6, 6))
tk.Label(form, text="🔑 Passwort festlegen:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
pw_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0, show="")
pw_entry.pack(fill="x", ipady=4, pady=(0, 6))
tk.Label(form, text="Passwort bestätigen:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
pw_confirm_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0, show="")
pw_confirm_entry.pack(fill="x", ipady=4, pady=(0, 6))
def do_save():
name = name_entry.get().strip()
if not name:
messagebox.showwarning("Pflichtfeld", "Bitte geben Sie Ihren Namen ein.", parent=dlg)
return
pw = pw_entry.get()
pw_confirm = pw_confirm_entry.get()
if not pw:
messagebox.showwarning("Pflichtfeld", "Bitte legen Sie ein Passwort fest.", parent=dlg)
return
if len(pw) < 4:
messagebox.showwarning("Passwort zu kurz", "Das Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
return
if pw != pw_confirm:
messagebox.showerror("Fehler", "Die Passwörter stimmen nicht überein.", parent=dlg)
pw_confirm_entry.delete(0, "end")
pw_confirm_entry.focus_set()
return
self._user_profile = {
"name": name,
"specialty": spec_entry.get().strip(),
"clinic": clinic_entry.get().strip(),
"code": code_entry.get().strip(),
"password_hash": self._hash_password(pw),
}
self._record_login()
dlg.destroy()
tk.Button(dlg, text="💾 Registrieren & Starten", font=("Segoe UI", 11, "bold"),
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
command=do_save).pack(pady=12)
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
name_entry.focus_set()
self.wait_window(dlg)
def _show_activation_dialog(self):
"""Dialog zum Eingeben/Aktualisieren des Aktivierungsschlüssels."""
from aza_activation import load_activation_key
current_key = load_activation_key() or ""
allowed, status_msg = check_app_access()
dlg = tk.Toplevel(self)
dlg.title("AZA Aktivierung")
dlg.configure(bg="#E8F4FA")
dlg.resizable(False, False)
dlg.geometry("460x300")
dlg.attributes("-topmost", True)
self._register_window(dlg)
center_window(dlg, 460, 300)
tk.Label(dlg, text="AZA Aktivierung", font=("Segoe UI", 14, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=10)
tk.Label(dlg, text=f"Status: {status_msg}", font=("Segoe UI", 9),
bg="#E8F4FA", fg="#2a7a3a" if allowed else "#c04040",
wraplength=420, justify="left").pack(fill="x", padx=16, pady=(8, 4))
form = tk.Frame(dlg, bg="#E8F4FA", padx=16, pady=4)
form.pack(fill="x")
tk.Label(form, text="Aktivierungsschlüssel:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w")
key_entry = tk.Entry(form, font=("Consolas", 12), bg="white", fg="#1a4d6d",
relief="flat", bd=0)
key_entry.pack(fill="x", ipady=4, pady=(0, 4))
if current_key:
key_entry.insert(0, current_key)
result_label = tk.Label(form, text="", font=("Segoe UI", 9),
bg="#E8F4FA", fg="#888888")
result_label.pack(fill="x")
def do_save():
k = key_entry.get().strip()
if not k:
result_label.configure(text="Bitte Schlüssel eingeben.", fg="#E05050")
return
valid, expiry, reason = validate_key(k)
if valid:
save_activation_key(k)
result_label.configure(text=f"Gespeichert: {reason}", fg="#2a7a3a")
self.set_status(f"Aktivierung: {reason}")
else:
result_label.configure(text=reason, fg="#E05050")
btn_frame = tk.Frame(dlg, bg="#E8F4FA")
btn_frame.pack(pady=8)
tk.Button(btn_frame, text="Speichern", font=("Segoe UI", 10, "bold"),
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
relief="flat", bd=0, padx=16, pady=4, cursor="hand2",
command=do_save).pack(side="left", padx=4)
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 10),
bg="#cccccc", fg="#333333", relief="flat", bd=0,
padx=16, pady=4, cursor="hand2",
command=dlg.destroy).pack(side="left", padx=4)
def _show_profile_editor(self):
"""Öffnet ein Fenster zum Bearbeiten des Benutzerprofils (inkl. Passwort ändern)."""
dlg = tk.Toplevel(self)
dlg.title("Profil bearbeiten")
dlg.configure(bg="#E8F4FA")
dlg.resizable(False, False)
dlg.geometry("380x440")
dlg.attributes("-topmost", True)
self._register_window(dlg)
center_window(dlg, 380, 440)
tk.Label(dlg, text="👤 Profil bearbeiten", font=("Segoe UI", 13, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=8)
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
form.pack(fill="x")
tk.Label(form, text="Name / Titel:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(8, 0))
name_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
name_e.pack(fill="x", ipady=4, pady=(0, 6))
name_e.insert(0, self._user_profile.get("name", ""))
tk.Label(form, text="Fachrichtung:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
spec_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
spec_e.pack(fill="x", ipady=4, pady=(0, 6))
spec_e.insert(0, self._user_profile.get("specialty", ""))
tk.Label(form, text="Praxis / Klinik:", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
clinic_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
clinic_e.pack(fill="x", ipady=4, pady=(0, 6))
clinic_e.insert(0, self._user_profile.get("clinic", ""))
tk.Label(form, text="Code (ZSR/GLN, optional):", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
code_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d", relief="flat")
code_e.pack(fill="x", ipady=4, pady=(0, 6))
code_e.insert(0, self._user_profile.get("code", ""))
sep = tk.Frame(form, bg="#B9ECFA", height=1)
sep.pack(fill="x", pady=(8, 6))
tk.Label(form, text="🔑 Passwort ändern (optional):", font=("Segoe UI", 10, "bold"),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
tk.Label(form, text="Leer lassen, um das Passwort beizubehalten.",
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w")
pw_old_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0, show="")
pw_new_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0, show="")
pw_confirm_e = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
relief="flat", bd=0, show="")
if self._user_profile.get("password_hash"):
tk.Label(form, text="Altes Passwort:", font=("Segoe UI", 9),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
pw_old_e.pack(fill="x", ipady=3, pady=(0, 4))
tk.Label(form, text="Neues Passwort:", font=("Segoe UI", 9),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
pw_new_e.pack(fill="x", ipady=3, pady=(0, 4))
tk.Label(form, text="Neues Passwort bestätigen:", font=("Segoe UI", 9),
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
pw_confirm_e.pack(fill="x", ipady=3, pady=(0, 4))
def do_save():
name = name_e.get().strip()
if not name:
messagebox.showwarning("Pflichtfeld", "Name darf nicht leer sein.", parent=dlg)
return
new_pw = pw_new_e.get()
new_pw_confirm = pw_confirm_e.get()
old_hash = self._user_profile.get("password_hash", "")
if new_pw:
if old_hash and not self._verify_password(pw_old_e.get(), old_hash):
messagebox.showerror("Fehler", "Das alte Passwort ist nicht korrekt.", parent=dlg)
return
if len(new_pw) < 4:
messagebox.showwarning("Zu kurz", "Das neue Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
return
if new_pw != new_pw_confirm:
messagebox.showerror("Fehler", "Die neuen Passwörter stimmen nicht überein.", parent=dlg)
return
pw_hash = self._hash_password(new_pw)
else:
pw_hash = old_hash
updated = {
"name": name,
"specialty": spec_e.get().strip(),
"clinic": clinic_e.get().strip(),
"code": code_e.get().strip(),
"password_hash": pw_hash,
}
for k in ("totp_secret_enc", "totp_active", "backup_codes"):
if k in self._user_profile:
updated[k] = self._user_profile[k]
self._user_profile = updated
save_user_profile(self._user_profile)
self.set_status(f"Profil gespeichert: {name}")
dlg.destroy()
btn_row = tk.Frame(dlg, bg="#E8F4FA")
btn_row.pack(pady=10)
tk.Button(btn_row, text="💾 Speichern", font=("Segoe UI", 10, "bold"),
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
relief="flat", padx=16, pady=4, cursor="hand2",
command=do_save).pack(side="left", padx=6)
tk.Button(btn_row, text="Abbrechen", font=("Segoe UI", 10),
bg="#C8DDE6", fg="#1a4d6d", activebackground="#B8CDD6",
relief="flat", padx=12, pady=4, cursor="hand2",
command=dlg.destroy).pack(side="left", padx=6)
if is_2fa_enabled():
sep2 = tk.Frame(dlg, bg="#B9ECFA", height=1)
sep2.pack(fill="x", padx=20, pady=(4, 4))
tfa_frame = tk.Frame(dlg, bg="#E8F4FA", padx=20)
tfa_frame.pack(fill="x")
is_active = self._user_profile.get("totp_active", False)
status_text = "2FA aktiv" if is_active else "2FA nicht aktiv"
status_color = "#27AE60" if is_active else "#E74C3C"
tk.Label(tfa_frame, text=f"🔐 {status_text}",
font=("Segoe UI", 10, "bold"), bg="#E8F4FA",
fg=status_color).pack(side="left")
def do_toggle_2fa():
if is_active:
if messagebox.askyesno("2FA deaktivieren",
"Zwei-Faktor-Authentifizierung wirklich deaktivieren?\n\n"
"Dies verringert die Sicherheit Ihres Kontos.",
parent=dlg):
self._user_profile.pop("totp_secret_enc", None)
self._user_profile.pop("backup_codes", None)
self._user_profile["totp_active"] = False
save_user_profile(self._user_profile)
dlg.destroy()
self._show_profile_editor()
else:
pw_check = simpledialog.askstring("Passwort",
"Bitte Passwort eingeben:", show="*", parent=dlg)
if pw_check and self._verify_password(pw_check,
self._user_profile.get("password_hash", "")):
dlg.destroy()
self._show_2fa_setup(pw_check)
elif pw_check:
messagebox.showerror("Fehler",
"Falsches Passwort.", parent=dlg)
btn_text = "Deaktivieren" if is_active else "2FA einrichten"
btn_color = "#E74C3C" if is_active else "#27AE60"
tk.Button(tfa_frame, text=btn_text, font=("Segoe UI", 9),
bg=btn_color, fg="white", relief="flat",
padx=8, pady=2, cursor="hand2",
command=do_toggle_2fa).pack(side="right")
def _reset_window_positions(self):
"""Setzt alle gespeicherten Fensterpositionen und KG-Einstellungen zurück."""
answer = messagebox.askyesno(
"Fensterpositionen zurücksetzen",
"Alle Fensterpositionen und KG-Einstellungen zurücksetzen?\n\n"
"Beim nächsten Start werden alle Fenster\n"
"in der Bildschirmmitte geöffnet.\n"
"Die KG-Detailstufe (Kürzer/Ausführlicher)\n"
"wird auf Standard zurückgesetzt.",
parent=self,
)
if not answer:
return
deleted = reset_all_window_positions()
self._update_kg_detail_display()
self._soap_section_levels = {k: 0 for k in _SOAP_SECTIONS}
self._update_soap_section_display()
try:
reset_button_heat()
except Exception:
pass
messagebox.showinfo(
"Zurückgesetzt",
f"{deleted} Fensterposition(en) zurückgesetzt.\n"
"KG-Detailstufe und Button-Farben auf Standard zurückgesetzt.\n\n"
"Bitte starten Sie die Anwendung neu,\n"
"damit die Änderung wirksam wird.",
parent=self,
)
def _toggle_minimize(self):
"""Fenster minimieren: Neu, Brief, OP-Bericht, Diktat oben; Aufnahme unten; Transparenz unten."""
if self._minimized:
self._status_row.pack(fill="x")
self.paned.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self._bottom_frame.pack(fill="x")
self._top_right.pack(side="right", anchor="n")
self._btn_row_left.pack(side="left")
if getattr(self, "_mini_frame", None) is not None:
try:
self._mini_frame.destroy()
except Exception:
pass
self._mini_frame = None
self._mini_btn_korrigieren = None
self._btn_minimize.configure(text="")
self._minimized = False
self.minsize(680, 560)
try:
g = getattr(self, "_geometry_before_minimize", None)
if g and len(g) >= 4:
self.geometry(f"{g[0]}x{g[1]}+{g[2]}+{g[3]}")
except Exception:
pass
else:
self._geometry_before_minimize = (
self.winfo_width(), self.winfo_height(), self.winfo_x(), self.winfo_y()
)
self.paned.pack_forget()
self._bottom_frame.pack_forget()
self._top_right.pack_forget()
self._btn_row_left.pack_forget()
self._btn_minimize.configure(text="")
self._minimized = True
self.minsize(360, 120)
top = self._btn_row_left.master
self._mini_frame = ttk.Frame(top, style="TopBar.TFrame", padding=(0, 0, 0, 4))
self._mini_frame.pack(fill="x")
top_row = ttk.Frame(self._mini_frame)
top_row.pack(fill="x")
# Buttons mit aktueller Skalierung
button_scale = load_button_scale()
font_scale = load_font_scale()
self._mini_btn_start = RoundedButton(
top_row, "⏺ Start", command=self.toggle_record,
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
width=80, height=26, canvas_bg="#B9ECFA",
)
self._mini_btn_start.set_button_size_scale(button_scale)
self._mini_btn_start.set_font_size_scale(font_scale)
self._mini_btn_start.pack(side="left", padx=(0, 4), anchor="n")
if self.is_recording and getattr(self, "_recording_mode", "") == "new":
self._mini_btn_start.configure(text="⏹ Stopp")
self._mini_btn_korrigieren = RoundedButton(
top_row, "⏺ Korrig.", command=self._toggle_record_append,
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
width=65, height=26, canvas_bg="#B9ECFA",
)
self._mini_btn_korrigieren.set_button_size_scale(button_scale)
self._mini_btn_korrigieren.set_font_size_scale(font_scale)
self._mini_btn_korrigieren.pack(side="left", padx=(0, 4), anchor="n")
if self.is_recording and getattr(self, "_recording_mode", "") == "append":
self._mini_btn_korrigieren.configure(text="⏹ Stopp")
btn_diktat = RoundedButton(top_row, "Diktat", command=self.open_diktat_window, width=60, height=26, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0")
btn_diktat.set_button_size_scale(button_scale)
btn_diktat.set_font_size_scale(font_scale)
btn_diktat.pack(side="left", padx=(0, 4), anchor="n")
btn_brief = RoundedButton(top_row, "Brief", command=self.open_brief_window, width=60, height=26, canvas_bg="#7EC8E3", bg="#7EC8E3", fg="#1a4d6d", active_bg="#6CB8D3")
btn_brief.set_button_size_scale(button_scale)
btn_brief.set_font_size_scale(font_scale)
btn_brief.pack(side="left", padx=(0, 4), anchor="n")
btn_op = RoundedButton(top_row, "OP-Bericht", command=self.open_op_bericht_window, width=90, height=26, canvas_bg="#A6E0F5", bg="#A6E0F5", fg="#1a4d6d", active_bg="#94D0E5")
btn_op.set_button_size_scale(button_scale)
btn_op.set_font_size_scale(font_scale)
btn_op.pack(side="left", padx=(0, 4), anchor="n")
btn_min = RoundedButton(top_row, "", command=self._toggle_minimize, width=28, height=26, canvas_bg="#B9ECFA")
btn_min.set_button_size_scale(button_scale)
btn_min.set_font_size_scale(font_scale)
btn_min.pack(side="left", padx=(4, 0), anchor="n")
btn_tile = RoundedButton(top_row, "", command=self.arrange_windows_top, width=28, height=26, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8")
btn_tile.set_button_size_scale(button_scale)
btn_tile.set_font_size_scale(font_scale)
btn_tile.pack(side="left", padx=(3, 0), anchor="n")
btn_reset_pos = RoundedButton(top_row, "", command=self._reset_window_positions, width=28, height=26, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8")
btn_reset_pos.set_button_size_scale(button_scale)
btn_reset_pos.set_font_size_scale(font_scale)
btn_reset_pos.pack(side="left", padx=(4, 0), anchor="n")
opacity_var = tk.DoubleVar(value=round(load_opacity() * 100))
def on_opacity_change(val):
try:
alpha = float(val) / 100.0
alpha = max(MIN_OPACITY, min(1.0, alpha))
self.attributes("-alpha", alpha)
save_opacity(alpha)
except Exception:
pass
mini_rclick = tk.Checkbutton(
self._mini_frame, text="Rechtsklick = Einfügen",
variable=self._rclick_paste_var,
command=self._toggle_rclick_paste,
bg="#B9ECFA", activebackground="#B9ECFA",
font=("Segoe UI", 8), anchor="w",
)
mini_rclick.pack(fill="x", padx=(4, 0))
self._mini_status_label = tk.Label(
self._mini_frame, textvariable=self.status_var,
fg=self._autotext_data.get("status_color", "#BD4500"),
bg="#B9ECFA", font=("Segoe UI", 8), anchor="w",
)
if self._autotext_data.get("status_color") != "hidden":
self._mini_status_label.pack(fill="x", padx=(4, 0))
opacity_row = ttk.Frame(self._mini_frame, padding=(0, 4, 0, 0))
opacity_row.pack(fill="x")
opacity_inner = ttk.Frame(opacity_row)
opacity_inner.pack(side="left")
lbl_half = tk.Label(opacity_inner, text="", font=("Segoe UI Symbol", 14),
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
lbl_half.pack(side="left", padx=(0, 1))
lbl_half.bind("<Button-1>", lambda e: (opacity_var.set(round(MIN_OPACITY * 100)),
on_opacity_change(str(MIN_OPACITY * 100))))
lbl_half.bind("<Enter>", lambda e: lbl_half.configure(fg="#1a4d6d"))
lbl_half.bind("<Leave>", lambda e: lbl_half.configure(fg="#7AAFC8"))
try:
s = ttk.Style(self)
s.configure("MiniOpacity.Horizontal.TScale", troughcolor="#c8ecf8", background="#5B8DB3")
except Exception:
pass
opacity_scale = ttk.Scale(
opacity_inner,
from_=40, to=100, variable=opacity_var,
orient="horizontal", length=50, command=on_opacity_change,
style="MiniOpacity.Horizontal.TScale",
)
opacity_scale.pack(side="left")
lbl_sun = tk.Label(opacity_inner, text="", font=("Segoe UI Symbol", 14),
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
lbl_sun.pack(side="left", padx=(1, 0))
lbl_sun.bind("<Button-1>", lambda e: (opacity_var.set(100),
on_opacity_change("100")))
lbl_sun.bind("<Enter>", lambda e: lbl_sun.configure(fg="#1a4d6d"))
lbl_sun.bind("<Leave>", lambda e: lbl_sun.configure(fg="#7AAFC8"))
# Fenstergröe dynamisch basierend auf Button-Skalierung
base_width = 580
base_height = 150
scaled_width = int(base_width * button_scale)
scaled_height = int(base_height * button_scale)
self.geometry(f"{scaled_width}x{scaled_height}")
def _register_window(self, win):
"""Registriert ein AZA-Fenster im zentralen Tracker und entfernt es beim Schliessen."""
try:
if win and win.winfo_exists():
self._window_registry.add(win)
orig_protocol = None
try:
orig_protocol = win.protocol("WM_DELETE_WINDOW")
except Exception:
pass
def _on_close():
try:
self._window_registry.discard(win)
except Exception:
pass
if orig_protocol and callable(orig_protocol):
try:
orig_protocol()
return
except Exception:
pass
try:
win.destroy()
except Exception:
pass
win.protocol("WM_DELETE_WINDOW", _on_close)
except Exception:
pass
def _get_registered_windows(self):
"""Liefert alle noch lebenden registrierten Fenster (ohne self)."""
out = []
for w in list(self._window_registry):
try:
if w and w.winfo_exists() and w is not self:
out.append(w)
elif w is self:
continue
else:
self._window_registry.discard(w)
except Exception:
self._window_registry.discard(w)
return out
def _get_work_area_for_window(self, win):
"""Returns (left, top, right, bottom) of the monitor work-area containing *win*."""
try:
user32 = ctypes.windll.user32
MONITOR_DEFAULTTONEAREST = 2
class RECT(ctypes.Structure):
_fields_ = [("left", wintypes.LONG),
("top", wintypes.LONG),
("right", wintypes.LONG),
("bottom", wintypes.LONG)]
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD),
("rcMonitor", RECT),
("rcWork", RECT),
("dwFlags", wintypes.DWORD)]
hwnd = win.winfo_id()
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
r = mi.rcWork
return (r.left, r.top, r.right, r.bottom)
except Exception:
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
return (0, 0, sw, sh)
def _get_all_toplevels(self):
"""Alle sichtbaren Toplevel-Fenster sammeln (rekursiv)."""
result = []
def _collect(parent):
for w in parent.winfo_children():
if isinstance(w, tk.Toplevel) and w.winfo_exists() and w.winfo_viewable():
result.append(w)
_collect(w)
_collect(self)
return result
def arrange_windows_top(self):
"""
Minimiert ALLE AZA-Fenster und dockt sie nebeneinander oben an.
"""
try:
print("=== ARRANGE WINDOWS START ===")
all_windows = [self] + self._get_registered_windows()
if not all_windows:
print("Keine Fenster gefunden")
return
self._tiling_active = True
print(f"Gefundene Fenster: {len(all_windows)}")
# SCHRITT 1: Minimiere alle Fenster
for w in all_windows:
try:
if not (w and w.winfo_exists()):
continue
minimize_fn = getattr(w, "_aza_minimize", None)
is_mini_fn = getattr(w, "_aza_is_minimized", None)
already_mini = False
if is_mini_fn and callable(is_mini_fn):
try:
already_mini = is_mini_fn()
except Exception:
already_mini = False
if minimize_fn and callable(minimize_fn) and not already_mini:
print(f"Minimiere Fenster: {w.title()}")
minimize_fn()
except Exception as e:
print(f"Fehler beim Minimieren: {e}")
# SCHRITT 2: Warte LAENGER, damit Tk alle Minimize-Operationen abgeschlossen hat
print("Warte auf Minimize-Abschluss (1500ms)...")
self.after(1500, lambda: self._perform_docking_logic_horizontal(all_windows))
except Exception as e:
print(f"arrange_windows_top error: {e}")
import traceback
traceback.print_exc()
self._tiling_active = False
def _perform_docking_logic_horizontal(self, windows):
"""
Positions windows horizontally in a row at the top.
"""
try:
print("=== DOCKING LOGIC START ===")
for w in windows:
try:
if w and w.winfo_exists():
w.update()
w.update_idletasks()
except Exception:
pass
L, T, R, _B = self._get_work_area_for_window(self)
work_w = max(1, R - L)
print(f"Work area: L={L}, T={T}, R={R}, work_w={work_w}")
gap = 12
pad = 15
y_top = T + pad
alive = [w for w in windows if w and w.winfo_exists()]
print(f"Alive windows: {len(alive)}")
if not alive:
self._tiling_active = False
return
window_info = []
for i, w in enumerate(alive):
try:
ww = w.winfo_width()
hh = w.winfo_height()
if ww < 80:
ww = 180
if hh < 80:
hh = 80
print(f"Window {i}: width={ww}, height={hh}")
window_info.append({'win': w, 'width': ww, 'height': hh})
except Exception as e:
print(f"Error getting window dims: {e}")
window_info.append({'win': w, 'width': 180, 'height': 80})
total_width = sum(info['width'] for info in window_info) + max(0, (len(alive) - 1) * gap)
print(f"Total width needed: {total_width}, available: {work_w - 2*pad}")
if total_width <= work_w - 2 * pad:
x_start = L + (work_w - total_width) // 2
print(f"All windows fit, centering at x={x_start}")
else:
x_start = L + pad
print(f"Windows don't fit, aligning left at x={x_start}")
x_pos = x_start
for i, info in enumerate(window_info):
w = info['win']
ww = info['width']
hh = info['height']
try:
x_clamped = x_pos
if x_clamped + ww > R - pad:
x_clamped = max(L + pad, R - ww - pad)
geom_str = f"{ww}x{hh}+{x_clamped}+{y_top}"
print(f"Setting window {i} geometry: {geom_str}")
w.geometry(geom_str)
w.update()
w.update_idletasks()
w.lift()
w.deiconify()
x_pos += ww + gap
except Exception as e:
print(f"Error placing window {i}: {e}")
print("=== DOCKING LOGIC END ===")
print(f"_tiling_active: {self._tiling_active}")
except Exception as e:
print(f"_perform_docking_logic_horizontal error: {e}")
import traceback
traceback.print_exc()
finally:
self.after(500, self._clear_tiling_flag)
def _clear_tiling_flag(self):
"""Clears the tiling flag and triggers final geometry save."""
self._tiling_active = False
print("_tiling_active: False (flag cleared)")
self.after(50, self._save_window_geometry)
def set_status(self, s: str):
self.status_var.set(s)
self.update_idletasks()
def _toggle_transcript_collapse(self, event=None):
self._transcript_collapsed = not self._transcript_collapsed
if self._transcript_collapsed:
self._transcript_frame.pack_forget()
self._transcript_toggle_label.configure(text="\u25B6 Transkript:")
else:
self._transcript_frame.pack(fill="both", expand=True)
self._transcript_toggle_label.configure(text="\u25BC Transkript:")
def _toggle_kg_collapse(self, event=None):
self._kg_collapsed = not self._kg_collapsed
if self._kg_collapsed:
self._kg_frame.pack_forget()
self._kg_toggle_label.configure(text="\u25B6 Krankengeschichte:")
else:
self._kg_frame.pack(fill="both", expand=True)
self._kg_toggle_label.configure(text="\u25BC Krankengeschichte:")
def _on_logo_click(self, event=None):
"""Logo-Klick: Aufnahme starten oder stoppen."""
self.toggle_record()
def _apply_status_color(self):
"""Wendet die gespeicherte Statusanzeige-Farbe an."""
color = self._autotext_data.get("status_color", "#BD4500")
if color == "hidden":
self._status_row.pack_forget()
else:
try:
self._status_row.pack(fill="x")
# Vor paned einordnen
self._status_row.pack(fill="x", before=self.paned)
except Exception:
self._status_row.pack(fill="x")
self.lbl_status.configure(fg=color)
def _start_timer(self, phase: str):
self._phase = phase
self._timer_sec = 0
self._timer_running = True
self._tick_timer()
def _tick_timer(self):
if not self._timer_running:
return
self._timer_sec += 1
if self._phase == "transcribe":
self.set_status("Transkribiere Audio (%d s)" % self._timer_sec)
elif self._phase == "kg":
self.set_status("Erstelle Krankengeschichte (%d s)" % self._timer_sec)
self.after(1000, self._tick_timer)
def _show_interaktion_window(self, meds: list, result: str) -> None:
"""Zeigt das Interaktionscheck-Ergebnis in einem Fenster."""
win = tk.Toplevel(self)
win.title("Interaktionscheck")
win.transient(self)
win.configure(bg="#B9ECFA")
win.minsize(500, 400)
win.attributes("-topmost", True)
self._register_window(win)
# Fensterposition: gespeichert laden oder zentrieren
setup_window_geometry_saving(win, "interaktionscheck", 700, 550)
add_resize_grip(win, 500, 400)
add_font_scale_control(win)
f = ttk.Frame(win, padding=12)
f.pack(fill="both", expand=True)
med_header = ttk.Frame(f)
med_header.pack(fill="x", anchor="w")
ttk.Label(med_header, text=f"Geprüfte Medikamente/Therapien: {', '.join(meds)}").pack(side="left")
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=18)
txt.pack(fill="both", expand=True, pady=(8, 8))
add_text_font_size_control(med_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="medikamenten_check")
txt.insert("1.0", result.strip())
self._bind_text_context_menu(txt)
# _show_text_window -> ausgelagert in Mixin-Modul
# _request_async_document -> ausgelagert in Mixin-Modul
# open_brief_window -> ausgelagert in Mixin-Modul
# open_rezept_window -> ausgelagert in Mixin-Modul
# open_kogu_window -> ausgelagert in Mixin-Modul
# open_diskussion_window -> ausgelagert in Mixin-Modul
# open_op_bericht_window -> ausgelagert in Mixin-Modul
def _open_uebersetzer(self):
"""Startet das Übersetzer-Programm (translate.py) als eigenständiges Fenster."""
if not self._check_ai_consent():
return
try:
if getattr(sys, "frozen", False):
import translate
threading.Thread(target=translate.main, daemon=True).start()
else:
import subprocess
script_dir = os.path.dirname(os.path.abspath(__file__))
translate_path = os.path.join(script_dir, "translate.py")
if not os.path.exists(translate_path):
messagebox.showerror("Fehler", f"translate.py nicht gefunden:\n{translate_path}")
return
kwargs = {"cwd": script_dir}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen([sys.executable, translate_path], **kwargs)
except Exception as e:
messagebox.showerror("Fehler", str(e))
def _open_email(self):
"""Startet das E-Mail-Programm (aza_email.py) als eigenständiges Fenster."""
try:
import subprocess
script_dir = os.path.dirname(os.path.abspath(__file__))
email_path = os.path.join(script_dir, "aza_email.py")
if not os.path.exists(email_path):
messagebox.showerror("Fehler", f"aza_email.py nicht gefunden:\n{email_path}")
return
# Einfacher Start ohne komplizierte Flags (funktioniert besser)
if sys.platform == "win32":
# Windows: Einfach mit pythonw.exe starten (kein Konsolenfenster)
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
if os.path.exists(pythonw):
subprocess.Popen([pythonw, email_path], cwd=script_dir)
else:
subprocess.Popen([sys.executable, email_path], cwd=script_dir,
creationflags=subprocess.CREATE_NO_WINDOW)
else:
subprocess.Popen([sys.executable, email_path], cwd=script_dir)
except Exception as e:
messagebox.showerror("Fehler", f"E-Mail konnte nicht gestartet werden:\n{str(e)}")
def _open_whatsapp(self):
"""Startet das WhatsApp-Programm (aza_whatsapp.py) als eigenständiges Fenster."""
try:
import subprocess
script_dir = os.path.dirname(os.path.abspath(__file__))
whatsapp_path = os.path.join(script_dir, "aza_whatsapp.py")
if not os.path.exists(whatsapp_path):
messagebox.showerror("Fehler", f"aza_whatsapp.py nicht gefunden:\n{whatsapp_path}")
return
# Einfacher Start ohne komplizierte Flags (funktioniert besser)
if sys.platform == "win32":
# Windows: Einfach mit pythonw.exe starten (kein Konsolenfenster)
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
if os.path.exists(pythonw):
subprocess.Popen([pythonw, whatsapp_path], cwd=script_dir)
else:
subprocess.Popen([sys.executable, whatsapp_path], cwd=script_dir,
creationflags=subprocess.CREATE_NO_WINDOW)
else:
subprocess.Popen([sys.executable, whatsapp_path], cwd=script_dir)
except Exception as e:
messagebox.showerror("Fehler", f"WhatsApp konnte nicht gestartet werden:\n{str(e)}")
def _open_docapp(self):
"""Startet das DocApp-Programm (aza_docapp.py) als eigenständiges Fenster."""
try:
if getattr(sys, "frozen", False):
import aza_docapp
threading.Thread(target=aza_docapp.main, daemon=True).start()
else:
import subprocess
script_dir = os.path.dirname(os.path.abspath(__file__))
docapp_path = os.path.join(script_dir, "aza_docapp.py")
if not os.path.exists(docapp_path):
messagebox.showerror("Fehler", f"aza_docapp.py nicht gefunden:\n{docapp_path}")
return
kwargs = {"cwd": script_dir}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen([sys.executable, docapp_path], **kwargs)
except Exception as e:
messagebox.showerror("Fehler", f"DocApp konnte nicht gestartet werden:\n{str(e)}")
def _open_macro(self):
"""Startet das Macro-Programm (aza_macro.py) als eigenständiges Fenster."""
self._launch_macro_process()
# ── Dev-Status-Fenster ──────────────────────────────────────────────
def _open_dev_status_window(self):
try:
existing = getattr(self, "_dev_status_window", None)
if existing is not None and existing.winfo_exists():
existing.deiconify()
existing.lift()
return
from dev_status_window import DevStatusWindow
self._dev_status_window = DevStatusWindow(self)
except Exception:
pass
# ── Kongress-Fenster ───────────────────────────────────────────────────
def _open_kongress_window(self):
from congress_window import CongressWindow
CongressWindow(self, self.client, self._autotext_data, save_autotext)
# ── Kongress 2 (Google CSE Trial) ─────────────────────────────────────
def _open_kongress2_window(self):
existing = getattr(self, "_kongress2_window", None)
if existing is not None and existing.winfo_exists():
existing.deiconify()
existing.lift()
existing.focus_force()
return
from kongress2_window import Kongress2Window
self._kongress2_window = Kongress2Window(self)
# ── News-Fenster ──────────────────────────────────────────────────────
def _open_news_window(self):
existing = getattr(self, "_news_window", None)
if existing is not None and existing.winfo_exists():
existing.deiconify()
existing.lift()
existing.focus_force()
return
win = tk.Toplevel(self)
self._news_window = win
win.title("Medizin-News")
win.configure(bg="#f7fafc")
win.minsize(480, 400)
add_resize_grip(win, 480, 400)
try:
sw = max(1200, int(self.winfo_screenwidth()))
sh = max(800, int(self.winfo_screenheight()))
w = max(500, int(sw * 0.30))
h = max(500, int(sh * 0.80))
x_off = 8 + w + 8
win.geometry(f"{w}x{h}+{x_off}+40")
except Exception:
pass
header = tk.Frame(win, bg="#e6f0e8", padx=10, pady=6)
header.pack(fill="x")
tk.Label(header, text="Medizin-News", bg="#e6f0e8", fg="#2a5a3a",
font=("Segoe UI", 10, "bold")).pack(side="left")
status_var = tk.StringVar(value="")
btn_bar = tk.Frame(header, bg="#e6f0e8")
btn_bar.pack(side="right")
ttk.Button(btn_bar, text="", width=3, command=lambda: _search()).pack(side="right", padx=2)
text_frame = tk.Frame(win, bg="#ffffff", bd=0)
text_frame.pack(fill="both", expand=True, padx=6, pady=(2, 4))
text_widget = tk.Text(text_frame, wrap="word", font=("Segoe UI", 9), bg="#ffffff",
fg="#23404f", relief="flat", padx=10, pady=8, cursor="arrow",
spacing1=1, spacing3=1)
scrollbar = ttk.Scrollbar(text_frame, orient="vertical", command=text_widget.yview)
text_widget.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
text_widget.pack(side="left", fill="both", expand=True)
text_widget.configure(state="disabled")
text_widget.tag_configure("nw_heading", font=("Segoe UI", 10, "bold"), foreground="#1a5a2a",
spacing1=8, spacing3=3, background="#eaf5ed")
text_widget.tag_configure("nw_bold", font=("Segoe UI", 9, "bold"), foreground="#0e3520")
text_widget.tag_configure("nw_normal", font=("Segoe UI", 9), foreground="#2b5040")
text_widget.tag_configure("nw_loading", font=("Segoe UI", 9), foreground="#6aab80")
status_bar = tk.Label(win, textvariable=status_var, bg="#e6f0e8", fg="#4a8a5c",
font=("Segoe UI", 8), anchor="w", padx=8)
status_bar.pack(fill="x", side="bottom")
nw_link_cnt = [0]
def _open_url(url):
try:
webbrowser.open(url)
except Exception:
pass
def _clean_md(line):
line = re.sub(r'\[([^\]]*)\]\(([^\)]*)\)', r'\1 \2', line)
return line
def _render_line(raw):
line = str(raw)
if not line.strip():
return
line = re.sub(r'\[([^\]]*)\]\(([^\)]*)\)', r'\1 \2', line)
cursor = 0
for m in re.finditer(r'https?://[^\s\)\]>,;]+', line):
url = m.group(0).rstrip('.,;)>')
if m.start() > cursor:
text_widget.insert("end", line[cursor:m.start()], "nw_normal")
nw_link_cnt[0] += 1
ltag = f"nwl_{nw_link_cnt[0]}"
text_widget.tag_configure(ltag, font=("Segoe UI", 8, "underline"),
foreground="#2a8a4a")
text_widget.tag_bind(ltag, "<Button-1>", lambda e, u=url: _open_url(u))
text_widget.tag_bind(ltag, "<Enter>",
lambda e: text_widget.configure(cursor="hand2"))
text_widget.tag_bind(ltag, "<Leave>",
lambda e: text_widget.configure(cursor="arrow"))
text_widget.insert("end", url, ltag)
cursor = m.end()
if cursor < len(line):
text_widget.insert("end", line[cursor:], "nw_normal")
def _search():
status_var.set("Suche aktuelle Medizin-News …")
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
text_widget.insert("end", "Suche die neuesten Medizin-News …\n", "nw_loading")
text_widget.insert("end", "Das kann 1530 Sekunden Jetzt ist es total kaputt. Jetzt werden nicht mehr die wichtigsten Organisationen, oben dargestellt pro Fachrichtung, welche man angekreuzt hatte, sondern alle Fachrichtungen. Oder ich habe nicht geschaut, ob das Häkchen plötzlich rausgefallen ist. Aber wenn kein Häkchen gesetzt ist, dann soll man den Benutzer fragen, welche Fachrichtungen er gerne hätte, damit man nicht alles aufzählen muss und viel Tokens verliert. Und bitte dieses Fenster, welches die Fachrichtungen abfragt, wenn kein Häkchen gesetzt wurde, bitte jeweils in der Mitte vom Bildschirm präsentieren..\n", "nw_loading")
text_widget.configure(state="disabled")
def _job():
try:
if not self.client:
raise RuntimeError("KI-Verbindung nicht eingerichtet. Bitte über das Startmenü einrichten.")
today = date.today().isoformat()
prompt = (
f"Suche im Internet nach den wichtigsten aktuellen Medizin-News "
f"weltweit von heute ({today}) und den letzten Tagen.\n\n"
f"Berücksichtige die renommiertesten Quellen weltweit:\n"
f"- NEJM (New England Journal of Medicine)\n"
f"- The Lancet\n"
f"- JAMA (Journal of the American Medical Association)\n"
f"- BMJ (British Medical Journal)\n"
f"- Nature Medicine, Nature, Science\n"
f"- Deutsches Ärzteblatt\n"
f"- Swiss Medical Weekly\n"
f"- Medscape, Reuters Health\n"
f"- WHO, FDA, EMA, Swissmedic\n"
f"- CDC, ECDC\n"
f"- PubMed/PMC (aktuelle Studien)\n\n"
f"Für jede News formatiere so:\n"
f"**Titel der News hier**\n"
f"Quelle · Datum\n"
f"Zusammenfassung in 23 Sätzen mit den wichtigsten Erkenntnissen.\n"
f"URL zum Originalartikel\n\n"
f"Zeige 2030 der wichtigsten News weltweit, neueste zuerst.\n"
f"Decke verschiedene Bereiche ab: Onkologie, Kardiologie, "
f"Infektiologie, Neurologie, Pharmakologie, Public Health etc.\n"
f"Verwende KEIN Markdown-Link-Format [text](url), "
f"schreibe die URL einfach direkt hin."
)
model = os.getenv("NEWS_SEARCH_MODEL", "gpt-4o-mini-search-preview").strip()
resp = self.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": (
"Du bist ein weltweit führender medizinischer News-Kurator. "
"Suche die aktuellsten, wichtigsten medizinischen Nachrichten "
"aus den renommiertesten Quellen weltweit. "
"Nur den Titel **fett** schreiben. "
"Quelle, Datum, Zusammenfassung und URL NICHT fett. "
"URLs direkt ausschreiben, KEINE Markdown-Links [text](url)."
)},
{"role": "user", "content": prompt},
],
)
result = (resp.choices[0].message.content or "").strip()
self.after(0, lambda: _display(result))
except Exception as exc:
self.after(0, lambda e=str(exc): _display_error(e))
threading.Thread(target=_job, daemon=True).start()
def _display(raw_text):
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
nw_link_cnt[0] = 0
for line in raw_text.split("\n"):
stripped = line.strip()
if not stripped:
text_widget.insert("end", "\n")
continue
if re.match(r'^#{1,4}\s', stripped):
clean = stripped.lstrip("#").strip()
text_widget.insert("end", f" {clean}\n", "nw_heading")
else:
_render_line(stripped)
text_widget.insert("end", "\n")
text_widget.configure(state="disabled")
n = sum(1 for l in raw_text.split("\n") if l.strip())
status_var.set(f"Fertig · {n} Zeilen")
def _display_error(msg):
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
if "insufficient_quota" in msg or "429" in msg:
text_widget.insert("end", "OpenAI-Guthaben aufgebraucht.\n\n", "nw_bold")
text_widget.insert("end", "Bitte Guthaben aufladen:\n", "nw_normal")
text_widget.tag_configure("url_billing", font=("Segoe UI", 9, "underline"), foreground="#2a8a4a")
text_widget.tag_bind("url_billing", "<Button-1>",
lambda e: _open_url("https://platform.openai.com/account/billing"))
text_widget.insert("end", "platform.openai.com/account/billing\n", "url_billing")
elif "OPENAI_API_KEY" in msg:
text_widget.insert("end", "KI-Verbindung nicht eingerichtet.\n", "nw_bold")
text_widget.insert("end", "Bitte über das Startmenü einrichten.", "nw_normal")
else:
text_widget.insert("end", f"Fehler: {msg}\n", "nw_normal")
text_widget.configure(state="disabled")
status_var.set("Fehler")
_search()
def _persist_news_settings(self):
try:
save_autotext(self._autotext_data)
except Exception:
pass
def _all_medical_specialties(self):
items = [
("dermatology", "Dermatologie"),
("general-medicine", "Allgemeinmedizin"),
("gynecology", "Gynäkologie"),
("internal-medicine", "Innere Medizin"),
("anesthesiology", "Anästhesiologie"),
("cardiology", "Kardiologie"),
("neurology", "Neurologie"),
("oncology", "Onkologie"),
("infectiology", "Infektiologie"),
("pediatrics", "Pädiatrie"),
("psychiatry", "Psychiatrie"),
("orthopedics", "Orthopädie"),
("ophthalmology", "Ophthalmologie"),
("otolaryngology", "HNO"),
("urology", "Urologie"),
("endocrinology", "Endokrinologie"),
("rheumatology", "Rheumatologie"),
("hematology", "Hämatologie"),
("gastroenterology", "Gastroenterologie"),
("nephrology", "Nephrologie"),
("pulmonology", "Pneumologie"),
("radiology", "Radiologie"),
("pathology", "Pathologie"),
("emergency-medicine", "Notfallmedizin"),
("surgery", "Chirurgie"),
("plastic-surgery", "Plastische Chirurgie"),
("immunology", "Immunologie"),
("allergy", "Allergologie"),
("geriatrics", "Geriatrie"),
("all", "Alle Fachrichtungen"),
]
no_all = sorted([x for x in items if x[0] != "all"], key=lambda x: x[1].lower())
return no_all + [("all", "Alle Fachrichtungen")]
def _fmt_eu_date(self, raw: str) -> str:
s = str(raw or "").strip()
if not s:
return ""
try:
d = datetime.fromisoformat(s.replace("Z", "+00:00"))
return d.strftime("%d.%m.%Y")
except Exception:
pass
try:
d2 = datetime.strptime(s[:10], "%Y-%m-%d")
return d2.strftime("%d.%m.%Y")
except Exception:
return s[:10]
def _ensure_user_specialty_preferences(self):
if self._autotext_data.get("user_specialties_selected"):
return
catalog = [x for x in self._all_medical_specialties() if x[0] != "all"]
dlg = tk.Toplevel(self)
dlg.title("Fachgebiet festlegen")
dlg.transient(self)
dlg.grab_set()
dlg.configure(bg="#F2F8FC")
dlg.minsize(420, 500)
add_resize_grip(dlg, 420, 500)
body = tk.Frame(dlg, bg="#F2F8FC", padx=12, pady=12)
body.pack(fill="both", expand=True)
tk.Label(body, text="Primäres Fachgebiet (Erststart)", bg="#F2F8FC", fg="#1a4d6d", font=("Segoe UI", 11, "bold")).pack(anchor="w")
primary_var = tk.StringVar(value="dermatology")
label_to_key = {label: key for key, label in catalog}
key_to_label = {key: label for key, label in catalog}
combo_val = tk.StringVar(value=key_to_label.get("dermatology", "Dermatologie"))
combo = ttk.Combobox(body, values=[label for _, label in catalog], textvariable=combo_val, state="readonly", width=32)
combo.pack(anchor="w", pady=(6, 10))
tk.Label(body, text="Weitere Fachgebiete (optional)", bg="#F2F8FC", fg="#1a4d6d").pack(anchor="w")
vars_map = {}
for key, label in catalog:
v = tk.BooleanVar(value=(key == "dermatology"))
vars_map[key] = v
tk.Checkbutton(body, text=label, variable=v, bg="#F2F8FC", activebackground="#F2F8FC", selectcolor="#E7F4FA").pack(anchor="w")
def _save():
selected = [k for k, v in vars_map.items() if v.get()]
chosen_label = combo_val.get().strip()
primary = label_to_key.get(chosen_label, "dermatology")
if primary not in selected:
selected.insert(0, primary)
if not selected:
selected = [primary]
self._autotext_data["user_specialty_default"] = primary
self._autotext_data["user_specialties_selected"] = selected
if not self._autotext_data.get("eventsSelectedSpecialties"):
self._autotext_data["eventsSelectedSpecialties"] = [primary]
if not self._autotext_data.get("newsSelectedSpecialties"):
self._autotext_data["newsSelectedSpecialties"] = [primary]
self._persist_news_settings()
dlg.destroy()
foot = tk.Frame(body, bg="#F2F8FC")
foot.pack(fill="x", pady=(10, 0))
ttk.Button(foot, text="Speichern", command=_save).pack(side="left")
ttk.Button(foot, text="Überspringen", command=dlg.destroy).pack(side="left", padx=(6, 0))
dlg.wait_window()
def _apply_large_window_geometry(self, window, side="left"):
try:
sw = max(1200, int(self.winfo_screenwidth()))
sh = max(800, int(self.winfo_screenheight()))
width = max(260, int(sw * 0.25))
height = max(500, int(sh * 0.80))
window.minsize(max(240, int(width * 0.85)), max(420, int(height * 0.80)))
gap = 12
start_x = 8
y = max(10, int(sh * 0.10))
if side == "right":
x = start_x + width + gap
else:
x = start_x
window.geometry(f"{width}x{height}+{x}+{y}")
except Exception:
pass
def _enable_mousewheel_scroll(self, canvas, host_widget):
def _on_mousewheel(event):
try:
if sys.platform == "darwin":
canvas.yview_scroll(int(-1 * event.delta), "units")
else:
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
except Exception:
pass
def _on_mousewheel_linux_up(_event):
canvas.yview_scroll(-1, "units")
def _on_mousewheel_linux_down(_event):
canvas.yview_scroll(1, "units")
def _bind(_event=None):
canvas.bind_all("<MouseWheel>", _on_mousewheel)
canvas.bind_all("<Button-4>", _on_mousewheel_linux_up)
canvas.bind_all("<Button-5>", _on_mousewheel_linux_down)
def _unbind(_event=None):
canvas.unbind_all("<MouseWheel>")
canvas.unbind_all("<Button-4>")
canvas.unbind_all("<Button-5>")
host_widget.bind("<Enter>", _bind)
host_widget.bind("<Leave>", _unbind)
def _run_macro1(self):
"""Führt das gespeicherte Makro-Profil 'macro1' aus."""
self._launch_macro_process("run", "macro1")
def _record_macro1(self):
"""Startet die Aufnahme für das Makro-Profil 'macro1'."""
self._launch_macro_process("record", "macro1")
def _launch_macro_process(self, mode=None, profile=None):
"""Startet aza_macro.py optional mit Modus/Profil."""
try:
import subprocess
script_dir = os.path.dirname(os.path.abspath(__file__))
macro_path = os.path.join(script_dir, "aza_macro.py")
if not os.path.exists(macro_path):
messagebox.showerror("Fehler", f"aza_macro.py nicht gefunden:\n{macro_path}")
return
cmd = [sys.executable, macro_path]
if mode:
cmd.append(str(mode))
if profile:
cmd.append(str(profile))
if sys.platform == "win32":
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
if os.path.exists(pythonw):
cmd = [pythonw, macro_path] + cmd[2:]
subprocess.Popen(cmd, cwd=script_dir)
else:
subprocess.Popen(cmd, cwd=script_dir,
creationflags=subprocess.CREATE_NO_WINDOW)
else:
subprocess.Popen(cmd, cwd=script_dir)
except Exception as e:
messagebox.showerror("Fehler", f"Macro konnte nicht gestartet werden:\n{str(e)}")
# _open_todo_window -> ausgelagert in Mixin-Modul
def _open_lernkarten_abfrage(self):
"""Startet das Lernkarten-Abfrage-Programm Vokabeln und Sätze üben mit Lernzielkontrolle."""
try:
import subprocess
script_dir = os.path.dirname(os.path.abspath(__file__))
abfrage_path = os.path.join(script_dir, "lernkarten_abfrage.py")
if not os.path.exists(abfrage_path):
messagebox.showerror("Fehler", f"lernkarten_abfrage.py nicht gefunden:\n{abfrage_path}")
return
kwargs = {"cwd": script_dir}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen([sys.executable, abfrage_path], **kwargs)
except Exception as e:
messagebox.showerror("Fehler", str(e))
def _open_arztzeugnis(self):
"""Öffnet ein Fenster zum Erstellen eines Arztzeugnisses mit Diktat, Drucken, E-Mail und Speichern."""
AZ_MIN_W, AZ_MIN_H = 520, 680
win = tk.Toplevel(self)
win.title("Arztzeugnis erstellen")
win.transient(self)
win.minsize(AZ_MIN_W, AZ_MIN_H)
win.configure(bg="#E8F4FA")
win.attributes("-topmost", True)
self._register_window(win)
add_resize_grip(win, AZ_MIN_W, AZ_MIN_H)
# Fensterposition: gespeichert laden oder zentrieren
saved_geom = load_toplevel_geometry("arztzeugnis")
if saved_geom:
win.geometry(saved_geom)
else:
win.geometry(f"{AZ_MIN_W}x{AZ_MIN_H}")
center_window(win, AZ_MIN_W, AZ_MIN_H)
_az_geom_after = [None]
def _az_save_geom(event=None):
if _az_geom_after[0]:
win.after_cancel(_az_geom_after[0])
_az_geom_after[0] = win.after(400, lambda: save_toplevel_geometry("arztzeugnis", win.geometry()))
win.bind("<Configure>", _az_save_geom)
def _az_on_close():
try:
save_toplevel_geometry("arztzeugnis", win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", _az_on_close)
# Header
header = tk.Frame(win, bg="#B9ECFA")
header.pack(fill="x")
tk.Label(header, text="Arztzeugnis", font=("Segoe UI", 14, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(pady=10)
# Formular
form = tk.Frame(win, bg="#E8F4FA", padx=16, pady=8)
form.pack(fill="x")
lbl_font = ("Segoe UI", 10, "bold")
ent_font = ("Segoe UI", 10)
def _add_field(parent, label_text, row):
tk.Label(parent, text=label_text, font=lbl_font, bg="#E8F4FA",
fg="#1a4d6d", anchor="w").grid(row=row, column=0, sticky="w", pady=(4, 0))
var = tk.StringVar()
ent = tk.Entry(parent, textvariable=var, font=ent_font, bg="white",
fg="#1a4d6d", relief="flat", bd=0, insertbackground="#1a4d6d")
ent.grid(row=row, column=1, sticky="ew", padx=(8, 0), pady=(4, 0), ipady=4)
return var, ent
form.columnconfigure(1, weight=1)
patient_var, patient_ent = _add_field(form, "Patient:", 0)
gebdat_var, gebdat_ent = _add_field(form, "Geb.-Datum:", 1)
datum_var, datum_ent = _add_field(form, "Datum:", 2)
datum_var.set(datetime.now().strftime("%d.%m.%Y"))
tk.Label(form, text="Diagnose:", font=lbl_font, bg="#E8F4FA",
fg="#1a4d6d", anchor="w").grid(row=3, column=0, sticky="nw", pady=(8, 0))
diagnose_text = tk.Text(form, font=ent_font, bg="white", fg="#1a4d6d",
relief="flat", bd=0, height=3, wrap="word",
insertbackground="#1a4d6d")
diagnose_text.grid(row=3, column=1, sticky="ew", padx=(8, 0), pady=(8, 0))
# Beurteilung / Freitext
tk.Label(win, text="Beurteilung / Zeugnis-Text:", font=lbl_font,
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", padx=16, pady=(8, 0))
text_frame = tk.Frame(win, bg="#E8F4FA", padx=16)
text_frame.pack(fill="both", expand=True, pady=(0, 4))
az_text = tk.Text(text_frame, font=("Segoe UI", 11), bg="white",
fg="#1a4d6d", relief="flat", bd=0, wrap="word",
insertbackground="#1a4d6d", padx=8, pady=6)
az_text.pack(fill="both", expand=True)
# Diktat
az_recorder = [None]
az_is_recording = [False]
az_rec_status = tk.StringVar(value="")
def _az_toggle_record():
if az_is_recording[0]:
az_is_recording[0] = False
btn_rec.configure(text="⏺ Diktieren", bg="#5B8DB3")
az_rec_status.set("Transkribiere")
recorder = az_recorder[0]
if recorder:
selfref = self
def _do():
try:
wav_path = recorder.stop_and_save_wav()
if wav_path:
text = selfref.transcribe_wav(wav_path)
if text:
def _insert():
if az_text.get("1.0", "end-1c").strip():
az_text.insert("insert", " " + text)
else:
az_text.insert("1.0", text)
az_rec_status.set("Diktat eingefügt.")
win.after(0, _insert)
else:
win.after(0, lambda: az_rec_status.set("Kein Text erkannt."))
else:
win.after(0, lambda: az_rec_status.set("Aufnahme fehlgeschlagen."))
except Exception as e:
win.after(0, lambda: az_rec_status.set(f"Fehler: {e}"))
threading.Thread(target=_do, daemon=True).start()
else:
az_is_recording[0] = True
btn_rec.configure(text="⏹ Stoppen", bg="#C03030")
az_rec_status.set("Aufnahme läuft")
az_recorder[0] = AudioRecorder()
az_recorder[0].start()
rec_frame = tk.Frame(win, bg="#E8F4FA")
rec_frame.pack(fill="x", padx=16, pady=(4, 0))
btn_rec = tk.Button(rec_frame, text="⏺ Diktieren", font=("Segoe UI", 10, "bold"),
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
relief="flat", bd=0, padx=14, pady=4, cursor="hand2",
command=_az_toggle_record)
btn_rec.pack(side="left")
tk.Label(rec_frame, textvariable=az_rec_status, font=("Segoe UI", 9),
bg="#E8F4FA", fg="#4a8aaa").pack(side="left", padx=(8, 0))
# Aktions-Buttons
action_frame = tk.Frame(win, bg="#D4EEF7", padx=16, pady=8)
action_frame.pack(fill="x", side="bottom")
def _btn_style(text, bg_color, active_color, cmd):
return tk.Button(action_frame, text=text, font=("Segoe UI", 10, "bold"),
bg=bg_color, fg="#1a4d6d", activebackground=active_color,
relief="flat", bd=0, padx=14, pady=6, cursor="hand2",
command=cmd)
def _get_az_text():
lines = []
lines.append("ARZTZEUGNIS")
lines.append("=" * 40)
if patient_var.get().strip():
lines.append(f"Patient: {patient_var.get().strip()}")
if gebdat_var.get().strip():
lines.append(f"Geb.-Datum: {gebdat_var.get().strip()}")
lines.append(f"Datum: {datum_var.get().strip()}")
diag = diagnose_text.get("1.0", "end-1c").strip()
if diag:
lines.append(f"\nDiagnose:\n{diag}")
body = az_text.get("1.0", "end-1c").strip()
if body:
lines.append(f"\nBeurteilung:\n{body}")
return "\n".join(lines)
def _az_save():
content = _get_az_text()
if not content.strip():
messagebox.showinfo("Speichern", "Kein Inhalt zum Speichern.", parent=win)
return
from tkinter import filedialog
path = filedialog.asksaveasfilename(
parent=win, title="Arztzeugnis speichern",
defaultextension=".txt",
filetypes=[("Textdatei", "*.txt"), ("Alle Dateien", "*.*")],
initialfile=f"Arztzeugnis_{patient_var.get().strip().replace(' ', '_') or 'Patient'}_{datum_var.get().strip().replace('.', '-')}.txt"
)
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
az_rec_status.set(f"Gespeichert: {os.path.basename(path)}")
def _az_print():
content = _get_az_text()
if not content.strip():
messagebox.showinfo("Drucken", "Kein Inhalt zum Drucken.", parent=win)
return
import tempfile, subprocess as _sp
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8")
tmp.write(content)
tmp.close()
try:
if sys.platform == "win32":
os.startfile(tmp.name, "print")
else:
_sp.Popen(["lpr", tmp.name])
az_rec_status.set("Druckauftrag gesendet.")
except Exception as e:
az_rec_status.set(f"Druckfehler: {e}")
def _az_email():
content = _get_az_text()
if not content.strip():
messagebox.showinfo("E-Mail", "Kein Inhalt zum Senden.", parent=win)
return
import urllib.parse
subject = urllib.parse.quote(f"Arztzeugnis {patient_var.get().strip()}")
body = urllib.parse.quote(content)
mailto = f"mailto:?subject={subject}&body={body}"
import webbrowser
webbrowser.open(mailto)
az_rec_status.set("E-Mail-Client geöffnet.")
_btn_style(" Speichern", "#B9ECFA", "#A8DCE8", _az_save).pack(side="left", padx=(0, 6))
_btn_style(" Drucken", "#C8E8F0", "#B8D8E6", _az_print).pack(side="left", padx=(0, 6))
_btn_style(" E-Mail", "#D4EEF7", "#C4DEE8", _az_email).pack(side="left", padx=(0, 6))
# open_diktat_window -> ausgelagert in Mixin-Modul
def _toggle_addon_collapse(self, event=None):
"""Klappt die Add-on-Buttons ein/aus."""
if self._addon_collapsed:
self._addon_buttons_container.pack(fill="x")
self._addon_toggle_label.configure(text="\u25BC Weitere Module")
self._addon_collapsed = False
else:
self._addon_buttons_container.pack_forget()
self._addon_toggle_label.configure(text="\u25B6 Weitere Module")
self._addon_collapsed = True
def _toggle_soap_collapse(self, event=None):
"""Klappt die SOAP-Steuerung (A, S, O, B, D, T, P) ein/aus."""
if self._soap_collapsed:
self._soap_container.pack(fill="x", before=self._soap_anchor)
self._soap_toggle_label.configure(text="\u25BC SOAP:")
self._soap_collapsed = False
else:
self._soap_container.pack_forget()
self._soap_toggle_label.configure(text="\u25B6 SOAP:")
self._soap_collapsed = True
self._autotext_data["soap_collapsed"] = self._soap_collapsed
save_autotext(self._autotext_data)
def _toggle_dokumente_collapse(self, event=None):
"""Klappt die Dokumente-Buttons (Brief, Rezept, Korrektur) ein/aus."""
if self._dokumente_collapsed:
self._dokumente_container.pack(fill="x", before=self._dokumente_anchor)
self._dokumente_toggle_label.configure(text="\u25BC Dokumente:")
self._dokumente_collapsed = False
else:
self._dokumente_container.pack_forget()
self._dokumente_toggle_label.configure(text="\u25B6 Dokumente:")
self._dokumente_collapsed = True
self._autotext_data["dokumente_collapsed"] = self._dokumente_collapsed
save_autotext(self._autotext_data)
def _toggle_textbloecke_collapse(self, event=None):
"""Klappt die Textblöcke (1, 2, 3, 4, 5) ein/aus."""
if self._textbloecke_collapsed:
# Aufklappen - VOR dem Anker einfügen!
self._textbloecke_container.pack(fill="x", before=self._textbloecke_anchor)
self._textbloecke_toggle_label.configure(text="▼ Textblöcke:")
self._textbloecke_collapsed = False
else:
# Einklappen
self._textbloecke_container.pack_forget()
self._textbloecke_toggle_label.configure(text="▶ Textblöcke:")
self._textbloecke_collapsed = True
# Speichern
self._autotext_data["textbloecke_collapsed"] = self._textbloecke_collapsed
save_autotext(self._autotext_data)
def _update_addon_buttons_visibility(self):
"""Aktualisiert die Sichtbarkeit der einzelnen Add-on-Buttons basierend auf Einstellungen."""
addon_buttons = self._autotext_data.get("addon_buttons", {})
for button_id, row in self._addon_button_rows.items():
if addon_buttons.get(button_id, True):
row.pack(fill="x")
else:
row.pack_forget()
# open_ordner_window -> ausgelagert in Mixin-Modul
def open_ki_pruefen(self):
"""Prüft den oberen Text (KG) per KI auf Logik, Zusammenhänge, Diagnose-Therapie-Passung."""
kg = self.txt_output.get("1.0", "end").strip()
if not kg:
messagebox.showinfo("KI-Kontrolle", "Bitte zuerst Text im oberen Feld (Krankengeschichte) eingeben.")
return
if not self.ensure_ready():
messagebox.showinfo("KI-Kontrolle", "Die KI-Verbindung ist noch nicht eingerichtet.\nBitte zuerst über das Startmenü einrichten.")
return
if not self._check_ai_consent():
return
self.set_status("KI-Kontrolle")
def worker():
try:
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": KI_PRUEFEN_PROMPT},
{"role": "user", "content": kg},
],
)
result = resp.choices[0].message.content.strip()
self.after(0, lambda: self._show_ki_pruefen_window(result, kg))
self.after(0, lambda: self.set_status("Fertig."))
except Exception as e:
self.after(0, lambda: self.set_status("Fehler."))
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
threading.Thread(target=worker, daemon=True).start()
def _show_ki_pruefen_window(self, result: str, original_kg: str) -> None:
"""Zeigt das KI-Prüfergebnis in einem Fenster; Button Übernehme Korrektur erzeugt korrigierte KG."""
win = tk.Toplevel(self)
win.title("KI-Kontrolle Logik & Zusammenhänge")
win.transient(self)
win.configure(bg="#B9ECFA")
win.minsize(550, 450)
win.attributes("-topmost", True)
self._register_window(win)
# Fensterposition: gespeichert laden oder zentrieren
setup_window_geometry_saving(win, "ki_kontrolle", 700, 600)
add_resize_grip(win, 550, 450)
add_font_scale_control(win)
f = ttk.Frame(win, padding=12)
f.pack(fill="both", expand=True)
ki_header = ttk.Frame(f)
ki_header.pack(fill="x", anchor="w")
ttk.Label(ki_header, text="KI-Kontrolle (Logik, Diagnose/Therapie-Passung):").pack(side="left")
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=16)
txt.pack(fill="both", expand=True, pady=(8, 8))
add_text_font_size_control(ki_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="ki_kontrolle")
txt.insert("1.0", result.strip())
self._bind_text_context_menu(txt)
btn_row = ttk.Frame(win, padding=(12, 0, 12, 12))
btn_row.pack(fill="x")
def do_uebernehme_korrektur():
if not self.ensure_ready():
return
self.set_status("Erstelle korrigierte Krankengeschichte")
prompt = """Du bist ein ärztlicher Dokumentationsassistent (Deutsch).
Es liegt eine kurze Krankengeschichte und eine Prüfkritik vor.
Aufgabe: Passe die Krankengeschichte MINIMAL an, sodass die Kritikpunkte adressiert werden. Die Korrektur muss KNAPP bleiben ähnlich kurz wie die Vorlage.
WICHTIG unbedingt einhalten:
- Ungefähr gleicher Umfang wie die ursprüngliche KG (keine langen Absätze, keine Aufblähung).
- Nur das ändern, was die Kritik ausdrücklich verlangt (z. B. ICD-Code präzisieren, fehlende Angabe ergänzen). Keine zusätzlichen Details erfinden (keine konkreten mm-Werte, keine ausformulierten ABCDE-Scores, keine langen Aufklärungs- oder Wundmanagement-Texte, wenn sie nicht in der Vorlage standen).
- Struktur der Vorlage beibehalten (gleiche Überschriften, Stichpunkte). Keine neuen Abschnitte erfinden, nur vorhandene präzisieren.
- Ausgabe NUR die korrigierte KG keine Sterne, keine Meta-Kommentare, keine Wiederholung der Kritik. Diagnosen mit ICD-10-GM in Klammern."""
def worker():
try:
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
user_content = (
"AKTUELLE KRANKENGESCHICHTE:\n" + (original_kg or "") + "\n\n"
"KRITIK / HINWEISE DER KI-PRFUNG:\n" + (result or "").strip()
)
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": user_content},
],
)
corrected = resp.choices[0].message.content.strip()
corrected = re.sub(r"\*+", "", corrected)
corrected = re.sub(r"#+", "", corrected)
for prefix in ("Aktuelle Krankengeschichte:", "Aktuelle Krankengeschichte", "Korrigierte Krankengeschichte:", "Korrigierte Krankengeschichte"):
if corrected.startswith(prefix):
corrected = corrected[len(prefix):].strip()
break
self.after(0, lambda: _apply_corrected(corrected))
self.after(0, lambda: self.set_status("Korrektur übernommen."))
except Exception as e:
self.after(0, lambda: self.set_status("Fehler."))
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
def _apply_corrected(text):
try:
self.txt_output.delete("1.0", "end")
self.txt_output.insert("1.0", text)
except (tk.TclError, AttributeError):
pass
import threading
threading.Thread(target=worker, daemon=True).start()
RoundedButton(
btn_row, "Übernehme Korrektur",
command=do_uebernehme_korrektur,
width=180, height=28, canvas_bg="#B9ECFA",
).pack(side="left")
def open_pruefen_window(self):
"""Öffnet das Korrektur-Fenster mit Korrekturen und Interaktionscheck."""
kg = self.txt_output.get("1.0", "end").strip()
transcript = self.txt_transcript.get("1.0", "end").strip()
raw = ""
if kg:
raw += "KRANKENGESCHICHTE:\n" + kg + "\n\n"
if transcript:
raw += "TRANSKRIPT:\n" + transcript + "\n\n"
raw = raw.strip() or "(Kein Text zum Prüfen)"
korrekturen = load_korrekturen()
corrected, applied = apply_korrekturen(raw, korrekturen)
display_text = extract_diagnosen_therapie_procedere(corrected)
win = tk.Toplevel(self)
win.title("Korrektur")
win.transient(self)
win.attributes("-topmost", True)
self._register_window(win)
default_pruefen_w, default_pruefen_h = 480, 550
# Fensterposition: gespeichert laden oder zentrieren
pruefen_geo = load_pruefen_geometry()
if pruefen_geo:
if len(pruefen_geo) >= 4:
w0, h0, x0, y0 = pruefen_geo[0], pruefen_geo[1], pruefen_geo[2], pruefen_geo[3]
win.geometry(f"{max(default_pruefen_w, w0)}x{max(default_pruefen_h, h0)}+{x0}+{y0}")
else:
w0, h0 = pruefen_geo[0], pruefen_geo[1]
win.geometry(f"{max(default_pruefen_w, w0)}x{max(default_pruefen_h, h0)}")
center_window(win, max(default_pruefen_w, w0), max(default_pruefen_h, h0))
else:
# Keine gespeicherte Position zentrieren
win.geometry(f"{default_pruefen_w}x{default_pruefen_h}")
center_window(win, default_pruefen_w, default_pruefen_h)
win.minsize(520, 560)
win.configure(bg="#B9ECFA")
def save_pruefen_size():
try:
w, h = win.winfo_width(), win.winfo_height()
x, y = win.winfo_x(), win.winfo_y()
if w > 200 and h > 150:
save_pruefen_geometry(w, h, x, y)
except Exception:
pass
win.bind("<Configure>", lambda e: win.after(500, save_pruefen_size) if e.widget is win else None)
add_resize_grip(win, 520, 560)
add_font_scale_control(win)
main_f = ttk.Frame(win, padding=12)
main_f.pack(fill="both", expand=True)
full_corrected = [corrected]
inter_header = ttk.Frame(main_f)
inter_header.pack(fill="x", anchor="w")
ttk.Label(inter_header, text="Geprüfter Text (Diagnosen, Procedere, Therapie):").pack(side="left")
txt = ScrolledText(main_f, wrap="word", font=self._text_font, bg="#F5FCFF", height=6)
txt.pack(fill="both", expand=True, pady=(0, 8))
add_text_font_size_control(inter_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="interaktionscheck")
txt.insert("1.0", display_text)
self._bind_text_context_menu(txt)
def on_txt_double_click(evt):
def grab_selection():
try:
selected = txt.get("sel.first", "sel.last").strip()
if selected:
wrong_inline.set(selected)
except tk.TclError:
pass
txt.after(10, grab_selection)
txt.bind("<Double-Button-1>", on_txt_double_click, add="+")
korr_frame = tk.Frame(main_f, bg="#D6EAF8", bd=1, relief="groove")
korr_frame.pack(fill="x", pady=(4, 8), ipady=4)
tk.Label(korr_frame, text="\u270E Korrektur eingeben:",
font=("Segoe UI", 9, "bold"), bg="#D6EAF8", fg="#1a4d6d"
).pack(anchor="w", padx=8, pady=(4, 0))
tk.Label(korr_frame, text="Doppelklick auf ein Wort oben befuellt 'Falsch' automatisch.",
font=("Segoe UI", 8, "italic"), bg="#D6EAF8", fg="#666"
).pack(anchor="w", padx=8)
fields_row = tk.Frame(korr_frame, bg="#D6EAF8")
fields_row.pack(fill="x", padx=8, pady=(4, 6))
wrong_inline = tk.StringVar()
right_inline = tk.StringVar()
tk.Label(fields_row, text="Falsch:", font=("Segoe UI", 10, "bold"),
bg="#D6EAF8", fg="#C0392B").pack(side="left")
wrong_entry = tk.Entry(fields_row, textvariable=wrong_inline, width=18,
font=("Segoe UI", 10), bg="#FFF", relief="solid", bd=1)
wrong_entry.pack(side="left", padx=(4, 12))
tk.Label(fields_row, text="Richtig:", font=("Segoe UI", 10, "bold"),
bg="#D6EAF8", fg="#27AE60").pack(side="left")
right_entry = tk.Entry(fields_row, textvariable=right_inline, width=18,
font=("Segoe UI", 10), bg="#FFF", relief="solid", bd=1)
right_entry.pack(side="left", padx=(4, 8))
editing_entry = [None]
listbox_corrections = []
def refresh_all_list():
k = load_korrekturen()
listbox.delete(0, "end")
listbox_corrections.clear()
for cat in ("medikamente", "diagnosen"):
for f, r in (k.get(cat) or {}).items():
listbox.insert("end", f"«{f}» «{r}» ({cat})")
listbox_corrections.append((f, r, cat))
def refresh_display():
new_text, _ = apply_korrekturen(raw, load_korrekturen())
full_corrected[0] = new_text
disp = extract_diagnosen_therapie_procedere(new_text)
txt.delete("1.0", "end")
txt.insert("1.0", disp)
refresh_all_list()
ttk.Label(main_f, text="Alle gespeicherten Korrekturen (werden automatisch bei neuer KG angewendet):").pack(anchor="w", pady=(4, 0))
list_f = ttk.Frame(main_f)
list_f.pack(fill="both", expand=True, pady=(0, 8))
list_scrollbar = tk.Scrollbar(list_f, orient="vertical")
list_scrollbar.pack(side="right", fill="y")
listbox = tk.Listbox(list_f, height=8, font=("Segoe UI", 10),
yscrollcommand=list_scrollbar.set)
listbox.pack(side="left", fill="both", expand=True)
list_scrollbar.config(command=listbox.yview)
refresh_all_list()
def on_listbox_double_click(evt):
sel = listbox.curselection()
if not sel or sel[0] >= len(listbox_corrections):
return
f, r, cat = listbox_corrections[sel[0]]
wrong_inline.set(f)
right_inline.set(r)
editing_entry[0] = (f, cat)
listbox.bind("<Double-Button-1>", on_listbox_double_click)
btn_row = ttk.Frame(main_f)
btn_row.pack(fill="x", pady=8)
def do_export():
from tkinter import filedialog
dest = filedialog.asksaveasfilename(
title="Korrekturen exportieren",
defaultextension=".json",
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
)
if not dest:
return
try:
with open(dest, "w", encoding="utf-8") as f:
json.dump(load_korrekturen(), f, ensure_ascii=False, indent=2)
self.set_status(f"Exportiert: {dest}")
messagebox.showinfo("Export", f"Korrekturen exportiert nach:\n{dest}")
except Exception as e:
messagebox.showerror("Export fehlgeschlagen", str(e))
def do_import():
from tkinter import filedialog
p = filedialog.askopenfilename(
title="Korrekturen importieren",
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
)
if not p:
return
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
existing = load_korrekturen()
for cat, mapping in data.items():
if isinstance(mapping, dict) and cat in existing:
existing[cat].update(mapping)
elif isinstance(mapping, dict):
existing[cat] = mapping
save_korrekturen(existing)
refresh_display()
self.set_status("Importiert.")
messagebox.showinfo("Import", "Korrekturen importiert.")
else:
messagebox.showerror("Import", "Ungültiges Format.")
except Exception as e:
messagebox.showerror("Import fehlgeschlagen", str(e))
def extract_meds_from_text(text: str):
meds = []
stopwords = ("dass", "diese", "oder", "und", "eine", "der", "die", "das", "therapie", "procedere", "diagnose", "keine", "sowie")
for m in re.findall(r"[A-Za-zäöü][A-Za-zäöü0-9\-]{3,}", text):
w = m.strip()
if w.lower() not in stopwords and not w.isdigit():
meds.append(w)
return list(dict.fromkeys(meds))[:12]
def do_interaktion():
text = full_corrected[0]
meds = extract_meds_from_text(text)
if not meds:
messagebox.showinfo("Interaktionscheck", "Keine Medikamentennamen erkannt. Bitte manuell prüfen.")
return
if not self._check_ai_consent():
return
if not self.ensure_ready():
import webbrowser
q = "+".join(meds[:6]) + "+Interaktion"
webbrowser.open(f"https://www.google.com/search?q={q}")
self.set_status("Websuche geöffnet (KI-Verbindung fehlt).")
return
self.set_status("Prüfe Interaktionen")
def worker():
try:
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
med_list = ", ".join(meds)
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": INTERACTION_PROMPT},
{"role": "user", "content": f"Medikamente/Therapien: {med_list}"},
],
)
result = resp.choices[0].message.content.strip()
self.after(0, lambda: self._show_interaktion_window(meds, result))
self.after(0, lambda: self.set_status("Fertig."))
except Exception as e:
self.after(0, lambda: self.set_status("Fehler Websuche als Fallback."))
self.after(0, lambda: messagebox.showwarning("Interaktionscheck", f"KI-Prüfung fehlgeschlagen.\n{str(e)}\n\nWebsuche wird geöffnet."))
self.after(0, lambda: _fallback_google(meds))
def _fallback_google(meds):
import webbrowser
q = "+".join(meds[:6]) + "+Interaktion"
webbrowser.open(f"https://www.google.com/search?q={q}")
self.set_status("Websuche geöffnet (Fallback).")
threading.Thread(target=worker, daemon=True).start()
def _apply_to_main(t):
"""Übernimmt den korrigierten Text ins Hauptfenster."""
if "KRANKENGESCHICHTE:" in t:
parts = t.split("TRANSKRIPT:")
kg_part = parts[0].replace("KRANKENGESCHICHTE:", "").strip()
self.txt_output.delete("1.0", "end")
self.txt_output.insert("1.0", kg_part)
if "TRANSKRIPT:" in t:
trans_part = t.split("TRANSKRIPT:")[1].split("VORSICHT:")[0].strip()
self.txt_transcript.delete("1.0", "end")
self.txt_transcript.insert("1.0", trans_part)
if t and "KRANKENGESCHICHTE:" not in t and "TRANSKRIPT:" not in t and t != "(Kein Text zum Prüfen)":
self.txt_output.delete("1.0", "end")
self.txt_output.insert("1.0", t)
def do_korrigieren():
w_val = wrong_inline.get().strip()
r_val = right_inline.get().strip()
if w_val and r_val:
k = load_korrekturen()
if "diagnosen" not in k:
k["diagnosen"] = {}
if editing_entry[0]:
old_f, cat = editing_entry[0]
if cat in k and old_f in k[cat]:
del k[cat][old_f]
k[cat][w_val] = r_val
editing_entry[0] = None
else:
k["diagnosen"][w_val] = r_val
save_korrekturen(k)
corrected_text, _ = apply_korrekturen(raw, k)
full_corrected[0] = corrected_text
disp = extract_diagnosen_therapie_procedere(corrected_text)
txt.delete("1.0", "end")
txt.insert("1.0", disp)
refresh_all_list()
_apply_to_main(corrected_text)
wrong_inline.set("")
right_inline.set("")
self.set_status(f"Korrektur gespeichert: «{w_val}» → «{r_val}» direkt in KG angewendet.")
return
t = full_corrected[0]
_apply_to_main(t)
self.set_status("Korrekturen in KG übernommen.")
RoundedButton(btn_row, "Korrigieren", command=do_korrigieren, width=100, height=28, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
btn_row2 = ttk.Frame(main_f, padding=(0, 4, 0, 0))
btn_row2.pack(fill="x")
RoundedButton(btn_row2, "Export", command=do_export, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
RoundedButton(btn_row2, "Import", command=do_import, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left")
def add_inline_and_update():
w, r = wrong_inline.get().strip(), right_inline.get().strip()
if not w or not r:
messagebox.showinfo("Hinweis", "Bitte Falsch- und Richtig-Feld ausfüllen.", parent=win)
return
k = load_korrekturen()
if "medikamente" not in k:
k["medikamente"] = {}
if "diagnosen" not in k:
k["diagnosen"] = {}
if editing_entry[0]:
old_f, cat = editing_entry[0]
if cat in k and old_f in k[cat]:
del k[cat][old_f]
k[cat][w] = r
editing_entry[0] = None
else:
k["diagnosen"][w] = r
save_korrekturen(k)
corrected_text, _ = apply_korrekturen(raw, k)
full_corrected[0] = corrected_text
disp = extract_diagnosen_therapie_procedere(corrected_text)
txt.delete("1.0", "end")
txt.insert("1.0", disp)
refresh_all_list()
_apply_to_main(corrected_text)
wrong_inline.set("")
right_inline.set("")
self.set_status(f"Korrektur gespeichert: «{w}» → «{r}» direkt in KG angewendet.")
btn_save = tk.Button(
fields_row, text="\u2714 Übernehmen", font=("Segoe UI", 10, "bold"),
bg="#27AE60", fg="white", activebackground="#1E8449",
relief="flat", padx=14, pady=4, cursor="hand2",
command=add_inline_and_update,
)
btn_save.pack(side="left", padx=(8, 0))
def _next_phase(self, phase: str):
self._phase = phase
self._timer_sec = 0
def _stop_timer(self):
self._timer_running = False
def _autocopy_kg(self, text: str):
if not text or not text.strip():
return
if not self._is_autocopy_enabled():
return
if not _win_clipboard_set(text):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(text))
def _fill_transcript(self, transcript: str):
korrekturen = load_korrekturen()
corrected_transcript, _ = apply_korrekturen(transcript, korrekturen)
self.txt_transcript.delete("1.0", "end")
self.txt_transcript.insert("1.0", corrected_transcript)
if corrected_transcript and corrected_transcript.strip():
try:
save_to_ablage("Transkript", corrected_transcript.strip())
except Exception:
pass
def _fill_kg_and_finish(self, kg: str):
self._stop_timer()
self.set_status("Fertig.")
korrekturen = load_korrekturen()
kg_corrected, _ = apply_korrekturen(kg, korrekturen)
cleaned_kg, comments_text = extract_kg_comments(kg_corrected)
cleaned_kg = strip_kg_warnings(cleaned_kg)
if cleaned_kg and cleaned_kg.strip():
try:
save_to_ablage("KG", cleaned_kg)
self.set_status("Fertig. KG automatisch gespeichert.")
except Exception:
pass
self.txt_output.delete("1.0", "end")
self.txt_output.insert("1.0", cleaned_kg)
self._autocopy_kg(cleaned_kg)
if cleaned_kg and cleaned_kg.strip():
self._increment_demo_usage_if_needed()
self._auto_refresh_kommentare()
def _diktat_into_widget(self, parent_win, text_widget, status_callback=None):
"""Öffnet kleines Aufnahme-Fenster, transkribiert und fügt Text an Cursorposition in text_widget ein."""
if not self.ensure_ready():
return
if not self._check_ai_consent():
return
rec_win = tk.Toplevel(parent_win)
rec_win.title("Diktat an Cursorposition einfügen")
rec_win.transient(parent_win)
rec_win.geometry("420x150")
rec_win.configure(bg="#B9ECFA")
rec_win.minsize(350, 130)
rec_win.attributes("-topmost", True)
self._register_window(rec_win)
add_resize_grip(rec_win, 350, 130)
apply_initial_scaling_to_window(rec_win)
rf = ttk.Frame(rec_win, padding=16)
rf.pack(fill="both", expand=True)
status_var = tk.StringVar(value="Bereit. Cursor im Text setzen, dann Aufnahme starten.")
ttk.Label(rf, textvariable=status_var).pack(pady=(0, 4))
autocopy_var = tk.BooleanVar(value=self._is_autocopy_enabled())
cb_autocopy = ttk.Checkbutton(rf, text="Autocopy nach Diktat", variable=autocopy_var)
cb_autocopy.pack(anchor="w", pady=(0, 8))
def _save_autocopy_from_cb():
self._autotext_data["autocopy_after_diktat"] = bool(autocopy_var.get())
save_autotext(self._autotext_data)
cb_autocopy.configure(command=_save_autocopy_from_cb)
diktat_rec = [None]
is_rec = [False]
def toggle_rec():
if not diktat_rec[0]:
diktat_rec[0] = AudioRecorder()
rec = diktat_rec[0]
if not is_rec[0]:
try:
rec.start()
is_rec[0] = True
btn_rec.configure(text="⏹ Stoppen")
status_var.set("Aufnahme läuft")
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
rec_win.destroy()
else:
is_rec[0] = False
btn_rec.configure(text="⏺ Aufnahme starten")
status_var.set("Transkribiere")
def worker():
try:
wav_path = rec.stop_and_save_wav()
transcript_text = self.transcribe_wav(wav_path)
transcript_text = self._diktat_apply_punctuation(transcript_text)
try:
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
self.after(0, lambda: _insert_done(transcript_text))
except Exception as e:
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
self.after(0, lambda: rec_win.destroy())
def _insert_done(text):
diktat_rec[0] = None
if text:
idx = text_widget.index(tk.INSERT)
text_widget.insert(idx, text)
if autocopy_var.get():
if not _win_clipboard_set(text):
try:
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(text))
except Exception:
pass
if status_callback:
status_callback("Diktat an Cursorposition eingefügt.")
status_var.set("Fertig.")
rec_win.destroy()
threading.Thread(target=worker, daemon=True).start()
btn_rec = RoundedButton(
rf, "⏺ Aufnahme starten", command=toggle_rec,
width=160, height=32, canvas_bg="#B9ECFA",
)
btn_rec.pack()
def ensure_ready(self):
if not self.client:
messagebox.showinfo(
"KI-Verbindung",
"Die KI-Verbindung ist noch nicht eingerichtet.\n\n"
"Bitte richten Sie den Zugang über den Startmenü-Eintrag\n"
"\"AZA \u2013 OpenAI Schlüssel einrichten\" ein\n"
"und starten Sie AZA anschliessend neu.",
)
return False
return True
def toggle_record(self):
if not self.ensure_ready():
return
if not self._check_ai_consent():
return
if not self.is_recording:
self._new_session()
try:
self.recorder.start()
self.is_recording = True
self._recording_mode = "new"
self.btn_record.configure(text="⏹ Stopp")
self.btn_record_append.configure(text="⏺ Korrigieren")
mini_start = getattr(self, "_mini_btn_start", None)
if mini_start:
try:
if mini_start.winfo_exists():
mini_start.configure(text="⏹ Stopp")
except Exception:
pass
mini_korr = getattr(self, "_mini_btn_korrigieren", None)
if mini_korr:
try:
if mini_korr.winfo_exists():
mini_korr.configure(text="⏺ Korrigieren")
except Exception:
pass
self.set_status("Aufnahme läuft")
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
self.set_status("Bereit.")
else:
self._stop_and_process_recording()
def _toggle_record_append(self):
"""Aufnahme korrigieren: ergänzt die bestehende KG, ohne sie zu löschen."""
if not self.ensure_ready():
return
if not self._check_ai_consent():
return
if not self.is_recording:
try:
self.recorder.start()
self.is_recording = True
self._recording_mode = "append"
self.btn_record_append.configure(text="⏹ Stopp")
self.btn_record.configure(text="⏺ Start")
mini_korr = getattr(self, "_mini_btn_korrigieren", None)
if mini_korr:
try:
if mini_korr.winfo_exists():
mini_korr.configure(text="⏹ Stopp")
except Exception:
pass
mini_start = getattr(self, "_mini_btn_start", None)
if mini_start:
try:
if mini_start.winfo_exists():
mini_start.configure(text="⏺ Start")
except Exception:
pass
self.set_status("Korrektur-Aufnahme läuft (sprich jetzt)")
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
self.set_status("Bereit.")
else:
self._stop_and_process_recording()
def _stop_and_process_recording(self):
"""Stoppt die Aufnahme und verarbeitet sie (neu oder Korrektur)."""
self.is_recording = False
mode = getattr(self, "_recording_mode", "new")
self.btn_record.configure(text="⏺ Start")
self.btn_record_append.configure(text="⏺ Korrigieren")
mini_start = getattr(self, "_mini_btn_start", None)
if mini_start:
try:
if mini_start.winfo_exists():
mini_start.configure(text="⏺ Start")
except Exception:
pass
mini = getattr(self, "_mini_btn_korrigieren", None)
if mini:
try:
if mini.winfo_exists():
mini.configure(text="⏺ Korrigieren")
except Exception:
pass
self.set_status("Stoppe Aufnahme")
existing_transcript = self.txt_transcript.get("1.0", "end").strip()
existing_kg = self.txt_output.get("1.0", "end").strip()
def worker():
def _safe_after(fn):
try:
if self.winfo_exists():
self.after(0, fn)
except Exception:
pass
try:
wav_path = self.recorder.stop_and_save_wav()
self.last_wav_path = wav_path
new_transcript = self.transcribe_wav(wav_path)
_safe_after(lambda: self._next_phase("kg"))
if mode == "append" and existing_transcript:
full_transcript = existing_transcript + "\n\n" + new_transcript
if existing_kg:
kg = strip_kg_warnings(self.merge_kg(existing_kg, full_transcript))
else:
kg = strip_kg_warnings(self.summarize_text(full_transcript))
else:
full_transcript = new_transcript
kg = strip_kg_warnings(self.summarize_text(full_transcript))
_safe_after(lambda: self._fill_transcript(full_transcript))
_safe_after(lambda: self._fill_kg_and_finish(kg))
except Exception as e:
_safe_after(lambda: self._stop_timer())
_safe_after(lambda: self.set_status(f"Fehler: {e}" if e else "Bereit."))
try:
self._debug_log(f"RECORDING_WORKER_ERROR error={repr(e)}\n{traceback.format_exc()}")
except Exception:
pass
_safe_after(lambda err=str(e): messagebox.showerror("Transkription fehlgeschlagen", err))
finally:
try:
if self.last_wav_path and os.path.exists(self.last_wav_path):
os.remove(self.last_wav_path)
except Exception:
pass
self.last_wav_path = None
self._start_timer("transcribe")
threading.Thread(target=worker, daemon=True).start()
_WHISPER_MEDICAL_PROMPT = (
"Transkribiere ausschliesslich den gesprochenen Inhalt woertlich auf Deutsch. "
"Antworte niemals auf Fragen, gib keine Erklaerungen, keine Zusammenfassung, keine Interpretation. "
"Wenn ein Wort unklar ist, schreibe die wahrscheinlichste gehoerte Form. "
"Medizinische Dokumentation auf Deutsch. "
"Capillitium, Fotodynamische Therapie, PDT, Basalzellkarzinom, Plattenepithelkarzinom, "
"Spinaliom, Spinaliom der Haut, Spinalzellkarzinom, "
"Melanom, Exzision, Biopsie, Kryotherapie, Kürettage, Histologie, Dermatoskopie, "
# Nävi / Muttermale
"Nävus, Nävi, Naevus, Naevi, Nävuszellnävus, dysplastischer Nävus, "
"Compound-Nävus, junktionaler Nävus, dermaler Nävus, Spitz-Nävus, "
# Effloreszenzen
"Erythem, Papel, Pustel, Makula, Plaque, Nodulus, Nodus, "
"Vesikel, Bulla, Erosion, Ulkus, Rhagade, Kruste, Squama, "
"Effloreszenzen, Lichenifikation, Exkoriation, "
# Häufige Diagnosen Dermatologie
"seborrhoische Keratose, Fibrom, Lipom, Atherom, Epidermoidzyste, "
"Verruca vulgaris, Verrucae, Kondylome, Molluscum contagiosum, "
"Hämangiom, Angiom, Keloid, hypertrophe Narbe, "
"Tinea, Mykose, Onychomykose, Herpes simplex, Herpes zoster, "
"Erysipel, Impetigo, Abszess, Phlegmone, Skabies, "
"Pemphigus, Pemphigoid, Lichen ruber, Lichen sclerosus, "
"Vitiligo, Pruritus, Prurigo, Mykosis fungoides, "
# Eingriffe / Befunde
"Shave-Biopsie, Stanzbiopsie, Inzisionsbiopsie, "
"Breslow-Dicke, Clark-Level, Sentinel-Lymphknoten, "
"Auflichtmikroskopie, Phototherapie, UVB, PUVA, "
# Allgemeinmedizin
"Anamnese, Diagnose, Therapie, Procedere, subjektiv, objektiv, "
"Abdomen, Thorax, Extremitäten, zervikal, lumbal, thorakal, sakral, "
"Sonographie, Röntgen, MRI, CT, EKG, Laborwerte, Blutbild, "
"Hypertonie, Diabetes mellitus, Hypercholesterinämie, Hypothyreose, "
"Antikoagulation, Thrombozytenaggregationshemmer, NSAR, ACE-Hemmer, "
"Immunsuppression, Kortikosteroide, Biologika, Methotrexat, "
"Psoriasis, Ekzem, Dermatitis, Urtikaria, Alopezie, Akne, Rosazea, "
"Aktinische Keratose, Morbus Bowen, Lentigo maligna, "
"Januar 2026, Februar 2026, März 2026, April 2026, Mai 2026, "
"Status nach, Z.n., s/p, i.v., p.o., s.c., "
"ICD-10, SOAP, Krankengeschichte, Kostengutsprache, Arztbrief."
)
_WHISPER_PROMPT_PREFIX = "Medizinische Dokumentation auf Deutsch"
_WHISPER_GENERAL_PROMPT = (
"Transkribiere ausschliesslich den gesprochenen Inhalt woertlich auf Deutsch. "
"Antworte niemals auf Fragen, gib keine Erklaerungen, keine Zusammenfassung, keine Interpretation. "
"Allgemeines Diktat auf Deutsch mit sinnvoller Zeichensetzung."
)
_WHISPER_GENERAL_PROMPT_PREFIX = "Allgemeines Diktat auf Deutsch"
def _build_transcribe_domain_toggle(self, parent):
"""Zwei exklusive Häkchen: Medizinisch vs. Allgemein."""
domain = str((self._autotext_data or {}).get("transcribe_domain", "medical")).strip().lower()
if domain not in ("medical", "general"):
domain = "medical"
self._transcribe_medical_var = tk.BooleanVar(value=(domain == "medical"))
self._transcribe_general_var = tk.BooleanVar(value=(domain == "general"))
self._transcribe_toggle_guard = False
box = ttk.Frame(parent)
box.pack(side="left", padx=(10, 0), anchor="n")
ttk.Label(box, text="Diktat:").pack(side="left", padx=(0, 4))
def _persist(domain_value: str):
try:
self._autotext_data["transcribe_domain"] = domain_value
save_autotext(self._autotext_data)
except Exception:
pass
def _set_domain(domain_value: str):
if getattr(self, "_transcribe_toggle_guard", False):
return
self._transcribe_toggle_guard = True
try:
if domain_value == "general":
self._transcribe_medical_var.set(False)
self._transcribe_general_var.set(True)
else:
self._transcribe_medical_var.set(True)
self._transcribe_general_var.set(False)
_persist(domain_value)
finally:
self._transcribe_toggle_guard = False
def _on_medical_toggle():
if self._transcribe_medical_var.get():
_set_domain("medical")
elif not self._transcribe_general_var.get():
_set_domain("medical")
def _on_general_toggle():
if self._transcribe_general_var.get():
_set_domain("general")
elif not self._transcribe_medical_var.get():
_set_domain("medical")
tk.Checkbutton(
box, text="Medizin", variable=self._transcribe_medical_var,
command=_on_medical_toggle, bg="#B9ECFA", fg="#1a4d6d",
activebackground="#B9ECFA", selectcolor="#E8F4FA",
).pack(side="left", padx=(0, 4))
tk.Checkbutton(
box, text="Allgemein", variable=self._transcribe_general_var,
command=_on_general_toggle, bg="#B9ECFA", fg="#1a4d6d",
activebackground="#B9ECFA", selectcolor="#E8F4FA",
).pack(side="left")
# Persistiert Normalisierung beim Start.
_persist("general" if domain == "general" else "medical")
def _set_transcribe_domain(self, domain_value: str):
domain = "general" if str(domain_value).strip().lower() == "general" else "medical"
if getattr(self, "_transcribe_toggle_guard", False):
return
self._transcribe_toggle_guard = True
try:
if hasattr(self, "_transcribe_medical_var"):
self._transcribe_medical_var.set(domain == "medical")
if hasattr(self, "_transcribe_general_var"):
self._transcribe_general_var.set(domain == "general")
try:
self._autotext_data["transcribe_domain"] = domain
save_autotext(self._autotext_data)
except Exception:
pass
finally:
self._transcribe_toggle_guard = False
def _get_transcribe_domain(self) -> str:
try:
if hasattr(self, "_transcribe_general_var") and bool(self._transcribe_general_var.get()):
return "general"
if hasattr(self, "_transcribe_medical_var") and bool(self._transcribe_medical_var.get()):
return "medical"
except Exception:
pass
domain = str((self._autotext_data or {}).get("transcribe_domain", "medical")).strip().lower()
return "general" if domain == "general" else "medical"
def _get_transcribe_prompt(self) -> str:
return self._WHISPER_GENERAL_PROMPT if self._get_transcribe_domain() == "general" else self._WHISPER_MEDICAL_PROMPT
def _transcribe_local(self, wav_path: str) -> str:
"""Lokale OpenAI-Transkription."""
with open(wav_path, "rb") as f:
is_gpt_transcribe = "gpt-" in TRANSCRIBE_MODEL
params = dict(model=TRANSCRIBE_MODEL, file=f, language="de")
selected_prompt = self._get_transcribe_prompt()
if is_gpt_transcribe:
params["prompt"] = selected_prompt
else:
params["prompt"] = selected_prompt
params["temperature"] = 0.0
resp = self.client.audio.transcriptions.create(**params)
text = getattr(resp, "text", "")
if text is None:
text = ""
stripped = text.strip()
if stripped.startswith(self._WHISPER_PROMPT_PREFIX) or stripped.startswith(self._WHISPER_GENERAL_PROMPT_PREFIX):
text = ""
estimated_tokens = len(text) // 4
add_token_usage(estimated_tokens)
self.after(0, self.update_token_display)
return text
_TRANSCRIBE_BACKEND_TIMEOUT = (5, 300) # (connect_timeout_s, read_timeout_s)
@staticmethod
def _read_backend_value_file(filename: str):
def _read_at(p: str):
try:
with open(p, "r", encoding="utf-8-sig") as f:
v = (f.read() or "").replace("\ufeff", "").strip(" \t\r\n")
return v if v else None
except Exception:
return None
script_dir = os.path.dirname(os.path.abspath(__file__))
for base in (script_dir, os.getcwd()):
if base:
p = os.path.join(base, filename)
if os.path.isfile(p):
v = _read_at(p)
if v:
return v
return None
@staticmethod
def _clean_backend_value(value: str):
if value is None:
return None
v = str(value).replace("\ufeff", "").strip(" \t\r\n")
return v if v else None
def get_backend_url(self):
url = self._clean_backend_value(os.getenv("MEDWORK_BACKEND_URL"))
if url:
return url.rstrip("/")
url = self._read_backend_value_file("backend_url.txt")
if url:
return url.rstrip("/")
raise RuntimeError("Backend-URL fehlt: MEDWORK_BACKEND_URL oder backend_url.txt setzen.")
def get_backend_token(self):
# backend_token.txt hat Vorrang (vermeidet Konflikte mit Umgebungsvariablen)
token = self._read_backend_value_file("backend_token.txt")
if token:
return token
tokens_env = os.getenv("MEDWORK_API_TOKENS")
if tokens_env and tokens_env.strip():
token = self._clean_backend_value(tokens_env.split(",")[0].strip())
else:
token = self._clean_backend_value(os.getenv("MEDWORK_API_TOKEN"))
if token:
return token
raise RuntimeError("Backend-Token fehlt: backend_token.txt oder MEDWORK_API_TOKEN setzen.")
def _open_billing_portal_from_ui(self):
try:
backend_url = self.get_backend_url()
api_token = self.get_backend_token()
except Exception:
self.set_status("Billing-Portal nicht verfügbar.")
return
if open_billing_portal(backend_url=backend_url, api_token=api_token):
self.set_status("Billing-Portal geöffnet.")
else:
self.set_status("Billing-Portal konnte nicht geöffnet werden.")
def _debug_log(self, msg: str):
try:
path = os.path.join(get_writable_data_dir(), "client_debug.log")
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
with open(path, "a", encoding="utf-8") as f:
f.write(f"[{ts}] {msg}\n")
except Exception:
pass
def _backend_health_state(self):
try:
backend_url = self.get_backend_url()
except Exception as e:
return False, "", str(e)
try:
with requests.Session() as session:
session.trust_env = False
session.proxies = {"http": None, "https": None}
r = session.get(f"{backend_url}/health", timeout=(1.5, 2.5))
if r.ok:
return True, backend_url, "ok"
return False, backend_url, f"HTTP {r.status_code}"
except Exception as e:
return False, backend_url, str(e)
def _refresh_backend_status(self):
ok, backend_url, detail = self._backend_health_state()
try:
if ok:
self._backend_status_var.set("Verbunden")
self._backend_status_label.configure(fg="#2E7D32")
else:
self._backend_status_var.set("Nicht verbunden")
self._backend_status_label.configure(fg="#BD4500")
tooltip = f"Server: {backend_url or 'nicht konfiguriert'}\nStatus: {'verbunden' if ok else 'nicht verbunden'}\n{detail}"
if not ok:
try:
from aza_firewall import get_firewall_hint_text
tooltip += "\n\n" + get_firewall_hint_text()
except Exception:
pass
add_tooltip(self._backend_status_label, tooltip)
except Exception:
pass
try:
if self._backend_status_after_id:
self.after_cancel(self._backend_status_after_id)
except Exception:
pass
try:
self._backend_status_after_id = self.after(10000, self._refresh_backend_status)
except Exception:
self._backend_status_after_id = None
def _start_backend_from_ui(self):
ok, backend_url, detail = self._backend_health_state()
if ok:
self.set_status("Backend läuft bereits.")
return
if not backend_url:
self.set_status("Backend-URL fehlt.")
messagebox.showerror("Backend starten", "Backend-URL fehlt (backend_url.txt oder MEDWORK_BACKEND_URL).")
return
parsed = urlparse(backend_url)
host = (parsed.hostname or "").strip().lower()
if host not in ("127.0.0.1", "localhost", "0.0.0.0"):
messagebox.showinfo("Backend starten", f"Automatischer Start nur für lokales Backend.\nAktuell: {backend_url}")
return
backend_script = os.path.join(os.path.dirname(__file__), "backend_main.py")
if not os.path.isfile(backend_script):
self.set_status("backend_main.py nicht gefunden.")
return
env = os.environ.copy()
try:
token = self.get_backend_token()
if token:
env["MEDWORK_API_TOKEN"] = token
except Exception:
pass
env.setdefault("AZA_TLS_REQUIRE", "0")
flags = 0
if sys.platform == "win32":
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
try:
subprocess.Popen(
[sys.executable, backend_script],
cwd=os.path.dirname(backend_script),
env=env,
creationflags=flags,
)
self.set_status("Backend wird gestartet...")
except Exception as e:
self.set_status(f"Backend-Start fehlgeschlagen: {e}")
err_msg = f"Start fehlgeschlagen:\n{e}\n\nLetzter Status: {detail}"
try:
from aza_firewall import get_firewall_hint_text
err_msg += "\n\n---\n" + get_firewall_hint_text()
except Exception:
pass
messagebox.showerror("Backend starten", err_msg)
return
def _wait_until_up():
for _ in range(12):
time.sleep(1.0)
ok_now, _, _ = self._backend_health_state()
if ok_now:
try:
self.after(0, lambda: self.set_status("Backend gestartet und erreichbar."))
except Exception:
pass
break
try:
self.after(0, self._refresh_backend_status)
except Exception:
pass
threading.Thread(target=_wait_until_up, daemon=True).start()
def _auto_start_backend_if_needed(self):
"""Startet das lokale Backend einmalig automatisch, falls es offline ist."""
if getattr(self, "_backend_autostart_attempted", False):
return
self._backend_autostart_attempted = True
ok, backend_url, _ = self._backend_health_state()
if ok:
return
if not backend_url:
return
parsed = urlparse(backend_url)
host = (parsed.hostname or "").strip().lower()
if host not in ("127.0.0.1", "localhost", "0.0.0.0"):
return
self.set_status("Backend offline starte automatisch...")
self._start_backend_from_ui()
def _resolve_audio_notiz_script(self) -> str | None:
"""Pfad zu audio_notiz_app.py (nur fuer Dev-Modus/Subprocess)."""
if getattr(sys, "frozen", False):
base = os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base, "apps", "diktat", "audio_notiz_app.py")
return path if os.path.isfile(path) else None
def _start_audio_notiz_addon(self, silent: bool = False) -> bool:
if not self._check_ai_consent():
return False
existing = getattr(self, "_audio_notiz_window", None)
if existing is not None:
try:
if hasattr(existing, "_toplevel") and existing._toplevel.winfo_exists():
existing._toplevel.deiconify()
existing._toplevel.lift()
return True
elif hasattr(existing, "winfo_exists") and existing.winfo_exists():
existing.deiconify()
existing.lift()
return True
except Exception:
pass
self._audio_notiz_window = None
if getattr(sys, "frozen", False):
try:
from apps.diktat.diktat_app import DiktatApp
app = DiktatApp(_as_toplevel_of=self)
self._audio_notiz_window = app
self.set_status("Audio-Notiz gestartet.")
return True
except Exception as e:
if not silent:
messagebox.showerror(
"Audio-Notiz",
f"Audio-Notiz konnte nicht gestartet werden:\n{e}"
)
return False
script_path = self._resolve_audio_notiz_script()
if not script_path:
if not silent:
messagebox.showerror(
"Audio-Notiz",
"audio_notiz_app.py nicht gefunden.\nErwartet unter apps/diktat/."
)
return False
try:
kwargs = {"cwd": os.path.dirname(script_path)}
if sys.platform == "win32":
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
if os.path.exists(pythonw):
subprocess.Popen([pythonw, script_path], **kwargs)
else:
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.Popen([sys.executable, script_path], **kwargs)
else:
subprocess.Popen([sys.executable, script_path], **kwargs)
self.set_status("Audio-Notiz gestartet.")
return True
except Exception as e:
if not silent:
messagebox.showerror("Audio-Notiz", f"Start fehlgeschlagen:\n{e}")
return False
def _auto_start_audio_notiz_if_enabled(self):
if getattr(self, "_audio_notiz_autostart_attempted", False):
return
self._audio_notiz_autostart_attempted = True
if not bool(self._autotext_data.get("audio_notiz_autostart", True)):
return
self._start_audio_notiz_addon(silent=True)
def transcribe_file_via_backend_with_fallback(self, audio_path: str) -> str:
"""Backend-Transkription ohne lokalen Fallback; Fehler werden hart geworfen."""
backend_url = self.get_backend_url()
backend_token = (self.get_backend_token() or "").strip()
audio_name = os.path.basename(audio_path)
x_user = (self._get_consent_user_id() or "default").strip() or "default"
health_response = None
response = None
def _latin1_safe(value) -> str:
s = value if isinstance(value, str) else str(value or "")
s = (
s.replace("\u2013", "-")
.replace("\u2014", "-")
.replace("\u2018", "'")
.replace("\u2019", "'")
.replace("\u201c", '"')
.replace("\u201d", '"')
)
return s.encode("latin-1", "ignore").decode("latin-1")
audio_name = _latin1_safe(audio_name)
x_user = _latin1_safe(x_user)
backend_token = _latin1_safe(backend_token)
device_id = _latin1_safe(_get_or_create_device_id())
ext = os.path.splitext(audio_path)[1].lower()
upload_ct = "audio/mp4" if ext == ".m4a" else "audio/wav"
try:
self._debug_log(
f"TRANSCRIBE_REQUEST url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} file={audio_name} x_user={x_user}"
)
headers = {"X-API-Token": backend_token, "X-User": x_user, "X-Device-Id": device_id}
with requests.Session() as session:
session.trust_env = False
session.proxies = {"http": None, "https": None}
health_response = session.get(f"{backend_url}/health", timeout=(2, 5))
health_body_snippet = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
self._debug_log(
f"TRANSCRIBE_HEALTH url={backend_url} status={health_response.status_code} body={health_body_snippet}"
)
if not health_response.ok:
raise RuntimeError(
f"Backend-Healthcheck fehlgeschlagen (HTTP {health_response.status_code}). Body: {health_body_snippet}"
)
with open(audio_path, "rb") as f:
response = session.post(
f"{backend_url}/v1/transcribe",
files={"file": (audio_name, f, upload_ct)},
data={
"language": "de",
"prompt": self._get_transcribe_prompt(),
"domain": self._get_transcribe_domain(),
"specialty": self._autotext_data.get("user_specialty_default", "dermatology"),
},
headers=headers,
timeout=self._TRANSCRIBE_BACKEND_TIMEOUT,
)
body_snippet = ((response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
if not response.ok:
status = response.status_code
if status in (401, 403):
msg = (
f"Token fehlt/ungueltig (HTTP {status}).\n\n"
"Loesung: Backend neu starten (Token aus backend_token.txt wird dann geladen).\n"
"Stelle sicher, dass backend_token.txt und Backend im gleichen Projektordner liegen."
)
elif status == 404:
msg = f"Endpoint nicht gefunden: /v1/transcribe (HTTP 404). Body: {body_snippet}"
elif status >= 500:
msg = f"Backend error (HTTP {status}). Body: {body_snippet}"
else:
msg = f"HTTP-Fehler vom Backend (HTTP {status}). Body: {body_snippet}"
self._debug_log(
f"TRANSCRIBE_HTTP_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
f"file={audio_name} status={status} body={body_snippet}"
)
raise RuntimeError(msg)
try:
data = response.json()
except Exception:
self._debug_log(
f"TRANSCRIBE_JSON_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
f"file={audio_name} status={response.status_code} body={((response.text or '')[:500]).replace(chr(10), ' ')}"
)
raise RuntimeError("Ungueltige JSON-Antwort vom Backend.")
if not data.get("success"):
backend_err = (data.get("error") or "").strip()
if backend_err:
raise RuntimeError(f"Backend meldet success != true. Fehler: {backend_err}")
raise RuntimeError("Backend meldet success != true.")
text = (data.get("transcript") or "").strip()
if not text:
raise RuntimeError("Backend lieferte leeres Transkript.")
estimated_tokens = len(text) // 4
add_token_usage(estimated_tokens)
try:
self.after(0, self.update_token_display)
except Exception:
pass
dur = data.get("duration_ms", "?")
try:
self.after(0, lambda: self.set_status(f"Transkription via Backend ({dur} ms)"))
except Exception:
pass
return text
except requests.exceptions.ConnectTimeout as e:
health_status = "n/a" if health_response is None else health_response.status_code
health_body = ""
if health_response is not None:
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
self._debug_log(
f"TRANSCRIBE_CONNECT_TIMEOUT url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
f"file={audio_name} exception={repr(e)} health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
)
try:
self.after(0, lambda: self.set_status("Backend nicht erreichbar (Verbindungs-Timeout)."))
except Exception:
pass
raise RuntimeError("Backend nicht erreichbar (Verbindungs-Timeout).")
except requests.exceptions.Timeout as e:
body_snippet = ""
status = "n/a"
if response is not None:
status = response.status_code
body_snippet = ((response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
health_status = "n/a" if health_response is None else health_response.status_code
health_body = ""
if health_response is not None:
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
self._debug_log(
f"TRANSCRIBE_TIMEOUT url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
f"file={audio_name} exception={repr(e)} status={status} body={body_snippet} "
f"health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
)
try:
self.after(0, lambda: self.set_status("Backend-Timeout bei Transkription."))
except Exception:
pass
raise RuntimeError("Timeout bei Backend-Transkription.")
except requests.exceptions.ConnectionError as e:
health_status = "n/a" if health_response is None else health_response.status_code
health_body = ""
if health_response is not None:
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
self._debug_log(
f"TRANSCRIBE_CONNECTION_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
f"file={audio_name} exception={repr(e)} health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
)
try:
self.after(0, lambda: self.set_status("Backend nicht erreichbar."))
except Exception:
pass
raise RuntimeError("Backend nicht erreichbar.")
except Exception as e:
body_snippet = ""
status = "n/a"
if response is not None:
status = response.status_code
body_snippet = ((response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
health_status = "n/a" if health_response is None else health_response.status_code
health_body = ""
if health_response is not None:
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
self._debug_log(
f"TRANSCRIBE_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
f"file={audio_name} exception={repr(e)} status={status} body={body_snippet} "
f"health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
)
try:
self.after(0, lambda: self.set_status("Backend-Transkription fehlgeschlagen."))
except Exception:
pass
raise
def transcribe_wav(self, wav_path: str) -> str:
uid = self._get_consent_user_id()
if not has_valid_consent(uid):
log_event("AI_BLOCKED", uid, success=False, detail="transcribe, kein Consent")
raise RuntimeError("KI-Einwilligung fehlt oder wurde widerrufen.")
log_event("AI_TRANSCRIBE", uid)
chunks = split_audio_into_chunks(wav_path)
parts: list[str] = []
try:
for i, chunk_path in enumerate(chunks):
if len(chunks) > 1:
try:
self.after(0, lambda idx=i, total=len(chunks):
self.set_status(f"Transkribiere Teil {idx+1}/{total}"))
except Exception:
pass
part = self.transcribe_file_via_backend_with_fallback(chunk_path)
if part:
parts.append(part)
finally:
for cp in chunks:
if cp != wav_path:
try:
os.remove(cp)
except Exception:
pass
text = "\n\n".join(parts)
return text.replace("ß", "ss") if text else text
def _get_consent_user_id(self) -> str:
return self._user_profile.get("name", "default")
def _check_ai_consent(self) -> bool:
"""Prueft ob eine gueltige KI-Einwilligung vorliegt. Zeigt Dialog falls nicht."""
uid = self._get_consent_user_id()
if has_valid_consent(uid):
return True
return self._show_consent_dialog()
def _show_consent_dialog(self) -> bool:
"""Zeigt den Einwilligungsdialog. Gibt True zurueck bei Zustimmung."""
consent_text = ""
try:
legal_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "legal", "ai_consent.md")
with open(legal_path, "r", encoding="utf-8") as fh:
consent_text = fh.read()
except (FileNotFoundError, OSError):
consent_text = "(KI-Einwilligungstext konnte nicht geladen werden.)"
result = {"accepted": False}
dlg = tk.Toplevel(self)
dlg.title("KI-Einwilligung erforderlich")
dlg.transient(self)
dlg.grab_set()
dlg.geometry("680x520")
dlg.minsize(500, 400)
dlg.attributes("-topmost", True)
self._register_window(dlg)
add_resize_grip(dlg, 500, 400)
frame = ttk.Frame(dlg, padding=12)
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Einwilligung zur KI-gestuetzten Datenverarbeitung",
font=("Segoe UI", 11, "bold")).pack(anchor="w", pady=(0, 8))
ttk.Label(frame, text="Bitte lesen Sie den folgenden Text und stimmen Sie zu,\n"
"bevor KI-Funktionen genutzt werden koennen.",
wraplength=600).pack(anchor="w", pady=(0, 8))
from tkinter.scrolledtext import ScrolledText
txt = ScrolledText(frame, wrap="word", font=("Segoe UI", 9), bg="#FAFAFA", height=16)
txt.pack(fill="both", expand=True, pady=(0, 8))
txt.insert("1.0", consent_text)
txt.configure(state="disabled")
check_var = tk.BooleanVar(value=False)
cb = ttk.Checkbutton(frame,
text="Ich habe den Text gelesen und stimme der KI-gestuetzten Verarbeitung zu.",
variable=check_var)
cb.pack(anchor="w", pady=(4, 8))
btn_frame = ttk.Frame(frame)
btn_frame.pack(fill="x")
def on_accept():
if not check_var.get():
messagebox.showwarning("Einwilligung", "Bitte setzen Sie das Haekchen, um zuzustimmen.", parent=dlg)
return
uid = self._get_consent_user_id()
record_consent(uid, source="ui")
log_event("CONSENT_GRANT", uid)
result["accepted"] = True
dlg.destroy()
def on_decline():
dlg.destroy()
ttk.Button(btn_frame, text="Zustimmen", command=on_accept).pack(side="left", padx=(0, 8))
ttk.Button(btn_frame, text="Ablehnen", command=on_decline).pack(side="left")
dlg.wait_window()
return result["accepted"]
def call_chat_completion(self, **kwargs):
"""Wrapper für chat.completions.create mit automatischem Token-Tracking, Soft-Lock und CH-Rechtschreibung (ss statt ß)."""
uid = self._get_consent_user_id()
if not has_valid_consent(uid):
log_event("AI_BLOCKED", uid, success=False, detail="chat, kein Consent")
raise RuntimeError("KI-Einwilligung fehlt oder wurde widerrufen.")
if is_capacity_low():
remaining = get_remaining_tokens()
if remaining <= 0:
messagebox.showwarning(
"KI-Kapazität aufgebraucht",
"Ihr KI-Guthaben ist aufgebraucht.\n\n"
"Bitte laden Sie unter aza-medwork.ch\n"
"neue Einheiten nach.",
)
raise RuntimeError("KI-Kapazität aufgebraucht.")
messagebox.showinfo(
"KI-Kapazität niedrig",
f"Ihr KI-Guthaben ist fast aufgebraucht.\n"
f"Verbleibend: {remaining:,} Einheiten.\n\n"
"Sie können unter aza-medwork.ch\n"
"Guthaben nachladen.",
)
model = kwargs.get("model", "unknown")
log_event("AI_CHAT", uid, detail=f"model={model}")
resp = self.client.chat.completions.create(**kwargs)
if hasattr(resp, 'usage'):
total_tokens = getattr(resp.usage, 'total_tokens', 0)
if total_tokens > 0:
add_token_usage(total_tokens)
self.after(0, self.update_token_display)
if resp and resp.choices:
for choice in resp.choices:
if choice.message and choice.message.content:
choice.message.content = choice.message.content.replace("ß", "ss")
return resp
def _demo_usage_path(self) -> str:
return os.path.join(get_writable_data_dir(), DEMO_USAGE_FILE)
def _load_demo_usage_count(self) -> int:
try:
with open(self._demo_usage_path(), "r", encoding="utf-8") as f:
data = json.load(f)
return int(data.get("count", 0))
except Exception:
return 0
def _save_demo_usage_count(self, count: int):
try:
with open(self._demo_usage_path(), "w", encoding="utf-8") as f:
json.dump({"count": int(max(0, count))}, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _demo_limit_reached(self) -> bool:
return self.license_mode == "demo" and self._load_demo_usage_count() >= DEMO_MAX_DICTATIONS
def _increment_demo_usage_if_needed(self):
if self.license_mode != "demo":
return
current = self._load_demo_usage_count()
self._save_demo_usage_count(current + 1)
def _show_demo_limit_message(self):
messagebox.showerror("Demo-Limit", "Demo-Limit erreicht. Bitte Lizenz aktivieren.")
def open_brief_window(self):
if self._demo_limit_reached():
self._show_demo_limit_message()
return
return TextWindowsMixin.open_brief_window(self)
@staticmethod
def _diktat_apply_punctuation(text: str) -> str:
"""Ersetzt gesprochene Satzzeichen/Anweisungen durch echte Zeichen (nur im Diktat-Fenster)."""
if not text or not text.strip():
return text
t = text
# Zeilenumbrüche / Absätze zuerst
t = re.sub(r"\s+neuer\s+Absatz\s*", "\n\n", t, flags=re.IGNORECASE)
t = re.sub(r"\s+neue\s+Zeile\s*", "\n", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Zeilenumbruch\s*", "\n", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Absatz\s+", "\n\n", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Absatz\s*$", "\n\n", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Absatzzeichen\s*", "\n\n", t, flags=re.IGNORECASE)
# Satzzeichen (werden nicht ausgeschrieben, sondern als Zeichen eingefügt)
t = re.sub(r"\s+Punkt\s+", ". ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Punkt\s*$", ".", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Komma\s+", ", ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Komma\s*$", ",", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Semikolon\s+", "; ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Semikolon\s*$", ";", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Strichpunkt\s+", "; ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Strichpunkt\s*$", ";", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Doppelpunkt\s+", ": ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Doppelpunkt\s*$", ":", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Fragezeichen\s+", "? ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Fragezeichen\s*$", "?", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Ausrufezeichen\s+", "! ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Ausrufezeichen\s*$", "!", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Gedankenstrich\s+", " ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Gedankenstrich\s*$", " ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Bindestrich\s+", "-", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Schrägstrich\s+", "/", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Klammer\s+auf\s+", " (", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Klammer\s+zu\s+", ") ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Auslassungspunkte\s+", " ", t, flags=re.IGNORECASE)
t = re.sub(r"\s+Auslassungspunkte\s*$", "", t, flags=re.IGNORECASE)
# Ordinalzahlen: erstens 1., zweitens 2., usw.
ord_map = [
(r"\b(erstens)\b", "1."),
(r"\b(zweitens)\b", "2."),
(r"\b(drittens)\b", "3."),
(r"\b(viertens)\b", "4."),
(r"\b(f\u00fcnftens)\b", "5."),
(r"\b(sechstens)\b", "6."),
(r"\b(siebtens)\b", "7."),
(r"\b(achtens)\b", "8."),
(r"\b(neuntens)\b", "9."),
(r"\b(zehntens)\b", "10."),
]
for pat, repl in ord_map:
t = re.sub(pat, repl, t, flags=re.IGNORECASE)
# Gesprochene Jahreszahlen in Ziffern umwandeln
_year_words = {
"zweitausendzwanzig": "2020", "zweitausendeinundzwanzig": "2021",
"zweitausendzweiundzwanzig": "2022", "zweitausenddreiundzwanzig": "2023",
"zweitausendvierundzwanzig": "2024", "zweitausendf\u00fcnfundzwanzig": "2025",
"zweitausendsechsundzwanzig": "2026", "zweitausendsiebenundzwanzig": "2027",
"zweitausendachtundzwanzig": "2028", "zweitausendneunundzwanzig": "2029",
"zweitausenddreissig": "2030", "zweitausenddrei\u00dfig": "2030",
"neunzehnhundertneunzig": "1990",
"zweitausend": "2000",
}
for word, year in sorted(_year_words.items(), key=lambda x: -len(x[0])):
t = re.sub(r"\b" + word + r"\b", year, t, flags=re.IGNORECASE)
# Gesprochene Tageszahlen vor Monaten: "den dritten Januar" "den 3. Januar"
_day_words = {
"ersten": "1.", "zweiten": "2.", "dritten": "3.", "vierten": "4.",
"f\u00fcnften": "5.", "sechsten": "6.", "siebten": "7.", "achten": "8.",
"neunten": "9.", "zehnten": "10.", "elften": "11.", "zw\u00f6lften": "12.",
"dreizehnten": "13.", "vierzehnten": "14.", "f\u00fcnfzehnten": "15.",
"sechzehnten": "16.", "siebzehnten": "17.", "achtzehnten": "18.",
"neunzehnten": "19.", "zwanzigsten": "20.", "einundzwanzigsten": "21.",
"zweiundzwanzigsten": "22.", "dreiundzwanzigsten": "23.",
"vierundzwanzigsten": "24.", "f\u00fcnfundzwanzigsten": "25.",
"sechsundzwanzigsten": "26.", "siebenundzwanzigsten": "27.",
"achtundzwanzigsten": "28.", "neunundzwanzigsten": "29.",
"dreissigsten": "30.", "drei\u00dfigsten": "30.", "einunddreissigsten": "31.",
"einunddrei\u00dfigsten": "31.",
}
_months = (r"(?:Januar|Februar|M\u00e4rz|April|Mai|Juni|Juli|August|"
r"September|Oktober|November|Dezember)")
for word, day in sorted(_day_words.items(), key=lambda x: -len(x[0])):
t = re.sub(r"\b" + word + r"\s+" + _months,
lambda m: day + " " + m.group(0).split()[-1], t, flags=re.IGNORECASE)
return t
def _build_system_prompt(self, base_prompt: str) -> str:
"""Baut den System-Prompt zusammen: Vorlage (höchste Priorität) + Detail-Level + Basis-Prompt."""
template = load_templates_text().strip()
detail_level = load_kg_detail_level()
detail_instr = get_kg_detail_instruction(detail_level)
diagnosis_coverage_instr = (
"VERBINDLICHE ERGÄNZUNG ZUR DIAGNOSE-ABDECKUNG:\n"
"- Alle im Transkript genannten medizinischen Diagnosen, Verdachtsdiagnosen und klinisch relevanten Nebenbefunde "
"müssen in der Krankengeschichte erwähnt werden (insbesondere auch Nebendiagnosen).\n"
"- Wenn im Transkript z. B. Warzen oder ähnliche Nebenbefunde genannt sind, müssen diese explizit dokumentiert werden.\n"
"- Es darf keine im Transkript klar genannte medizinische Diagnose/Nebendiagnose in der KG fehlen.\n"
"- Verwende in der Diagnose medizinische Fachsprache statt Laienbegriffen (z. B. Warze/Warzen → Verruca vulgaris), "
"mit passendem ICD-10-GM-Code."
)
user_specialty = self._autotext_data.get("user_specialty_default", "")
specialty_label = dict(self._all_medical_specialties()).get(user_specialty, "")
specialty_instr = ""
if specialty_label:
specialty_instr = (
f"FACHGEBIET DES ARZTES: {specialty_label}\n"
f"Der dokumentierende Arzt ist Facharzt für {specialty_label}. "
f"Berücksichtige dies bei der Dokumentation: {specialty_label}-spezifische Terminologie, "
f"Befundbeschreibungen und Diagnosen bevorzugt verwenden, aber auch alle anderen "
f"medizinischen Befunde und Diagnosen vollständig dokumentieren."
)
parts = []
if specialty_instr:
parts.append(specialty_instr)
if template:
parts.append(
"ZWINGENDE VORLAGE DES ARZTES (hat höchste Priorität, MUSS vollständig eingehalten werden):\n"
"Die folgende Vorlage definiert verbindlich, wie die Krankengeschichte aufgebaut und formuliert werden soll. "
"Struktur, Reihenfolge, Stil und alle Vorgaben aus dieser Vorlage haben Vorrang vor allen anderen Anweisungen. "
"Baue die Krankengeschichte ZUERST nach dieser Vorlage auf, dann ergänze fehlende Standardabschnitte.\n\n"
f"{template}"
)
parts.append(base_prompt)
if detail_instr:
parts.append(detail_instr)
soap_levels = load_soap_section_levels()
soap_instr = get_soap_section_instruction(soap_levels)
if soap_instr:
parts.append(soap_instr)
soap_visibility = load_soap_visibility()
soap_order = load_soap_order()
order_instr = get_soap_order_instruction(soap_order, soap_visibility)
if order_instr:
parts.append(order_instr)
vis_instr = get_soap_visibility_instruction(soap_visibility)
if vis_instr:
parts.append(vis_instr)
parts.append(diagnosis_coverage_instr)
return "\n\n".join(parts)
def summarize_text(self, transcript: str) -> str:
if self._demo_limit_reached():
self._stop_timer()
self.set_status("Demo-Limit erreicht. Bitte Lizenz aktivieren.")
self._show_demo_limit_message()
return ""
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
system_content = self._build_system_prompt(SYSTEM_PROMPT)
user_text = f"""TRANSKRIPT:
{transcript}
WICHTIG:
- Nenne alle im Transkript erwähnten Diagnosen und medizinischen Nebenbefunde in der KG.
- Erwähnte Nebendiagnosen/Nebenbefunde (z. B. Warzen) dürfen nicht fehlen.
- Formuliere Diagnosen in medizinischer Fachsprache (z. B. Warzen als Verruca vulgaris).
"""
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": user_text},
],
)
return resp.choices[0].message.content
def merge_kg(self, existing_kg: str, full_transcript: str) -> str:
"""Ergänzt die bestehende KG um neue Informationen aus dem Transkript."""
if self._demo_limit_reached():
self._stop_timer()
self.set_status("Demo-Limit erreicht. Bitte Lizenz aktivieren.")
self._show_demo_limit_message()
return existing_kg
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
system_content = self._build_system_prompt(MERGE_PROMPT)
user_text = f"""BESTEHENDE KRANKENGESCHICHTE:
{existing_kg}
VOLLSTÄNDIGES TRANSKRIPT (bisher + Ergänzung):
{full_transcript}
Aktualisiere die KG: neue Informationen aus dem Transkript in die passenden Abschnitte einfügen, gleiche Überschriften beibehalten.
WICHTIG:
- Alle im Transkript genannten Diagnosen und medizinischen Nebenbefunde müssen in der aktualisierten KG enthalten sein.
- Das gilt auch für Nebendiagnosen/Nebenbefunde (z. B. Warzen), sofern sie im Transkript genannt sind.
- Formuliere Diagnosen in medizinischer Fachsprache (z. B. Warzen als Verruca vulgaris)."""
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": user_text},
],
)
return resp.choices[0].message.content
def make_kg_from_text(self):
if not self.ensure_ready():
return
transcript = self.txt_transcript.get("1.0", "end").strip()
if not transcript:
self.set_status("Keine Audio vorhanden oder kein Transkript vorhanden.")
return
self._start_timer("kg")
def worker():
try:
kg = strip_kg_warnings(self.summarize_text(transcript))
self.after(0, lambda k=kg: self._fill_kg_and_finish(k))
except Exception as e:
self.after(0, lambda: self._stop_timer())
self.after(0, lambda: self.set_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
threading.Thread(target=worker, daemon=True).start()
def _recompute_kg_inline(self):
"""Generiert die KG neu und schreibt das Ergebnis direkt ins KG-Feld (txt_output) im Hauptfenster, ohne Popup."""
if not self.ensure_ready():
return
transcript = self.txt_transcript.get("1.0", "end").strip()
if not transcript:
self.set_status("Kein Transkript vorhanden.")
return
if not self._check_ai_consent():
return
self._start_timer("kg")
self.set_status("KG wird mit neuer Vorlage neu generiert...")
def worker():
try:
kg = strip_kg_warnings(self.summarize_text(transcript))
def _apply(k):
self._stop_timer()
self.set_status("KG aktualisiert.")
cleaned = k.strip() if k else ""
self.txt_output.delete("1.0", "end")
self.txt_output.insert("1.0", cleaned)
self._autocopy_kg(cleaned)
if cleaned:
try:
save_to_ablage("KG", cleaned)
except Exception:
pass
self.after(0, lambda k=kg: _apply(k))
except Exception as e:
self.after(0, lambda: self._stop_timer())
self.after(0, lambda: self.set_status("Fehler bei KG-Erneuerung."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
threading.Thread(target=worker, daemon=True).start()
# KG bearbeiten: Kürzer / Ausführlicher / Vorlage
def _update_kg_detail_display(self):
"""Aktualisiert die Anzeige des Detail-Levels auf den Buttons."""
level = load_kg_detail_level()
if level < 0:
self.btn_kg_kuerzer.configure(text=f"Kürzer ({level})")
self.btn_kg_ausfuehrlicher.configure(text="Ausführlicher")
elif level > 0:
self.btn_kg_kuerzer.configure(text="Kürzer")
self.btn_kg_ausfuehrlicher.configure(text=f"Ausführlicher (+{level})")
else:
self.btn_kg_kuerzer.configure(text="Kürzer")
self.btn_kg_ausfuehrlicher.configure(text="Ausführlicher")
def _rebuild_soap_section_controls(self):
"""Baut die SOAP-Section-Controls neu auf (Reihenfolge + Sichtbarkeit aus Vorlage)."""
soap_inner = self._soap_inner
bg = self._soap_bg
for w in soap_inner.winfo_children():
w.destroy()
self._soap_section_labels.clear()
order = load_soap_order()
visibility = load_soap_visibility()
_FG = "#1a4d6d"
_ARROW_FG = "#7AAFC8"
_ARROW_HOVER = "#1a4d6d"
for sec_key in order:
if not visibility.get(sec_key, True):
continue
sec_frame = tk.Frame(soap_inner, bg=bg)
sec_frame.pack(side="left", padx=(0, 14))
lv = self._soap_section_levels.get(sec_key, 0)
lbl_text = sec_key if lv == 0 else f"{sec_key} {lv:+d}"
sec_label = tk.Label(sec_frame, text=lbl_text, font=("Segoe UI", 9, "bold"),
bg=bg, fg=_FG, anchor="center", width=4, pady=1)
sec_label.pack(side="left")
self._soap_section_labels[sec_key] = sec_label
btn_up = tk.Label(sec_frame, text="\u25B2", font=("Segoe UI", 8),
bg=bg, fg=_ARROW_FG, cursor="hand2",
bd=0, highlightthickness=0, padx=0, pady=0)
btn_up.pack(side="left", padx=(1, 0))
btn_down = tk.Label(sec_frame, text="\u25BC", font=("Segoe UI", 8),
bg=bg, fg=_ARROW_FG, cursor="hand2",
bd=0, highlightthickness=0, padx=0, pady=0)
btn_down.pack(side="left", padx=(0, 0))
def make_adjust(key, delta):
return lambda e: self._adjust_soap_section(key, delta)
btn_up.bind("<Button-1>", make_adjust(sec_key, +1))
btn_down.bind("<Button-1>", make_adjust(sec_key, -1))
def make_hover(w, enter_fg, leave_fg):
w.bind("<Enter>", lambda e, ww=w, c=enter_fg: ww.configure(fg=c))
w.bind("<Leave>", lambda e, ww=w, c=leave_fg: ww.configure(fg=c))
make_hover(btn_up, _ARROW_HOVER, _ARROW_FG)
make_hover(btn_down, _ARROW_HOVER, _ARROW_FG)
def make_reset(key):
return lambda e: self._adjust_soap_section(key, 0, reset=True)
sec_label.bind("<Double-Button-1>", make_reset(sec_key))
sec_label.configure(cursor="hand2")
reset_lbl = tk.Label(soap_inner, text=" \u21BA ", font=("Segoe UI", 10),
bg=bg, fg="#7AAFC8", cursor="hand2")
reset_lbl.pack(side="left", padx=(10, 0))
reset_lbl.bind("<Button-1>", lambda e: self._reset_all_soap_sections())
reset_lbl.bind("<Enter>", lambda e: reset_lbl.configure(fg="#1a4d6d"))
reset_lbl.bind("<Leave>", lambda e: reset_lbl.configure(fg="#7AAFC8"))
def _update_soap_section_display(self):
"""Aktualisiert die Anzeige aller SOAP-Section-Labels."""
for key in _SOAP_SECTIONS:
lbl = self._soap_section_labels.get(key)
if lbl:
lv = self._soap_section_levels.get(key, 0)
lbl.configure(text=key if lv == 0 else f"{key} {lv:+d}")
def _adjust_soap_section(self, key: str, delta: int, reset: bool = False):
"""Passt eine SOAP-Abschnitts-Detailstufe an und wendet optional sofort auf die aktuelle KG an."""
if reset:
self._soap_section_levels[key] = 0
else:
old = self._soap_section_levels.get(key, 0)
self._soap_section_levels[key] = max(-3, min(3, old + delta))
save_soap_section_levels(self._soap_section_levels)
self._update_soap_section_display()
lv = self._soap_section_levels[key]
name = _SOAP_LABELS[key]
if reset:
self.set_status(f"{name}: zurückgesetzt auf Standard.")
elif lv == 0:
self.set_status(f"{name}: Standard-Länge.")
else:
direction = "kürzer" if lv < 0 else "ausführlicher"
self.set_status(f"{name}: Stufe {lv:+d} ({direction}) wird bei jeder KG-Erstellung berücksichtigt.")
# Sofort auf aktuelle KG anwenden, wenn vorhanden
kg_text = self.txt_output.get("1.0", "end").strip()
if kg_text and not reset:
self._apply_soap_section_edit(key, delta)
def _reset_all_soap_sections(self):
"""Setzt alle SOAP-Section-Levels auf 0 zurück."""
self._soap_section_levels = {k: 0 for k in _SOAP_SECTIONS}
save_soap_section_levels(self._soap_section_levels)
self._update_soap_section_display()
self.set_status("Alle SOAP-Abschnittsstufen zurückgesetzt.")
def _apply_soap_section_edit(self, key: str, delta: int):
"""Wendet eine Kürzung/Erweiterung auf einen einzelnen SOAP-Abschnitt der aktuellen KG an."""
kg_text = self.txt_output.get("1.0", "end").strip()
if not kg_text:
return
if not self.ensure_ready():
return
name = _SOAP_LABELS[key]
action = "gekürzt" if delta < 0 else "erweitert"
self.set_status(f"{name} wird {action}")
def worker():
try:
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
other_sections = ", ".join(
n for k, n in _SOAP_LABELS.items() if k != key)
if delta < 0:
task = (
f"Kürze LEICHT NUR den Abschnitt '{name}' in der folgenden Krankengeschichte. "
f"Formuliere die vorhandenen Punkte knapper — gleiche Fakten, kürzere Wortwahl. "
f"Stil beibehalten (Stichpunkte bleiben Stichpunkte). Nur eine kleine Reduktion. "
f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. "
f"Gib die KOMPLETTE Krankengeschichte aus."
)
else:
task = (
f"Formuliere NUR den Abschnitt '{name}' in der folgenden Krankengeschichte LEICHT ausführlicher. "
f"Vorhandene Stichpunkte in vollständigere Sätze umformulieren. "
f"WICHTIG: NUR die bereits vorhandenen Informationen ausführlicher formulieren — "
f"KEINE neuen Fakten, Befunde, Werte oder Details erfinden! "
f"Nichts hinzufügen, was nicht bereits im Text steht. "
f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. "
f"Gib die KOMPLETTE Krankengeschichte aus."
)
template = load_templates_text().strip()
sys_parts = []
if template:
sys_parts.append(
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität):\n" + template)
sys_parts.append(
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n" + task +
"\nDiagnosen mit ICD-10-GM-Codes in eckigen Klammern beibehalten. "
"Verwende \u2022 statt - als Aufzählungszeichen. "
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile die Punkte folgen direkt untereinander. "
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
"Keine Sternchen (*). "
"Keine Meta-Kommentare. Ausgabe: nur die komplette KG."
)
soap_levels = load_soap_section_levels()
soap_instr = get_soap_section_instruction(soap_levels)
if soap_instr:
sys_parts.append(soap_instr)
_vis = load_soap_visibility()
_vis_instr = get_soap_visibility_instruction(_vis)
if _vis_instr:
sys_parts.append(_vis_instr)
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": "\n\n".join(sys_parts)},
{"role": "user", "content": kg_text},
],
)
result = strip_kg_warnings(resp.choices[0].message.content)
self.after(0, lambda: self._apply_kg_edit(result, f"{name} {action}"))
except Exception as e:
self.after(0, lambda: self.set_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
threading.Thread(target=worker, daemon=True).start()
def _kg_kuerzer(self):
"""Kürzt die aktuelle KG UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen."""
level = load_kg_detail_level()
new_level = max(-3, level - 1)
save_kg_detail_level(new_level)
self._update_kg_detail_display()
kg_text = self.txt_output.get("1.0", "end").strip()
if not kg_text:
self.set_status(f"KG-Stil gespeichert: Stufe {new_level} (kürzer). Nächste KG wird kürzer erstellt.")
return
if not self.ensure_ready():
return
self.set_status(f"KG wird gekürzt (Stufe {new_level})")
def worker():
try:
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
template = load_templates_text().strip()
sys_parts = []
if template:
sys_parts.append(
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template)
sys_parts.append(
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n"
"Kürze die folgende Krankengeschichte deutlich. "
"Fasse Stichpunkte zusammen, entferne Wiederholungen und "
"reduziere auf das Wesentliche. Behalte die SOAP-Struktur bei "
"(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). "
"Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. "
"Verwende \u2022 statt - als Aufzählungszeichen. "
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile die Punkte folgen direkt untereinander. "
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
"Keine Sternchen (*). "
"Keine Meta-Kommentare. Ausgabe: nur die gekürzte KG."
)
soap_levels = load_soap_section_levels()
soap_instr = get_soap_section_instruction(soap_levels)
if soap_instr:
sys_parts.append(soap_instr)
_vis = load_soap_visibility()
_vis_instr = get_soap_visibility_instruction(_vis)
if _vis_instr:
sys_parts.append(_vis_instr)
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": "\n\n".join(sys_parts)},
{"role": "user", "content": kg_text},
],
)
result = strip_kg_warnings(resp.choices[0].message.content)
self.after(0, lambda: self._apply_kg_edit(result, f"Gekürzt (Stufe {new_level})"))
except Exception as e:
self.after(0, lambda: self.set_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
threading.Thread(target=worker, daemon=True).start()
def _kg_ausfuehrlicher(self):
"""Macht die aktuelle KG ausführlicher UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen."""
level = load_kg_detail_level()
new_level = min(3, level + 1)
save_kg_detail_level(new_level)
self._update_kg_detail_display()
kg_text = self.txt_output.get("1.0", "end").strip()
transcript = self.txt_transcript.get("1.0", "end").strip()
if not kg_text:
self.set_status(f"KG-Stil gespeichert: Stufe +{new_level} (ausführlicher). Nächste KG wird ausführlicher erstellt.")
return
if not self.ensure_ready():
return
self.set_status(f"KG wird ausführlicher (Stufe +{new_level})")
def worker():
try:
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS:
model = DEFAULT_SUMMARY_MODEL
template = load_templates_text().strip()
sys_parts = []
if template:
sys_parts.append(
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template)
sys_parts.append(
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n"
"Formuliere die folgende Krankengeschichte ausführlicher. "
"Wandle vorhandene Stichpunkte in vollständige Sätze um. "
"STRIKT NUR vorhandene Informationen ausführlicher ausformulieren "
"KEINE neuen Fakten, Befunde, Werte, Mengenangaben oder klinische Details erfinden! "
"Nichts hinzufügen, was nicht bereits im Text steht. "
"Behalte die SOAP-Struktur bei "
"(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). "
"Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. "
"Verwende \u2022 statt - als Aufzählungszeichen. "
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile die Punkte folgen direkt untereinander. "
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
"Keine Sternchen (*). "
"Keine Meta-Kommentare. Ausgabe: nur die ausführlichere KG."
)
soap_levels = load_soap_section_levels()
soap_instr = get_soap_section_instruction(soap_levels)
if soap_instr:
sys_parts.append(soap_instr)
_vis = load_soap_visibility()
_vis_instr = get_soap_visibility_instruction(_vis)
if _vis_instr:
sys_parts.append(_vis_instr)
user_content = f"KRANKENGESCHICHTE:\n{kg_text}"
if transcript:
user_content += f"\n\nORIGINAL-TRANSKRIPT (als Quelle für Details):\n{transcript}"
resp = self.call_chat_completion(
model=model,
messages=[
{"role": "system", "content": "\n\n".join(sys_parts)},
{"role": "user", "content": user_content},
],
)
result = strip_kg_warnings(resp.choices[0].message.content)
self.after(0, lambda: self._apply_kg_edit(result, f"Ausführlicher (Stufe +{new_level})"))
except Exception as e:
self.after(0, lambda: self.set_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
threading.Thread(target=worker, daemon=True).start()
def _apply_kg_edit(self, new_kg: str, label: str):
"""Wendet eine KG-Bearbeitung an (Kürzer/Ausführlicher) und zeigt das Ergebnis."""
if new_kg and new_kg.strip():
self.txt_output.delete("1.0", "end")
self.txt_output.insert("1.0", new_kg.strip())
try:
save_to_ablage("KG", new_kg.strip())
except Exception:
pass
self.set_status(f"KG {label}.")
else:
self.set_status("Keine Änderung erhalten.")
def _open_kg_vorlage(self):
"""Öffnet ein Fenster, in dem der Arzt eine Vorlage für die KG-Erstellung definieren kann."""
VOR_W, VOR_H = 540, 620
win = tk.Toplevel(self)
win.title("Vorlage KG-Erstellung")
win.transient(self)
win.minsize(420, 500)
win.configure(bg="#E8F4FA")
win.attributes("-topmost", True)
self._register_window(win)
add_resize_grip(win, 420, 500)
saved_geom = load_toplevel_geometry("kg_vorlage")
if saved_geom:
win.geometry(saved_geom)
else:
win.geometry(f"{VOR_W}x{VOR_H}")
center_window(win, VOR_W, VOR_H)
def _on_close():
try:
save_toplevel_geometry("kg_vorlage", win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", _on_close)
win.bind("<Configure>", lambda e: save_toplevel_geometry("kg_vorlage", win.geometry()))
# Header
header = tk.Frame(win, bg="#C8E8C8")
header.pack(fill="x")
tk.Label(header, text=" Vorlage für KG-Erstellung", font=("Segoe UI", 12, "bold"),
bg="#C8E8C8", fg="#2A5A2A").pack(padx=12, pady=8)
# SOAP-Reihenfolge mit Profilen
order_frame = tk.LabelFrame(win, text=" Abschnitts-Reihenfolge ", font=("Segoe UI", 9, "bold"),
bg="#E8F4FA", fg="#1a4d6d", padx=10, pady=6)
order_frame.pack(fill="x", padx=14, pady=(10, 4))
presets_data = load_soap_presets()
active_preset_idx = [presets_data.get("active", 0)]
profile_bar = tk.Frame(order_frame, bg="#E8F4FA")
profile_bar.pack(fill="x", pady=(0, 4))
_profile_btns = []
_PROF_ACTIVE = {"bg": "#1a4d6d", "fg": "white", "font": ("Segoe UI", 9, "bold")}
_PROF_INACTIVE = {"bg": "#D4EEF7", "fg": "#1a4d6d", "font": ("Segoe UI", 9)}
order_list = []
current_visibility = {}
visibility_vars = {}
row_widgets = []
drag_info = {"active": False, "src_idx": -1, "num_labels": []}
list_frame = tk.Frame(order_frame, bg="#E8F4FA")
list_frame.pack(fill="x")
def _load_preset(idx):
active_preset_idx[0] = idx
preset = presets_data["presets"][idx]
order_list.clear()
order_list.extend(preset.get("order", list(DEFAULT_SOAP_ORDER)))
current_visibility.clear()
vis = preset.get("visibility", {})
current_visibility.update({k: vis.get(k, True) for k in DEFAULT_SOAP_ORDER})
for k in DEFAULT_SOAP_ORDER:
if k in visibility_vars:
visibility_vars[k].set(current_visibility.get(k, True))
else:
visibility_vars[k] = tk.BooleanVar(value=current_visibility.get(k, True))
for i, btn in enumerate(_profile_btns):
marker = "\u25cf " if i == idx else "\u25cb "
btn.configure(text=f" {marker}Profil {i+1} ",
**(_PROF_ACTIVE if i == idx else _PROF_INACTIVE))
_rebuild_order_ui()
def _save_current_to_preset():
idx = active_preset_idx[0]
presets_data["presets"][idx]["order"] = list(order_list)
presets_data["presets"][idx]["visibility"] = dict(current_visibility)
presets_data["active"] = idx
for pi in range(NUM_SOAP_PRESETS):
is_active = (pi == active_preset_idx[0])
style = _PROF_ACTIVE if is_active else _PROF_INACTIVE
marker = "\u25cf " if is_active else "\u25cb "
btn = tk.Label(profile_bar, text=f" {marker}Profil {pi+1} ", cursor="hand2",
padx=10, pady=3, **style)
btn.pack(side="left", padx=(0, 4))
btn.bind("<Button-1>", lambda e, i=pi: _load_preset(i))
btn.bind("<Enter>", lambda e, b=btn, i=pi: b.configure(
bg="#2a6a8d" if i == active_preset_idx[0] else "#B8DDE8"))
btn.bind("<Leave>", lambda e, b=btn, i=pi: b.configure(
**(_PROF_ACTIVE if i == active_preset_idx[0] else _PROF_INACTIVE)))
_profile_btns.append(btn)
tk.Label(order_frame, text="Drag-and-Drop · Häkchen = in KG anzeigen · nur aktives Profil wird verwendet:",
font=("Segoe UI", 8), bg="#E8F4FA", fg="#4a8aaa").pack(anchor="w", pady=(0, 4))
def _on_visibility_toggle(key):
current_visibility[key] = visibility_vars[key].get()
_rebuild_order_ui()
def _drag_start(event, idx):
drag_info["active"] = True
drag_info["src_idx"] = idx
row_widgets[idx].configure(highlightbackground="#FFA500", highlightthickness=2)
def _drag_motion(event):
if not drag_info["active"]:
return
src = drag_info["src_idx"]
y = event.y_root
target = src
for i, row in enumerate(row_widgets):
try:
ry = row.winfo_rooty()
rh = row.winfo_height()
if ry <= y < ry + rh:
target = i
break
except Exception:
pass
if target == src:
return
item = order_list.pop(src)
order_list.insert(target, item)
row = row_widgets.pop(src)
row_widgets.insert(target, row)
nlbl = drag_info["num_labels"].pop(src)
drag_info["num_labels"].insert(target, nlbl)
for w in row_widgets:
w.pack_forget()
for i, w in enumerate(row_widgets):
w.pack(fill="x", pady=1)
for i, lbl in enumerate(drag_info["num_labels"]):
lbl.configure(text=f"{i + 1}.")
for w in row_widgets:
w.configure(highlightbackground="#B0D4E8", highlightthickness=1)
row_widgets[target].configure(highlightbackground="#FFA500", highlightthickness=2)
drag_info["src_idx"] = target
def _drag_end(event):
if drag_info["active"]:
drag_info["active"] = False
_rebuild_order_ui()
def _rebuild_order_ui():
for w in list_frame.winfo_children():
w.destroy()
row_widgets.clear()
drag_info["num_labels"] = []
for idx, key in enumerate(order_list):
vis = visibility_vars.get(key, tk.BooleanVar(value=True)).get()
row_bg = "white" if vis else "#F0F0F0"
fg_color = "#1a4d6d" if vis else "#AAAAAA"
row = tk.Frame(list_frame, bg=row_bg, highlightbackground="#B0D4E8",
highlightthickness=1, padx=4, pady=2)
row.pack(fill="x", pady=1)
handle = tk.Label(row, text="", font=("Segoe UI", 10),
bg=row_bg, fg="#B0D4E8", cursor="fleur", padx=2)
handle.pack(side="left", padx=(2, 4))
cb = tk.Checkbutton(row, variable=visibility_vars.get(key),
bg=row_bg, activebackground=row_bg,
command=lambda k=key: _on_visibility_toggle(k))
cb.pack(side="left", padx=(0, 0))
num_lbl = tk.Label(row, text=f"{idx + 1}.", font=("Segoe UI", 10, "bold"),
bg=row_bg, fg=fg_color, width=2)
num_lbl.pack(side="left", padx=(2, 2))
drag_info["num_labels"].append(num_lbl)
name_lbl = tk.Label(row, text=f"{key} {_SOAP_LABELS.get(key, key)}",
font=("Segoe UI", 10), bg=row_bg, fg=fg_color, anchor="w")
name_lbl.pack(side="left", fill="x", expand=True, padx=4)
for widget in (handle, name_lbl, num_lbl, row):
widget.bind("<ButtonPress-1>", lambda e, i=idx: _drag_start(e, i))
widget.bind("<B1-Motion>", _drag_motion)
widget.bind("<ButtonRelease-1>", _drag_end)
handle.bind("<Enter>", lambda e, h=handle: h.configure(fg="#1a4d6d"))
handle.bind("<Leave>", lambda e, h=handle: h.configure(fg="#B0D4E8"))
row_widgets.append(row)
_load_preset(active_preset_idx[0])
def _reset_order():
order_list.clear()
order_list.extend(DEFAULT_SOAP_ORDER)
for k in visibility_vars:
visibility_vars[k].set(True)
current_visibility[k] = True
_rebuild_order_ui()
reset_order_btn = tk.Label(order_frame, text=" Standard-Reihenfolge", font=("Segoe UI", 8),
bg="#E8F4FA", fg="#7EC8E3", cursor="hand2")
reset_order_btn.pack(anchor="e", pady=(4, 0))
reset_order_btn.bind("<Button-1>", lambda e: _reset_order())
reset_order_btn.bind("<Enter>", lambda e: reset_order_btn.configure(fg="#1a4d6d"))
reset_order_btn.bind("<Leave>", lambda e: reset_order_btn.configure(fg="#7EC8E3"))
# Freitext-Vorlage
tk.Label(win, text="Zusätzliche Anweisungen für die KI (optional):",
font=("Segoe UI", 9, "bold"), bg="#E8F4FA", fg="#1a4d6d"
).pack(anchor="w", padx=14, pady=(8, 2))
example_frame = tk.Frame(win, bg="#F5FBF5", padx=10, pady=4)
example_frame.pack(fill="x", padx=14, pady=(0, 4))
tk.Label(example_frame,
text="z.B. «Fachrichtung: Dermatologie» · «Procedere immer mit Kontrolltermin» · «Kurz und stichpunktartig»",
font=("Segoe UI", 7), bg="#F5FBF5", fg="#4A7A4A",
wraplength=460, justify="left").pack(anchor="w")
txt_frame = tk.Frame(win, bg="#E8F4FA", padx=14)
txt_frame.pack(fill="both", expand=True, pady=(0, 4))
vorlage_text = tk.Text(txt_frame, font=("Segoe UI", 11), bg="white",
fg="#1a4d6d", relief="flat", bd=0, wrap="word",
insertbackground="#1a4d6d", padx=8, pady=6, height=5)
vorlage_text.pack(fill="both", expand=True)
current = load_templates_text()
if current:
vorlage_text.insert("1.0", current)
status_var = tk.StringVar(value="")
# Buttons
btn_frame = tk.Frame(win, bg="#D4EEF7", padx=14, pady=8)
btn_frame.pack(fill="x")
def _save():
text = vorlage_text.get("1.0", "end-1c").strip()
save_templates_text(text)
_save_current_to_preset()
save_soap_presets(presets_data)
self._rebuild_soap_section_controls()
status_var.set(f" Profil {active_preset_idx[0]+1} + Vorlage gespeichert.")
def _clear():
vorlage_text.delete("1.0", "end")
save_templates_text("")
_reset_order()
_save_current_to_preset()
save_soap_presets(presets_data)
self._rebuild_soap_section_controls()
status_var.set("Alles zurückgesetzt Standard-Format wird verwendet.")
def _save_and_close():
_save()
_on_close()
transcript = self.txt_transcript.get("1.0", "end").strip()
if transcript:
self._recompute_kg_inline()
save_btn = tk.Button(btn_frame, text=" Speichern", font=("Segoe UI", 11, "bold"),
bg="#5BDB7B", fg="#1a4d6d", activebackground="#4BCB6B",
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
command=_save)
save_btn.pack(side="left", padx=(0, 6))
tk.Button(btn_frame, text="Speichern & Schliessen", font=("Segoe UI", 9),
bg="#7EC8E3", fg="#1a4d6d", activebackground="#6CB8D3",
relief="flat", bd=0, padx=12, pady=4, cursor="hand2",
command=_save_and_close).pack(side="left", padx=(0, 6))
tk.Button(btn_frame, text="Zurücksetzen", font=("Segoe UI", 9),
bg="#E0E0E0", fg="#666", activebackground="#D0D0D0",
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
command=_clear).pack(side="left", padx=(0, 6))
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 9),
bg="#C8DDE6", fg="#1a4d6d", activebackground="#B8CDD6",
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
command=_on_close).pack(side="right")
tk.Label(win, textvariable=status_var, font=("Segoe UI", 8, "bold"),
bg="#E8F4FA", fg="#2A7A2A").pack(fill="x", padx=14, pady=(0, 4))
def _rebuild_textblock_buttons(self):
"""Erstellt die Textblock-Buttons neu basierend auf _textbloecke_data."""
for w in self._textbloecke_rows_frame.winfo_children():
w.destroy()
self._textbloecke_buttons = []
slots = sorted(self._textbloecke_data.keys(), key=int)
for i in range(0, len(slots), 2):
row_slots = slots[i : i + 2]
block_row = ttk.Frame(self._textbloecke_rows_frame, padding=(0, 4, 0, 0))
block_row.pack(fill="x")
block_inner = ttk.Frame(block_row)
block_inner.pack(anchor="center")
for slot_str in row_slots:
btn = RoundedButton(
block_inner, self._textblock_label(slot_str),
command=None,
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", width=70, height=26, canvas_bg="#B9ECFA",
radius=0,
)
btn._textblock_slot = slot_str
def make_click(s):
return lambda: self._copy_textblock(s)
btn.configure(command=make_click(slot_str))
def on_btn_press(e, s):
self._textblock_copy_to_clipboard(s)
btn.bind("<ButtonPress-1>", lambda e, s=slot_str: on_btn_press(e, s), add="+")
btn.pack(side="left", padx=(0, 4))
def make_ctx(s):
return lambda e: self._textblock_context(e, s)
def make_save_clip(s):
return lambda e: self._textblock_save_from_clipboard_or_selection(s)
btn.bind("<Button-3>", make_ctx(slot_str))
btn.bind("<Shift-Button-1>", lambda e, s=slot_str: make_save_clip(s)(e))
self._textbloecke_buttons.append((slot_str, btn))
def _add_textblock(self):
"""Fügt einen Textblock hinzu wenn vorher mit - entfernt, wird der gespeicherte Inhalt wiederhergestellt."""
slots = sorted(self._textbloecke_data.keys(), key=int)
next_num = int(slots[-1]) + 1 if slots else 1
slot_str = str(next_num)
if self._removed_textbloecke:
restored = self._removed_textbloecke.pop()
self._textbloecke_data[slot_str] = {"name": restored.get("name", f"Textblock {slot_str}"), "content": restored.get("content", "")}
else:
self._textbloecke_data[slot_str] = {"name": f"Textblock {slot_str}", "content": ""}
save_textbloecke(self._textbloecke_data)
self._rebuild_textblock_buttons()
self.set_status(f"Textblock {slot_str} hinzugefügt.")
def _remove_textblock(self):
"""Entfernt den letzten Textblock. Inhalt wird gespeichert für spätere Wiederverwendung mit +."""
slots = sorted(self._textbloecke_data.keys(), key=int)
if len(slots) <= 2:
self.set_status("Mindestens 2 Textblöcke müssen bestehen bleiben.")
return
last = slots[-1]
self._removed_textbloecke.append(dict(self._textbloecke_data[last]))
del self._textbloecke_data[last]
save_textbloecke(self._textbloecke_data)
self._rebuild_textblock_buttons()
self.set_status(f"Textblock {last} entfernt (Inhalt für + gespeichert).")
def _textblock_label(self, slot: str) -> str:
"""Anzeigetext für einen Textblock-Button (Name oder gekürzter Inhalt)."""
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
name = (d.get("name") or "").strip()
content = (d.get("content") or "").strip()
max_len = 12
if name and name != f"Textblock {slot}":
return (name[:max_len] + "") if len(name) > max_len else name
if content:
return (content[:max_len] + "") if len(content) > max_len else content
return f"Textblock {slot}"
def _refresh_textblock_button(self, slot: str):
"""Aktualisiert den angezeigten Text eines Textblock-Buttons."""
for s, btn in self._textbloecke_buttons:
if s == slot:
btn.configure(text=self._textblock_label(slot))
break
# Typische SOAP-/KG-Überschriften für Abschnitts-Kopieren (Doppelklick)
_KG_SECTION_HEADERS = (
"subjektiv", "objektiv", "diagnose", "diagnosen", "beurteilung",
"therapie", "procedere", "anlass", "befunde", "empfehlung",
"assessment", "plan", "befund", "verlauf", "medikation",
)
def _get_kg_section_at_cursor(self, text_widget, index_override: str | None = None):
"""Wenn Cursor auf einer Abschnittsüberschrift steht: (start, end, text) des Abschnitts, sonst None."""
try:
insert = text_widget.index(index_override or tk.INSERT)
line_no = int(insert.split(".")[0])
line_start = f"{line_no}.0"
line_end = f"{line_no}.end"
line_text = (text_widget.get(line_start, line_end) or "").strip()
if not line_text:
return None
line_lower = line_text.lower().strip()
# Erlaubt:
# - "Therapie"
# - "Therapie:"
# - "1. Therapie"
# - "1. Therapie:"
if line_lower.endswith(":"):
head = line_lower.rstrip(":").strip()
elif "." in line_lower and line_lower.split(".", 1)[0].strip().isdigit():
head = line_lower.split(".", 1)[1].strip().rstrip(":").strip()
else:
head = line_lower
if not any(h == head or head.startswith(h + " ") or head.startswith(h + ":") for h in self._KG_SECTION_HEADERS):
return None
content = text_widget.get("1.0", "end")
lines = content.split("\n")
start_line = line_no
end_line = len(lines)
def _is_section_header(ln_text):
"""Prüft ob eine Zeile eine bekannte SOAP-/KG-Abschnittsüberschrift ist."""
lt = (ln_text or "").strip().lower()
if not lt:
return False
if lt.endswith(":"):
h2 = lt.rstrip(":").strip()
elif "." in lt and lt.split(".", 1)[0].strip().isdigit():
h2 = lt.split(".", 1)[1].strip().rstrip(":").strip()
else:
h2 = lt
return any(k == h2 or h2.startswith(k + " ") or h2.startswith(k + ":") for k in self._KG_SECTION_HEADERS)
for i in range(start_line + 1, len(lines) + 1):
if i > len(lines):
break
if _is_section_header(lines[i - 1]):
end_line = i - 1
break
while end_line > start_line and not (lines[end_line - 1] or "").strip():
end_line -= 1
section_text = "\n".join(lines[start_line - 1 : end_line]).rstrip()
if not section_text:
return None
start_idx = f"{start_line}.0"
end_idx = f"{end_line}.end"
return (start_idx, end_idx, section_text)
except (tk.TclError, AttributeError, ValueError, IndexError):
return None
def _bind_kg_section_copy(self, text_widget):
"""Doppelklick auf eine SOAP-Überschrift: ganzen Abschnitt in Zwischenablage kopieren."""
def on_double_click(event):
click_index = None
try:
click_index = text_widget.index(f"@{event.x},{event.y}")
except Exception:
pass
result = self._get_kg_section_at_cursor(text_widget, click_index)
if result:
_, _, section_text = result
try:
if not _win_clipboard_set(section_text):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(section_text))
# Falls globales Rechtsklick-Paste deaktiviert wurde: einmaliges Paste-Fenster aktivieren.
try:
self._arm_one_click_external_paste()
except Exception:
pass
self.set_status("Abschnitt kopiert.")
except (tk.TclError, AttributeError):
pass
text_widget.bind("<Double-Button-1>", on_double_click, add="+")
def _bind_text_context_menu(self, text_widget):
"""Rechtsklick-Menü: Kopieren, Einfügen, Markierung in Textblock speichern."""
def on_right_click(event):
menu = tk.Menu(text_widget, tearoff=0)
menu.add_command(
label="Kopieren",
command=lambda: _do_copy(),
)
menu.add_command(
label="Einfügen",
command=lambda: _do_paste(),
)
slots = sorted(getattr(self, "_textbloecke_data", {}).keys())
if slots:
save_menu = tk.Menu(menu, tearoff=0)
for slot in slots:
lbl = self._textblock_label(slot)
save_menu.add_command(
label=lbl,
command=lambda s=slot, w=text_widget: _do_save_to_textblock(s, w),
)
menu.add_cascade(label="Markierung in Textblock speichern", menu=save_menu)
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _do_copy():
try:
if hasattr(text_widget, "selection_present") and text_widget.selection_present():
sel = text_widget.get(tk.SEL_FIRST, tk.SEL_LAST)
if not _win_clipboard_set(sel):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(sel))
self.set_status("Kopiert.")
except (tk.TclError, AttributeError):
pass
def _do_paste():
try:
text = self.clipboard_get()
if isinstance(text, str):
text_widget.insert(tk.INSERT, text)
self.set_status("Eingefügt.")
except (tk.TclError, AttributeError):
pass
def _do_save_to_textblock(slot, w):
try:
if hasattr(w, "selection_present") and w.selection_present():
text = w.get(tk.SEL_FIRST, tk.SEL_LAST)
else:
text = self._get_selection_from_focus()
except (tk.TclError, AttributeError):
text = ""
if not (text or "").strip():
self.set_status("Keine Markierung zuerst Text markieren.")
return
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
self._refresh_textblock_button(slot)
self.set_status("Auswahl in Textblock gespeichert.")
text_widget.bind("<Button-3>", on_right_click)
def _bind_textblock_pending(self, text_widget):
"""Wie textbloecke.py: ButtonPress-1 = Auswahl für Drag; Cursorposition ständig aktualisieren für Einfügen."""
if not hasattr(self, "_textblock_drag_data"):
self._textblock_drag_data = {"text": None, "active": False}
self._bind_text_context_menu(text_widget)
def _save_as_last():
"""Speichert Widget + Cursorposition wird vor Klick auf Textblock-Button gesichert."""
try:
if text_widget.winfo_exists():
self._last_focused_text_widget = text_widget
self._last_insert_index = text_widget.index(tk.INSERT)
except (tk.TclError, AttributeError):
pass
text_widget.bind("<FocusIn>", lambda e: _save_as_last(), add="+")
text_widget.bind("<FocusOut>", lambda e: _save_as_last(), add="+")
text_widget.bind("<KeyRelease>", lambda e: _save_as_last(), add="+")
text_widget.bind("<ButtonRelease-1>", lambda e: _save_as_last(), add="+")
def get_selection_from_widget(w):
"""Wie textbloecke.py get_selection(): Auswahl per tag_nextrange('sel')."""
try:
sel = w.tag_nextrange("sel", "1.0")
if sel:
return w.get(*sel)
except (tk.TclError, AttributeError):
pass
try:
if hasattr(w, "selection_present") and w.selection_present():
return w.get(tk.SEL_FIRST, tk.SEL_LAST)
except (tk.TclError, AttributeError):
pass
return None
def start_drag(event):
"""Bei gedrückter Maustaste im Textfeld: Auswahl sichern (wie textbloecke.py start_drag)."""
s = get_selection_from_widget(text_widget)
if s and (s or "").strip():
self._textblock_drag_data["text"] = s
self._textblock_drag_data["active"] = False
else:
self._textblock_drag_data["text"] = None
self._textblock_drag_data["active"] = False
text_widget.bind("<ButtonPress-1>", start_drag, add="+")
text_widget.bind("<B1-Motion>", self._textblock_on_drag_motion, add="+")
text_widget.bind("<ButtonRelease-1>", self._on_global_drag_release, add="+")
self._bind_autotext(text_widget)
def _check_autotext_focus_out(self):
"""Prüft, ob der Fokus noch in unserer App ist (Hauptfenster oder Toplevel)."""
try:
w = self.focus_get()
if w is None:
self._autotext_focus_in_app[0] = False
return
top = w.winfo_toplevel()
if top == self:
self._autotext_focus_in_app[0] = True
return
if hasattr(top, "master") and top.master == self:
self._autotext_focus_in_app[0] = True
return
self._autotext_focus_in_app[0] = False
except (tk.TclError, AttributeError):
self._autotext_focus_in_app[0] = False
def _bind_autotext(self, text_widget):
"""Ersetzt Abkürzungen durch Autotext nach Leerzeichen/Zeilenumbruch etc. (dauerhaft gespeichert, wenn aktiviert)."""
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
def on_focus_in(event):
self._autotext_focus_in_app[0] = True
def on_focus_out(event):
self.after(50, self._check_autotext_focus_out)
text_widget.bind("<FocusIn>", on_focus_in, add="+")
text_widget.bind("<FocusOut>", on_focus_out, add="+")
def on_keyrelease(event):
if not getattr(self, "_autotext_data", {}).get("enabled", True):
return
entries = (self._autotext_data.get("entries") or {})
if not entries:
return
try:
text_widget.update_idletasks()
insert = text_widget.index(tk.INSERT)
text_before = text_widget.get("1.0", insert)
if not text_before:
return
last_char = text_before[-1]
if last_char not in AUTOTEXT_TERMINATORS:
return
word_end = len(text_before) - 1
i = word_end - 1
while i >= 0 and text_before[i] not in AUTOTEXT_TERMINATORS:
i -= 1
word_start = i + 1
word = text_before[word_start:word_end]
if not word or word not in entries:
return
expansion = entries[word]
start_idx = text_widget.index(f"{insert} - {len(word) + 1} chars")
text_widget.delete(start_idx, insert)
text_widget.insert(start_idx, expansion + last_char)
except (tk.TclError, AttributeError, IndexError):
pass
text_widget.bind("<KeyRelease>", lambda e: self.after(80, lambda: on_keyrelease(e)), add="+")
def _run_global_autotext_listener(self):
"""Globaler Tastatur-Hook (Windows) für externe Autotext-Expansion.
Architektur identisch mit dem bewährten Stand (Kopie 13, 16.02.2026):
- on_press zeichnet ALLE Tasten auf (kein Fokus-Check)
- on_release prüft Fokus und expandiert nur extern
- Einfacher Worker mit Queue + Lock
"""
if not _HAS_PYNPUT:
return
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
controller = KbdController()
buffer = self._autotext_global_buffer
replace_queue = []
queue_lock = threading.Lock()
REPLACE_DELAY = 0.2
def key_to_char(key):
try:
if key == Key.space:
return " "
if key == Key.enter:
return "\n"
if key == Key.tab:
return "\t"
if hasattr(key, "char") and key.char:
return key.char
except Exception:
pass
return None
def key_to_terminator_char(key):
try:
if key == Key.space:
return " "
if key == Key.enter:
return "\n"
if key == Key.tab:
return "\t"
if hasattr(key, "vk") and key.vk == 13:
return "\n"
if hasattr(key, "char") and key.char and key.char in AUTOTEXT_TERMINATORS:
return key.char
except Exception:
pass
return None
def worker():
while True:
time.sleep(0.02)
with queue_lock:
if not replace_queue:
continue
n_back, text = replace_queue.pop(0)
injecting_ref = getattr(self, "_autotext_injecting", [False])
injecting_ref[0] = True
time.sleep(REPLACE_DELAY)
try:
data = load_autotext()
if not data.get("enabled", True):
injecting_ref[0] = False
continue
saved = _win_clipboard_get()
if not _win_clipboard_set(text):
injecting_ref[0] = False
continue
for _ in range(n_back):
controller.press(Key.backspace)
controller.release(Key.backspace)
time.sleep(0.1)
with controller.pressed(Key.ctrl):
controller.tap(KeyCode.from_char("v"))
time.sleep(0.05)
if saved:
_win_clipboard_set(saved)
except Exception as _w_exc:
try:
print(f"[AZA] Autotext-Worker Fehler: {_w_exc}", file=sys.stderr)
except Exception:
pass
injecting_ref[0] = False
threading.Thread(target=worker, daemon=True).start()
def on_press(key):
if getattr(self, "_autotext_injecting", [False])[0]:
return
try:
if key == Key.backspace:
if buffer:
buffer.pop()
else:
ch = key_to_char(key)
if ch:
buffer.append(ch)
if len(buffer) > 200:
buffer.pop(0)
except Exception:
pass
def on_release(key):
if getattr(self, "_autotext_injecting", [False])[0]:
return
if getattr(self, "_autotext_focus_in_app", [False])[0]:
return
terminator_char = key_to_terminator_char(key)
if terminator_char is None:
return
try:
data = load_autotext()
if not data.get("enabled", True):
return
entries = data.get("entries") or {}
if not entries or len(buffer) < 2:
return
i = len(buffer) - 2
while i >= 0 and buffer[i] not in AUTOTEXT_TERMINATORS:
i -= 1
word = "".join(buffer[i + 1 : -1])
if not word:
return
expansion = entries.get(word)
if expansion is None:
for k, v in entries.items():
if k.lower() == word.lower():
expansion = v
break
if not expansion:
return
n_back = len(word) + 1
text = expansion + terminator_char
with queue_lock:
replace_queue.append((n_back, text))
del buffer[-(len(word) + 1) :]
buffer.extend(list(expansion + terminator_char))
while len(buffer) > 200:
buffer.pop(0)
except Exception as _rel_exc:
try:
print(f"[AZA] Autotext on_release Fehler: {_rel_exc}", file=sys.stderr)
except Exception:
pass
try:
with KbdListener(on_press=on_press, on_release=on_release) as listener:
listener.join()
except Exception as _at_exc:
import sys as _sys
try:
print(f"[AZA] Globaler Autotext-Listener beendet: {_at_exc}", file=_sys.stderr)
except Exception:
pass
def _toggle_kommentare_auto(self):
val = self._kommentare_auto_var.get()
self._autotext_data["kommentare_auto_open"] = val
save_autotext(self._autotext_data)
def _auto_refresh_kommentare(self):
"""Aktualisiert das Kommentare-Fenster automatisch, wenn es offen ist.
Oeffnet es automatisch, wenn kommentare_auto_open aktiv ist und KG-Inhalt vorhanden."""
win_open = False
if hasattr(self, "_kommentare_win") and self._kommentare_win is not None:
try:
self._kommentare_win.winfo_exists()
win_open = True
except tk.TclError:
self._kommentare_win = None
if win_open and hasattr(self, "_kommentare_txt") and self._kommentare_txt is not None:
self._refresh_kommentare(self._kommentare_txt)
return
if self._autotext_data.get("kommentare_auto_open", False):
kg_text = self.txt_output.get("1.0", "end").strip()
if kg_text:
self._open_kommentare_fenster()
def _open_kommentare_fenster(self):
"""Oeffnet das Kommentare-Fenster neben dem Hauptfenster.
Zeigt kurze medizinische Hinweise basierend auf dem KG-Inhalt."""
if hasattr(self, "_kommentare_win") and self._kommentare_win is not None:
try:
if not self._kommentare_win.winfo_exists():
raise tk.TclError("destroyed")
self._kommentare_win.deiconify()
self._kommentare_win.lift()
self._kommentare_win.focus_force()
return
except tk.TclError:
self._kommentare_win = None
kw = tk.Toplevel(self)
kw.title("Kommentare")
kw.configure(bg="#FFFFFF")
kw.minsize(320, 300)
main_x = self.winfo_x()
main_w = self.winfo_width()
main_y = self.winfo_y()
kw.geometry(f"380x520+{main_x + main_w + 10}+{main_y}")
add_resize_grip(kw, 320, 300)
self._kommentare_win = kw
header = tk.Frame(kw, bg="#E8F4F8")
header.pack(fill="x")
tk.Label(header, text="Medizinische Kommentare", font=("Segoe UI Semibold", 11),
bg="#E8F4F8", fg="#1a4d6d").pack(side="left", padx=10, pady=6)
btn_refresh = tk.Button(header, text="\u21bb Aktualisieren", font=("Segoe UI", 9),
bg="#5B8DB3", fg="white", relief="flat", cursor="hand2",
command=lambda: self._refresh_kommentare(txt_area))
btn_refresh.pack(side="right", padx=10, pady=6)
opts_frame = tk.Frame(kw, bg="#F5F9FC")
opts_frame.pack(fill="x", padx=6, pady=(2, 0))
def _toggle_auto_open():
val = self._kommentare_auto_var.get()
self._autotext_data["kommentare_auto_open"] = val
save_autotext(self._autotext_data)
tk.Checkbutton(opts_frame, text="Kommentare-Fenster nach KG-Erstellung automatisch oeffnen",
variable=self._kommentare_auto_var, font=("Segoe UI", 8),
bg="#F5F9FC", fg="#555", activebackground="#F5F9FC", selectcolor="#F5F9FC",
command=_toggle_auto_open).pack(anchor="w", padx=4)
txt_area = ScrolledText(kw, wrap="word", font=("Segoe UI", 10),
bg="#FFFFFF", fg="#333333", relief="flat",
padx=10, pady=8)
txt_area.pack(fill="both", expand=True)
self._kommentare_txt = txt_area
txt_area.tag_configure("heading", font=("Segoe UI Semibold", 10, "bold"),
foreground="#1a4d6d")
txt_area.tag_configure("clickable", font=("Segoe UI Semibold", 10, "bold"),
foreground="#1a4d6d", underline=True)
txt_area.tag_configure("body", font=("Segoe UI", 9), foreground="#444444")
txt_area.tag_configure("warn", font=("Segoe UI", 9, "bold"), foreground="#C0392B")
txt_area.insert("1.0", "Kommentare werden automatisch bei KG-Erstellung geladen.\n"
"Oder klicken Sie 'Aktualisieren'.\n\n"
"Pro Diagnose / Medikament:\n"
"\u2022 Kurzkommentar\n"
"\u2022 Red Flags / Gefahren\n"
"\u2022 Kurze Therapieempfehlung")
txt_area.configure(state="disabled")
status = tk.Label(kw, text="Bereit", font=("Segoe UI", 8),
bg="#F5F5F5", fg="#888888", anchor="w")
status.pack(fill="x", padx=4, pady=(0, 2))
self._kommentare_status = status
def on_close():
self._kommentare_win = None
self._kommentare_txt = None
kw.destroy()
kw.protocol("WM_DELETE_WINDOW", on_close)
kg_text = self.txt_output.get("1.0", "end").strip()
if kg_text:
self.after(300, lambda: self._refresh_kommentare(txt_area))
def _refresh_kommentare(self, txt_widget):
"""Generiert medizinische Kurzkommentare zum aktuellen KG-Inhalt."""
kg_text = self.txt_output.get("1.0", "end").strip()
transcript = self.txt_transcript.get("1.0", "end").strip()
if not kg_text and not transcript:
txt_widget.configure(state="normal")
txt_widget.delete("1.0", "end")
txt_widget.insert("1.0", "Kein KG-Inhalt oder Transkript vorhanden.\n"
"Bitte zuerst eine Aufnahme machen oder KG erstellen.")
txt_widget.configure(state="disabled")
return
if not self._check_ai_consent():
return
if hasattr(self, "_kommentare_status") and self._kommentare_status:
try:
self._kommentare_status.configure(text="Kommentare werden generiert...")
except (tk.TclError, AttributeError):
pass
txt_widget.configure(state="normal")
txt_widget.delete("1.0", "end")
txt_widget.insert("1.0", "Wird geladen...")
txt_widget.configure(state="disabled")
source = kg_text if kg_text else transcript
def worker():
try:
from openai import OpenAI
client = OpenAI(api_key=get_openai_api_key())
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": (
"Du bist ein medizinischer Kurzkommentar-Assistent für Ärzte. "
"Gib zu jeder erkannten Diagnose, jedem erwähnten Medikament und jeder erwähnten "
"Therapie/Prozedur einen kurzen, nützlichen Kommentar. "
"Regeln: "
"- Pro Diagnose maximal 2 kurze Zeilen. "
"- Inhalt: Wichtigstes, Red Flags, kurze Therapieempfehlung. "
"- Pro Medikament EXAKT 1 Satz: NUR die Indikation (wofür zugelassen). "
" VERBOTEN im Inline-Kommentar: Einnahmehinweise, Dosierung, Nebenwirkungen, "
" Nahrungsbezug, Tageszeit, Maximaldosis, Applikationsart. "
" Diese Details gehören in das Detailfenster, NICHT in den Inline-Kommentar. "
" NIEMALS Sätze wie 'mit Wasser einnehmen', 'bevorzugt morgens', 'nach Bedarf', "
" 'nüchtern einnehmen', 'mit Nahrung', 'häufigste Nebenwirkungen' im Inline-Kommentar. "
"- Pro Therapie/Prozedur maximal 2 kurze Sätze: "
" 1) Was ist das / wofür. "
" 2) Wichtigster Hinweis (Kontraindikation, Nachsorge). "
"- SICHERHEITSREGEL MEDIKAMENTE: "
" 1) Tagge NUR Begriffe als [MED], die du mit hoher Sicherheit als real existierendes, "
" zugelassenes Medikament oder pharmazeutischen Wirkstoff kennst. "
" 2) Allgemeine Begriffe (Creme, Salbe, Tropfen, Tabletten, Pflegeprodukte) "
" sind KEINE Medikamente. "
" 3) Unbekannte Namen: NICHT als [MED] taggen, KEINE Fachinfos schreiben. "
" 4) Ähnliche aber nicht exakte Namen: NICHT als [MED] taggen. "
" 5) Im Zweifel: Lieber NICHT taggen als falsch taggen. "
"- THERAPIEN/PROZEDUREN: "
" Tagge Therapien und Prozeduren mit [THER]. "
" Beispiele: Kryotherapie, Exzision, Phototherapie, Biopsie, Desensibilisierung. "
" NICHT als [MED] oder [DX] taggen. "
"- Medizinisch konservativ, keine Überinterpretation. "
"- WICHTIG Format: Jede Überschrift MUSS mit [DX], [MED] oder [THER] beginnen. "
"[DX] für Diagnosen, [MED] für Medikamente/Wirkstoffe, [THER] für Therapien/Prozeduren. "
"Beispiel: '[DX] Aktinische Keratose', '[MED] Bilastin', '[THER] Kryotherapie'. "
"Dann Kommentar darunter. "
"- Sprache: Deutsch. "
"- Lieber kurz und konservativ als ausführlich."
)},
{"role": "user", "content": f"Bitte kommentiere folgende Krankengeschichte:\n\n{source}"}
],
temperature=0.3,
max_tokens=1000,
)
result = resp.choices[0].message.content.strip()
self._last_kommentar_result = result
self.after(0, lambda r=result: self._apply_kommentare(txt_widget, r))
except Exception as exc:
self.after(0, lambda e=str(exc): self._apply_kommentare(
txt_widget, f"Fehler: {e}"))
threading.Thread(target=worker, daemon=True).start()
def _apply_kommentare(self, txt_widget, text):
"""Setzt den Kommentar-Text ins Widget und macht Ueberschriften klickbar.
Fuer [MED]-Eintraege wird VOR der Anzeige validate_medication_name()
aufgerufen. Nur bei sicher erkanntem Medikament wird die AI-Kurzinfo
angezeigt. Sonst Warnung + optionaler Kandidatenvorschlag mit
klickbarem Uebernehmen-Link.
"""
try:
txt_widget.configure(state="normal")
txt_widget.delete("1.0", "end")
lines = text.split("\n")
skip_until_next_heading = False
for i, line in enumerate(lines):
stripped = line.strip()
is_heading = (stripped.startswith("**") and stripped.endswith("**")) or \
(stripped.startswith("##")) or \
(stripped.startswith("[DX]") or stripped.startswith("[MED]") or
stripped.startswith("[THER]")) or \
(stripped and stripped[0].isdigit() and "." in stripped[:4] and len(stripped) > 3)
if is_heading:
skip_until_next_heading = False
clean = stripped.replace("**", "").replace("##", "").strip()
is_med = clean.startswith("[MED]")
is_dx = clean.startswith("[DX]")
is_ther = clean.startswith("[THER]")
display_name = clean.replace("[MED]", "").replace("[DX]", "").replace("[THER]", "").strip()
if display_name and display_name[0].isdigit() and "." in display_name[:4]:
display_name = display_name.split(".", 1)[-1].strip()
detail_text = self._collect_comment_detail(lines, i)
if is_med:
is_valid, candidate = validate_medication_name(display_name)
else:
is_valid, candidate = True, None
if is_med and not is_valid:
skip_until_next_heading = True
tag_name = f"diag_{i}"
txt_widget.tag_configure(tag_name, font=("Segoe UI Semibold", 10, "bold"),
foreground="#999", underline=True)
txt_widget.insert("end", "\u26A0 " + display_name + "\n", tag_name)
txt_widget.tag_bind(tag_name, "<Button-1>",
lambda e, t=display_name, d=detail_text: self._show_med_detail(t, d))
txt_widget.tag_bind(tag_name, "<Enter>",
lambda e: txt_widget.config(cursor="hand2"))
txt_widget.tag_bind(tag_name, "<Leave>",
lambda e: txt_widget.config(cursor=""))
warn_tag = f"warn_{i}"
txt_widget.tag_configure(warn_tag, foreground="#B0B0B0",
font=("Segoe UI", 9, "italic"))
txt_widget.insert("end", " Kein sicher erkanntes Medikament.\n", warn_tag)
if candidate:
cand_tag = f"cand_{i}"
txt_widget.tag_configure(cand_tag, foreground="#2471A3",
font=("Segoe UI", 9, "bold underline"))
txt_widget.insert("end", f" Meinten Sie evtl. {candidate}? ",)
txt_widget.insert("end", "[Übernehmen]", cand_tag)
txt_widget.insert("end", "\n")
txt_widget.tag_bind(cand_tag, "<Button-1>",
lambda e, old=display_name, new=candidate, tw=txt_widget:
self._accept_med_candidate(old, new, tw))
txt_widget.tag_bind(cand_tag, "<Enter>",
lambda e: txt_widget.config(cursor="hand2"))
txt_widget.tag_bind(cand_tag, "<Leave>",
lambda e: txt_widget.config(cursor=""))
continue
tag_name = f"diag_{i}"
if is_ther:
fg_color = "#6A1B9A"
elif is_med:
fg_color = "#2E7D32"
else:
fg_color = "#1a4d6d"
txt_widget.tag_configure(tag_name, font=("Segoe UI Semibold", 10, "bold"),
foreground=fg_color, underline=True)
if is_ther:
prefix = "\U0001F529 "
elif is_med:
prefix = "\U0001F48A "
elif is_dx:
prefix = "\U0001F3E5 "
else:
prefix = ""
txt_widget.insert("end", prefix + display_name + "\n", tag_name)
if is_med:
txt_widget.tag_bind(tag_name, "<Button-1>",
lambda e, t=display_name, d=detail_text: self._show_med_detail(t, d))
elif is_ther:
txt_widget.tag_bind(tag_name, "<Button-1>",
lambda e, t=display_name, d=detail_text: self._show_ther_detail(t, d))
else:
txt_widget.tag_bind(tag_name, "<Button-1>",
lambda e, t=display_name, d=detail_text: self._show_dx_detail(t, d))
txt_widget.tag_bind(tag_name, "<Enter>",
lambda e, tn=tag_name: txt_widget.config(cursor="hand2"))
txt_widget.tag_bind(tag_name, "<Leave>",
lambda e, tn=tag_name: txt_widget.config(cursor=""))
elif skip_until_next_heading:
continue
else:
txt_widget.insert("end", line + "\n")
txt_widget.configure(state="disabled")
if hasattr(self, "_kommentare_status") and self._kommentare_status:
try:
self._kommentare_status.configure(text="Kommentare aktualisiert.")
except (tk.TclError, AttributeError):
pass
except (tk.TclError, AttributeError):
pass
def _accept_med_candidate(self, old_name, new_name, txt_widget):
"""Uebernimmt den Kandidatenvorschlag und aktualisiert die Kommentare.
Ersetzt im letzten AI-Ergebnis den alten Namen durch den Kandidaten
und rendert das Widget neu sowohl im Heading-Tag als auch im Fliesstext.
"""
try:
if hasattr(self, "_last_kommentar_result") and self._last_kommentar_result:
updated = self._last_kommentar_result.replace(
f"[MED] {old_name}", f"[MED] {new_name}")
updated = updated.replace(old_name, new_name)
self._last_kommentar_result = updated
self._apply_kommentare(txt_widget, updated)
except Exception:
pass
def _collect_comment_detail(self, lines, heading_idx):
"""Sammelt den Text nach einer Ueberschrift bis zur naechsten Ueberschrift."""
detail_lines = []
for j in range(heading_idx + 1, len(lines)):
stripped = lines[j].strip()
if not stripped:
detail_lines.append("")
continue
is_next_heading = (stripped.startswith("**") and stripped.endswith("**")) or \
(stripped.startswith("##")) or \
stripped.startswith("[DX]") or \
stripped.startswith("[MED]") or \
stripped.startswith("[THER]") or \
(stripped and stripped[0].isdigit() and "." in stripped[:4] and len(stripped) > 3)
if is_next_heading:
break
detail_lines.append(lines[j])
return "\n".join(detail_lines).strip()
_MED_QUELLEN = {
"compendium.ch": {
"label": "Compendium (CH)",
"url": "https://compendium.ch/search?q={q}",
},
"BASG": {
"label": "BASG Register (AT)",
"url": "https://aspregister.basg.gv.at/aspregister/faces/aspregister.jspx?_afrLoop=1&_adf.ctrl-state=search&query={q}",
},
"BfArM": {
"label": "BfArM AMIce (DE)",
"url": "https://www.bfarm.de/SiteGlobals/Forms/Suche/AMICESearch/AMICESearchForm.html?queryString={q}",
},
}
def _get_med_source_url(self, med_name):
quelle = self._autotext_data.get("medikament_quelle", "compendium.ch")
info = self._MED_QUELLEN.get(quelle, self._MED_QUELLEN["compendium.ch"])
import urllib.parse
return info["url"].replace("{q}", urllib.parse.quote_plus(med_name))
# ─── Diagnose-Quellen ───
_DX_QUELLEN = {
"DocCheck": {
"label": "DocCheck Flexikon",
"url": "https://flexikon.doccheck.com/de/Spezial:Suche?search={q}&go=Seite",
},
"MSD": {
"label": "MSD Manual Patienten",
"url": "https://www.msdmanuals.com/de/heim/SearchResults?query={q}",
},
}
def _get_default_dx_quelle(self):
lang = self._autotext_data.get("selectedLanguage", "system")
if lang in ("system", "de", ""):
return "DocCheck"
return "MSD"
def _get_dx_source_url(self, dx_name):
saved = self._autotext_data.get("diagnose_quelle", "")
quelle = saved if saved and saved in self._DX_QUELLEN else self._get_default_dx_quelle()
info = self._DX_QUELLEN[quelle]
import urllib.parse
return info["url"].replace("{q}", urllib.parse.quote_plus(dx_name))
_DETAIL_SECTION_HEADINGS_MED = [
"Indikation / Wofuer", "Haeufigste Nebenwirkungen",
"Anwendung / Applikation", "Dosierung / Dauer",
"Schwangerschaft / Stillzeit", "Wichtige Hinweise",
]
_DETAIL_SECTION_HEADINGS_DX = [
"Was ist das?", "Typische Symptome / Klinik",
"Typische Therapie / Behandlung",
"Wichtige Warnhinweise / Red Flags",
]
_DETAIL_SECTION_HEADINGS_THER = [
"Was ist das?", "Indikationen",
"Durchfuehrung", "Wichtige Hinweise",
]
_THER_QUELLEN = {
"DocCheck": {
"label": "DocCheck Flexikon",
"url": "https://flexikon.doccheck.com/de/Spezial:Suche?search={q}&go=Seite",
},
"PharmaWiki": {
"label": "PharmaWiki (pharmawiki.ch)",
"url": "https://www.pharmawiki.ch/wiki/index.php?wiki={q}",
},
}
def _get_ther_source_url(self, ther_name):
quelle = self._autotext_data.get("therapie_quelle", "DocCheck")
info = self._THER_QUELLEN.get(quelle, self._THER_QUELLEN["DocCheck"])
import urllib.parse
return info["url"].replace("{q}", urllib.parse.quote_plus(ther_name))
def _build_detail_window(self, title, header_bg, prompt, detail, quelle_dict, quelle_key, open_url_fn,
quelle_label="Quelle:",
original_quelle_dict=None, original_quelle_key=None,
original_quelle_label="Originalquelle:"):
"""Gemeinsamer Fenster-Builder fuer Med- und Dx-Detail mit klickbaren Abschnitten."""
dlg = tk.Toplevel(self)
dlg.title(f"Detail: {title}")
dlg.configure(bg="#FFFFFF")
w, h = 520, 540
sw = dlg.winfo_screenwidth()
sh = dlg.winfo_screenheight()
dlg.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
dlg.attributes("-topmost", True)
self._register_window(dlg)
add_resize_grip(dlg, 400, 370)
tk.Label(dlg, text=title, font=("Segoe UI Semibold", 12, "bold"),
bg=header_bg, fg="#1a4d6d", anchor="w").pack(fill="x", ipadx=10, ipady=6)
quelle_frame = tk.Frame(dlg, bg="#F5F9FC")
quelle_frame.pack(fill="x", padx=8, pady=(4, 0))
tk.Label(quelle_frame, text=quelle_label, font=("Segoe UI", 8),
bg="#F5F9FC", fg="#555").pack(side="left")
saved_q = self._autotext_data.get(quelle_key, "")
q_keys = list(quelle_dict.keys())
q_labels = [quelle_dict[k]["label"] for k in q_keys]
label_to_key = dict(zip(q_labels, q_keys))
current_key = saved_q if saved_q in quelle_dict else q_keys[0]
q_display = tk.StringVar(value=quelle_dict[current_key]["label"])
combo = ttk.Combobox(quelle_frame, textvariable=q_display,
values=q_labels, state="readonly", width=34)
combo.pack(side="left", padx=(4, 0))
if original_quelle_dict and original_quelle_key:
oq_frame = tk.Frame(dlg, bg="#F5F9FC")
oq_frame.pack(fill="x", padx=8, pady=(2, 0))
tk.Label(oq_frame, text=original_quelle_label, font=("Segoe UI", 8),
bg="#F5F9FC", fg="#555").pack(side="left")
oq_saved = self._autotext_data.get(original_quelle_key, "")
oq_keys = list(original_quelle_dict.keys())
oq_labels = [original_quelle_dict[k]["label"] for k in oq_keys]
oq_label_to_key = dict(zip(oq_labels, oq_keys))
oq_current = oq_saved if oq_saved in original_quelle_dict else oq_keys[0]
oq_display = tk.StringVar(value=original_quelle_dict[oq_current]["label"])
oq_combo = ttk.Combobox(oq_frame, textvariable=oq_display,
values=oq_labels, state="readonly", width=34)
oq_combo.pack(side="left", padx=(4, 0))
def _on_oq_change(*_):
oq_key = oq_label_to_key.get(oq_display.get(), oq_keys[0])
self._autotext_data[original_quelle_key] = oq_key
save_autotext(self._autotext_data)
oq_combo.bind("<<ComboboxSelected>>", _on_oq_change)
hint = tk.Label(dlg, text="\u2139 Überschrift oder Button unten klicken = Originalquelle im Browser öffnen",
font=("Segoe UI", 7, "italic"), bg="#FFFFFF", fg="#999", anchor="w")
hint.pack(fill="x", padx=12, pady=(2, 0))
txt = ScrolledText(dlg, wrap="word", font=("Segoe UI", 10),
bg="#FFFFFF", fg="#333", relief="flat", padx=10, pady=8)
txt.pack(fill="both", expand=True, padx=4, pady=(2, 0))
if header_bg == "#E8F4E8":
known_headings = self._DETAIL_SECTION_HEADINGS_MED
elif header_bg == "#F3E5F5":
known_headings = self._DETAIL_SECTION_HEADINGS_THER
else:
known_headings = self._DETAIL_SECTION_HEADINGS_DX
def _render_with_links(text):
try:
txt.configure(state="normal")
txt.delete("1.0", "end")
lines = text.split("\n")
tag_counter = [0]
for line in lines:
stripped = line.strip()
is_section = any(stripped.lower().startswith(h.lower()) or
stripped.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
.startswith(h.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue"))
for h in known_headings)
if is_section and len(stripped) < 60:
tag_name = f"sec_{tag_counter[0]}"
tag_counter[0] += 1
txt.tag_configure(tag_name, font=("Segoe UI Semibold", 10, "bold underline"),
foreground="#1565C0")
txt.insert("end", stripped + "\n", tag_name)
txt.tag_bind(tag_name, "<Button-1>",
lambda e, n=title: open_url_fn(n))
txt.tag_bind(tag_name, "<Enter>",
lambda e: txt.config(cursor="hand2"))
txt.tag_bind(tag_name, "<Leave>",
lambda e: txt.config(cursor=""))
else:
txt.insert("end", line + "\n")
txt.configure(state="disabled")
except (tk.TclError, AttributeError):
pass
def _do_reload():
try:
txt.configure(state="normal")
txt.delete("1.0", "end")
txt.insert("1.0", "Lade Quelldaten und Kurzinfo..." if callable(prompt) else "Lade strukturierte Kurzinfo...")
txt.configure(state="disabled")
except tk.TclError:
return
def _worker():
try:
actual_prompt = prompt() if callable(prompt) else prompt
from openai import OpenAI
client = OpenAI(api_key=get_openai_api_key())
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": actual_prompt},
{"role": "user", "content": f"Bitte Kurzinfo zu: {title}\n\nKontext:\n{detail[:500]}"}
],
temperature=0.2, max_tokens=500,
)
result = resp.choices[0].message.content.strip()
self.after(0, lambda r=result: _render_with_links(r))
except Exception as exc:
self.after(0, lambda e=str(exc): _render_with_links(f"Fehler: {e}"))
threading.Thread(target=_worker, daemon=True).start()
def _on_q_change(*_):
key = label_to_key.get(q_display.get(), q_keys[0])
self._autotext_data[quelle_key] = key
save_autotext(self._autotext_data)
if callable(prompt) and detail:
_do_reload()
combo.bind("<<ComboboxSelected>>", _on_q_change)
if detail and hasattr(self, "_check_ai_consent") and self._check_ai_consent():
_do_reload()
else:
txt.insert("1.0", detail if detail else "Keine weiteren Details verfuegbar.")
txt.configure(state="disabled")
btn_frame = tk.Frame(dlg, bg="#FFFFFF")
btn_frame.pack(fill="x", padx=8, pady=(4, 8))
tk.Button(btn_frame, text="Originalquelle oeffnen", font=("Segoe UI", 9, "bold"),
bg="#5B8DB3", fg="white", relief="flat", cursor="hand2",
command=lambda: open_url_fn(title)).pack(side="left", padx=(0, 8))
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 9),
bg="#EBEDF0", fg="#333", relief="flat",
command=dlg.destroy).pack(side="left")
# ─── Inhaltsquellen (Content Sources) getrennt von Originallink ───
_MED_CONTENT_QUELLEN = {
"DocCheck": {
"label": "DocCheck Flexikon",
},
"PharmaWiki": {
"label": "PharmaWiki (pharmawiki.ch)",
},
}
def _fetch_doccheck_info(self, med_name):
"""Extrahiert strukturierte Medikamenten-Info von DocCheck Flexikon.
DocCheck nutzt standard <h2>/<h3> Headings (server-rendered).
Returns (source_text, source_url) oder (None, None).
"""
import urllib.request
import urllib.parse
import re
try:
url = f"https://flexikon.doccheck.com/de/{urllib.parse.quote(med_name)}"
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept-Language": "de-DE,de;q=0.9",
})
with urllib.request.urlopen(req, timeout=8) as resp:
dc_html = resp.read().decode("utf-8", errors="replace")
if "Suchergebnisse" in dc_html or "Seite nicht gefunden" in dc_html:
return (None, None)
clean = re.sub(r"<script[^>]*>.*?</script>", "", dc_html, flags=re.DOTALL)
clean = re.sub(r"<style[^>]*>.*?</style>", "", clean, flags=re.DOTALL)
sections: dict[str, str] = {}
for m in re.finditer(
r"<h[23][^>]*>(.*?)</h[23]>(.*?)(?=<h[23]|</article|</main|</body|$)",
clean, re.DOTALL,
):
heading = re.sub(r"<[^>]+>", "", m.group(1)).strip()
body = re.sub(r"<[^>]+>", " ", m.group(2))
body = re.sub(r"\s+", " ", body).strip()
if heading and body and len(body) > 15 and heading != "Inhaltsverzeichnis":
sections[heading] = body[:500]
if not sections:
return (None, None)
relevant = [
"Definition", "Indikation", "Indikationen", "Dosierung",
"Darreichungsform", "Darreichungsformen",
"Wirkmechanismus", "Pharmakokinetik",
"Nebenwirkungen", "Kontraindikation", "Kontraindikationen",
"Wechselwirkungen",
]
parts: list[str] = []
for key in relevant:
for sname, scontent in sections.items():
if key.lower() in sname.lower():
parts.append(f"{sname}: {scontent}")
break
if not parts:
parts = [f"{n}: {c}" for n, c in list(sections.items())[:6]]
return ("\n\n".join(parts), url)
except Exception:
return (None, None)
def _fetch_pharmawiki_info(self, med_name):
"""Extrahiert strukturierte Medikamenten-Info von PharmaWiki.ch.
PharmaWiki nutzt <span id="subtitle"> fuer Sektionsueberschriften.
Returns (source_text, product_name, darreichungsform, source_url)
oder (None, None, None, None).
"""
import urllib.request
import urllib.parse
import re
try:
url = f"https://www.pharmawiki.ch/wiki/index.php?wiki={urllib.parse.quote(med_name)}"
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept-Language": "de-CH,de;q=0.9",
})
with urllib.request.urlopen(req, timeout=8) as resp:
pw_html = resp.read().decode("utf-8", errors="replace")
if "Suchresultate" in pw_html or "wurde nicht gefunden" in pw_html:
return (None, None, None, None)
sections: dict[str, str] = {}
for m in re.finditer(
r'<span\s+id="subtitle"[^>]*>(.*?)</span>(.*?)'
r'(?=<span\s+id="subtitle"|<span\s+id="bottom"|<div\s+id="footer"|$)',
pw_html, re.DOTALL,
):
heading = re.sub(r"<[^>]+>", "", m.group(1)).strip()
body = re.sub(r"<[^>]+>", " ", m.group(2))
body = re.sub(r"\s+", " ", body).strip()
if heading and body and len(body) > 15:
sections[heading] = body[:500]
if not sections:
return (None, None, None, None)
relevant = [
"Produkte", "Indikationen", "Dosierung", "Wirkungen",
"Kontraindikationen", "Interaktionen",
"Unerw\u00fcnschte Wirkungen", "Anwendung", "Verabreichung",
]
parts: list[str] = []
for key in relevant:
for sname, scontent in sections.items():
if key.lower() in sname.lower():
parts.append(f"{sname}: {scontent}")
break
if not parts:
parts = [f"{n}: {c}" for n, c in list(sections.items())[:5]]
product_name = None
darreichungsform = None
for sname, scontent in sections.items():
if "produkte" in sname.lower():
prod_match = re.search(
r"\(([A-Z][a-z\u00e4\u00f6\u00fc\-]+(?:\u00ae|&reg;)?)\)", scontent
)
if prod_match:
product_name = prod_match.group(1).replace("&reg;", "\u00ae")
df_match = re.search(
r"(Filmtabletten?|Tabletten?|Kapseln?|Weichkapseln?|Hartkapseln?|"
r"Tropfen|Sirup|Creme|Salbe|Gel|L[o\u00f6]sung|"
r"orale[rn]?\s+Suspension|Injektionsl[o\u00f6]sung|"
r"Suppositorien|Spray|Pflaster|Fertigspritze[n]?|"
r"Augentropfen|Nasenspray|Brausetabletten?|"
r"Schmelztabletten?|Retardtabletten?|Granulat|Pulver)",
scontent, re.IGNORECASE,
)
if df_match:
darreichungsform = df_match.group(1)
break
return ("\n\n".join(parts), product_name, darreichungsform, url)
except Exception:
return (None, None, None, None)
def _show_med_detail(self, title, detail):
"""Medikamenten-Detailfenster.
Zwei getrennte Ebenen:
- INHALTSQUELLE (content source): DocCheck/PharmaWiki -> Dropdown "Inhaltsquelle:"
gespeichert als med_content_quelle in _autotext_data
- ORIGINALQUELLE (external link): CH=Compendium/AT=BASG/DE=BfArM -> Button
gespeichert als medikament_quelle in _autotext_data (UNVERAENDERT)
"""
def _build_prompt():
"""Laeuft im Hintergrund-Thread: Quelldaten aus gewaehlter Inhaltsquelle holen."""
content_quelle = self._autotext_data.get("med_content_quelle", "DocCheck")
source_text = None
source_name = None
if content_quelle == "DocCheck":
dc_text, dc_url = self._fetch_doccheck_info(title)
if dc_text:
source_text = dc_text
source_name = "DocCheck Flexikon"
else:
pw_text, pw_prod, pw_df, pw_url = self._fetch_pharmawiki_info(title)
if pw_text:
source_text = pw_text
source_name = "PharmaWiki (Fallback)"
else:
pw_text, pw_prod, pw_df, pw_url = self._fetch_pharmawiki_info(title)
if pw_text:
source_text = pw_text
source_name = "PharmaWiki (pharmawiki.ch)"
if not source_text:
facts = get_medication_facts(title)
if facts:
source_text = (
f"Indikation: {facts['indikation']}\n"
f"Einnahme: {facts['einnahme']}\n"
f"Dosierung: {facts['dosierung']}\n"
f"Wichtig: {facts['wichtig']}"
)
source_name = f"Kuratierte Fakten ({facts['quelle']})"
if source_text:
source_block = (
f"=== QUELLDATEN ({source_name}) ===\n"
f"Wirkstoff/Medikament: {title}\n"
f"{source_text}\n"
f"=== ENDE QUELLDATEN ===\n"
)
return (
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
"Dir werden QUELLDATEN aus einer pharmazeutischen Quelle geliefert. "
"STRIKTE REGEL: Verwende AUSSCHLIESSLICH die gelieferten Quelldaten. "
"Erfinde NICHTS hinzu. Wenn ein Punkt nicht in den Quelldaten steht, "
"schreibe 'Siehe Fachinformation'.\n\n"
"Formatiere die Quelldaten in folgende Abschnitte:\n\n"
"Indikation / Wofür\n(NUR aus Quelldaten)\n\n"
"Anwendung / Applikation\n(NUR aus Quelldaten)\n\n"
"Dosierung\n(NUR aus Quelldaten)\n\n"
"Wichtige Hinweise\n(NUR aus Quelldaten)\n\n"
"Quelle\n" + source_name + "\n\n"
"Regeln: Max 150 Wörter. Fachsprache erlaubt. "
"NICHTS erfinden, NICHTS verallgemeinern, KEINE generischen Sätze. "
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile. "
"Sprache: Deutsch.\n\n" + source_block
)
return (
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
"SICHERHEITSREGEL: Für dieses Medikament konnten KEINE Quelldaten geladen werden. "
"Weder DocCheck, PharmaWiki noch kuratierte Datenbank haben belastbare Daten geliefert.\n\n"
"Erstelle eine MINIMALE Zusammenfassung:\n\n"
"Indikation / Wofür\n(zugelassene Indikationen, kurz falls bekannt)\n\n"
"Anwendung / Applikation\n"
"Keine sichere Quellenzuordnung möglich. Bitte Fachinformation konsultieren.\n\n"
"Dosierung\nKeine gesicherte Angabe. Siehe Fachinformation.\n\n"
"Quelle\n"
"Keine verifizierte Quelle verfügbar. Bitte Fachinformation konsultieren.\n\n"
"STRIKTE VERBOTE:\n"
"- KEINE Einnahmehinweise\n- KEINE Dosierungsangaben\n"
"- KEINE Maximaldosen\n- KEINE Nebenwirkungsauflistung\n"
"- KEINE generischen Sätze\n"
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile. "
"Sprache: Deutsch."
)
def _open(name):
import webbrowser
webbrowser.open(self._get_med_source_url(name))
if not self._autotext_data.get("med_content_quelle"):
self._autotext_data["med_content_quelle"] = "DocCheck"
if not self._autotext_data.get("medikament_quelle"):
self._autotext_data["medikament_quelle"] = "compendium.ch"
self._build_detail_window(
title, "#E8F4E8", _build_prompt, detail,
self._MED_CONTENT_QUELLEN, "med_content_quelle", _open,
quelle_label="Inhaltsquelle:",
original_quelle_dict=self._MED_QUELLEN,
original_quelle_key="medikament_quelle",
original_quelle_label="Originalquelle (per Klick):",
)
def _show_dx_detail(self, title, detail):
"""Diagnose-Detailfenster mit Quellenauswahl."""
prompt = (
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
"Erstelle eine kompakte Zusammenfassung zur Diagnose. "
"Verwende EXAKT diese Abschnittsüberschriften als eigene Zeilen:\n\n"
"Was ist das?\n"
"(Definition, kurz und prägnant)\n\n"
"Typische Symptome / Klinik\n"
"(klinisches Bild, Leitsymptome)\n\n"
"Typische Therapie / Behandlung\n"
"(Erstlinientherapie, ggf. Alternativen, kurz)\n\n"
"Wichtige Warnhinweise / Red Flags\n"
"(wann dringend handeln, Differentialdiagnosen beachten)\n\n"
"Regeln: Max 150 Wörter. Fachsprache erlaubt. Medizinisch konservativ. "
"Keine Eskalation in schwerere Diagnosen. Keine Überinterpretation. "
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile, gefolgt vom Inhalt. "
"Sprache: Deutsch."
)
saved = self._autotext_data.get("diagnose_quelle", "")
if not saved:
self._autotext_data["diagnose_quelle"] = self._get_default_dx_quelle()
def _open(name):
import webbrowser
webbrowser.open(self._get_dx_source_url(name))
self._build_detail_window(title, "#E8F4F8", prompt, detail,
self._DX_QUELLEN, "diagnose_quelle", _open)
def _show_ther_detail(self, title, detail):
"""Therapie-/Prozedur-Detailfenster mit eigenen Therapie-Quellen (DocCheck/PharmaWiki)."""
prompt = (
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
"Es handelt sich um eine THERAPIE oder PROZEDUR, NICHT um ein Medikament. "
"Verwende KEINE Medikamenten-Logik (keine Einnahmehinweise, keine Dosierung). "
"Erstelle eine kompakte Zusammenfassung zur medizinischen Therapie/Prozedur. "
"Verwende EXAKT diese Abschnittsüberschriften als eigene Zeilen:\n\n"
"Was ist das?\n"
"(Definition der Therapie/Prozedur, kurz und prägnant)\n\n"
"Indikationen\n"
"(wofür wird diese Therapie/Prozedur typischerweise eingesetzt?)\n\n"
"Durchführung\n"
"(kurze Beschreibung des Ablaufs, soweit klinisch relevant)\n\n"
"Wichtige Hinweise\n"
"(Kontraindikationen, Risiken, Nachsorge, max 2 Sätze)\n\n"
"Quelle\n"
"Allgemeines Fachwissen. Für Details siehe Fachliteratur.\n\n"
"Regeln: Max 120 Wörter. Fachsprache erlaubt. Medizinisch konservativ. "
"Keine Überinterpretation. KEINE Medikamenten-Informationen hier einfügen. "
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile, gefolgt vom Inhalt. "
"Sprache: Deutsch."
)
def _open(name):
import webbrowser
webbrowser.open(self._get_ther_source_url(name))
self._build_detail_window(title, "#F3E5F5", prompt, detail,
self._THER_QUELLEN, "therapie_quelle", _open)
def _run_global_right_click_paste_listener(self):
"""
Globaler Rechtsklick-Hook (Windows): je nach Einstellung wird direkt
eingefügt (global) oder nur im One-Click-Modus nach Copy.
"""
if sys.platform != "win32" or _user32 is None:
return
# Primär: pynput-Maushook (wenn vorhanden)
if _HAS_PYNPUT_MOUSE:
def on_click(x, y, button, pressed):
if pressed:
return
if button != MouseButton.right:
return
try:
hwnd = _user32.GetForegroundWindow()
self._handle_global_right_click(hwnd)
except Exception:
pass
try:
with MouseListener(on_click=on_click) as listener:
listener.join()
return
except Exception:
# Fallback unten
pass
# Fallback: polling über WinAPI, falls pynput.mouse fehlt/fehlschlägt
VK_RBUTTON = 0x02
was_down = False
while True:
try:
is_down = bool(_user32.GetAsyncKeyState(VK_RBUTTON) & 0x8000)
if was_down and not is_down:
hwnd = _user32.GetForegroundWindow()
self._handle_global_right_click(hwnd)
was_down = is_down
except Exception:
pass
time.sleep(0.01)
def _handle_global_right_click(self, hwnd):
if not hwnd:
return
try:
always_on = self._is_global_right_click_paste_enabled()
if (not always_on) and float(getattr(self, "_one_click_paste_until", 0.0)) < time.time():
return
if self._is_own_process_window(hwnd):
return
if not always_on:
self._one_click_paste_until = 0.0
self._last_external_hwnd = hwnd
self._paste_to_hwnd_direct(hwnd)
except Exception:
pass
def _is_own_process_window(self, hwnd: int) -> bool:
"""Thread-sicher ohne Tk: Fenster gehört diesem Prozess?"""
if _user32 is None:
return False
try:
pid = wintypes.DWORD(0)
_user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
return int(pid.value) == int(os.getpid())
except Exception:
return False
def _paste_to_hwnd_direct(self, hwnd) -> bool:
"""Direkter WinAPI-Paste ohne Tk-Calls (für Background-Threads)."""
if _user32 is None or not hwnd:
return False
try:
_user32.SetForegroundWindow(hwnd)
time.sleep(0.05)
self._send_escape_key()
time.sleep(0.15)
self._release_all_modifier_keys()
time.sleep(0.02)
self._send_ctrl_v()
return True
except Exception:
return False
def _send_escape_key(self):
if _user32 is None:
return
try:
VK_ESCAPE = 0x1B
_user32.keybd_event(VK_ESCAPE, 0, 0, 0)
_user32.keybd_event(VK_ESCAPE, 0, 2, 0)
except Exception:
pass
def _is_global_right_click_paste_enabled(self) -> bool:
try:
return bool((self._autotext_data or {}).get("global_right_click_paste", True))
except Exception:
return True
def _toggle_rclick_paste(self):
new_val = self._rclick_paste_var.get()
if self._autotext_data is None:
self._autotext_data = {}
self._autotext_data["global_right_click_paste"] = new_val
try:
from aza_persistence import save_autotext
save_autotext(self._autotext_data)
except Exception:
pass
def _is_autocopy_enabled(self) -> bool:
try:
return bool((self._autotext_data or {}).get("autocopy_after_diktat", True))
except Exception:
return True
def _is_aza_window_hwnd(self, hwnd: int) -> bool:
"""True, wenn hwnd zu einem unserer AZA-Fenster gehört."""
try:
if hwnd == self.winfo_id():
return True
except Exception:
pass
try:
for w in self._get_registered_windows():
try:
if w.winfo_id() == hwnd:
return True
except Exception:
pass
except Exception:
pass
return False
def _textblock_slot_at(self, x_root: int, y_root: int):
"""Wie textbloecke.py button_index_at: Slot ('1''4') des Buttons unter (x_root, y_root) per Rechteck-Test."""
if not hasattr(self, "_textbloecke_buttons"):
return None
try:
for slot, btn in self._textbloecke_buttons:
bx = btn.winfo_rootx()
by = btn.winfo_rooty()
bw = max(1, btn.winfo_width())
bh = max(1, btn.winfo_height())
if bx <= x_root <= bx + bw and by <= y_root <= by + bh:
return slot
except (tk.TclError, AttributeError):
pass
return None
def _textblock_on_drag_motion(self, event):
"""Wie textbloecke.py on_drag_motion: Sobald Maus sich bewegt und wir Text haben Drag aktiv."""
d = getattr(self, "_textblock_drag_data", None)
if d is not None and d.get("text"):
d["active"] = True
def _on_global_drag_release(self, event):
"""Wie textbloecke.py on_release: Maus losgelassen wenn Drag aktiv und über Button dort speichern; sonst in Zwischenablage für externes Einfügen."""
d = getattr(self, "_textblock_drag_data", None)
if d is None:
return
if not d.get("active") or not d.get("text"):
d["text"] = None
d["active"] = False
return
slot = self._textblock_slot_at(event.x_root, event.y_root)
if slot:
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = d["text"] or ""
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Text in Textblock gezogen und gespeichert.")
self._just_dropped_on_textblock = True
else:
try:
t = (d["text"] or "").strip()
if not _win_clipboard_set(t):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(t))
self.set_status("Text in Zwischenablage in Editor/andere App mit Strg+V einfügen.")
except (tk.TclError, AttributeError):
pass
d["text"] = None
d["active"] = False
def _on_focus_out_for_external_paste(self, event):
"""Wenn unser Fenster den Fokus verliert: externes Fenster merken, Cursor-Ziel bei uns verwerfen."""
if event.widget is not self:
return
self._last_focused_text_widget = None
self.after(120, self._store_last_external_window)
def _store_last_external_window(self):
"""Unter Windows: merkt sich das aktuell aktive Fenster (externes Programm)."""
if _user32 is None:
return
try:
hwnd = _user32.GetForegroundWindow()
if hwnd and hwnd != self.winfo_id():
self._last_external_hwnd = hwnd
except Exception:
pass
def _paste_to_external_window(self):
"""Unter Windows: aktiviert das zuletzt gespeicherte externe Fenster und sendet Strg+V."""
if _user32 is None:
return False
hwnd = getattr(self, "_last_external_hwnd", None)
if not hwnd:
return False
return self._paste_to_hwnd_direct(hwnd)
def _send_escape_then_ctrl_v(self):
self._send_escape_key()
self.after(30, self._send_ctrl_v)
def _release_all_modifier_keys(self):
"""Setzt Ctrl/Alt/Shift/Win explizit auf KEYUP, verhindert Ghost-Keys."""
if _user32 is None:
return
try:
KEYEVENTF_KEYUP = 0x0002
for vk in (0x10, 0x11, 0x12, 0x5B, 0x5C): # Shift, Ctrl, Alt, LWin, RWin
_user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0)
except Exception:
pass
def _send_ctrl_v(self):
"""Sendet Strg+V (Einfügen) an das aktive Fenster."""
if _user32 is None:
return
if self._send_key_combo_sendinput(0x11, 0x56):
return
try:
VK_CONTROL = 0x11
VK_V = 0x56
_user32.keybd_event(VK_CONTROL, 0, 0, 0)
time.sleep(0.02)
_user32.keybd_event(VK_V, 0, 0, 0)
time.sleep(0.02)
_user32.keybd_event(VK_V, 0, 2, 0)
time.sleep(0.02)
_user32.keybd_event(VK_CONTROL, 0, 2, 0)
except Exception:
pass
def _send_key_combo_sendinput(self, vk_mod: int, vk_key: int) -> bool:
if _user32 is None:
return False
try:
KEYEVENTF_KEYUP = 0x0002
INPUT_KEYBOARD = 1
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.c_size_t),
]
class INPUT(ctypes.Structure):
_fields_ = [("type", wintypes.DWORD), ("ki", KEYBDINPUT)]
seq = [
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_mod, 0, 0, 0, 0)),
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_key, 0, 0, 0, 0)),
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_key, 0, KEYEVENTF_KEYUP, 0, 0)),
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_mod, 0, KEYEVENTF_KEYUP, 0, 0)),
]
arr = (INPUT * len(seq))(*seq)
sent = int(_user32.SendInput(len(arr), ctypes.byref(arr), ctypes.sizeof(INPUT)))
return sent == len(arr)
except Exception:
return False
def _arm_one_click_external_paste(self, seconds: int = 120):
"""Aktiviert einmaliges Rechtsklick->Einfügen in externen Apps (z. B. Word)."""
try:
self._one_click_paste_until = time.time() + max(5, int(seconds))
except Exception:
self._one_click_paste_until = time.time() + 120
def _textblock_copy_to_clipboard(self, slot: str):
"""Kopiert den Textblock-Inhalt in die Zwischenablage (z. B. beim Drücken/Ziehen in anderen Editor)."""
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
if not content.strip():
return
try:
if not _win_clipboard_set(content):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(content))
except (tk.TclError, AttributeError):
pass
def _copy_textblock(self, slot: str):
"""Klick auf Button: Text dort einfügen, wo der Cursor ist bei uns im Hauptfenster oder im externen Programm."""
if getattr(self, "_just_dropped_on_textblock", False):
self._just_dropped_on_textblock = False
return
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
if not content.strip():
self.set_status("Textblock ist leer. Rechtsklick → 'Aus Zwischenablage speichern' oder Shift+Klick zum Befüllen.")
return
try:
if not _win_clipboard_set(content):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(content))
except (tk.TclError, AttributeError):
pass
w = getattr(self, "_last_focused_text_widget", None)
try:
if w is not None and w.winfo_exists() and hasattr(w, "insert"):
w.focus_set()
w.insert(tk.INSERT, content)
w.see(tk.INSERT)
self.set_status("Textblock an Cursorposition eingefügt.")
return
except (tk.TclError, AttributeError):
pass
ext_hwnd = getattr(self, "_last_external_hwnd", None)
if sys.platform == "win32" and _user32 and ext_hwnd:
def _do_external_paste():
self._paste_to_hwnd_direct(ext_hwnd)
self.set_status("Textblock in externes Programm eingefügt.")
self.after(80, _do_external_paste)
return
if hasattr(self, "txt_output"):
try:
w2 = self.txt_output
if w2.winfo_exists() and hasattr(w2, "insert"):
w2.focus_set()
w2.insert(tk.INSERT, content)
w2.see(tk.INSERT)
self.set_status("Textblock an Cursorposition eingefügt.")
return
except (tk.TclError, AttributeError):
pass
self.set_status("Textblock in Zwischenablage → Cursor setzen, dann Strg+V.")
def _get_selection_from_focus(self) -> str:
"""Liefert die aktuelle Auswahl aus einem Text-Widget (Transkript, KG, Diktat, Brief etc.)."""
def find_text_with_selection(widget):
try:
if widget.winfo_class() == "Text" and hasattr(widget, "selection_present") and widget.selection_present():
return widget.get(tk.SEL_FIRST, tk.SEL_LAST)
for c in widget.winfo_children():
r = find_text_with_selection(c)
if r:
return r
except (tk.TclError, AttributeError):
pass
return ""
w = self.focus_get()
try:
if w is not None and hasattr(w, "selection_present") and w.selection_present():
return w.get(tk.SEL_FIRST, tk.SEL_LAST)
except (tk.TclError, AttributeError):
pass
return find_text_with_selection(self) or ""
def _save_selection_to_textblock(self, event, slot: str):
"""Speichert die aktuelle Textauswahl (aus beliebigem Feld) in den Textblock. Shift+Klick oder Menü."""
text = self._get_selection_from_focus()
if not text.strip():
self.set_status("Keine Auswahl Text markieren oder Rechtsklick Aus Zwischenablage speichern.")
return
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Auswahl in Textblock gespeichert.")
def _textblock_context(self, event, slot: str):
"""Rechtsklick-Menü: Einfügen, Speichern (Auswahl/Zwischenablage), Umbenennen, Löschen."""
menu = tk.Menu(self, tearoff=0)
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
if content.strip():
menu.add_command(
label="An Cursorposition einfügen",
command=lambda: self._textblock_insert_at_cursor(slot),
)
ext_hwnd = getattr(self, "_last_external_hwnd", None)
if sys.platform == "win32" and _user32 and ext_hwnd:
menu.add_command(
label="In externes Feld einfügen",
command=lambda: self._textblock_paste_to_external(slot),
)
menu.add_separator()
menu.add_command(
label="Aus Zwischenablage speichern",
command=lambda: self._textblock_save_from_clipboard(slot),
)
menu.add_command(
label="Markierung speichern",
command=lambda: self._textblock_save_from_selection(slot),
)
menu.add_separator()
menu.add_command(
label="Umbenennen",
command=lambda: self._textblock_rename(slot),
)
menu.add_command(
label="Textblock leeren",
command=lambda: self._textblock_clear(slot),
)
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _textblock_paste_to_external(self, slot: str):
"""Fügt den Textblock direkt in das zuletzt aktive externe Fenster ein."""
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
if not content.strip():
self.set_status("Textblock ist leer.")
return
try:
if not _win_clipboard_set(content):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(content))
except (tk.TclError, AttributeError):
pass
ext_hwnd = getattr(self, "_last_external_hwnd", None)
if ext_hwnd and _user32:
def _do():
self._paste_to_hwnd_direct(ext_hwnd)
self.set_status("Textblock in externes Programm eingefügt.")
self.after(80, _do)
else:
self.set_status("Kein externes Fenster erkannt → Strg+V zum Einfügen.")
def _textblock_insert_at_cursor(self, slot: str):
"""Fügt den Textblock-Inhalt an der Cursorposition ein (intern oder extern)."""
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
if not content.strip():
self.set_status("Textblock ist leer.")
return
w = getattr(self, "_last_focused_text_widget", None)
if w is None and hasattr(self, "txt_output"):
w = self.txt_output
try:
if w is not None and w.winfo_exists() and hasattr(w, "insert"):
w.focus_set()
w.insert(tk.INSERT, content)
w.see(tk.INSERT)
self.set_status("Textblock an Cursorposition eingefügt.")
return
except (tk.TclError, AttributeError):
pass
try:
if not _win_clipboard_set(content):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(content))
except (tk.TclError, AttributeError):
pass
ext_hwnd = getattr(self, "_last_external_hwnd", None)
if sys.platform == "win32" and _user32 and ext_hwnd:
def _do_ext():
self._paste_to_hwnd_direct(ext_hwnd)
self.set_status("Textblock in externes Programm eingefügt.")
self.after(80, _do_ext)
return
self.set_status("Textblock in Zwischenablage → mit Strg+V einfügen.")
def _textblock_clear(self, slot: str):
"""Löscht den Inhalt (und optional den Namen) des Textblocks."""
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = ""
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Textblock gelöscht.")
def _textblock_save_from_selection(self, slot: str):
"""Speichert die Auswahl des fokussierten Textfelds in den Textblock."""
text = self._get_selection_from_focus()
if not text.strip():
messagebox.showinfo("Hinweis", "Bitte zuerst Text markieren (z. B. im Transkript, in der KG oder im Diktat-Fenster).")
return
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Auswahl in Textblock gespeichert.")
def _textblock_save_from_clipboard_or_selection(self, slot: str):
"""Shift+Klick: Speichert Textauswahl (falls vorhanden) oder Zwischenablage in den Textblock."""
text = self._get_selection_from_focus()
if not text.strip():
try:
text = self.clipboard_get()
except Exception:
text = ""
if isinstance(text, str) and text.strip():
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Text in Textblock gespeichert (Shift+Klick).")
else:
self.set_status("Kein Text markiert oder in Zwischenablage.")
def _textblock_save_from_clipboard(self, slot: str):
"""Speichert den Inhalt der Zwischenablage in den Textblock (überschreibt)."""
try:
text = self.clipboard_get()
except Exception:
text = ""
if isinstance(text, str):
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Textblock gespeichert.")
else:
self.set_status("Kein Text in Zwischenablage.")
def _textblock_rename(self, slot: str):
"""Umbenennen direkt am Button: Entry überlagert den Button, kein neues Fenster."""
def _do_rename():
btn = None
for s, b in self._textbloecke_buttons:
if s == slot:
btn = b
break
if not btn or not btn.winfo_exists():
return
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
current = (d.get("name") or "").strip() or f"Textblock {slot}"
parent = btn.nametowidget(btn.winfo_parent())
entry = ttk.Entry(parent, width=12, font=("Segoe UI", 11))
entry.insert(0, current)
entry.select_range(0, tk.END)
parent.update_idletasks()
entry.place(x=btn.winfo_x(), y=btn.winfo_y(), width=btn.winfo_width(), height=btn.winfo_height())
entry.focus_set()
def finish(save: bool):
if save:
new_name = entry.get().strip()
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["name"] = new_name or f"Textblock {slot}"
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Button umbenannt.")
entry.place_forget()
entry.destroy()
entry.bind("<Return>", lambda e: finish(True))
entry.bind("<Escape>", lambda e: finish(False))
self.after(150, _do_rename)
def _new_session(self):
# Vor dem Leeren: KG und (falls vorhanden) Brief/Rezept/KOGU zuverlässig in Ablage speichern (JSON + .txt)
def get_str(widget_or_str):
if widget_or_str is None:
return ""
if hasattr(widget_or_str, "get"):
try:
s = widget_or_str.get("1.0", "end")
return (s if isinstance(s, str) else str(s)).strip()
except Exception:
return ""
return (str(widget_or_str)).strip()
kg_text = get_str(self.txt_output)
brief_text = get_str(self._last_brief_text)
rezept_text = get_str(self._last_rezept_text)
kogu_text = get_str(self._last_kogu_text)
try:
if kg_text:
save_to_ablage("KG", kg_text)
if brief_text:
save_to_ablage("Briefe", brief_text)
if rezept_text:
save_to_ablage("Rezepte", rezept_text)
if kogu_text:
save_to_ablage("Kostengutsprachen", kogu_text)
except Exception as e:
try:
messagebox.showerror("Speichern bei Neu", str(e))
except Exception:
pass
self._last_brief_text = ""
self._last_rezept_text = ""
self._last_kogu_text = ""
self.txt_transcript.delete("1.0", "end")
self.txt_output.delete("1.0", "end")
self.set_status("Bereit.")
def copy_output(self):
text = self.txt_output.get("1.0", "end").strip()
if not text:
self.set_status("Kein Diktat/KG vorhanden zum Kopieren.")
return
if not _win_clipboard_set(text):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(text))
self._arm_one_click_external_paste()
self.set_status("KG kopiert.")
def copy_transcript(self):
text = self.txt_transcript.get("1.0", "end").strip()
if not text:
self.set_status("Keine Audio aufgenommen oder kein Transkript vorhanden.")
return
if not _win_clipboard_set(text):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(text))
self._arm_one_click_external_paste()
self.set_status("Transkript kopiert.")
def _get_setup_helper_path() -> Optional[str]:
"""Gibt den Pfad zum setup_openai_runtime.ps1 zurück, falls vorhanden."""
candidates = []
if getattr(sys, "frozen", False):
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
candidates.append(os.path.join(exe_dir, "setup_openai_runtime.ps1"))
script_dir = os.path.dirname(os.path.abspath(__file__))
candidates.append(os.path.join(script_dir, "setup_openai_runtime.ps1"))
for p in candidates:
if os.path.isfile(p):
return p
return None
def _run_setup_helper() -> bool:
"""Startet den Setup-Helfer und wartet auf Beendigung. Gibt True zurück bei Erfolg."""
helper = _get_setup_helper_path()
if not helper:
return False
try:
result = subprocess.run(
["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", helper],
cwd=os.path.dirname(helper),
)
return result.returncode == 0
except Exception:
return False
def _show_openai_key_setup_dialog():
"""Premium-Dialog: API-Key direkt eingeben und verschlüsselt im Tresor speichern."""
has_helper = _get_setup_helper_path() is not None
_BG = "#FFFFFF"
_ACCENT = "#0078D7"
_ACCENT_HOVER = "#005FA3"
_TEXT = "#2D3436"
_SUBTLE = "#636E72"
_BORDER = "#E2E8F0"
_SUCCESS = "#00B894"
dlg = tk.Toplevel()
dlg.title("AZA \u2013 KI-Verbindung einrichten")
dlg.configure(bg=_BG)
dlg.resizable(False, False)
w, h = 540, 460
dlg.geometry(f"{w}x{h}")
dlg.attributes("-topmost", True)
try:
dlg.update_idletasks()
sw = dlg.winfo_screenwidth()
sh = dlg.winfo_screenheight()
dlg.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
except Exception:
pass
content = tk.Frame(dlg, bg=_BG)
content.pack(fill="both", expand=True, padx=40, pady=28)
tk.Label(content, text="\U0001F512",
font=("Segoe UI", 28), fg=_ACCENT, bg=_BG).pack(anchor="w")
tk.Label(content, text="KI-Verbindung einrichten",
font=("Segoe UI", 18, "bold"), fg=_TEXT, bg=_BG
).pack(anchor="w", pady=(6, 4))
tk.Label(content,
text="Geben Sie Ihren OpenAI-Schlüssel ein, um die\n"
"KI-Unterstützung zu aktivieren.\n\n"
"Hinweis: Der Schlüssel wird pro Windows-Benutzer\n"
"verschlüsselt gespeichert. Jeder Benutzer auf\n"
"diesem Computer muss seinen eigenen Schlüssel einrichten.",
font=("Segoe UI", 11), fg=_SUBTLE, bg=_BG,
justify="left").pack(anchor="w", pady=(0, 18))
tk.Label(content, text="API-Schlüssel:",
font=("Segoe UI", 10, "bold"), fg=_TEXT, bg=_BG
).pack(anchor="w", pady=(0, 4))
key_frame = tk.Frame(content, bg=_BORDER, highlightthickness=0)
key_frame.pack(fill="x", pady=(0, 6))
key_entry = tk.Entry(
key_frame, font=("Consolas", 12), bg="white", fg=_TEXT,
relief="flat", bd=0, show="\u2022",
)
key_entry.pack(fill="x", ipady=8, padx=2, pady=2)
status_label = tk.Label(
content, text="\U0001F512 Ihr Schlüssel wird verschlüsselt gespeichert",
font=("Segoe UI", 8), fg=_SUBTLE, bg=_BG, justify="left",
)
status_label.pack(anchor="w", pady=(0, 14))
result = {"action": None}
def do_activate():
key_val = key_entry.get().strip()
if not key_val:
status_label.configure(text="\u26A0 Bitte geben Sie einen Schlüssel ein.", fg="#E05050")
return
if len(key_val) < 10:
status_label.configure(text="\u26A0 Dieser Schlüssel ist zu kurz.", fg="#E05050")
return
ok = store_api_key(key_val)
if ok:
os.environ["OPENAI_API_KEY"] = key_val
result["action"] = "stored"
dlg.destroy()
else:
status_label.configure(text="\u26A0 Speichern fehlgeschlagen.", fg="#E05050")
def do_helper():
result["action"] = "setup"
dlg.destroy()
def do_config():
result["action"] = "config"
dlg.destroy()
def do_skip():
result["action"] = "skip"
dlg.destroy()
key_entry.bind("<Return>", lambda e: do_activate())
btn_area = tk.Frame(content, bg=_BG)
btn_area.pack(fill="x", pady=(0, 10))
btn_primary = tk.Button(
btn_area, text="\u2713 Schlüssel aktivieren",
font=("Segoe UI", 11, "bold"),
bg=_ACCENT, fg="white", activebackground=_ACCENT_HOVER,
activeforeground="white",
relief="flat", bd=0, padx=24, pady=10, cursor="hand2",
command=do_activate,
)
btn_primary.pack(side="left")
def _on_enter_p(e):
btn_primary.configure(bg=_ACCENT_HOVER)
def _on_leave_p(e):
btn_primary.configure(bg=_ACCENT)
btn_primary.bind("<Enter>", _on_enter_p)
btn_primary.bind("<Leave>", _on_leave_p)
btn_skip = tk.Button(
btn_area, text="Später",
font=("Segoe UI", 10),
bg=_BG, fg=_SUBTLE, activebackground="#F0F0F0",
relief="solid", bd=1, padx=18, pady=8, cursor="hand2",
highlightbackground=_BORDER,
command=do_skip,
)
btn_skip.pack(side="left", padx=(12, 0))
links = tk.Frame(content, bg=_BG)
links.pack(fill="x")
if has_helper:
lnk_setup = tk.Label(links, text="\u2192 Einrichtungsassistent starten",
font=("Segoe UI", 9, "underline"), fg=_SUBTLE, bg=_BG,
cursor="hand2")
lnk_setup.pack(anchor="w", pady=(0, 2))
lnk_setup.bind("<Button-1>", lambda e: do_helper())
lnk_config = tk.Label(links, text="\u2192 Konfiguration manuell öffnen",
font=("Segoe UI", 9, "underline"), fg=_SUBTLE, bg=_BG,
cursor="hand2")
lnk_config.pack(anchor="w")
lnk_config.bind("<Button-1>", lambda e: do_config())
dlg.protocol("WM_DELETE_WINDOW", do_skip)
dlg.grab_set()
dlg.wait_window(dlg)
if result["action"] == "stored":
return
elif result["action"] == "setup":
success = _run_setup_helper()
if success and has_openai_api_key():
return
if success:
messagebox.showinfo(
"Einrichtung",
"Die Einrichtung wurde abgeschlossen.\n\n"
"Bitte starten Sie AZA neu, damit die\n"
"KI-Funktionen aktiv werden.",
)
else:
messagebox.showinfo(
"Einrichtung",
"Die automatische Einrichtung ist auf diesem\n"
"System nicht verfügbar.\n\n"
"Bitte nutzen Sie den Startmenü-Eintrag\n"
"\"AZA \u2013 OpenAI Schlüssel einrichten\".",
)
elif result["action"] == "config":
open_runtime_config_in_editor()
def _has_remote_backend() -> bool:
"""True when a non-localhost backend URL is configured."""
url = os.getenv("MEDWORK_BACKEND_URL", "").strip()
if not url:
for base in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
p = os.path.join(base, "backend_url.txt")
if os.path.isfile(p):
try:
with open(p, "r", encoding="utf-8-sig") as f:
url = f.read().replace("\ufeff", "").strip()
except Exception:
continue
if url:
break
if not url:
return False
return not any(h in url for h in ("127.0.0.1", "localhost", "0.0.0.0"))
def _show_activation_gate() -> bool:
"""Prüft Zugang und zeigt bei Bedarf Schlüssel-Eingabe.
Returns True wenn die App starten darf, False wenn beendet werden soll.
"""
if _has_remote_backend():
print("[ACTIVATION] Remote-Backend konfiguriert lokales Aktivierungs-Gate uebersprungen. Backend-Lizenzstatus ist fuehrend.")
return True
allowed, msg = check_app_access()
if allowed:
print(f"[ACTIVATION] {msg}")
stored = load_activation_key()
is_trial = not stored or not validate_key(stored)[0]
if is_trial:
trial_msg = msg + "\n\nSie k\u00f6nnen jetzt einen Aktivierungsschl\u00fcssel eingeben\noder die Testversion weiter nutzen."
root = tk.Tk()
root.withdraw()
result = _activation_key_dialog(root, trial_msg, can_continue=True)
root.destroy()
if result:
valid, expiry, reason = validate_key(result)
if valid:
save_activation_key(result)
print(f"[ACTIVATION] Schlüssel akzeptiert: {reason}")
return True
while True:
root = tk.Tk()
root.withdraw()
result = _activation_key_dialog(root, msg)
root.destroy()
if result is None:
return False
valid, expiry, reason = validate_key(result)
if valid:
save_activation_key(result)
print(f"[ACTIVATION] Schlüssel akzeptiert: {reason}")
return True
msg = f"Schlüssel ungültig: {reason}\nBitte erneut versuchen."
def _activation_key_dialog(parent, message: str, can_continue: bool = False) -> Optional[str]:
"""Premium-Dialog zur Eingabe eines Aktivierungsschlüssels.
can_continue=True: Trial-Modus "Weiter ohne Schlüssel" statt "Beenden".
"""
_BG = "#FFFFFF"
_ACCENT = "#0078D7"
_ACCENT_HOVER = "#005FA3"
_TEXT = "#2D3436"
_SUBTLE = "#636E72"
_BORDER = "#E2E8F0"
result = {"key": None}
dlg = tk.Toplevel(parent)
dlg.title("AZA \u2013 Aktivierung")
dlg.configure(bg=_BG)
dlg.resizable(True, True)
w, h = 540, 520
dlg.minsize(460, 420)
dlg.geometry(f"{w}x{h}")
dlg.attributes("-topmost", True)
try:
dlg.update_idletasks()
sw = dlg.winfo_screenwidth()
sh = dlg.winfo_screenheight()
dlg.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
except Exception:
pass
content = tk.Frame(dlg, bg=_BG)
content.pack(fill="both", expand=True, padx=40, pady=28)
tk.Label(content, text="\U0001F511",
font=("Segoe UI", 28), fg=_ACCENT, bg=_BG).pack(anchor="w")
title_text = "AZA Aktivierung" if not can_continue else "AZA \u2013 Testversion aktiv"
tk.Label(content, text=title_text,
font=("Segoe UI", 18, "bold"), fg=_TEXT, bg=_BG
).pack(anchor="w", pady=(6, 4))
tk.Label(content, text=message, font=("Segoe UI", 10),
fg=_SUBTLE, bg=_BG, wraplength=420,
justify="left").pack(anchor="w", pady=(0, 16))
tk.Label(content, text="Aktivierungsschl\u00fcssel:",
font=("Segoe UI", 10, "bold"), fg=_TEXT, bg=_BG
).pack(anchor="w", pady=(0, 4))
key_frame = tk.Frame(content, bg=_BORDER)
key_frame.pack(fill="x", pady=(0, 4))
key_entry = tk.Entry(key_frame, font=("Consolas", 13), bg="white", fg=_TEXT,
relief="flat", bd=0)
key_entry.pack(fill="x", ipady=6, padx=2, pady=2)
status_label = tk.Label(content, text="Format: AZA-YYYYMMDD-XXXXXXXXXXXX",
font=("Segoe UI", 8), fg="#999999", bg=_BG)
status_label.pack(anchor="w", pady=(0, 14))
def do_activate(event=None):
k = key_entry.get().strip()
if not k:
status_label.configure(text="\u26A0 Bitte Schl\u00fcssel eingeben.", fg="#E05050")
return
result["key"] = k
dlg.destroy()
key_entry.bind("<Return>", do_activate)
btn_frame = tk.Frame(content, bg=_BG)
btn_frame.pack(fill="x")
btn_act = tk.Button(btn_frame, text="\u2713 Aktivieren",
font=("Segoe UI", 11, "bold"),
bg=_ACCENT, fg="white", activebackground=_ACCENT_HOVER,
activeforeground="white",
relief="flat", bd=0, padx=22, pady=10, cursor="hand2",
command=do_activate)
btn_act.pack(side="left")
def _on_e(e):
btn_act.configure(bg=_ACCENT_HOVER)
def _on_l(e):
btn_act.configure(bg=_ACCENT)
btn_act.bind("<Enter>", _on_e)
btn_act.bind("<Leave>", _on_l)
sec_text = "Weiter ohne Schl\u00fcssel \u2192" if can_continue else "Beenden"
tk.Button(btn_frame, text=sec_text, font=("Segoe UI", 10),
bg=_BG, fg=_SUBTLE, activebackground="#F0F0F0",
relief="solid", bd=1, padx=18, pady=8, cursor="hand2",
highlightbackground=_BORDER,
command=dlg.destroy).pack(side="left", padx=(12, 0))
import tkinter.ttk as _ttk
_grip = _ttk.Sizegrip(dlg)
_grip.place(relx=1.0, rely=1.0, anchor="se")
dlg.protocol("WM_DELETE_WINDOW", dlg.destroy)
key_entry.focus_set()
dlg.grab_set()
parent.wait_window(dlg)
return result["key"]
if __name__ == "__main__":
_root_env = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.isfile(_root_env):
load_dotenv(dotenv_path=_root_env, override=True)
else:
load_dotenv(override=True)
# Windows DPI fix (optional)
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
# Aktivierungs-/Ablauf-Check
if not _show_activation_gate():
sys.exit(0)
try:
from keygen_license import show_license_dialog_and_exit_if_invalid
show_license_dialog_and_exit_if_invalid("KG-Diktat")
except ImportError:
pass
ensure_runtime_config_template_exists()
if not has_openai_api_key():
try:
_show_openai_key_setup_dialog()
except Exception:
pass
_AZA_BACKEND_READY = start_backend()
if not _AZA_BACKEND_READY:
backend_error = get_backend_error().strip()
error_text = "Lokales AZA-Backend konnte nicht automatisch gestartet werden."
if backend_error:
error_text += "\n\nTechnische Details:\n" + backend_error
try:
from aza_firewall import get_firewall_hint_text
error_text += "\n\n---\n" + get_firewall_hint_text()
except Exception:
pass
try:
from tkinter import messagebox
messagebox.showerror("AZA Backend-Start fehlgeschlagen", error_text)
except Exception:
pass
raise RuntimeError(error_text)
try:
_aza_update_info = check_for_updates()
if _aza_update_info and _aza_update_info.get("update_available"):
latest_version = _aza_update_info.get("latest_version", "unbekannt")
download_url = _aza_update_info.get("download_url", "")
release_notes = _aza_update_info.get("release_notes") or []
notes_text = ""
if release_notes:
notes_text = "\n\nNeu in dieser Version:\n- " + "\n- ".join(str(x) for x in release_notes)
try:
from tkinter import messagebox
answer = messagebox.askyesno(
"AZA Update verfuegbar",
(
f"Eine neue AZA-Version ist verfuegbar: {latest_version}"
f"{notes_text}\n\n"
"Soll der neue Installer jetzt heruntergeladen werden?"
),
)
if answer:
installer_path = download_update_installer(download_url)
if installer_path:
start_now = messagebox.askyesno(
"AZA Update heruntergeladen",
(
"Der neue Installer wurde erfolgreich heruntergeladen:\n\n"
f"{installer_path}\n\n"
"Soll der Installer jetzt gestartet werden?\n"
"AZA wird danach beendet, damit das Update sauber installiert werden kann."
),
)
if start_now:
started = launch_update_installer(installer_path)
if started:
messagebox.showinfo(
"AZA Update gestartet",
"Der Installer wurde gestartet. AZA wird jetzt beendet."
)
import sys
sys.exit(0)
else:
messagebox.showerror(
"AZA Update fehlgeschlagen",
(
"Der Installer wurde heruntergeladen, konnte aber nicht gestartet werden.\n\n"
f"Bitte manuell ausfuehren:\n{installer_path}"
),
)
else:
messagebox.showerror(
"AZA Update fehlgeschlagen",
"Der Installer konnte nicht heruntergeladen werden."
)
except Exception:
print(f"AZA Update verfuegbar: {latest_version}")
except Exception:
pass
while True:
skip, _launch_mod = should_skip_launcher()
if skip:
selected_module = _launch_mod
else:
launcher = AzaLauncher()
selected_module = launcher.run()
if not selected_module:
break
if selected_module == "translator":
log_event("APP_START", detail="module=translator")
try:
import translate
translate.main()
except Exception as e:
print(f"Uebersetzer-Fehler: {e}")
log_event("APP_STOP", detail="module=translator")
continue
if selected_module == "notizen":
log_event("APP_START", detail="module=notizen")
try:
from apps.diktat.audio_notiz_app import main as audio_main
audio_main()
except Exception as e:
print(f"Audio-Notiz-Fehler: {e}")
log_event("APP_STOP", detail="module=notizen")
continue
app = KGDesktopApp(start_module=selected_module)
log_event("APP_START", detail=f"module={selected_module}")
app.mainloop()
log_event("APP_STOP", detail=f"module={selected_module}")
if not getattr(app, "_return_to_launcher", False):
break