7073 lines
312 KiB
Python
7073 lines
312 KiB
Python
# -*- 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 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
|
||
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_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,
|
||
)
|
||
|
||
|
||
|
||
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):
|
||
super().__init__()
|
||
self.check_license_status()
|
||
print(f"Lizenzmodus: {'VOLL' if self.license_mode == 'active' else 'DEMO'}")
|
||
self.title("KI Assistent PRAXIS LINDENGUT AG")
|
||
|
||
# Logo als Icon setzen (Titelleiste ~71x71 Pixel)
|
||
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
|
||
|
||
# Bild laden (weißer Hintergrund bleibt!)
|
||
img = Image.open(logo_path)
|
||
|
||
# Für Titelleiste: ~71 Pixel
|
||
icon_img = img.resize((57, 57), Image.Resampling.LANCZOS)
|
||
|
||
# Speichere temporär als .ico (Windows braucht .ico für iconphoto)
|
||
import tempfile
|
||
temp_ico = tempfile.NamedTemporaryFile(suffix='.ico', delete=False)
|
||
icon_img.save(temp_ico.name, format='ICO')
|
||
temp_ico.close()
|
||
|
||
# Setze als Fenster-Icon
|
||
self.iconbitmap(temp_ico.name)
|
||
|
||
# Aufräumen
|
||
try:
|
||
os.unlink(temp_ico.name)
|
||
except:
|
||
pass
|
||
except Exception as e:
|
||
print(f"Logo 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)
|
||
|
||
# Gespeicherte Gröe/Position, Sash und Transkript-Höhe laden oder Standard
|
||
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])
|
||
x, y = saved[2], saved[3]
|
||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||
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:
|
||
self.geometry(f"{DEFAULT_WINDOW_WIDTH}x{DEFAULT_WINDOW_HEIGHT}")
|
||
center_window(self, DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
|
||
|
||
# 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
|
||
self._user_profile = load_user_profile()
|
||
if not self._user_profile.get("name") or self._user_profile.get("password_hash"):
|
||
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._ensure_user_specialty_preferences()
|
||
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(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(1400, self._auto_start_audio_notiz_if_enabled)
|
||
|
||
self.after(1200, self._open_dev_status_window)
|
||
|
||
self._window_registry.add(self)
|
||
|
||
if not self.api_key_present:
|
||
_cfg_path = get_runtime_env_file_path()
|
||
_answer = messagebox.askyesno(
|
||
"OpenAI API-Schluessel fehlt",
|
||
"AZA ist installiert, aber auf diesem Computer ist noch kein "
|
||
"OpenAI-API-Schluessel hinterlegt.\n\n"
|
||
"Bitte oeffnen Sie die lokale Konfigurationsdatei und tragen Sie "
|
||
"dort OPENAI_API_KEY ein. Erst danach sind KI-Funktionen verfuegbar.\n\n"
|
||
f"Konfigurationsdatei:\n{_cfg_path}\n\n"
|
||
"Soll die Datei jetzt geoeffnet werden?",
|
||
)
|
||
if _answer:
|
||
open_runtime_config_in_editor()
|
||
|
||
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 = (os.getenv("MEDWORK_BACKEND_URL") or "").strip().rstrip("/")
|
||
if not backend_url:
|
||
raise ValueError("MEDWORK_BACKEND_URL fehlt")
|
||
tokens_env = os.getenv("MEDWORK_API_TOKENS")
|
||
if tokens_env and tokens_env.strip():
|
||
# take first token as primary
|
||
api_token = tokens_env.split(",")[0].strip()
|
||
else:
|
||
api_token = os.getenv("MEDWORK_API_TOKEN")
|
||
api_token = (api_token or "").strip()
|
||
if not api_token:
|
||
raise ValueError("MEDWORK_API_TOKEN fehlt")
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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 Budget verfügbar\n0% = Budget aufgebraucht\n\n"
|
||
tooltip_text += "Linksklick: Echte Daten von OpenAI laden\nRechtsklick: Budget einstellen"
|
||
add_tooltip(self._token_label, tooltip_text)
|
||
|
||
# Token-Label anklickbar machen
|
||
def refresh_usage(e=None):
|
||
self.set_status("Lade Verbrauch von OpenAI...")
|
||
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="Backend: prüfe…")
|
||
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, "Backend", 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,
|
||
"Startet backend_main.py in einem neuen Fenster (nur bei lokalem Backend).",
|
||
)
|
||
|
||
# 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"Maximale Transparenz ({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, "Transparenz anpassen:\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, "Voll sichtbar (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._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_billing = RoundedButton(
|
||
self._top_right, "Billing verwalten", command=self._open_billing_portal_from_ui,
|
||
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=140, 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, "Stripe Billing Portal öffnen")
|
||
|
||
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(anchor="center")
|
||
|
||
# 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")
|
||
ttk.Label(transcript_header, text="Transkript:").pack(side="left")
|
||
|
||
trans_frame = ttk.Frame(top_left)
|
||
trans_frame.pack(fill="both", expand=True)
|
||
self.txt_transcript = ScrolledText(
|
||
trans_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 Add-on (provisorisch):",
|
||
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, "Übersetzer (provisorisch)", 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")
|
||
|
||
kn_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["kongress_news"] = kn_row
|
||
kn_row.pack(fill="x")
|
||
kn_inner = tk.Frame(kn_row, bg="#EEF7FB")
|
||
kn_inner.pack(anchor="center")
|
||
RoundedButton(
|
||
kn_inner, "Kongresse", command=self._open_kongress_window,
|
||
bg="#e3ecf4", fg="#1e4060", active_bg="#d4e0ee", width=88, height=38, canvas_bg="#EEF7FB",
|
||
radius=0,
|
||
).pack(side="left", padx=(0, 1))
|
||
RoundedButton(
|
||
kn_inner, "News", command=self._open_news_window,
|
||
bg="#e6f0e8", fg="#1a5a3a", active_bg="#d6e8da", width=88, height=38, canvas_bg="#EEF7FB",
|
||
radius=0,
|
||
).pack(side="left", padx=(1, 0))
|
||
RoundedButton(
|
||
kn_inner, "Kongress 2", command=self._open_kongress2_window,
|
||
bg="#fce4ec", fg="#880e4f", active_bg="#f8bbd0", width=88, height=38, canvas_bg="#EEF7FB",
|
||
radius=0,
|
||
).pack(side="left", padx=(1, 0))
|
||
|
||
# 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))
|
||
# Rechtsklick auf "Macro 1" startet Aufnahme und speichert das Makro-Profil.
|
||
self.btn_macro1.bind("<Button-3>", lambda e: self._record_macro1())
|
||
|
||
# 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")
|
||
ttk.Label(kg_header, text="Krankengeschichte:").pack(side="left")
|
||
|
||
self.txt_output = ScrolledText(
|
||
top_right, 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!)
|
||
logo_frame = tk.Frame(self, bg="#B9ECFA")
|
||
logo_frame.place(relx=0.01, rely=0.97, anchor="sw") # Ganz links unten, aber höher
|
||
|
||
# Logo ZUERST (links)
|
||
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)
|
||
img_small = img.resize((82, 82), Image.Resampling.LANCZOS)
|
||
self.bottom_logo_photo = ImageTk.PhotoImage(img_small)
|
||
|
||
logo_label = tk.Label(logo_frame, image=self.bottom_logo_photo, bg="#B9ECFA")
|
||
logo_label.pack(side="left", padx=(0, 10))
|
||
except Exception as e:
|
||
print(f"Bottom-Logo konnte nicht geladen werden: {e}")
|
||
|
||
# Text-Teil DANACH (rechts vom Logo)
|
||
text_frame = tk.Frame(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")) # 24 19 (20% kleiner)
|
||
aza_label1.pack(anchor="w")
|
||
|
||
aza_label2 = tk.Label(text_frame, text="Informatik zu fairen Preisen",
|
||
bg="#B9ECFA", fg="#1a4d6d",
|
||
font=("Segoe UI", 11)) # 21 11 (50% kleiner)
|
||
aza_label2.pack(anchor="w")
|
||
|
||
|
||
# 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 _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.destroy()
|
||
|
||
# _open_settings -> ausgelagert in Mixin-Modul
|
||
|
||
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="Abkürzungen: Tippen Sie z. B. mfg + Leerzeichen wird zu mit freundlichen Grüen. Mehrzeilige Ersetzungen möglich.").pack(anchor="w")
|
||
list_f = ttk.Frame(af)
|
||
list_f.pack(fill="both", expand=True, pady=(8, 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)
|
||
entries = dict(self._autotext_data.get("entries") or {})
|
||
|
||
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()
|
||
|
||
edit_f = ttk.Frame(af)
|
||
edit_f.pack(fill="x", pady=(0, 8))
|
||
ttk.Label(edit_f, text="Abkürzung:").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||
abbrev_var = tk.StringVar()
|
||
ttk.Entry(edit_f, textvariable=abbrev_var, width=18).grid(row=0, column=1, sticky="w", padx=(0, 16))
|
||
ttk.Label(edit_f, text="Ersetzung (mehrzeilig möglich):").grid(row=1, column=0, sticky="nw", padx=(0, 8), pady=(4, 0))
|
||
repl_text = ScrolledText(edit_f, wrap="word", height=4, width=42, font=self._text_font, bg="#F5FCFF")
|
||
repl_text.grid(row=1, column=1, sticky="ew", pady=(4, 0))
|
||
edit_f.columnconfigure(1, weight=1)
|
||
|
||
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.showwarning("Diktieren", "Kein API-Key konfiguriert. Bitte in den Einstellungen eintragen.")
|
||
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))
|
||
|
||
def do_run_as_admin():
|
||
if not _run_as_admin():
|
||
messagebox.showerror("Fehler", "Konnte nicht als Administrator starten.")
|
||
|
||
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))
|
||
if sys.platform == "win32" and not _is_admin():
|
||
ttk.Button(btn_row, text="Als Administrator starten", command=do_run_as_admin).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)
|
||
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)
|
||
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}")
|
||
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)
|
||
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", ""))
|
||
|
||
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(),
|
||
"password_hash": self._hash_password(pw),
|
||
}
|
||
save_user_profile(self._user_profile)
|
||
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_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", ""))
|
||
|
||
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(),
|
||
"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
|
||
|
||
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 _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) Diktat, Übersetzung, Lernkarten, Gespräch mit KI als eigenständiges Fenster."""
|
||
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:
|
||
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
|
||
|
||
# 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, docapp_path], cwd=script_dir)
|
||
else:
|
||
subprocess.Popen([sys.executable, docapp_path], cwd=script_dir,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
subprocess.Popen([sys.executable, docapp_path], cwd=script_dir)
|
||
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)
|
||
|
||
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 15–30 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("OPENAI_API_KEY nicht gesetzt.")
|
||
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 2–3 Sätzen mit den wichtigsten Erkenntnissen.\n"
|
||
f"URL zum Originalartikel\n\n"
|
||
f"Zeige 20–30 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", "OPENAI_API_KEY nicht gesetzt.\n", "nw_bold")
|
||
text_widget.insert("end", "Bitte in der .env Datei setzen.", "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)
|
||
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)
|
||
|
||
# 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 Add-on (provisorisch):")
|
||
self._addon_collapsed = False
|
||
else:
|
||
self._addon_buttons_container.pack_forget()
|
||
self._addon_toggle_label.configure(text="\u25B6 Add-on (provisorisch):")
|
||
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.showwarning("KI-Kontrolle", "Kein API-Key konfiguriert. Bitte in den Einstellungen eintragen.")
|
||
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 Korrekturen & Interaktionscheck")
|
||
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)
|
||
|
||
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="x", pady=(0, 8))
|
||
listbox = tk.Listbox(list_f, height=4, font=("Segoe UI", 10))
|
||
listbox.pack(side="left", fill="both", expand=True)
|
||
refresh_all_list()
|
||
|
||
editing_entry = [None]
|
||
|
||
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 (kein API-Key).")
|
||
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 do_korrigieren():
|
||
t = full_corrected[0]
|
||
if "KRANKENGESCHICHTE:" in t:
|
||
kg_part = t.split("TRANSKRIPT:")[0].replace("KRANKENGESCHICHTE:", "").strip()
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", kg_part)
|
||
self.set_status("Korrekturen in KG übernommen.")
|
||
elif "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)
|
||
self.set_status("Korrekturen in Transkript übernommen.")
|
||
elif t and t != "(Kein Text zum Prüfen)":
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", 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))
|
||
RoundedButton(btn_row, "Interaktionscheck", command=do_interaktion, width=140, height=28, canvas_bg="#B9ECFA").pack(side="left")
|
||
|
||
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")
|
||
|
||
bottom_add = ttk.Frame(win, padding=(12, 0, 12, 12))
|
||
bottom_add.pack(fill="x")
|
||
ttk.Label(bottom_add, text="Falsch:").pack(side="left", padx=(0, 4))
|
||
wrong_inline = tk.StringVar()
|
||
ttk.Entry(bottom_add, textvariable=wrong_inline, width=22).pack(side="left", padx=(0, 12))
|
||
ttk.Label(bottom_add, text=" Richtig:").pack(side="left", padx=4)
|
||
right_inline = tk.StringVar()
|
||
ttk.Entry(bottom_add, textvariable=right_inline, width=22).pack(side="left", padx=(0, 12))
|
||
|
||
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)
|
||
|
||
# Korrekturen neu anwenden (auf den Originaltext)
|
||
corrected_text, _ = apply_korrekturen(raw, k)
|
||
full_corrected[0] = corrected_text
|
||
|
||
# Anzeige im Korrektur-Fenster aktualisieren
|
||
disp = extract_diagnosen_therapie_procedere(corrected_text)
|
||
txt.delete("1.0", "end")
|
||
txt.insert("1.0", disp)
|
||
|
||
# Liste aktualisieren
|
||
refresh_all_list()
|
||
|
||
# Direkt in Hauptfenster-KG und Transkript übernehmen
|
||
t = corrected_text
|
||
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)
|
||
|
||
wrong_inline.set("")
|
||
right_inline.set("")
|
||
self.set_status(f"Korrektur gespeichert: «{w}» «{r}» direkt in KG angewendet.")
|
||
|
||
btn_save = RoundedButton(
|
||
bottom_add, "Speichern und anwenden",
|
||
command=add_inline_and_update,
|
||
width=200, height=30, canvas_bg="#7EC8E3"
|
||
)
|
||
btn_save.pack(side="left", padx=(12, 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 _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()
|
||
|
||
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, 12))
|
||
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 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.showerror(
|
||
"API-Key fehlt",
|
||
"OPENAI_API_KEY ist nicht gesetzt.\n\n"
|
||
"Lege eine '.env' Datei an (im gleichen Ordner wie dieses Script):\n"
|
||
"OPENAI_API_KEY=sk-...\n"
|
||
)
|
||
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 = (2, 30) # (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("Backend: online")
|
||
self._backend_status_label.configure(fg="#2E7D32")
|
||
else:
|
||
self._backend_status_var.set("Backend: offline")
|
||
self._backend_status_label.configure(fg="#BD4500")
|
||
tooltip = f"URL: {backend_url or 'nicht gesetzt'}\nStatus: {'online' if ok else 'offline'}\n{detail}"
|
||
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}")
|
||
messagebox.showerror("Backend starten", f"Start fehlgeschlagen:\n{e}\n\nLetzter Status: {detail}")
|
||
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:
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
candidates = [
|
||
os.path.join(script_dir, "apps", "audio_notiz", "audio_notiz_app.py"),
|
||
os.path.join(script_dir, "apps", "diktat", "audio_notiz_app.py"),
|
||
]
|
||
for path in candidates:
|
||
if os.path.isfile(path):
|
||
return path
|
||
return None
|
||
|
||
def _start_audio_notiz_addon(self, silent: bool = False) -> bool:
|
||
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/audio_notiz oder 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)
|
||
text = self.transcribe_file_via_backend_with_fallback(wav_path)
|
||
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)
|
||
|
||
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 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.")
|
||
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 ERGAENZUNG ZUR DIAGNOSE-ABDECKUNG:\n"
|
||
"- Alle im Transkript genannten medizinischen Diagnosen, Verdachtsdiagnosen und klinisch relevanten Nebenbefunde "
|
||
"muessen in der Krankengeschichte erwaehnt werden (insbesondere auch Nebendiagnosen).\n"
|
||
"- Wenn im Transkript z. B. Warzen oder aehnliche Nebenbefunde genannt sind, muessen 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 erwaehnten Diagnosen und medizinischen Nebenbefunde in der KG.
|
||
- Erwaehnte Nebendiagnosen/Nebenbefunde (z. B. Warzen) duerfen 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}
|
||
|
||
VOLLSTNDIGES 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 muessen in der aktualisierten KG enthalten sein.
|
||
- Das gilt auch fuer 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: self._stop_timer())
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
def _save_kg_and_show(k):
|
||
if k and str(k).strip():
|
||
try:
|
||
save_to_ablage("KG", str(k).strip())
|
||
except Exception:
|
||
pass
|
||
self._show_text_window("KG erneut erstellen", k, buttons="kg")
|
||
self.after(0, lambda k=kg: _save_kg_and_show(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()
|
||
|
||
# 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 FR WORT UNVERNDERT. "
|
||
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 FR WORT UNVERNDERT. "
|
||
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)
|
||
|
||
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):
|
||
btn.configure(**(_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):
|
||
style = _PROF_ACTIVE if pi == active_preset_idx[0] else _PROF_INACTIVE
|
||
btn = tk.Label(profile_bar, text=f" 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()
|
||
|
||
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_sel(s):
|
||
return lambda e: self._save_selection_to_textblock(e, s)
|
||
btn.bind("<Button-3>", make_ctx(slot_str))
|
||
btn.bind("<Shift-Button-1>", make_save_sel(slot_str))
|
||
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):
|
||
"""Hintergrund-Thread: Globaler Tastatur-Hook (Windows). Ersetzung in Worker-Thread mit Verzögerung, damit der Listener nicht blockiert."""
|
||
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:
|
||
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:
|
||
pass
|
||
|
||
try:
|
||
with KbdListener(on_press=on_press, on_release=on_release) as listener:
|
||
listener.join()
|
||
except Exception:
|
||
pass
|
||
|
||
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.04)
|
||
self._send_escape_key()
|
||
time.sleep(0.03)
|
||
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 _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 _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)
|
||
_user32.keybd_event(VK_V, 0, 0, 0)
|
||
_user32.keybd_event(VK_V, 0, 2, 0)
|
||
_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 Text auf Button ziehen.")
|
||
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
|
||
if sys.platform == "win32" and _user32 and getattr(self, "_last_external_hwnd", None):
|
||
if self._paste_to_external_window():
|
||
self.set_status("Textblock in externes Programm (Word/Notepad) eingefügt.")
|
||
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),
|
||
)
|
||
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_insert_at_cursor(self, slot: str):
|
||
"""Fügt den Textblock-Inhalt an der Cursorposition ein (oder in Zwischenablage)."""
|
||
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
|
||
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(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.")
|
||
|
||
|
||
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
|
||
|
||
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:
|
||
from tkinter import messagebox as _mb
|
||
_cfg = get_runtime_env_file_path()
|
||
_open = _mb.askyesno(
|
||
"OpenAI API-Schluessel fehlt",
|
||
"Auf diesem Computer ist noch kein OpenAI-API-Schluessel hinterlegt.\n\n"
|
||
"KI-Funktionen (Diktat, Transkription) sind erst nach Einrichtung verfuegbar.\n\n"
|
||
f"Konfigurationsdatei:\n{_cfg}\n\n"
|
||
"Soll die Datei jetzt zum Bearbeiten geoeffnet werden?",
|
||
)
|
||
if _open:
|
||
open_runtime_config_in_editor()
|
||
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 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
|
||
|
||
app = KGDesktopApp()
|
||
log_event("APP_START", detail="KG-Diktat Desktop")
|
||
app.mainloop()
|
||
log_event("APP_STOP", detail="KG-Diktat Desktop")
|
||
|