# -*- 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 hashlib from difflib import SequenceMatcher import threading import tempfile import wave from datetime import datetime, timedelta 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 as _requests # Windows: für Einfügen in externes Fenster (Word, Notepad etc.) if sys.platform == "win32": try: import ctypes _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 # ─── 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 class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettingsMixin, AzaOrdnerMixin, AzaArbeitsplanMixin): def __init__(self): super().__init__() 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("", self._on_focus_out_for_external_paste) self._geometry_save_after_id = None self.bind("", 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() load_dotenv() api_key = os.getenv("OPENAI_API_KEY", "").strip() 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_global_buffer = [] self._autotext_injecting = [False] self._autotext_focus_in_app = [False] if _HAS_PYNPUT and sys.platform == "win32": threading.Thread(target=self._run_global_autotext_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() try: self.attributes("-alpha", load_opacity()) except Exception: pass self.after(100, self._restore_sash) self.after(1500, self._check_old_kg_entries) # 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._window_registry.add(self) if not self.api_key_present: messagebox.showwarning( "OPENAI_API_KEY fehlt", "Ich finde keinen OPENAI_API_KEY.\n\n" "Lege im selben Ordner wie dieses Script eine Datei '.env' an mit:\n" "OPENAI_API_KEY=sk-...\n\n" "Oder setze die Umgebungsvariable OPENAI_API_KEY.\n" ) 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_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("", refresh_usage) self._token_label.bind("", open_budget) # 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", 7), bg="#B9ECFA", fg="#7AAFC8", cursor="hand2") opacity_lbl_half.pack(side="left", padx=(0, 1)) opacity_lbl_half.bind("", lambda e: (self._opacity_var_main.set(round(MIN_OPACITY * 100)), on_opacity_main(str(MIN_OPACITY * 100)))) opacity_lbl_half.bind("", lambda e: opacity_lbl_half.configure(fg="#1a4d6d")) opacity_lbl_half.bind("", 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", 7), bg="#B9ECFA", fg="#7AAFC8", cursor="hand2") opacity_lbl_sun.pack(side="left", padx=(1, 8)) opacity_lbl_sun.bind("", lambda e: (self._opacity_var_main.set(100), on_opacity_main("100"))) opacity_lbl_sun.bind("", lambda e: opacity_lbl_sun.configure(fg="#1a4d6d")) opacity_lbl_sun.bind("", lambda e: opacity_lbl_sun.configure(fg="#7AAFC8")) add_tooltip(opacity_lbl_sun, "Voll sichtbar (100%)") self.btn_settings = RoundedButton( self._top_right, "⚙", command=self._open_settings, width=36, height=28, canvas_bg="#B9ECFA", ) self.btn_settings.pack(side="right", anchor="n") self._scalable_widgets.append(self.btn_settings) add_tooltip(self.btn_settings, "Einstellungen ö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("", 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("", 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("", 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") # 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)) # 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("", 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("", 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("", 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("", 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("", self._textblock_on_drag_motion) self.bind_all("", 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, ImageTk img = Image.open(logo_path) img_small = img.resize((62, 62), 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-Liste automatisch öffnen, wenn in Einstellungen aktiviert (Standard: an) if (self._autotext_data.get("todo_auto_open", True) and self._autotext_data.get("addon_buttons", {}).get("todo", True)): self.after(500, self._open_todo_window) def _on_configure(self, event): """Nach Verschieben/Resize: Größe und Position mit Verzögerung speichern (nur Hauptfenster).""" 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.""" 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, "_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("<>", 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 SHA-256-Hash des Passworts.""" return hashlib.sha256(pw.encode("utf-8")).hexdigest() 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 pw_hash = self._hash_password(pw) if pw_hash == self._user_profile.get("password_hash"): dlg.destroy() else: err_label.configure(text="❌ Falsches Passwort. Bitte erneut versuchen.") pw_entry.delete(0, "end") pw_entry.focus_set() pw_entry.bind("", 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_registration_dialog(self): """Erstregistrierung: Profil + Passwort festlegen.""" dlg = tk.Toplevel(self) dlg.title("Registrierung – AzA Profil") dlg.configure(bg="#E8F4FA") dlg.resizable(False, False) dlg.geometry("400x480") self._register_window(dlg) dlg.attributes("-topmost", True) dlg.grab_set() center_window(dlg, 400, 480) 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 self._hash_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 self._user_profile = { "name": name, "specialty": spec_e.get().strip(), "clinic": clinic_e.get().strip(), "password_hash": pw_hash, } 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) 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", 7), bg="#B9ECFA", fg="#7AAFC8", cursor="hand2") lbl_half.pack(side="left", padx=(0, 1)) lbl_half.bind("", lambda e: (opacity_var.set(round(MIN_OPACITY * 100)), on_opacity_change(str(MIN_OPACITY * 100)))) lbl_half.bind("", lambda e: lbl_half.configure(fg="#1a4d6d")) lbl_half.bind("", 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", 7), bg="#B9ECFA", fg="#7AAFC8", cursor="hand2") lbl_sun.pack(side="left", padx=(1, 0)) lbl_sun.bind("", lambda e: (opacity_var.set(100), on_opacity_change("100"))) lbl_sun.bind("", lambda e: lbl_sun.configure(fg="#1a4d6d")) lbl_sun.bind("", 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: import ctypes from ctypes import wintypes 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): """ Robust: 1) Alle AZA-Fenster (inkl. Hauptfenster) in den internen Mini-Modus bringen 2) Danach (verzögert) alle Fenster oben sauber in eine Reihe positionieren - Hauptfenster mittig - andere rechts daneben - keine Überlappung Debug schreibt nach: window_tile_debug.txt """ import time as _tile_time def _log(msg: str): try: p = os.path.join(os.path.dirname(__file__), "window_tile_debug.txt") except Exception: p = "window_tile_debug.txt" try: with open(p, "a", encoding="utf-8") as f: f.write(f"{_tile_time.strftime('%H:%M:%S')} {msg}\n") except Exception: pass try: others = self._get_registered_windows() wins = [self] + others _log(f"FOUND {len(wins)} windows: " + ", ".join( [getattr(w, "title", lambda: "?")() for w in wins if w is not None])) for w in wins: try: minimize_fn = getattr(w, "_aza_minimize", None) is_mini_fn = getattr(w, "_aza_is_minimized", None) already = False if is_mini_fn: try: already = bool(is_mini_fn()) except Exception: already = False if minimize_fn and not already: minimize_fn() except Exception as e: _log(f"MINIMIZE ERR {w}: {e}") def _place(): try: try: self.update() except Exception: self.update_idletasks() wa_left, wa_top, wa_right, wa_bottom = self._get_work_area_for_window(self) area_w = wa_right - wa_left gap = 10 y = wa_top + 10 _log(f"WORK AREA: left={wa_left} top={wa_top} right={wa_right} " f"bottom={wa_bottom} area_w={area_w}") def _get_wh(win): try: win.update_idletasks() except Exception: pass try: ww = int(win.winfo_width()) hh = int(win.winfo_height()) except Exception: ww, hh = 0, 0 if ww < 80: try: ww = int(win.winfo_reqwidth()) except Exception: ww = 220 if hh < 60: try: hh = int(win.winfo_reqheight()) except Exception: hh = 120 return ww, hh w_main, h_main = _get_wh(self) x_main = wa_left + max(0, (area_w - w_main) // 2) try: self.wm_state("normal") except Exception: pass self.geometry(f"{w_main}x{h_main}+{x_main}+{y}") try: self.lift() except Exception: pass _log(f"MAIN placed: {w_main}x{h_main}+{x_main}+{y}") x = x_main + w_main + gap others = [w for w in wins if w is not self] try: others.sort(key=lambda w: (w.title() or "", str(w))) except Exception: pass for w in others: try: if w is None or not w.winfo_exists(): continue try: w.wm_state("normal") except Exception: pass ww, hh = _get_wh(w) if x + ww > wa_right - 5: x = max(wa_left, wa_right - ww - 5) w.geometry(f"{ww}x{hh}+{x}+{y}") try: w.lift() except Exception: pass _log(f"WIN placed: '{getattr(w, 'title', lambda: '?')()}' " f"{ww}x{hh}+{x}+{y}") x += ww + gap except Exception as e: _log(f"PLACE ERR {w}: {e}") except Exception as e: _log(f"PLACE OUTER ERR: {e}") self.after(250, _place) except Exception as e: _log(f"ARRANGE OUTER ERR: {e}") 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: 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)}") # _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("", _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 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-PRÜFUNG:\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("", 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("", 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.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(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("Diktat", 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) 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 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.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… (sprich jetzt)") 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.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.")) 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 = ( "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" 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") if is_gpt_transcribe: params["prompt"] = self._WHISPER_MEDICAL_PROMPT else: params["prompt"] = self._WHISPER_MEDICAL_PROMPT params["temperature"] = 0.0 resp = self.client.audio.transcriptions.create(**params) text = getattr(resp, "text", "") if text is None: text = "" if text.strip().startswith(self._WHISPER_PROMPT_PREFIX): text = "" estimated_tokens = len(text) // 4 add_token_usage(estimated_tokens) self.after(0, self.update_token_display) return text @staticmethod def _read_backend_url_file(): try: path = os.path.join(os.path.dirname(__file__), "backend_url.txt") with open(path, "r", encoding="utf-8") as f: url = f.read().strip() return url if url else None except Exception: return None BACKEND_URL = os.getenv("MEDWORK_BACKEND_URL") or _read_backend_url_file.__func__() or "http://127.0.0.1:8001" _TRANSCRIBE_BACKEND_TIMEOUT = 8 def _read_backend_token_file(): try: path = os.path.join(os.path.dirname(__file__), "backend_token.txt") with open(path, "r", encoding="utf-8") as f: token = f.read().strip() return token if token else None except Exception: return None _BACKEND_API_TOKEN = ( os.getenv("MEDWORK_API_TOKEN") or _read_backend_token_file() or "" ) def transcribe_file_via_backend_with_fallback(self, audio_path: str) -> str: """Backend-Transkription (8s Timeout) mit Silent Fallback auf lokal. Wird aus Worker-Threads aufgerufen, blockiert UI nie.""" if not self._BACKEND_API_TOKEN: return self._transcribe_local(audio_path) try: headers = {"X-Api-Token": self._BACKEND_API_TOKEN} with open(audio_path, "rb") as f: r = _requests.post( f"{self.BACKEND_URL}/v1/transcribe", files={"file": (os.path.basename(audio_path), f, "audio/wav")}, data={"language": "de"}, headers=headers, timeout=self._TRANSCRIBE_BACKEND_TIMEOUT, ) if not r.ok: raise RuntimeError(f"HTTP {r.status_code}") try: data = r.json() except ValueError: raise RuntimeError("Ungültige JSON-Antwort") if not data.get("success"): raise RuntimeError("success != true") text = data.get("transcript", "").strip() if not text: raise RuntimeError("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 Exception: try: self.after(0, lambda: self.set_status( "Server nicht erreichbar \u2013 lokales Diktat aktiv.")) except Exception: pass return self._transcribe_local(audio_path) def transcribe_wav(self, wav_path: str) -> str: return self.transcribe_file_via_backend_with_fallback(wav_path) def call_chat_completion(self, **kwargs): """Wrapper für chat.completions.create mit automatischem Token-Tracking.""" 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) return resp @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) parts = [] 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) return "\n\n".join(parts) def summarize_text(self, transcript: str) -> str: 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} """ 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.""" model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL if model not in ALLOWED_SUMMARY_MODELS: model = DEFAULT_SUMMARY_MODEL system_content = self._build_system_prompt(MERGE_PROMPT) user_text = f"""BESTEHENDE KRANKENGESCHICHTE: {existing_kg} VOLLSTÄNDIGES TRANSKRIPT (bisher + Ergänzung): {full_transcript} Aktualisiere die KG: neue Informationen aus dem Transkript in die passenden Abschnitte einfügen, gleiche Überschriften beibehalten.""" 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: messagebox.showinfo("Hinweis", "Bitte zuerst diktieren oder Text in das Transkript-Feld einfügen.") 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("", make_adjust(sec_key, +1)) btn_down.bind("", make_adjust(sec_key, -1)) def make_hover(w, enter_fg, leave_fg): w.bind("", lambda e, ww=w, c=enter_fg: ww.configure(fg=c)) w.bind("", 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("", 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("", lambda e: self._reset_all_soap_sections()) reset_lbl.bind("", lambda e: reset_lbl.configure(fg="#1a4d6d")) reset_lbl.bind("", lambda e: reset_lbl.configure(fg="#7AAFC8")) def _update_soap_section_display(self): """Aktualisiert die Anzeige aller SOAP-Section-Labels.""" for key in _SOAP_SECTIONS: lbl = self._soap_section_labels.get(key) if lbl: lv = self._soap_section_levels.get(key, 0) lbl.configure(text=key if lv == 0 else f"{key} {lv:+d}") def _adjust_soap_section(self, key: str, delta: int, reset: bool = False): """Passt eine SOAP-Abschnitts-Detailstufe an und wendet optional sofort auf die aktuelle KG an.""" if reset: self._soap_section_levels[key] = 0 else: old = self._soap_section_levels.get(key, 0) self._soap_section_levels[key] = max(-3, min(3, old + delta)) save_soap_section_levels(self._soap_section_levels) self._update_soap_section_display() lv = self._soap_section_levels[key] name = _SOAP_LABELS[key] if reset: self.set_status(f"{name}: zurückgesetzt auf Standard.") elif lv == 0: self.set_status(f"{name}: Standard-Länge.") else: direction = "kürzer" if lv < 0 else "ausführlicher" self.set_status(f"{name}: Stufe {lv:+d} ({direction}) – wird bei jeder KG-Erstellung berücksichtigt.") # Sofort auf aktuelle KG anwenden, wenn vorhanden kg_text = self.txt_output.get("1.0", "end").strip() if kg_text and not reset: self._apply_soap_section_edit(key, delta) def _reset_all_soap_sections(self): """Setzt alle SOAP-Section-Levels auf 0 zurück.""" self._soap_section_levels = {k: 0 for k in _SOAP_SECTIONS} save_soap_section_levels(self._soap_section_levels) self._update_soap_section_display() self.set_status("Alle SOAP-Abschnittsstufen zurückgesetzt.") def _apply_soap_section_edit(self, key: str, delta: int): """Wendet eine Kürzung/Erweiterung auf einen einzelnen SOAP-Abschnitt der aktuellen KG an.""" kg_text = self.txt_output.get("1.0", "end").strip() if not kg_text: return if not self.ensure_ready(): return name = _SOAP_LABELS[key] action = "gekürzt" if delta < 0 else "erweitert" self.set_status(f"{name} wird {action}…") def worker(): try: model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL if model not in ALLOWED_SUMMARY_MODELS: model = DEFAULT_SUMMARY_MODEL other_sections = ", ".join( n for k, n in _SOAP_LABELS.items() if k != key) if delta < 0: task = ( f"Kürze LEICHT NUR den Abschnitt '{name}' in der folgenden Krankengeschichte. " f"Formuliere die vorhandenen Punkte knapper – gleiche Fakten, kürzere Wortwahl. " f"Stil beibehalten (Stichpunkte bleiben Stichpunkte). Nur eine kleine Reduktion. " f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. " f"Gib die KOMPLETTE Krankengeschichte aus." ) else: task = ( f"Formuliere NUR den Abschnitt '{name}' in der folgenden Krankengeschichte LEICHT ausführlicher. " f"Vorhandene Stichpunkte in vollständigere Sätze umformulieren. " f"WICHTIG: NUR die bereits vorhandenen Informationen ausführlicher formulieren – " f"KEINE neuen Fakten, Befunde, Werte oder Details erfinden! " f"Nichts hinzufügen, was nicht bereits im Text steht. " f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. " f"Gib die KOMPLETTE Krankengeschichte aus." ) template = load_templates_text().strip() sys_parts = [] if template: sys_parts.append( "ZWINGENDE VORLAGE DES ARZTES (höchste Priorität):\n" + template) sys_parts.append( "Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n" + task + "\nDiagnosen mit ICD-10-GM-Codes in eckigen Klammern beibehalten. " "Verwende \u2022 statt - als Aufzählungszeichen. " "Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. " "Überschriften OHNE Doppelpunkt, OHNE Einrückung. " "Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. " "Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile – die Punkte folgen direkt untereinander. " "Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. " "Keine Sternchen (*). " "Keine Meta-Kommentare. Ausgabe: nur die komplette KG." ) soap_levels = load_soap_section_levels() soap_instr = get_soap_section_instruction(soap_levels) if soap_instr: sys_parts.append(soap_instr) _vis = load_soap_visibility() _vis_instr = get_soap_visibility_instruction(_vis) if _vis_instr: sys_parts.append(_vis_instr) resp = self.call_chat_completion( model=model, messages=[ {"role": "system", "content": "\n\n".join(sys_parts)}, {"role": "user", "content": kg_text}, ], ) result = strip_kg_warnings(resp.choices[0].message.content) self.after(0, lambda: self._apply_kg_edit(result, f"{name} {action}")) except Exception as e: self.after(0, lambda: self.set_status("Fehler.")) self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self)) threading.Thread(target=worker, daemon=True).start() def _kg_kuerzer(self): """Kürzt die aktuelle KG UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen.""" level = load_kg_detail_level() new_level = max(-3, level - 1) save_kg_detail_level(new_level) self._update_kg_detail_display() kg_text = self.txt_output.get("1.0", "end").strip() if not kg_text: self.set_status(f"KG-Stil gespeichert: Stufe {new_level} (kürzer). Nächste KG wird kürzer erstellt.") return if not self.ensure_ready(): return self.set_status(f"KG wird gekürzt… (Stufe {new_level})") def worker(): try: model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL if model not in ALLOWED_SUMMARY_MODELS: model = DEFAULT_SUMMARY_MODEL template = load_templates_text().strip() sys_parts = [] if template: sys_parts.append( "ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template) sys_parts.append( "Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n" "Kürze die folgende Krankengeschichte deutlich. " "Fasse Stichpunkte zusammen, entferne Wiederholungen und " "reduziere auf das Wesentliche. Behalte die SOAP-Struktur bei " "(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). " "Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. " "Verwende \u2022 statt - als Aufzählungszeichen. " "Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. " "Überschriften OHNE Doppelpunkt, OHNE Einrückung. " "Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. " "Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile – die Punkte folgen direkt untereinander. " "Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. " "Keine Sternchen (*). " "Keine Meta-Kommentare. Ausgabe: nur die gekürzte KG." ) soap_levels = load_soap_section_levels() soap_instr = get_soap_section_instruction(soap_levels) if soap_instr: sys_parts.append(soap_instr) _vis = load_soap_visibility() _vis_instr = get_soap_visibility_instruction(_vis) if _vis_instr: sys_parts.append(_vis_instr) resp = self.call_chat_completion( model=model, messages=[ {"role": "system", "content": "\n\n".join(sys_parts)}, {"role": "user", "content": kg_text}, ], ) result = strip_kg_warnings(resp.choices[0].message.content) self.after(0, lambda: self._apply_kg_edit(result, f"Gekürzt (Stufe {new_level})")) except Exception as e: self.after(0, lambda: self.set_status("Fehler.")) self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self)) threading.Thread(target=worker, daemon=True).start() def _kg_ausfuehrlicher(self): """Macht die aktuelle KG ausführlicher UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen.""" level = load_kg_detail_level() new_level = min(3, level + 1) save_kg_detail_level(new_level) self._update_kg_detail_display() kg_text = self.txt_output.get("1.0", "end").strip() transcript = self.txt_transcript.get("1.0", "end").strip() if not kg_text: self.set_status(f"KG-Stil gespeichert: Stufe +{new_level} (ausführlicher). Nächste KG wird ausführlicher erstellt.") return if not self.ensure_ready(): return self.set_status(f"KG wird ausführlicher… (Stufe +{new_level})") def worker(): try: model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL if model not in ALLOWED_SUMMARY_MODELS: model = DEFAULT_SUMMARY_MODEL template = load_templates_text().strip() sys_parts = [] if template: sys_parts.append( "ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template) sys_parts.append( "Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n" "Formuliere die folgende Krankengeschichte ausführlicher. " "Wandle vorhandene Stichpunkte in vollständige Sätze um. " "STRIKT NUR vorhandene Informationen ausführlicher ausformulieren – " "KEINE neuen Fakten, Befunde, Werte, Mengenangaben oder klinische Details erfinden! " "Nichts hinzufügen, was nicht bereits im Text steht. " "Behalte die SOAP-Struktur bei " "(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). " "Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. " "Verwende \u2022 statt - als Aufzählungszeichen. " "Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. " "Überschriften OHNE Doppelpunkt, OHNE Einrückung. " "Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. " "Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile – die Punkte folgen direkt untereinander. " "Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. " "Keine Sternchen (*). " "Keine Meta-Kommentare. Ausgabe: nur die ausführlichere KG." ) soap_levels = load_soap_section_levels() soap_instr = get_soap_section_instruction(soap_levels) if soap_instr: sys_parts.append(soap_instr) _vis = load_soap_visibility() _vis_instr = get_soap_visibility_instruction(_vis) if _vis_instr: sys_parts.append(_vis_instr) user_content = f"KRANKENGESCHICHTE:\n{kg_text}" if transcript: user_content += f"\n\nORIGINAL-TRANSKRIPT (als Quelle für Details):\n{transcript}" resp = self.call_chat_completion( model=model, messages=[ {"role": "system", "content": "\n\n".join(sys_parts)}, {"role": "user", "content": user_content}, ], ) result = strip_kg_warnings(resp.choices[0].message.content) self.after(0, lambda: self._apply_kg_edit(result, f"Ausführlicher (Stufe +{new_level})")) except Exception as e: self.after(0, lambda: self.set_status("Fehler.")) self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self)) threading.Thread(target=worker, daemon=True).start() def _apply_kg_edit(self, new_kg: str, label: str): """Wendet eine KG-Bearbeitung an (Kürzer/Ausführlicher) und zeigt das Ergebnis.""" if new_kg and new_kg.strip(): self.txt_output.delete("1.0", "end") self.txt_output.insert("1.0", new_kg.strip()) try: save_to_ablage("KG", new_kg.strip()) except Exception: pass self.set_status(f"KG {label}.") else: self.set_status("Keine Änderung erhalten.") def _open_kg_vorlage(self): """Öffnet ein Fenster, in dem der Arzt eine Vorlage für die KG-Erstellung definieren kann.""" VOR_W, VOR_H = 540, 620 win = tk.Toplevel(self) win.title("Vorlage – KG-Erstellung") win.transient(self) win.minsize(420, 500) win.configure(bg="#E8F4FA") win.attributes("-topmost", True) self._register_window(win) 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("", 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("", lambda e, i=pi: _load_preset(i)) btn.bind("", lambda e, b=btn, i=pi: b.configure( bg="#2a6a8d" if i == active_preset_idx[0] else "#B8DDE8")) btn.bind("", 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("", lambda e, i=idx: _drag_start(e, i)) widget.bind("", _drag_motion) widget.bind("", _drag_end) handle.bind("", lambda e, h=handle: h.configure(fg="#1a4d6d")) handle.bind("", 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("", lambda e: _reset_order()) reset_order_btn.bind("", lambda e: reset_order_btn.configure(fg="#1a4d6d")) reset_order_btn.bind("", 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("", 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("", make_ctx(slot_str)) btn.bind("", 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): """Wenn Cursor auf einer Abschnittsüberschrift steht: (start, end, text) des Abschnitts, sonst None.""" try: insert = text_widget.index(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() 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: return None if not any(h == head or head.startswith(h + " ") or head.startswith(h + ":") or h in head 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: return False return any(k == h2 or h2.startswith(k + " ") or h2.startswith(k + ":") or k in h2 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): result = self._get_kg_section_at_cursor(text_widget) if result: _, _, section_text = result try: if not _win_clipboard_set(section_text): self.clipboard_clear() self.clipboard_append(section_text) self.set_status("Abschnitt kopiert.") except (tk.TclError, AttributeError): pass text_widget.bind("", 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(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("", 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("", lambda e: _save_as_last(), add="+") text_widget.bind("", lambda e: _save_as_last(), add="+") text_widget.bind("", lambda e: _save_as_last(), add="+") text_widget.bind("", 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("", start_drag, add="+") text_widget.bind("", self._textblock_on_drag_motion, add="+") text_widget.bind("", 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("", on_focus_in, add="+") text_widget.bind("", 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("", 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 _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(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 try: _user32.SetForegroundWindow(hwnd) self.after(80, self._send_ctrl_v) return True except Exception: return False def _send_ctrl_v(self): """Sendet Strg+V (Einfügen) an das aktive Fenster.""" if _user32 is None: 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 _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(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(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(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("", lambda e: finish(True)) entry.bind("", 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: messagebox.showinfo("Hinweis", "Keine Krankengeschichte zum Kopieren.") return if not _win_clipboard_set(text): self.clipboard_clear() self.clipboard_append(text) self.set_status("KG kopiert.") def copy_transcript(self): text = self.txt_transcript.get("1.0", "end").strip() if not text: messagebox.showinfo("Hinweis", "Kein Transkript zum Kopieren.") return if not _win_clipboard_set(text): self.clipboard_clear() self.clipboard_append(text) self.set_status("Transkript kopiert.") if __name__ == "__main__": load_dotenv() # 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 app = KGDesktopApp() app.mainloop()