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

5887 lines
248 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
KG-Diktat Desktop (Aufnahme -> Transkription -> Krankengeschichte)
===============================================================
- Ein einziges .py file (Tkinter GUI)
- Button: Aufnahme starten / stoppen
- Nach Stop: Audio wird transkribiert (gpt-4o-mini-transcribe) und automatisch zur KG zusammengefasst (Default: gpt-5.2, letzte Einstellung wird gespeichert)
Voraussetzungen (einmalig):
py -3.11 -m pip install openai python-dotenv sounddevice
Optional für Autotext in Word/Editor/überall in Windows:
py -3.11 -m pip install pynput
Autotext läuft automatisch beim Start der App (kein separater Prozess).
Hinweis:
- Der OpenAI API Key muss verfügbar sein (empfohlen: .env im gleichen Ordner):
OPENAI_API_KEY=sk-...
Start:
py -3.11 kg_desktop_diktat.py
"""
import os
import re
import json
import sys
import time
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
# 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
# -----------------------------
# Resize-Griff (gut greifbar zum Vergrößern/Verkleinern)
# -----------------------------
def add_resize_grip(win, min_w=None, min_h=None):
"""Fügt unten rechts einen großen, gut greifbaren Resize-Griff hinzu (ca. 32×32 px)."""
mw, mh = win.minsize() if win.minsize() else (300, 200)
min_w = min_w if min_w is not None else mw
min_h = min_h if min_h is not None else mh
grip = tk.Frame(win, width=32, height=32, bg="#D0D0D0", cursor="sizing", highlightthickness=1, highlightbackground="#999")
grip.place(relx=1, rely=1, anchor="se")
grip.pack_propagate(False)
lbl = tk.Label(grip, text="", font=("Segoe UI", 14), bg="#D0D0D0", fg="#666", cursor="sizing")
lbl.pack(fill="both", expand=True)
data = [0, 0, 0, 0]
def on_press(e):
data[0], data[1] = e.x_root, e.y_root
data[2], data[3] = win.winfo_width(), win.winfo_height()
def on_motion(e):
nw = max(min_w, data[2] + (e.x_root - data[0]))
nh = max(min_h, data[3] + (e.y_root - data[1]))
win.geometry(f"{int(nw)}x{int(nh)}")
for w in (grip, lbl):
w.bind("<ButtonPress-1>", on_press)
w.bind("<B1-Motion>", on_motion)
# -----------------------------
# Schriftgrößen-Regler
# -----------------------------
def add_font_scale_control(win, on_change_callback=None):
"""Fügt oben rechts einen Schriftgrößen-Regler und Button-Größen-Regler hinzu."""
# Fenster zur globalen Liste hinzufügen
if win not in _ALL_WINDOWS:
_ALL_WINDOWS.append(win)
# Bei Fenster-Schließung aus Liste entfernen
def on_close():
try:
if win in _ALL_WINDOWS:
_ALL_WINDOWS.remove(win)
except Exception:
pass
try:
win.destroy()
except Exception:
pass
win.protocol("WM_DELETE_WINDOW", on_close)
frame = ttk.Frame(win, padding=(4, 4))
frame.place(relx=1.0, rely=0, anchor="ne")
# Schriftgrößen-Regler
ttk.Label(frame, text="Aa", font=("Segoe UI", 10)).pack(side="left", padx=(0, 4))
current_font_scale = load_font_scale()
font_scale_var = tk.DoubleVar(value=current_font_scale)
def on_font_scale_change(val):
new_scale = float(val)
save_font_scale(new_scale)
if on_change_callback:
on_change_callback(new_scale)
# Nur Schriftgrößen skalieren
scale_window_fonts(win, new_scale)
font_scale = ttk.Scale(
frame,
from_=MIN_FONT_SCALE,
to=MAX_FONT_SCALE,
orient="horizontal",
length=80,
variable=font_scale_var,
command=on_font_scale_change
)
font_scale.pack(side="left", padx=(0, 8))
# Button-Größen-Regler
ttk.Label(frame, text="", font=("Segoe UI", 12)).pack(side="left", padx=(0, 4))
current_button_scale = load_button_scale()
button_scale_var = tk.DoubleVar(value=current_button_scale)
def on_button_scale_change(val):
new_scale = float(val)
save_button_scale(new_scale)
# Nur Button-Größen skalieren
scale_window_buttons(win, new_scale)
button_scale = ttk.Scale(
frame,
from_=MIN_BUTTON_SCALE,
to=MAX_BUTTON_SCALE,
orient="horizontal",
length=80,
variable=button_scale_var,
command=on_button_scale_change
)
button_scale.pack(side="left")
# Initial skalieren
win.after(50, lambda: scale_window_fonts(win, current_font_scale))
win.after(100, lambda: scale_window_buttons(win, current_button_scale))
return frame, font_scale_var, button_scale_var
def scale_window_fonts(win, scale: float):
"""Skaliert nur die Schriftgrößen in Text-Widgets und Labels."""
try:
def scale_recursive(widget):
try:
if isinstance(widget, (tk.Text, ScrolledText)):
try:
current_font = widget.cget("font")
if isinstance(current_font, str):
current_font = tkfont.Font(font=current_font)
elif isinstance(current_font, tuple):
base_size = 10 # Basis-Größe
new_size = max(8, int(base_size * scale))
widget.configure(font=(current_font[0], new_size))
except Exception:
pass
elif isinstance(widget, tk.Label):
try:
current_font = widget.cget("font")
if isinstance(current_font, tuple) and len(current_font) >= 2:
base_size = 10 # Basis-Größe
new_size = max(8, int(base_size * scale))
widget.configure(font=(current_font[0], new_size))
except Exception:
pass
elif isinstance(widget, RoundedButton):
# Nur Font-Größe setzen, nicht Button-Größe
widget.set_font_size_scale(scale)
for child in widget.winfo_children():
scale_recursive(child)
except Exception:
pass
scale_recursive(win)
except Exception:
pass
def scale_window_buttons(win, scale: float):
"""Skaliert nur die Button-Größen."""
try:
def scale_recursive(widget):
try:
if isinstance(widget, RoundedButton):
widget.set_button_size_scale(scale)
for child in widget.winfo_children():
scale_recursive(child)
except Exception:
pass
scale_recursive(win)
except Exception:
pass
def scale_window_widgets(win, scale: float):
"""Legacy-Funktion für Rückwärtskompatibilität - skaliert beides."""
scale_window_fonts(win, scale)
scale_window_buttons(win, scale)
# -----------------------------
# Abgerundeter Button (Canvas)
# -----------------------------
def _round_rect(canvas, x1, y1, x2, y2, r=8, **kw):
"""Zeichnet ein abgerundetes Rechteck auf dem Canvas (Bögen + Rechtecke)."""
fill = kw.get("fill", kw.get("outline", "gray"))
st = {"fill": fill, "outline": fill}
if r <= 0:
canvas.create_rectangle(x1, y1, x2, y2, **kw)
return
canvas.create_rectangle(x1 + r, y1, x2 - r, y2, **kw)
canvas.create_rectangle(x1, y1 + r, x2, y2 - r, **kw)
canvas.create_arc(x1, y1, x1 + 2 * r, y1 + 2 * r, start=90, extent=90, style=tk.PIESLICE, **kw)
canvas.create_arc(x2 - 2 * r, y1, x2, y1 + 2 * r, start=0, extent=90, style=tk.PIESLICE, **kw)
canvas.create_arc(x2 - 2 * r, y2 - 2 * r, x2, y2, start=270, extent=90, style=tk.PIESLICE, **kw)
canvas.create_arc(x1, y2 - 2 * r, x1 + 2 * r, y2, start=180, extent=90, style=tk.PIESLICE, **kw)
class RoundedButton(tk.Canvas):
"""Button mit abgerundeten Ecken (gleiche Nutzung wie ttk.Button)."""
def __init__(self, parent, text, command=None, bg="#7EC8E3", fg="#1a4d6d", active_bg="#5AB9E8", radius=8, width=None, height=None, canvas_bg=None, **kw):
self._base_width = width if width is not None else 120
self._base_height = height if height is not None else 32
kw.setdefault("highlightthickness", 0)
if canvas_bg is not None:
kw["bg"] = canvas_bg
super().__init__(parent, width=self._base_width, height=self._base_height, **kw)
self._command = command
self._bg, self._fg = bg, fg
self._active_bg = active_bg
self._radius = radius
self._text = text
self._base_font_size = 11
self._font_size_scale = 1.0
self._button_size_scale = 1.0
self.bind("<Button-1>", self._on_click)
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
self.bind("<Configure>", lambda e: self._draw())
self._draw()
def set_font_size_scale(self, scale: float):
"""Setzt nur den Schriftgrößen-Skalierungsfaktor."""
self._font_size_scale = scale
self._draw()
def set_button_size_scale(self, scale: float):
"""Setzt nur den Button-Größen-Skalierungsfaktor."""
self._button_size_scale = scale
# Button-Größe dynamisch anpassen
new_width = int(self._base_width * scale)
new_height = int(self._base_height * scale)
self.configure(width=new_width, height=new_height)
self._draw()
def set_font_scale(self, scale: float):
"""Legacy-Methode für Rückwärtskompatibilität - setzt beides."""
self.set_font_size_scale(scale)
self.set_button_size_scale(scale)
def _draw(self, active=False):
self.delete("all")
w, h = self.winfo_width(), self.winfo_height()
if w <= 1:
w = int(self._base_width * self._button_size_scale)
if h <= 1:
h = int(self._base_height * self._button_size_scale)
fill = self._active_bg if active else self._bg
_round_rect(self, 0, 0, w, h, self._radius, fill=fill, outline=fill)
font_size = max(8, int(self._base_font_size * self._font_size_scale))
self.create_text(w // 2, h // 2, text=self._text, fill=self._fg, font=("Segoe UI", font_size))
def _on_click(self, event):
if self._command:
self._command()
def _on_enter(self, event):
self._draw(active=True)
def _on_leave(self, event):
self._draw(active=False)
def configure(self, **kw):
if "command" in kw:
self._command = kw.pop("command")
if "text" in kw:
self._text = kw.pop("text")
self._draw()
super().configure(**kw)
# -----------------------------
# Modelle
# -----------------------------
TRANSCRIBE_MODEL = "gpt-4o-mini-transcribe" # STT (Audio -> Text) # https://platform.openai.com/docs/guides/speech-to-text
DEFAULT_SUMMARY_MODEL = "gpt-5.2" # KG-Zusammenfassung (Default)
ALLOWED_SUMMARY_MODELS = ["gpt-5.2", "gpt-5-mini", "gpt-5-nano"]
# Anzeige im Einstellungs-Dialog (Zahnrad)
MODEL_LABELS = {
"gpt-5.2": 'schnell ("teures" KI)',
"gpt-5-mini": "mittleres KI",
"gpt-5-nano": "langsameres (günstigeres KI)",
}
CONFIG_FILENAME = "kg_diktat_config.txt"
WINDOW_CONFIG_FILENAME = "kg_diktat_window.txt"
SIGNATURE_CONFIG_FILENAME = "kg_diktat_signature.txt"
KORREKTUREN_CONFIG_FILENAME = "kg_diktat_korrekturen.json"
ABLAGE_BASE_DIR = "kg_diktat_ablage"
ABLAGE_SUBFOLDERS = ("KG", "Briefe", "Rezepte", "Kostengutsprachen")
ABLAGE_LABELS = {"KG": "KG", "Briefe": "Brief", "Rezepte": "Rezept", "Kostengutsprachen": "KOGU"}
PRUEFEN_WINDOW_CONFIG_FILENAME = "kg_diktat_pruefen_window.txt"
ORDNER_WINDOW_CONFIG_FILENAME = "kg_diktat_ordner_window.txt"
TEXT_WINDOW_CONFIG_FILENAME = "kg_diktat_text_window.txt"
DIKTAT_WINDOW_CONFIG_FILENAME = "kg_diktat_diktat_window.txt"
DISKUSSION_WINDOW_CONFIG_FILENAME = "kg_diktat_diskussion_window.txt"
SETTINGS_WINDOW_CONFIG_FILENAME = "kg_diktat_settings_window.txt"
TEXTBLOECKE_CONFIG_FILENAME = "kg_diktat_textbloecke.json"
TEMPLATES_CONFIG_FILENAME = "kg_diktat_templates.txt"
OP_BERICHT_TEMPLATE_CONFIG_FILENAME = "kg_diktat_op_bericht_template.txt"
ARZTBRIEF_VORLAGE_CONFIG_FILENAME = "kg_diktat_arztbrief_vorlage.txt"
OPACITY_CONFIG_FILENAME = "kg_diktat_opacity.txt"
AUTOTEXT_CONFIG_FILENAME = "kg_diktat_autotext.json"
FONT_SCALE_CONFIG_FILENAME = "kg_diktat_font_scale.txt"
BUTTON_SCALE_CONFIG_FILENAME = "kg_diktat_button_scale.txt"
TOKEN_USAGE_CONFIG_FILENAME = "kg_diktat_token_usage.txt"
DEFAULT_OPACITY = 0.9
MIN_OPACITY = 0.4
DEFAULT_FONT_SCALE = 1.0
MIN_FONT_SCALE = 0.7
MAX_FONT_SCALE = 1.8
DEFAULT_BUTTON_SCALE = 1.0
MIN_BUTTON_SCALE = 0.8
MAX_BUTTON_SCALE = 2.0
# Optimale Reset-Werte
OPTIMAL_FONT_SCALE = 0.85
OPTIMAL_BUTTON_SCALE = 1.3
def _config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_FILENAME)
def _window_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), WINDOW_CONFIG_FILENAME)
def _clamp_geometry_str(geom: str, min_w: int, min_h: int) -> str:
"""Begrenzt gespeicherte Geometrie-String auf Mindestgröße (alle Buttons sichtbar)."""
if not geom or "x" not in geom:
return f"{min_w}x{min_h}"
parts = geom.replace("+", "x").split("x")
try:
w = max(min_w, int(parts[0].strip()))
h = max(min_h, int(parts[1].strip()))
if len(parts) >= 4:
return f"{w}x{h}+{parts[2].strip()}+{parts[3].strip()}"
return f"{w}x{h}"
except (ValueError, IndexError):
return f"{min_w}x{min_h}"
def load_window_geometry():
"""Liest gespeicherte Fenstergröße, Position, Sash (Breite) und Transkript-Höhe. Rückgabe: (w, h, x, y, sash_h, sash_v) oder None."""
try:
path = _window_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
parts = f.read().strip().split()
if len(parts) >= 4:
w, h, x, y = int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])
if w >= 400 and h >= 300:
sash_h = int(parts[4]) if len(parts) >= 5 else None
sash_v = int(parts[5]) if len(parts) >= 6 else None
return (w, h, x, y, sash_h, sash_v)
except Exception:
pass
return None
def save_window_geometry(
width: int, height: int, x: int, y: int, sash: int = None, sash_transcript: int = None
) -> None:
"""Speichert Fenstergröße, Position, Sash (Breite) und Transkript-Höhe dauerhaft."""
try:
with open(_window_config_path(), "w", encoding="utf-8") as f:
if sash is not None and sash_transcript is not None:
f.write(f"{width} {height} {x} {y} {sash} {sash_transcript}\n")
elif sash is not None:
f.write(f"{width} {height} {x} {y} {sash}\n")
else:
f.write(f"{width} {height} {x} {y}\n")
except Exception:
pass
def _opacity_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), OPACITY_CONFIG_FILENAME)
def load_opacity() -> float:
"""Liest die Fenster-Transparenz (0.41.0). Standard 0.9."""
try:
path = _opacity_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
v = float(f.read().strip())
return max(MIN_OPACITY, min(1.0, v))
except Exception:
pass
return DEFAULT_OPACITY
def save_opacity(value: float) -> None:
"""Speichert die Fenster-Transparenz."""
try:
v = max(MIN_OPACITY, min(1.0, value))
with open(_opacity_config_path(), "w", encoding="utf-8") as f:
f.write(str(v))
except Exception:
pass
def _autotext_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), AUTOTEXT_CONFIG_FILENAME)
def _font_scale_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), FONT_SCALE_CONFIG_FILENAME)
def _button_scale_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), BUTTON_SCALE_CONFIG_FILENAME)
def load_font_scale() -> float:
"""Liest den Schriftgrößen-Skalierungsfaktor (0.71.8). Standard 1.0."""
try:
path = _font_scale_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
v = float(f.read().strip())
return max(MIN_FONT_SCALE, min(MAX_FONT_SCALE, v))
except Exception:
pass
return DEFAULT_FONT_SCALE
def save_font_scale(value: float) -> None:
"""Speichert den Schriftgrößen-Skalierungsfaktor."""
try:
v = max(MIN_FONT_SCALE, min(MAX_FONT_SCALE, value))
with open(_font_scale_config_path(), "w", encoding="utf-8") as f:
f.write(str(v))
except Exception:
pass
def load_button_scale() -> float:
"""Liest den Button-Größen-Skalierungsfaktor (0.82.0). Standard 1.0."""
try:
path = _button_scale_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
v = float(f.read().strip())
return max(MIN_BUTTON_SCALE, min(MAX_BUTTON_SCALE, v))
except Exception:
pass
return DEFAULT_BUTTON_SCALE
def save_button_scale(value: float) -> None:
"""Speichert den Button-Größen-Skalierungsfaktor."""
try:
v = max(MIN_BUTTON_SCALE, min(MAX_BUTTON_SCALE, value))
with open(_button_scale_config_path(), "w", encoding="utf-8") as f:
f.write(str(v))
except Exception:
pass
def _token_usage_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), TOKEN_USAGE_CONFIG_FILENAME)
def load_token_usage() -> dict:
"""Liest die Token-Nutzung. Format: {'used': int, 'total': int}"""
try:
path = _token_usage_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.loads(f.read().strip())
return data
except Exception:
pass
return {"used": 0, "total": 1000000} # Standard: 1M Tokens
def save_token_usage(used: int, total: int = 1000000) -> None:
"""Speichert die Token-Nutzung."""
try:
with open(_token_usage_config_path(), "w", encoding="utf-8") as f:
json.dump({"used": used, "total": total}, f)
except Exception:
pass
def add_token_usage(tokens: int) -> None:
"""Fügt verbrauchte Tokens hinzu."""
try:
data = load_token_usage()
data["used"] = data.get("used", 0) + tokens
save_token_usage(data["used"], data.get("total", 1000000))
except Exception:
pass
def load_autotext() -> dict:
"""Lädt Autotext und Einstellungen (enabled, entries, diktat_auto_start, textbloecke_visible, addon_visible, kg_auto_delete_old)."""
try:
path = _autotext_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return {
"enabled": data.get("enabled", True),
"entries": data.get("entries") if isinstance(data.get("entries"), dict) else {},
"diktat_auto_start": data.get("diktat_auto_start", True),
"textbloecke_visible": data.get("textbloecke_visible", True),
"addon_visible": data.get("addon_visible", True),
"kg_auto_delete_old": data.get("kg_auto_delete_old", False),
}
except Exception:
pass
return {
"enabled": True, "entries": {}, "diktat_auto_start": True,
"textbloecke_visible": True, "addon_visible": True, "kg_auto_delete_old": False,
}
def save_autotext(data: dict) -> None:
"""Speichert Autotext und alle Einstellungs-Checkboxen dauerhaft."""
try:
with open(_autotext_config_path(), "w", encoding="utf-8") as f:
json.dump(
{
"enabled": data.get("enabled", True),
"entries": data.get("entries") or {},
"diktat_auto_start": data.get("diktat_auto_start", True),
"textbloecke_visible": data.get("textbloecke_visible", True),
"addon_visible": data.get("addon_visible", True),
"kg_auto_delete_old": data.get("kg_auto_delete_old", False),
},
f, ensure_ascii=False, indent=2,
)
except Exception:
pass
def _is_admin() -> bool:
"""Prüft, ob die Anwendung mit Administratorrechten läuft (für globalen Autotext)."""
if sys.platform != "win32":
return False
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def _run_as_admin() -> bool:
"""Startet die Anwendung mit Administratorrechten neu. Beendet die aktuelle Instanz."""
if sys.platform != "win32":
return False
try:
import ctypes
args = " ".join([f'"{a}"' if " " in a else a for a in sys.argv])
ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, args, None, 1)
sys.exit(0)
return True
except Exception:
return False
def _win_clipboard_set(text: str) -> bool:
"""Text in Windows-Zwischenablage (für globalen Autotext per Strg+V)."""
if sys.platform != "win32":
return False
try:
import ctypes
from ctypes import wintypes
CF_UNICODETEXT = 13
GMEM_DDESHARE = 0x2000
kernel32 = ctypes.WinDLL("kernel32")
user32 = ctypes.WinDLL("user32")
user32.OpenClipboard.argtypes = [wintypes.HWND]
user32.OpenClipboard.restype = wintypes.BOOL
user32.CloseClipboard.argtypes = []
user32.EmptyClipboard.argtypes = []
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
user32.SetClipboardData.restype = wintypes.HANDLE
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
for _ in range(5):
if user32.OpenClipboard(None):
break
time.sleep(0.03)
else:
return False
try:
user32.EmptyClipboard()
data = (text + "\0").encode("utf-16-le")
h = kernel32.GlobalAlloc(GMEM_DDESHARE, len(data))
if not h:
return False
ptr = kernel32.GlobalLock(h)
if ptr:
ctypes.memmove(ptr, data, len(data))
kernel32.GlobalUnlock(h)
user32.SetClipboardData(CF_UNICODETEXT, h)
return True
finally:
user32.CloseClipboard()
except Exception:
return False
def _win_clipboard_get() -> str:
"""Text aus Windows-Zwischenablage lesen."""
if sys.platform != "win32":
return ""
try:
import ctypes
from ctypes import wintypes
CF_UNICODETEXT = 13
user32 = ctypes.WinDLL("user32")
kernel32 = ctypes.WinDLL("kernel32")
if not user32.OpenClipboard(None):
return ""
h = user32.GetClipboardData(CF_UNICODETEXT)
user32.CloseClipboard()
if not h:
return ""
ptr = kernel32.GlobalLock(h)
if not ptr:
return ""
buf = (ctypes.c_char * 131072).from_address(ptr)
data = bytearray()
for i in range(0, 131070, 2):
if buf[i] == 0 and buf[i + 1] == 0:
break
data.extend([buf[i], buf[i + 1]])
kernel32.GlobalUnlock(h)
return data.decode("utf-16-le", errors="ignore")
except Exception:
return ""
def _signature_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), SIGNATURE_CONFIG_FILENAME)
def load_signature_name() -> str:
"""Liest den gespeicherten Namen für die Unterschrift."""
try:
path = _signature_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return ""
def save_signature_name(name: str) -> None:
"""Speichert den Namen für die Unterschrift."""
try:
with open(_signature_config_path(), "w", encoding="utf-8") as f:
f.write((name or "").strip())
except Exception:
pass
def _korrekturen_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), KORREKTUREN_CONFIG_FILENAME)
def load_korrekturen() -> dict:
"""Lädt die Korrekturen-Datenbank: {'medikamente': {falsch: richtig}, 'diagnosen': {...}}."""
try:
path = _korrekturen_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {"medikamente": {}, "diagnosen": {}}
def save_korrekturen(data: dict) -> None:
"""Speichert die Korrekturen-Datenbank."""
try:
with open(_korrekturen_config_path(), "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _ablage_base_path():
"""Pfad zum Ablage-Basisordner: Dokumente/KG_Diktat_Ablage."""
docs = os.path.join(os.path.expanduser("~"), "Documents")
if not os.path.isdir(docs):
docs = os.path.expanduser("~")
return os.path.join(docs, "KG_Diktat_Ablage")
def _ablage_json_path():
"""Eine zentrale JSON-Datei Inhalt wird hier zuverlässig gespeichert."""
return os.path.join(_ablage_base_path(), "ablage.json")
def ensure_ablage_dirs():
"""Erstellt Basisordner und alle Unterordner."""
base = _ablage_base_path()
os.makedirs(base, exist_ok=True)
for sub in ABLAGE_SUBFOLDERS:
os.makedirs(os.path.join(base, sub), exist_ok=True)
def _load_ablage_json():
"""Lädt ablage.json. Rückgabe: {"KG": [{"content": "...", "name": "..."}], ...}."""
path = _ablage_json_path()
if not os.path.isfile(path):
return {c: [] for c in ABLAGE_SUBFOLDERS}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return {c: [] for c in ABLAGE_SUBFOLDERS}
for c in ABLAGE_SUBFOLDERS:
if c not in data or not isinstance(data[c], list):
data[c] = []
return data
except Exception:
return {c: [] for c in ABLAGE_SUBFOLDERS}
def _save_ablage_json(data: dict) -> bool:
"""Schreibt ablage.json. Rückgabe: True bei Erfolg."""
try:
ensure_ablage_dirs()
path = _ablage_json_path()
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
def save_to_ablage(category: str, content: str):
"""
Speichert in ablage.json (eine zentrale JSON-Datei).
Laden erfolgt über die App (Ordner → Auswählen → „Ausgewählte Datei in App laden“).
Rückgabe: Pfad der ablage.json bei Erfolg, sonst None.
"""
if category not in ABLAGE_SUBFOLDERS:
try:
messagebox.showerror("Speichern", f"Unbekannte Kategorie: {category}")
except Exception:
pass
return None
raw = content if isinstance(content, str) else (str(content) if content is not None else "")
content = raw.strip()
if not content:
return None
try:
ensure_ablage_dirs()
label = ABLAGE_LABELS.get(category, category)
now = datetime.now()
date_str = now.strftime("%d.%m.%Y")
time_str = now.strftime("%H:%M")
data = _load_ablage_json()
n = len(data.get(category, [])) + 1
name = f"{n} {label} {date_str} {time_str}.txt"
entry = {"content": content, "name": name}
data.setdefault(category, []).append(entry)
if not _save_ablage_json(data):
raise RuntimeError("ablage.json konnte nicht geschrieben werden.")
return _ablage_json_path()
except Exception as e:
try:
messagebox.showerror("Speichern fehlgeschlagen", f"Pfad: {_ablage_base_path()}\nFehler: {e}")
except Exception:
pass
return None
def list_ablage_files(category: str):
"""Listet Einträge aus ablage.json, neueste zuoberst (höchste Nummer zuerst)."""
data = _load_ablage_json()
names = [e.get("name", "") for e in data.get(category, []) if isinstance(e, dict) and e.get("name")]
def sort_key(name):
m = re.match(r"^(\d+)", str(name))
return (-int(m.group(1)), name) if m else (0, name)
names.sort(key=sort_key)
return names
def _parse_entry_date(name: str):
"""Parst Datum aus Eintragsnamen (z.B. '1 KG 04.02.2026 10.txt') → datetime oder None."""
if not name:
return None
m = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", str(name))
if not m:
return None
try:
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
return datetime(y, mo, d)
except (ValueError, IndexError):
return None
def get_old_kg_entries(days: int = 14):
"""Liefert KG-Einträge, die älter als days Tage sind."""
data = _load_ablage_json()
entries = data.get("KG", [])
if not isinstance(entries, list):
return []
cutoff = datetime.now() - timedelta(days=days)
return [e for e in entries if isinstance(e, dict) and _parse_entry_date(e.get("name", "")) and _parse_entry_date(e.get("name", "")) < cutoff]
def delete_kg_entries_older_than(days: int = 14) -> int:
"""Löscht KG-Einträge älter als days Tage. Rückgabe: Anzahl gelöschter Einträge."""
data = _load_ablage_json()
entries = data.get("KG", [])
if not isinstance(entries, list):
return 0
cutoff = datetime.now() - timedelta(days=days)
kept = [e for e in entries if not isinstance(e, dict) or not _parse_entry_date(e.get("name", "")) or _parse_entry_date(e.get("name", "")) >= cutoff]
deleted = len(entries) - len(kept)
if deleted > 0:
data["KG"] = kept
_save_ablage_json(data)
return deleted
def delete_all_ablage_entries(category: str) -> int:
"""Löscht alle Einträge einer Kategorie. Rückgabe: Anzahl gelöschter Einträge."""
if category not in ABLAGE_SUBFOLDERS:
return 0
data = _load_ablage_json()
count = len(data.get(category, []))
if count > 0:
data[category] = []
_save_ablage_json(data)
return count
def get_ablage_content(category: str, filename: str) -> str:
"""Liest Inhalt aus ablage.json (Eintrag anhand name). Liefert nur Text, nie JSON-Rohdaten."""
if not filename or filename == "ablage.json":
return ""
data = _load_ablage_json()
for e in data.get(category, []):
if isinstance(e, dict) and e.get("name") == filename:
return (e.get("content") or "").strip() or ""
return ""
def _pruefen_window_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), PRUEFEN_WINDOW_CONFIG_FILENAME)
def _ordner_window_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), ORDNER_WINDOW_CONFIG_FILENAME)
def _text_window_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), TEXT_WINDOW_CONFIG_FILENAME)
def _diktat_window_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), DIKTAT_WINDOW_CONFIG_FILENAME)
def _diskussion_window_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), DISKUSSION_WINDOW_CONFIG_FILENAME)
def _settings_window_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), SETTINGS_WINDOW_CONFIG_FILENAME)
def load_settings_geometry() -> str:
"""Liest gespeicherte Geometry des Einstellungs-Fensters (z. B. '460x300+100+50')."""
try:
path = _settings_window_config_path()
if os.path.isfile(path):
geom = open(path, "r", encoding="utf-8").read().strip()
if geom:
return geom
except Exception:
pass
return ""
def save_settings_geometry(geom: str) -> None:
"""Speichert Größe und Position des Einstellungs-Fensters."""
try:
if geom:
with open(_settings_window_config_path(), "w", encoding="utf-8") as f:
f.write(geom + "\n")
except Exception:
pass
def _textbloecke_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), TEXTBLOECKE_CONFIG_FILENAME)
def load_textbloecke():
"""Lädt die Textblöcke: {"1": {"name": "...", "content": "..."}, ...}. Mindestens 2 Slots."""
try:
path = _textbloecke_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
out = {}
for k, v in data.items():
if isinstance(k, str) and k.isdigit() and isinstance(v, dict):
out[k] = {"name": (v.get("name") or "").strip(), "content": v.get("content") or ""}
slots = sorted(out.keys(), key=int)
if len(slots) >= 2:
return {s: out[s] for s in slots}
except Exception:
pass
return {"1": {"name": "Textblock 1", "content": ""}, "2": {"name": "Textblock 2", "content": ""}}
def save_textbloecke(data: dict) -> None:
"""Speichert die Textblöcke dauerhaft. Alle Slots werden gespeichert."""
try:
full = {}
for k, v in (data or {}).items():
if isinstance(k, str) and isinstance(v, dict):
full[k] = {"name": (v.get("name") or "").strip(), "content": v.get("content") or ""}
if len(full) < 2:
return
with open(_textbloecke_config_path(), "w", encoding="utf-8") as f:
json.dump(full, f, ensure_ascii=False, indent=2)
f.flush()
try:
os.fsync(f.fileno())
except Exception:
pass
except Exception:
pass
def load_pruefen_geometry():
"""Liest gespeicherte Größe und Position des Prüfen-Fensters. Rückgabe: (w, h, x, y) oder (w, h) oder None."""
try:
path = _pruefen_window_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
parts = f.read().strip().split()
if len(parts) >= 2:
w, h = int(parts[0]), int(parts[1])
if w >= 300 and h >= 250:
if len(parts) >= 4:
x, y = int(parts[2]), int(parts[3])
return (w, h, x, y)
return (w, h)
except Exception:
pass
return None
def save_pruefen_geometry(width: int, height: int, x: int = None, y: int = None) -> None:
"""Speichert Größe und Position des Prüfen-Fensters."""
try:
with open(_pruefen_window_config_path(), "w", encoding="utf-8") as f:
if x is not None and y is not None:
f.write(f"{width} {height} {x} {y}\n")
else:
f.write(f"{width} {height}\n")
except Exception:
pass
def load_ordner_geometry() -> str:
"""Liest gespeicherte Geometry des Ordner-Fensters (z. B. '640x500+100+50')."""
try:
path = _ordner_window_config_path()
if os.path.isfile(path):
geom = open(path, "r", encoding="utf-8").read().strip()
if geom:
return geom
except Exception:
pass
return ""
def save_ordner_geometry(geom: str) -> None:
try:
with open(_ordner_window_config_path(), "w", encoding="utf-8") as f:
f.write(geom)
except Exception:
pass
def load_text_window_geometry() -> str:
try:
path = _text_window_config_path()
if os.path.isfile(path):
return open(path, "r", encoding="utf-8").read().strip()
except Exception:
pass
return ""
def save_text_window_geometry(geom: str) -> None:
try:
with open(_text_window_config_path(), "w", encoding="utf-8") as f:
f.write(geom)
except Exception:
pass
def load_diktat_geometry() -> str:
try:
path = _diktat_window_config_path()
if os.path.isfile(path):
return open(path, "r", encoding="utf-8").read().strip()
except Exception:
pass
return ""
def save_diktat_geometry(geom: str) -> None:
try:
with open(_diktat_window_config_path(), "w", encoding="utf-8") as f:
f.write(geom)
except Exception:
pass
def load_diskussion_geometry() -> str:
try:
path = _diskussion_window_config_path()
if os.path.isfile(path):
return open(path, "r", encoding="utf-8").read().strip()
except Exception:
pass
return ""
def save_diskussion_geometry(geom: str) -> None:
try:
with open(_diskussion_window_config_path(), "w", encoding="utf-8") as f:
f.write(geom)
except Exception:
pass
def extract_diagnosen_therapie_procedere(text: str) -> str:
"""Extrahiert nur Diagnosen, Therapie und Procedere aus dem Text."""
if "KRANKENGESCHICHTE:" in text:
kg_part = text.split("TRANSKRIPT:")[0].replace("KRANKENGESCHICHTE:", "").strip()
else:
kg_part = text
lines = kg_part.split("\n")
result = []
in_block = False
target_headers = ("Diagnose:", "Diagnosen:", "Therapie:", "Procedere:")
for line in lines:
stripped = line.strip()
if any(stripped.startswith(h) for h in target_headers):
if result:
result.append("")
result.append(line)
in_block = True
elif in_block:
if stripped and stripped.endswith(":"):
in_block = False
else:
result.append(line)
out = "\n".join(result).strip()
return out if out else kg_part
def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
def apply_korrekturen(text: str, korrekturen: dict) -> tuple:
"""Wendet Korrekturen an. Rückgabe: (korrigierter_text, [(falsch, richtig), ...])."""
result = text
applied = []
FUZZY_THRESHOLD = 0.85
for kategorie, mapping in korrekturen.items():
if not isinstance(mapping, dict):
continue
for falsch, richtig in mapping.items():
if not falsch or not richtig:
continue
pattern = r"\b" + re.escape(falsch) + r"\b"
if re.search(pattern, result, re.IGNORECASE):
result = re.sub(pattern, richtig, result, flags=re.IGNORECASE)
applied.append((falsch, richtig))
words = re.findall(r"[A-Za-zÄÖÜäöüß0-9\-]+", result)
for kategorie, mapping in korrekturen.items():
if not isinstance(mapping, dict):
continue
for falsch, richtig in mapping.items():
if not falsch or not richtig or (falsch, richtig) in applied:
continue
for w in set(words):
if len(w) < 4:
continue
if _similarity(w, falsch) >= FUZZY_THRESHOLD:
pattern = r"\b" + re.escape(w) + r"\b"
result = re.sub(pattern, richtig, result)
applied.append((falsch, richtig))
words = re.findall(r"[A-Za-zÄÖÜäöüß0-9\-]+", result)
break
return result, applied
def _kogu_gruss_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), KOGU_GRUSS_CONFIG_FILENAME)
def load_kogu_gruss() -> str:
"""Liest den gespeicherten Schlusssatz für KOGU."""
try:
path = _kogu_gruss_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
s = f.read().strip()
if s in KOGU_GRUSS_OPTIONS:
return s
except Exception:
pass
return KOGU_GRUSS_OPTIONS[0]
def save_kogu_gruss(gruss: str) -> None:
"""Speichert den Schlusssatz für KOGU."""
try:
with open(_kogu_gruss_config_path(), "w", encoding="utf-8") as f:
f.write((gruss or "").strip())
except Exception:
pass
def _kogu_templates_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), KOGU_TEMPLATES_CONFIG_FILENAME)
def load_kogu_templates() -> str:
"""Liest die gespeicherte Vorlage für Kostengutsprachen (eigene Wünsche an Typ/Format/Inhalt)."""
try:
path = _kogu_templates_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return ""
def save_kogu_templates(text: str) -> None:
"""Speichert die Vorlage für Kostengutsprachen."""
try:
with open(_kogu_templates_config_path(), "w", encoding="utf-8") as f:
f.write((text or "").strip())
except Exception:
pass
def _diskussion_vorlage_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), DISKUSSION_VORLAGE_CONFIG_FILENAME)
def load_diskussion_vorlage() -> str:
"""Liest die Vorlage für die KI-Diskussion (legt fest, wie die KI mit dem Nutzer diskutiert)."""
try:
path = _diskussion_vorlage_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return ""
def save_diskussion_vorlage(text: str) -> None:
"""Speichert die Vorlage für die KI-Diskussion."""
try:
with open(_diskussion_vorlage_config_path(), "w", encoding="utf-8") as f:
f.write((text or "").strip())
except Exception:
pass
def _op_bericht_template_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), OP_BERICHT_TEMPLATE_CONFIG_FILENAME)
def load_op_bericht_template() -> str:
"""Liest die gespeicherte Vorlage für den OP-Bericht (eigene Wünsche an Format/Inhalt)."""
try:
path = _op_bericht_template_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return ""
def save_op_bericht_template(text: str) -> None:
"""Speichert die Vorlage für den OP-Bericht."""
try:
with open(_op_bericht_template_config_path(), "w", encoding="utf-8") as f:
f.write((text or "").strip())
except Exception:
pass
ARZTBRIEF_VORLAGE_DEFAULT = """Reihenfolge: 1. Diagnose, 2. Anlass, 3. Befunde, 4. Empfehlung, Therapie
Ordne den Arztbrief zwingend nach obiger Reihenfolge. Jeder Abschnitt mit eigener Überschrift (z. B. Diagnose:, Anlass:, Befunde:, Therapie:). Fehlende Abschnitte weglassen. Keine Sternchen (*). Diagnosen mit ICD-10-Code beibehalten."""
def _arztbrief_vorlage_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), ARZTBRIEF_VORLAGE_CONFIG_FILENAME)
def load_arztbrief_vorlage() -> str:
"""Liest die gespeicherte Vorlage für den Arztbrief (Reihenfolge + Anweisungen)."""
try:
path = _arztbrief_vorlage_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return ARZTBRIEF_VORLAGE_DEFAULT
def save_arztbrief_vorlage(text: str) -> None:
"""Speichert die Vorlage für den Arztbrief."""
try:
with open(_arztbrief_vorlage_config_path(), "w", encoding="utf-8") as f:
f.write((text or "").strip())
except Exception:
pass
def load_saved_model() -> str:
"""Liest die zuletzt gewählte KG-Modell-ID aus der Config-Datei."""
try:
path = _config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
model = f.read().strip()
if model in ALLOWED_SUMMARY_MODELS:
return model
except Exception:
pass
return DEFAULT_SUMMARY_MODEL
def save_model(model: str) -> None:
"""Speichert die gewählte KG-Modell-ID in der Config-Datei."""
if model not in ALLOWED_SUMMARY_MODELS:
return
try:
with open(_config_path(), "w", encoding="utf-8") as f:
f.write(model)
except Exception:
pass
def _templates_config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), TEMPLATES_CONFIG_FILENAME)
def load_templates_text() -> str:
"""Liest den gespeicherten Template-Text (z. B. Fachrichtung/Kontext für die KI)."""
try:
path = _templates_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return ""
def save_templates_text(text: str) -> None:
"""Speichert den Template-Text."""
try:
with open(_templates_config_path(), "w", encoding="utf-8") as f:
f.write((text or "").strip())
except Exception:
pass
# -----------------------------
# Prompt
# -----------------------------
SYSTEM_PROMPT = """
Du bist ein ärztlicher Dokumentationsassistent (Deutsch).
AUFGABE:
Erstelle aus dem Transkript automatisch eine strukturierte SOAP-Krankengeschichte.
WICHTIGE REGELN (strikt einhalten):
- Schreibe NUR Abschnitte, zu denen im Transkript tatsächlich Informationen vorhanden sind.
- Wenn zu einem SOAP-Teil KEINE Information diktiert wurde:
→ Abschnitt vollständig WEGLASSEN
→ KEIN Platzhalter, KEIN Kommentar, KEIN Hinweis wie "nicht erwähnt"
- Keine Fakten erfinden.
- Keine Meta-Kommentare.
- Medikamente/Therapie nur als Vorschläge (ärztlich zu prüfen).
- KEINE Warnungen oder Hinweise im Fließtext einbauen (z. B. "Verordnung/Beginn dokumentiert", "ärztliche Weiterverordnung und Überprüfung erforderlich"). Nur die klinische Aussage formulieren, ohne Klammer-Hinweise.
FORMAT (nur vorhandene Abschnitte ausgeben):
Subjektiv:
- Beschwerden, Verlauf, Symptome (nur wenn vorhanden)
Sozialanamnese: (nur wenn im Transkript erwähnt)
- Beruf, Stressfaktoren, Ferien, Lebensumstände usw. als Stichpunkte.
Familienanamnese: (nur wenn im Transkript etwas dazu gesagt wird)
- Relevante Angaben zur Familie (Erkrankungen, Risiken). Abschnitt weglassen, wenn nichts diktiert wurde.
Objektiv:
- NUR Beschreibung der Problematik/Befunde: was wurde beobachtet, untersucht, gesehen (z. B. Hautbefund, Vitalzeichen, körperlicher Status). KEINE Diagnosen hier auflisten Objektiv ist die Beschreibung, Diagnose die Benennung der Erkrankung.
Diagnose:
- PFLICHT: Jede Diagnosezeile im Abschnitt „Diagnose:“ MUSS mit schweizerischem ICD-10-GM-Code in Klammern enden. Format strikt: „- Bezeichnung (ICD-Code)“ z. B. „- Aktinische Keratosen (L57.0)“ oder „- Myalgie (M79.1)“. Keine Diagnose ohne (Code) ausgeben.
- Die eigentlichen Diagnosen: zuerst Bezeichnung, dann Leerzeichen, dann den passenden ICD-10-GM-Code (Schweiz) in Klammern. Nicht dieselbe Aufzählung wie unter Objektiv Objektiv = Befundbeschreibung, Diagnose = Benennung der Erkrankungen mit Code.
- Nicht überinterpretieren: Nur kodieren, was tatsächlich gesagt bzw. medizinisch klar ist. Z. B. „Stress im Beruf“ oder „viel um die Ohren“ nicht als psychische/psychiatrische Diagnose kodieren; nur echte, genannte Diagnosen mit passendem ICD-10 versehen.
Therapie: ODER Procedere: (nur EINEN dieser Abschnittsnamen verwenden, je nach Inhalt)
- Punkte direkt darunter auflisten, OHNE Unterüberschrift "Therapie:" oder "Procedere:" nochmal zu wiederholen.
- Zulässig: Diagnostik, Medikation, Aufklärung, Verlauf/Kontrolle usw. als Stichpunkte.
""".strip()
LETTER_PROMPT = """
Du bist ärztliche Dokumentationsassistenz in einem Schweizer Praxisnetz.
Erstelle einen ärztlichen Kurzbrief für den behandelnden Hausarzt.
Struktur (in dieser Reihenfolge) Abschnittsüberschriften WORTWÖRTLICH ausgeben:
1. Diagnose: (oder Diagnosen:) als Überschrift ausgeschrieben, darunter jede Zeile mit schweizerischem ICD-10-GM-Code in Klammern, Format „- Diagnose (Code)“.
2. Beurteilung als Überschrift ausgeschrieben, darunter Befunde/Beurteilung in vollständigen Sätzen (nicht nur Stichworte).
3. Procedere: ODER Therapie: als Überschrift ausgeschrieben, darunter Medikation, Verordnungen, nächste Schritte als Stichpunkte.
Regeln:
- KEINE Briefkopfzeile (kein Adressblock). Die Abschnittsüberschriften „Diagnose:“ / „Diagnosen:“, „Beurteilung“, „Procedere:“ / „Therapie:“ müssen wörtlich stehen.
- Bei Beurteilung: vollständige Sätze. Bei Diagnose und Procedere/Therapie: Stichworte/Stichpunkte sind erlaubt. Keine persönlichen Anreden, keine Meta-Kommentare.
- Nutze bevorzugt die Angaben aus der Krankengeschichte (falls vorhanden). Fehlen dort Abschnitte, ergänze anhand des Transkripts.
- Diagnoseliste MUSS alle genannten Diagnosen mit ICD-10-GM-Code enthalten. Keine neuen Diagnosen erfinden.
- Falls keine Diagnose vorhanden ist, schreibe unter „Diagnose:“ die Zeile „- keine gesicherte Diagnose dokumentiert“.
- Verwende KEINE Sternchen (*), keine Sonderzeichen zur Hervorhebung.
- Sprache: Deutsch, sachlich, kompakt.
""".strip()
KOGU_PROMPT = """
Du bist ärztliche Dokumentationsassistenz.
Erstelle eine formelle Kostengutsprache auf Basis des Transkripts.
Stil:
- Sachliches, neutrales Deutsch mit vollständigen Sätzen.
- Keine Aufzählungsziffern oder Mehrfachüberschriften.
- Keine Sternchen (*) oder Sonderzeichen zur Hervorhebung.
Struktur (nur Abschnitte ausgeben, zu denen Informationen vorliegen):
Klinischer Hintergrund ODER Anamnese:
Kurzer Überblick über den klinischen Hintergrund bzw. die Anamnese (maximal zwei Sätze).
Diagnosen:
Jede Diagnosezeile im Format „- Diagnose (ICD-Code)“ mit schweizerischem ICD-10-GM-Code.
Begründung:
Prägnante medizinische Argumentation, warum die beantragte Leistung erforderlich ist (konkrete Beschwerden, Verlauf, Therapieziel).
Beantragte Leistung:
Beschreibung der gewünschten Leistung inkl. Dauer, Frequenz oder Produkt, sofern im Transkript erwähnt. Fehlende Angaben nicht erfinden, sondern unaufgeregt darauf hinweisen.
Gib pro Abschnitt ausschließlich eine Überschrift aus (keine Varianten wie „Patient/ Kontexte“). Wenn ein Abschnitt entfällt, lass die Überschrift komplett weg.
""".strip()
LETTER_SHORTEN_PROMPT = """
Fasse den folgenden Arztbrief kürzer zusammen. Behalte alle wichtigen medizinischen Fakten, Diagnosen mit ICD-10-Code und Empfehlungen. Verwende keine Sternchen (*). Ausgabe: nur der gekürzte Brief.
""".strip()
LETTER_EXPAND_PROMPT = """
Schreibe den folgenden Arztbrief ausführlicher um. Verwende vollständige Sätze statt Stichworte. Behalte die Struktur (Anlass, Befunde, Diagnosen mit ICD-10, Empfehlungen). Verwende keine Sternchen (*). Ausgabe: nur der ausführlichere Brief.
""".strip()
LETTER_KI_UEBERARBEITET_PROMPT = """
Überarbeite den folgenden Arztbrief leicht: mache ihn professioneller, logischer und sprachlich schöner.
- Alle medizinischen Fakten, Diagnosen (mit ICD-10) und Empfehlungen unverändert beibehalten.
- Keine Sternchen (*), keine übertriebenen Änderungen.
- Flüssigere Formulierungen, klarere Struktur, sachlicher Stil.
- Nicht zu extrem nur moderate Verbesserungen.
Ausgabe: nur der überarbeitete Brief.
""".strip()
KG_SHORTEN_PROMPT = """
Fasse die folgende Krankengeschichte kürzer zusammen. Behalte alle wichtigen medizinischen Fakten, Diagnosen mit ICD-10-Code und Empfehlungen. Struktur (z. B. Subjektiv, Objektiv, Diagnose, Therapie/Procedere) beibehalten. Keine Sternchen (*). Ausgabe: nur die gekürzte Krankengeschichte.
""".strip()
KG_EXPAND_PROMPT = """
Schreibe die folgende Krankengeschichte ausführlicher um. Verwende vollständige Sätze statt Stichworte wo sinnvoll. Behalte die Struktur und alle Diagnosen mit ICD-10-Code. Keine Sternchen (*). Ausgabe: nur die ausführlichere Krankengeschichte.
""".strip()
KOGU_SHORTEN_PROMPT = """
Fasse die folgende Kostengutsprache kürzer zusammen. Struktur (Klinischer Hintergrund/Anamnese, Diagnosen, Begründung, Beantragte Leistung) beibehalten, Diagnosen mit ICD-10-Code erhalten. Keine Sternchen (*), keine Ziffern.
""".strip()
KOGU_EXPAND_PROMPT = """
Formuliere die folgende Kostengutsprache ausführlicher und begründe medizinisch präziser. Struktur (Klinischer Hintergrund/Anamnese, Diagnosen, Begründung, Beantragte Leistung) und ICD-10-Codes beibehalten. Keine Sternchen (*), keine Ziffern.
""".strip()
OP_BERICHT_PROMPT = """
Du bist ärztliche Dokumentationsassistenz.
Erstelle einen OP-Bericht (Operationsbericht) auf Basis des Transkripts.
Stil:
- Sachliches, neutrales Deutsch mit vollständigen Sätzen.
- Typische Struktur: Eingriff/Operation, Befund, Durchführung, Verlauf, ggf. Komplikationen.
- Keine Sternchen (*) oder Sonderzeichen zur Hervorhebung.
- Nur Inhalte aus dem Transkript verwenden, nichts erfinden.
""".strip()
OP_BERICHT_SHORTEN_PROMPT = """
Fasse den folgenden OP-Bericht kürzer zusammen. Behalte alle medizinisch relevanten Fakten (Eingriff, Befund, Durchführung). Keine Sternchen (*). Ausgabe: nur der gekürzte OP-Bericht.
""".strip()
OP_BERICHT_EXPAND_PROMPT = """
Schreibe den folgenden OP-Bericht ausführlicher um. Verwende vollständige Sätze statt Stichworte. Behalte die Struktur (Eingriff, Befund, Durchführung, Verlauf). Keine Sternchen (*). Ausgabe: nur der ausführlichere OP-Bericht.
""".strip()
KOGU_GRUSS_OPTIONS = [
"Mit freundlichen Grüssen",
"Freundliche Grüsse",
"Mit freundlichen Grüssen und besten Wünschen",
"Hochachtungsvoll",
]
KOGU_GRUSS_CONFIG_FILENAME = "kg_diktat_kogu_gruss.txt"
KOGU_TEMPLATES_CONFIG_FILENAME = "kg_diktat_kogu_templates.txt"
DISKUSSION_VORLAGE_CONFIG_FILENAME = "kg_diktat_diskussion_vorlage.txt"
RECIPE_PROMPT = """
Du bist ärztliche Dokumentationsassistenz.
Erstelle eine Medikamenten-/Rezeptliste. Nur Inhalte, die für eine Rezeptierung relevant sind: Medikamente, Dosierungen, Verordnungen. KEINE Kontrollen, KEINE allgemeinen Empfehlungen, KEINE Laborkontrollen.
Regeln:
- NUR Medikamente und Therapien, die verordnet werden sollen (Rezept-Inhalt).
- Kontrollen, Verlaufskontrollen, Labor etc. weglassen.
- Gib NUR Bereiche aus, zu denen tatsächlich Angaben vorliegen.
- Übernimm nur Angaben, die ausdrücklich genannt wurden nichts ergänzen.
- Gib Dosierungen nur wieder, wenn sie vorliegen.
- Verwende KEINE Sternchen (*), keine Raute (#), keine Sonderzeichen.
- Sprache: Deutsch, stichpunktartig.
""".strip()
INTERACTION_PROMPT = """
Du bist ärztliche/pharmazeutische Assistenz.
Aufgabe: Prüfe die folgenden Medikamente auf mögliche Wechselwirkungen.
Gib eine strukturierte Übersicht auf Deutsch:
- Relevante Wechselwirkungen zwischen den genannten Substanzen
- Kontraindikationen oder Vorsichtshinweise
- Falls keine bekannten Interaktionen: kurzer Hinweis
Nur klinisch relevante Informationen. Keine Sterne, keine Ziffern. Stichpunktartig.
""".strip()
KI_PRUEFEN_PROMPT = """
Du bist ein ärztlicher Dokumentationsassistent (Deutsch).
Aufgabe: Prüfe die Krankengeschichte auf Logik, Diagnose-Therapie-Passung und innere Zusammenhänge.
Regeln:
- Sehr knapp und zusammengefasst antworten (max. 35 Stichpunkte oder ein kurzer Absatz).
- Nur wesentliche Unstimmigkeiten oder Verbesserungsvorschläge nennen.
- Wenn alles schlüssig ist: eine kurze Bestätigung in einem Satz.
- Keine Sterne, keine Ziffern, keine Wiederholungen. Inhaltlich korrekt und präzise.
- WICHTIG: Nach jedem Satz einen Absatz (Leerzeile) einfügen, zur besseren Übersicht.
""".strip()
MERGE_PROMPT = """
Du bist ein ärztlicher Dokumentationsassistent (Deutsch).
AUFGABE:
Es liegt eine bestehende Krankengeschichte (SOAP) und ein ergänzendes Transkript vor.
Fasse das ergänzende Transkript zusammen und füge die neuen Informationen in die bestehende KG ein.
REGELN:
- Gleiche Überschriften beibehalten (Subjektiv, ggf. Sozialanamnese, ggf. Familienanamnese, Objektiv, Diagnose, Therapie bzw. Procedere).
- Objektiv = Befundbeschreibung (was gesehen/untersucht). Diagnose: PFLICHT jede Zeile mit ICD-10-GM in Klammern, Format „- Bezeichnung (ICD-Code)“ z. B. „- Aktinische Keratosen (L57.0)“. Keine Diagnose ohne (Code). Nicht überinterpretieren (z. B. Stress im Beruf nicht als psychische Diagnose kodieren).
- Sozialanamnese und Familienanamnese nur, wenn im Transkript erwähnt.
- Neue Punkte in die passenden Abschnitte einfügen (anhängen), keine Doppelungen.
- Bestehende Formulierungen unverändert lassen; nur ergänzen.
- Keine Meta-Kommentare, keine Warnungen im Fließtext.
- Ausgabe: die vollständige, aktualisierte KG (bestehende + Ergänzungen).
""".strip()
def strip_kg_warnings(text: str) -> str:
"""Entfernt typische Warn-/Hinweisphrasen aus der KG-Ausgabe (z. B. Verordnung/Beginn dokumentiert)."""
import re
def remove_warning_block(m):
content = m.group(1)
if any(
x in content
for x in (
"dokumentiert",
"Weiterverordnung",
"Überprüfung erforderlich",
)
):
return ""
return m.group(0)
result = re.sub(r"\(([^)]*)\)", remove_warning_block, text)
result = re.sub(r" +", " ", result)
result = re.sub(r"\n{3,}", "\n\n", result)
# Therapie/Procedere-Format bereinigen: nur "Therapie:" oder "Procedere:", keine doppelte "- Therapie:"
result = re.sub(r"Therapie/Procedere\s*:", "Therapie:", result, flags=re.IGNORECASE)
result = re.sub(r"^-\s*Therapie\s*:\s*", "- ", result, flags=re.MULTILINE | re.IGNORECASE)
result = re.sub(r"^-\s*Procedere\s*:\s*", "- ", result, flags=re.MULTILINE | re.IGNORECASE)
result = re.sub(r"\n{3,}", "\n\n", result)
return result.strip()
# Klammer-Inhalte, die als Vorsicht/Warnzeichen ins graue Kommentarfeld gehören (nur diese)
COMMENT_KEYWORDS = (
"ärztlich zu prüfen",
"wirkstoff nicht genannt",
"kontrolle",
"vorsicht",
"warnung",
"weiterverordnung",
"überprüfung",
"blutkontrolle",
"leberwerte",
"red flag",
"interaktion",
"nebenwirkung",
"kontraindikation",
"aufklärung",
)
def _is_warning_comment(text: str) -> bool:
"""True, wenn der Klammer-Text eine Vorsicht/Warnung für den Arzt darstellt."""
t = text.lower().strip()
return any(kw in t for kw in COMMENT_KEYWORDS)
def _is_icd10_code(text: str) -> bool:
"""True, wenn der Klammer-Text ein ICD-10-GM-Code ist (z. B. L57.0, M79.1). Diese bleiben in der KG."""
import re
t = text.strip()
return bool(re.match(r"^[A-Z][0-9]{2}(\.[0-9]{1,2})?$", t, re.IGNORECASE))
def extract_kg_comments(text: str) -> tuple:
"""Entfernt Klammer-Inhalte aus der KG, außer ICD-10-Codes. Nur Vorsicht/Warnzeichen kommen ins graue Kommentarfeld."""
import re
lines = text.split("\n")
cleaned_lines = []
comments = []
for line in lines:
rest = line
line_comments = []
new_rest = ""
last_end = 0
for m in re.finditer(r"\(([^)]*)\)", rest):
content = m.group(1).strip()
if _is_icd10_code(content):
new_rest += rest[last_end : m.end()]
else:
new_rest += rest[last_end : m.start()]
if content:
line_comments.append(content)
last_end = m.end()
new_rest += rest[last_end:]
new_rest = re.sub(r" +", " ", new_rest).strip()
if line_comments:
context = new_rest.strip()
if context.startswith("- "):
context = context[2:].strip()
for c in line_comments:
if _is_warning_comment(c):
comments.append(f"- {context}: {c}")
cleaned_lines.append(new_rest)
cleaned = "\n".join(cleaned_lines)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
comments_text = "\n".join(comments) if comments else ""
return cleaned, comments_text
# -----------------------------
# Audio Recorder (sounddevice)
# -----------------------------
class AudioRecorder:
"""
Minimaler Recorder mit sounddevice.
Speichert als 16kHz mono WAV (16-bit PCM).
"""
def __init__(self, samplerate=16000, channels=1):
self.samplerate = samplerate
self.channels = channels
self._stream = None
self._frames = []
self._recording = False
def start(self):
try:
import sounddevice as sd
except Exception as e:
raise RuntimeError(
"Python-Paket 'sounddevice' fehlt.\n\n"
"Installiere es mit:\n"
" py -3.11 -m pip install sounddevice\n\n"
f"Details: {e}"
)
self._frames = []
self._recording = True
def callback(indata, frames, time_info, status):
if status:
# status ist oft unkritisch (z.B. underflow); wir sammeln trotzdem weiter
pass
if self._recording:
# indata ist numpy array (float32)
self._frames.append(indata.copy())
self._stream = sd.InputStream(
samplerate=self.samplerate,
channels=self.channels,
callback=callback,
dtype="float32",
blocksize=0,
)
self._stream.start()
def stop_and_save_wav(self) -> str:
if not self._stream:
raise RuntimeError("Recorder wurde nicht gestartet.")
self._recording = False
self._stream.stop()
self._stream.close()
self._stream = None
if not self._frames:
raise RuntimeError("Keine Audio-Daten aufgenommen (leer).")
# Konvertiere float32 [-1,1] -> int16 PCM
import numpy as np
audio = np.concatenate(self._frames, axis=0)
audio = np.clip(audio, -1.0, 1.0)
pcm16 = (audio * 32767.0).astype(np.int16)
# Schreibe WAV in temp file
fd, path = tempfile.mkstemp(suffix=".wav", prefix="kg_rec_")
os.close(fd)
with wave.open(path, "wb") as wf:
wf.setnchannels(self.channels)
wf.setsampwidth(2) # 16-bit
wf.setframerate(self.samplerate)
wf.writeframes(pcm16.tobytes())
return path
# -----------------------------
# GUI App
# -----------------------------
# Standard-Fenstergröße (entspricht aktueller bevorzugter Größe aus kg_diktat_window.txt)
DEFAULT_WINDOW_WIDTH = 720
DEFAULT_WINDOW_HEIGHT = 830
# Globale Liste aller offenen Fenster für Skalierung
_ALL_WINDOWS = []
class KGDesktopApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("KI Assistent PRAXIS LINDENGUT AG")
MAIN_MIN_W, MAIN_MIN_H = 650, 550 # 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}")
# Fenster immer im Vordergrund
self.attributes("-topmost", True)
self._last_external_hwnd = None
self.bind("<FocusOut>", self._on_focus_out_for_external_paste)
self._geometry_save_after_id = None
self.bind("<Configure>", self._on_configure)
self.protocol("WM_DELETE_WINDOW", self._on_close)
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.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)
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, "⏺ Aufnahme starten", command=self.toggle_record,
bg="#5B8DB3", fg="white", active_bg="#4A7A9E", width=208, height=47,
canvas_bg="#B9ECFA",
)
self.btn_record.pack(side="left", anchor="n")
self._scalable_widgets.append(self.btn_record)
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._top_right = ttk.Frame(top, style="TopBar.TFrame")
self._top_right.pack(side="right", anchor="n")
# Token-Anzeige
token_data = load_token_usage()
used = token_data.get("used", 0)
total = token_data.get("total", 1000000)
percent = int((used / total * 100)) if total > 0 else 0
self._token_label = ttk.Label(
self._top_right,
text=f"🪙 {percent}%",
font=("Segoe UI", 9),
foreground="#BD4500" if percent > 80 else "#1a4d6d"
)
self._token_label.pack(side="left", padx=(0, 8))
# Reset-Button (↺)
def reset_scales():
save_font_scale(OPTIMAL_FONT_SCALE)
save_button_scale(OPTIMAL_BUTTON_SCALE)
self._font_scale_var.set(OPTIMAL_FONT_SCALE)
self._button_scale_var.set(OPTIMAL_BUTTON_SCALE)
self._apply_font_scale_global(OPTIMAL_FONT_SCALE)
self._apply_button_scale_global(OPTIMAL_BUTTON_SCALE)
self.set_status("Optimale Größen wiederhergestellt.")
self.btn_reset_scales = RoundedButton(
self._top_right, "", command=reset_scales, width=28, height=24, canvas_bg="#B9ECFA",
bg="#7EC8E3", fg="#1a4d6d", active_bg="#5AB9E8"
)
self.btn_reset_scales.pack(side="left", padx=(0, 8), anchor="n")
# Schriftgrößen-Regler
self._font_scale_var = tk.DoubleVar(value=load_font_scale())
def on_font_scale_change(val):
scale = float(val)
save_font_scale(scale)
self._apply_font_scale_global(scale)
ttk.Label(self._top_right, text="Aa", font=("Segoe UI", 10)).pack(side="left", padx=(0, 4))
self._font_scale_slider = ttk.Scale(
self._top_right,
from_=MIN_FONT_SCALE,
to=MAX_FONT_SCALE,
variable=self._font_scale_var,
orient="horizontal",
length=60,
command=on_font_scale_change,
)
self._font_scale_slider.pack(side="left", padx=(0, 6))
# Button-Größen-Regler
self._button_scale_var = tk.DoubleVar(value=load_button_scale())
def on_button_scale_change(val):
scale = float(val)
save_button_scale(scale)
self._apply_button_scale_global(scale)
ttk.Label(self._top_right, text="", font=("Segoe UI", 12)).pack(side="left", padx=(0, 4))
self._button_scale_slider = ttk.Scale(
self._top_right,
from_=MIN_BUTTON_SCALE,
to=MAX_BUTTON_SCALE,
variable=self._button_scale_var,
orient="horizontal",
length=60,
command=on_button_scale_change,
)
self._button_scale_slider.pack(side="left", padx=(0, 8))
# 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
ttk.Label(self._top_right, text="", font=("Segoe UI", 10)).pack(side="left", padx=(0, 4))
self._opacity_scale_main = ttk.Scale(
self._top_right,
from_=40, to=100,
variable=self._opacity_var_main,
orient="horizontal",
length=88,
command=on_opacity_main,
)
self._opacity_scale_main.pack(side="left", padx=(0, 10))
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)
self._status_row = ttk.Frame(self, padding=(10, 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.paned = ttk.PanedWindow(self, orient="horizontal")
self.paned.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self.paned.bind("<Configure>", self._on_paned_configure)
self._minimized = False
self._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")
self.paned_transcript = ttk.PanedWindow(left, orient="vertical")
self.paned_transcript.pack(fill="both", expand=True)
self.paned_transcript.bind("<Configure>", self._on_paned_configure)
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)
ttk.Label(top_left, text="Transkript:").pack(anchor="w")
trans_frame = ttk.Frame(top_left, height=120)
trans_frame.pack_propagate(False)
trans_frame.pack(fill="x")
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)
trans_btn_row = ttk.Frame(top_left, padding=(0, 4, 0, 0))
trans_btn_row.pack(fill="x")
trans_btn_inner = ttk.Frame(trans_btn_row)
trans_btn_inner.pack(anchor="center")
RoundedButton(
trans_btn_inner, "Diktat", command=self.open_diktat_window,
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=80, height=26, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
RoundedButton(
trans_btn_inner, "Ordner", command=self.open_ordner_window,
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=80, height=26, canvas_bg="#B9ECFA",
).pack(side="left")
# Container für Textblöcke (wird bei Einstellung ein-/ausgeblendet)
ttk.Frame(top_left, height=26).pack(fill="x")
self._textbloecke_container = ttk.Frame(top_left)
self._textbloecke_container.pack(fill="x")
self._textbloecke_data = load_textbloecke()
self._removed_textbloecke = [] # Stack: entfernte Textblöcke (name, content) für Wiederverwendung bei +
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")
self.bind_all("<B1-Motion>", self._textblock_on_drag_motion)
self.bind_all("<ButtonRelease-1>", self._on_global_drag_release)
if not self._autotext_data.get("textbloecke_visible", True):
self._textbloecke_container.pack_forget()
# Anchor für Textblöcke und Add-on (immer vorhanden)
self._addon_anchor = ttk.Frame(top_left)
self._addon_anchor.pack(fill="x")
# Add-on-Container (Übersetzer etc.) ein-/ausblendbar über Einstellungen
self._addon_container = ttk.Frame(top_left)
addon_inner = ttk.Frame(self._addon_container)
addon_inner.pack(fill="x")
ttk.Frame(addon_inner, height=12).pack(fill="x")
addon_lbl = ttk.Label(addon_inner, text="Add-on:", font=("Segoe UI", 10))
addon_lbl.pack(anchor="center")
ttk.Frame(addon_inner, height=12).pack(fill="x")
uebersetzer_row = ttk.Frame(addon_inner, padding=(0, 0, 0, 0))
uebersetzer_row.pack(fill="x")
RoundedButton(
uebersetzer_row, "Übersetzer (provisorisch)", command=self._open_uebersetzer,
bg="#CADFE8", fg="#1a4d6d", active_bg="#B8D5E2", width=180, height=28, canvas_bg="#CADFE8",
radius=0,
).pack(anchor="center")
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 erneut erstellen", command=self.make_kg_from_text,
width=160, height=28, canvas_bg="#B9ECFA",
)
self.btn_make_kg.pack(side="left")
self.btn_copy = RoundedButton(
right_top, "KG kopieren", command=self.copy_output,
width=120, height=28, canvas_bg="#B9ECFA",
)
self.btn_copy.pack(side="left", padx=(8, 0))
ttk.Label(right, text="Krankengeschichte:").pack(anchor="w")
self.txt_output = ScrolledText(
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)
# Brief, Rezept, KOGU, KI-Kontrolle, Korrektur: alle gleiche Größe (100x28)
letter_bar = ttk.Frame(right, padding=(0, 6), 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="#e1f6fc", bg="#e1f6fc", fg="#1a4d6d", active_bg="#c8ecf8",
)
self.btn_letter.pack(side="left")
self.btn_recipe = RoundedButton(
letter_bar, "Rezept", command=self.open_rezept_window,
width=100, height=28, canvas_bg="#c2f1ff", bg="#c2f1ff", fg="#1a4d6d", active_bg="#a8e8f5",
)
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="#b0e4ff", bg="#b0e4ff", fg="#1a4d6d", active_bg="#92d8f5",
)
self.btn_op_bericht.pack(side="left", padx=(8, 0))
kogu_bar = ttk.Frame(right, 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="#a1e8ff", bg="#a1e8ff", fg="#1a4d6d", active_bg="#88daf0",
)
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="#c8e6f5", bg="#c8e6f5", fg="#1a4d6d", active_bg="#a8d8ed",
)
self.btn_diskussion.pack(side="left", padx=(8, 0))
self.btn_lernkarten = RoundedButton(
kogu_bar, "Lernkarten", command=self._open_lernkarten_abfrage,
width=100, height=28, canvas_bg="#b8e8d8", bg="#b8e8d8", fg="#1a4d6d", active_bg="#98d8c8",
)
self.btn_lernkarten.pack(side="left", padx=(8, 0))
korrektur_bar = ttk.Frame(right, 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",
)
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",
)
self.btn_korrektur.pack(side="left", padx=(8, 0))
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: ⏺ Aufnahme starten → ⏹ stoppen → automatische Transkription → automatische KG. "
"Alternativ Text einfügen und „KG erneut erstellen“."
).pack(anchor="w")
add_resize_grip(self)
# 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()))
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-Höhe): mit Verzögerung speichern."""
if event.widget is not self.paned and event.widget is not self.paned_transcript:
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) wieder her. Standard: Transkript 1/3 der Breite."""
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
try:
left_h = self.paned_transcript.winfo_height()
if left_h < 100:
left_h = 400
if self._saved_sash_transcript is not None:
sash_v = max(80, min(self._saved_sash_transcript, max(80, left_h - 80)))
else:
# ca. 85 % für Transkript + Diktat/Ordner-Buttons, 15 % für Statuszeile
sash_v = max(120, int(left_h * 0.85))
self.paned_transcript.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)
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)
base_size = 10
new_size = max(8, 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."""
try:
token_data = load_token_usage()
used = token_data.get("used", 0)
total = token_data.get("total", 1000000)
percent = int((used / total * 100)) if total > 0 else 0
self._token_label.configure(
text=f"🪙 {percent}%",
foreground="#BD4500" if percent > 80 else "#1a4d6d"
)
except Exception:
pass
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()
self.destroy()
def _open_settings(self):
SETTINGS_MIN_W, SETTINGS_MIN_H = 580, 450
win = tk.Toplevel(self)
win.title("Einstellungen")
win.transient(self)
win.minsize(SETTINGS_MIN_W, SETTINGS_MIN_H)
saved_geom = load_settings_geometry()
if saved_geom:
try:
win.geometry(_clamp_geometry_str(saved_geom, SETTINGS_MIN_W, SETTINGS_MIN_H))
except Exception:
win.geometry(f"{SETTINGS_MIN_W}x{SETTINGS_MIN_H}")
if not saved_geom:
win.geometry(f"{SETTINGS_MIN_W}x{SETTINGS_MIN_H}")
win.update_idletasks()
sw = win.winfo_screenwidth()
sh = win.winfo_screenheight()
w, h = SETTINGS_MIN_W, SETTINGS_MIN_H
x = max(0, (sw - w) // 2)
y = max(0, (sh - h) // 2)
win.geometry(f"{w}x{h}+{x}+{y}")
add_resize_grip(win, SETTINGS_MIN_W, SETTINGS_MIN_H)
add_font_scale_control(win)
f = ttk.Frame(win, padding=16)
f.pack(fill="both", expand=True)
ttk.Label(f, text="KG-Modell:").grid(row=0, column=0, sticky="w", pady=(0, 8))
display_values = [MODEL_LABELS[m] for m in ALLOWED_SUMMARY_MODELS]
current = MODEL_LABELS.get(self.model_var.get(), display_values[0])
model_var_dialog = tk.StringVar(value=current)
model_box = ttk.Combobox(
f, textvariable=model_var_dialog, values=display_values, state="readonly", width=42
)
model_box.grid(row=0, column=1, sticky="ew", padx=(12, 0), pady=(0, 8))
f.columnconfigure(1, weight=1)
def open_templates():
tw = tk.Toplevel(win)
tw.title("Templates")
tw.transient(win)
tw.geometry("620x370")
tw.configure(bg="#B9ECFA")
tw.minsize(450, 280)
add_resize_grip(tw, 450, 280)
add_font_scale_control(tw)
tf = ttk.Frame(tw, padding=12)
tf.pack(fill="both", expand=True)
ttk.Label(tf, text="Kontext für die KI (z. B. „Ich bin ein Dermatologe und schreibe dermatologische Berichte.“). Wird bei der KG-Erstellung berücksichtigt:").pack(anchor="w")
ttxt = ScrolledText(tf, wrap="word", font=self._text_font, bg="#F5FCFF", height=8)
ttxt.pack(fill="both", expand=True, pady=(4, 8))
ttxt.insert("1.0", load_templates_text())
self._bind_autotext(ttxt)
btn_f = ttk.Frame(tf)
btn_f.pack(fill="x")
def save_and_close():
save_templates_text(ttxt.get("1.0", "end").strip())
tw.destroy()
ttk.Button(btn_f, text="OK", command=save_and_close).pack(side="left", padx=(0, 8))
ttk.Button(btn_f, text="Abbrechen", command=tw.destroy).pack(side="left")
def do_reset():
save_templates_text("")
messagebox.showinfo("Reset", "Template-Text wurde zurückgesetzt und ist jetzt leer.")
ttk.Button(f, text="Templates", command=open_templates).grid(row=1, column=0, pady=(8, 4), sticky="w")
ttk.Button(f, text="Reset", command=do_reset).grid(row=1, column=1, pady=(8, 4), sticky="w", padx=(12, 0))
diktat_auto_var = tk.BooleanVar(value=self._autotext_data.get("diktat_auto_start", True))
cb_diktat_auto = ttk.Checkbutton(f, text="Diktat startet sofort (wenn Häkchen weg: Aufnahme manuell starten)", variable=diktat_auto_var)
cb_diktat_auto.grid(row=2, column=0, columnspan=2, sticky="w", pady=(8, 2))
textbloecke_visible_var = tk.BooleanVar(value=self._autotext_data.get("textbloecke_visible", True))
cb_textbloecke = ttk.Checkbutton(f, text="Textblöcke anzeigen (Inhalt bleibt gespeichert, wenn ausgeblendet)", variable=textbloecke_visible_var)
cb_textbloecke.grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 2))
addon_visible_var = tk.BooleanVar(value=self._autotext_data.get("addon_visible", True))
cb_addon = ttk.Checkbutton(f, text="Add-ons anzeigen (z. B. Übersetzer)", variable=addon_visible_var)
cb_addon.grid(row=4, column=0, columnspan=2, sticky="w", pady=(4, 2))
kg_auto_delete_var = tk.BooleanVar(value=self._autotext_data.get("kg_auto_delete_old", False))
cb_kg_auto = ttk.Checkbutton(f, text="KG-Einträge älter als 2 Wochen automatisch löschen (Speicher schonen)", variable=kg_auto_delete_var)
cb_kg_auto.grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 2))
autotext_var = tk.BooleanVar(value=self._autotext_data.get("enabled", True))
cb_autotext = ttk.Checkbutton(f, text="Autotext (Abkürzungen z. B. „mfg“ → „mit freundlichen Grüßen“)", variable=autotext_var)
cb_autotext.grid(row=6, column=0, columnspan=2, sticky="w", pady=(4, 2))
def open_autotext_manage():
self._open_autotext_dialog(win)
ttk.Button(f, text="Autotext verwalten", command=open_autotext_manage).grid(row=7, column=0, pady=(2, 4), sticky="w")
def save_and_close():
try:
save_settings_geometry(win.geometry())
except Exception:
pass
win.destroy()
def on_ok():
selected_label = model_var_dialog.get().strip()
for model_id, label in MODEL_LABELS.items():
if label == selected_label:
self.model_var.set(model_id)
save_model(model_id)
break
self._autotext_data["enabled"] = bool(autotext_var.get())
self._autotext_data["diktat_auto_start"] = bool(diktat_auto_var.get())
self._autotext_data["textbloecke_visible"] = bool(textbloecke_visible_var.get())
self._autotext_data["addon_visible"] = bool(addon_visible_var.get())
self._autotext_data["kg_auto_delete_old"] = bool(kg_auto_delete_var.get())
save_autotext(self._autotext_data)
save_and_close()
# UI-Updates nach Schließen des Einstellungsfensters (verhindert Hang)
def _apply_ui():
try:
if self._autotext_data["textbloecke_visible"]:
self._textbloecke_container.pack(fill="x", before=self._addon_spacer_frame)
else:
self._textbloecke_container.pack_forget()
if self._autotext_data["addon_visible"]:
self._addon_container.pack(fill="x", before=self._addon_anchor)
self.update_idletasks()
h = self.winfo_height()
if h < 500:
self.geometry(f"{self.winfo_width()}x500")
else:
self._addon_container.pack_forget()
self.update_idletasks()
except Exception:
pass
self.after(50, _apply_ui)
win.protocol("WM_DELETE_WINDOW", save_and_close)
ttk.Button(f, text="OK", command=on_ok).grid(row=8, column=0, columnspan=2, pady=(12, 0))
win.focus_set()
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.geometry("660x500")
aw.configure(bg="#B9ECFA")
af = ttk.Frame(aw, padding=12)
af.pack(fill="both", expand=True)
ttk.Label(af, text="Abkürzungen: Tippen Sie z. B. „mfg“ + Leerzeichen → wird zu „mit freundlichen Grüßen“. Mehrzeilige Ersetzungen möglich.").pack(anchor="w")
list_f = ttk.Frame(af)
list_f.pack(fill="both", expand=True, pady=(8, 8))
listbox = tk.Listbox(list_f, height=8, font=("Segoe UI", 11))
listbox.pack(side="left", fill="both", expand=True)
scroll = ttk.Scrollbar(list_f, orient="vertical", command=listbox.yview)
scroll.pack(side="right", fill="y")
listbox.configure(yscrollcommand=scroll.set)
entries = dict(self._autotext_data.get("entries") or {})
def refresh_list():
listbox.delete(0, "end")
for k in sorted(entries.keys()):
listbox.insert("end", f"«{k}» → {((entries[k] or '').replace(chr(10), '')[:40])}" if len((entries[k] or "").replace("\n", " ")) > 40 else f"«{k}» → {((entries[k] or '').replace(chr(10), ''))}")
refresh_list()
edit_f = ttk.Frame(af)
edit_f.pack(fill="x", pady=(0, 8))
ttk.Label(edit_f, text="Abkürzung:").grid(row=0, column=0, sticky="w", padx=(0, 8))
abbrev_var = tk.StringVar()
ttk.Entry(edit_f, textvariable=abbrev_var, width=18).grid(row=0, column=1, sticky="w", padx=(0, 16))
ttk.Label(edit_f, text="Ersetzung (mehrzeilig möglich):").grid(row=1, column=0, sticky="nw", padx=(0, 8), pady=(4, 0))
repl_text = ScrolledText(edit_f, wrap="word", height=4, width=42, font=self._text_font, bg="#F5FCFF")
repl_text.grid(row=1, column=1, sticky="ew", pady=(4, 0))
edit_f.columnconfigure(1, weight=1)
def on_select(evt):
sel = listbox.curselection()
if not sel:
return
idx = sel[0]
keys = sorted(entries.keys())
if idx >= len(keys):
return
k = keys[idx]
abbrev_var.set(k)
repl_text.delete("1.0", "end")
repl_text.insert("1.0", entries.get(k, ""))
listbox.bind("<<ListboxSelect>>", on_select)
def do_add():
ab = abbrev_var.get().strip()
if not ab:
messagebox.showinfo("Hinweis", "Bitte eine Abkürzung eingeben.")
return
entries[ab] = repl_text.get("1.0", "end").rstrip("\n")
self._autotext_data["entries"] = entries
save_autotext(self._autotext_data)
refresh_list()
abbrev_var.set("")
repl_text.delete("1.0", "end")
self.set_status("Autotext-Eintrag hinzugefügt/aktualisiert.")
def do_delete():
sel = listbox.curselection()
if not sel:
messagebox.showinfo("Hinweis", "Bitte einen Eintrag in der Liste auswählen.")
return
keys = sorted(entries.keys())
idx = sel[0]
if idx >= len(keys):
return
k = keys[idx]
del entries[k]
self._autotext_data["entries"] = entries
save_autotext(self._autotext_data)
refresh_list()
abbrev_var.set("")
repl_text.delete("1.0", "end")
self.set_status("Autotext-Eintrag gelöscht.")
def do_diktieren():
if not self.ensure_ready():
messagebox.showwarning("Diktieren", "Kein API-Key konfiguriert. Bitte in den Einstellungen eintragen.")
return
rec_win = tk.Toplevel(aw)
rec_win.title("Autotext Diktieren")
rec_win.transient(aw)
rec_win.geometry("420x130")
rec_win.configure(bg="#B9ECFA")
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")
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._btn_minimize.configure(text="")
self._minimized = False
self.minsize(600, 500)
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")
RoundedButton(top_row, "Neu", command=self._new_session, width=50, height=26, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 4), anchor="n")
RoundedButton(top_row, "Brief", command=self.open_brief_window, width=70, height=26, canvas_bg="#e1f6fc", bg="#e1f6fc", fg="#1a4d6d", active_bg="#c8ecf8").pack(side="left", padx=(0, 4), anchor="n")
RoundedButton(top_row, "OP-Bericht", command=self.open_op_bericht_window, width=70, height=26, canvas_bg="#b0e4ff", bg="#b0e4ff", fg="#1a4d6d", active_bg="#92d8f5").pack(side="left", padx=(0, 4), anchor="n")
RoundedButton(top_row, "Diktat", command=self.open_diktat_window, width=70, height=26, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0").pack(side="left", padx=(0, 4), anchor="n")
RoundedButton(top_row, "", command=self._toggle_minimize, width=28, height=26, canvas_bg="#B9ECFA").pack(side="left", padx=(4, 0), anchor="n")
aufnahme_row = ttk.Frame(self._mini_frame)
aufnahme_row.pack(fill="x", pady=(6, 0))
RoundedButton(
aufnahme_row, "⏺ Aufnahme starten", command=self.toggle_record,
bg="#5B8DB3", fg="white", active_bg="#4A7A9E", width=208, height=47,
canvas_bg="#B9ECFA",
).pack(side="left", 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, 8, 0, 0))
opacity_row.pack(fill="x")
opacity_inner = ttk.Frame(opacity_row)
opacity_inner.pack(side="left")
ttk.Label(opacity_inner, text="", font=("Segoe UI", 10)).pack(side="left", padx=(0, 6))
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=100, command=on_opacity_change,
style="MiniOpacity.Horizontal.TScale",
)
opacity_scale.pack(side="left")
self.geometry("540x210")
def set_status(self, s: str):
self.status_var.set(s)
self.update_idletasks()
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.geometry("700x550")
win.configure(bg="#B9ECFA")
win.minsize(500, 400)
add_resize_grip(win, 500, 400)
add_font_scale_control(win)
f = ttk.Frame(win, padding=12)
f.pack(fill="both", expand=True)
ttk.Label(f, text=f"Geprüfte Medikamente/Therapien: {', '.join(meds)}").pack(anchor="w")
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=18)
txt.pack(fill="both", expand=True, pady=(8, 8))
txt.insert("1.0", result.strip())
self._bind_text_context_menu(txt)
def _show_text_window(self, title: str, content: str, buttons: str = "copy") -> None:
"""Zeigt ein Fenster mit Text. buttons: 'copy' | 'brief' | 'rezept' | 'kg'."""
import re
TEXT_WIN_MIN_W, TEXT_WIN_MIN_H = 700, 550
win = tk.Toplevel(self)
win.title(title)
win.transient(self)
win.minsize(TEXT_WIN_MIN_W, TEXT_WIN_MIN_H)
saved_geom = load_text_window_geometry()
if saved_geom:
try:
win.geometry(_clamp_geometry_str(saved_geom, TEXT_WIN_MIN_W, TEXT_WIN_MIN_H))
except Exception:
win.geometry("780x740")
else:
win.geometry("620x600")
win.configure(bg="#B9ECFA")
text_win_status_var = tk.StringVar(value="Bereit.")
def tw_status(s):
text_win_status_var.set(s)
self.set_status(s)
_text_win_geom_after = [None]
def save_text_win_geom():
try:
save_text_window_geometry(win.geometry())
except Exception:
pass
def on_text_win_configure(e):
if e.widget is win and _text_win_geom_after[0]:
self.after_cancel(_text_win_geom_after[0])
if e.widget is win:
_text_win_geom_after[0] = self.after(400, save_text_win_geom)
win.bind("<Configure>", on_text_win_configure)
add_resize_grip(win, TEXT_WIN_MIN_W, TEXT_WIN_MIN_H)
add_font_scale_control(win)
text_frame = ttk.Frame(win, padding=12)
text_frame.pack(fill="both", expand=True)
text_widget = ScrolledText(
text_frame, wrap="word", font=self._text_font, bg="#F5FCFF", state="normal"
)
text_widget.pack(fill="both", expand=True)
self._bind_textblock_pending(text_widget)
# Statusleiste unterhalb Textfeld, oberhalb Buttons wie Diktat (Orange, mit Rand)
status_row = tk.Frame(text_frame, bg="#FFE4CC", height=24, padx=8, pady=4)
status_row.pack(fill="x", pady=(2, 0))
status_row.pack_propagate(False)
lbl_status = tk.Label(
status_row, textvariable=text_win_status_var, fg="#BD4500", bg="#FFE4CC",
font=self._text_font, anchor="w",
)
lbl_status.pack(side="left", fill="x", expand=True)
def build_rezept_full_text(med_content: str, sig_name: str) -> str:
"""Rezept mit Überschrift, Datum, Inhalt und Unterschrift."""
date_str = datetime.now().strftime("%d.%m.%Y")
med = (med_content or "").strip()
med = re.sub(r"\*+", "", med)
med = re.sub(r"#+", "", med)
parts = ["Rezept", "", f"Datum: {date_str}", "", med]
parts.append("")
parts.append(f"Unterschrift: {sig_name or ''}")
return "\n".join(parts).rstrip()
if buttons == "rezept":
sig_name = load_signature_name()
full_text = build_rezept_full_text(content, sig_name)
text_widget.insert("1.0", full_text)
sig_frame = ttk.Frame(win, padding=(12, 0, 12, 8))
sig_frame.pack(fill="x")
ttk.Label(sig_frame, text="Name (Unterschrift):").pack(side="left", padx=(0, 8))
sig_var = tk.StringVar(value=sig_name)
sig_entry = ttk.Entry(sig_frame, textvariable=sig_var, width=40)
sig_entry.pack(side="left", padx=(0, 8))
def save_sig_and_update():
new_name = sig_var.get().strip()
save_signature_name(new_name)
full = build_rezept_full_text(content, new_name)
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
text_widget.insert("1.0", full)
tw_status("Unterschrift gespeichert.")
ttk.Button(sig_frame, text="Speichern", command=save_sig_and_update).pack(side="left")
elif buttons == "kogu":
def build_kogu_full_text(main_content: str, gruss: str, sig_name: str) -> str:
mc = (main_content or "").strip()
mc = re.sub(r"\*+", "", mc)
mc = re.sub(r"#+", "", mc)
parts = [mc]
if gruss:
parts.append("")
parts.append(gruss)
if sig_name:
parts.append(sig_name)
return "\n".join(parts).rstrip()
gruss_val = load_kogu_gruss()
sig_name = load_signature_name()
full_text = build_kogu_full_text(content, gruss_val, sig_name)
text_widget.insert("1.0", full_text)
sig_frame = ttk.Frame(win, padding=(12, 0, 12, 8))
sig_frame.pack(fill="x")
ttk.Label(sig_frame, text="Schlusssatz:").pack(side="left", padx=(0, 8))
gruss_var = tk.StringVar(value=gruss_val)
gruss_combo = ttk.Combobox(
sig_frame, textvariable=gruss_var, values=KOGU_GRUSS_OPTIONS,
state="readonly", width=36
)
gruss_combo.pack(side="left", padx=(0, 16))
ttk.Label(sig_frame, text="Unterschrift:").pack(side="left", padx=(0, 8))
sig_var = tk.StringVar(value=sig_name)
ttk.Entry(sig_frame, textvariable=sig_var, width=32).pack(side="left", padx=(0, 8))
def _extract_kogu_main(text: str) -> str:
t = text.strip()
parts = t.rsplit("\n\n", 1)
if len(parts) == 2:
rest = parts[1].strip()
if rest.split("\n")[0] in KOGU_GRUSS_OPTIONS:
return parts[0].rstrip()
return t
def save_kogu_sig_and_update():
new_gruss = gruss_var.get().strip()
new_sig = sig_var.get().strip()
save_kogu_gruss(new_gruss)
save_signature_name(new_sig)
main = _extract_kogu_main(text_widget.get("1.0", "end"))
full = build_kogu_full_text(main, new_gruss, new_sig)
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
text_widget.insert("1.0", full)
tw_status("Schlusssatz und Unterschrift gespeichert.")
ttk.Button(sig_frame, text="Speichern", command=save_kogu_sig_and_update).pack(side="left")
else:
text = (content or "").strip()
text = re.sub(r"\*+", "", text)
text = re.sub(r"#+", "", text)
text_widget.insert("1.0", text)
btn_frame = ttk.Frame(win, padding=(12, 0, 12, 12))
btn_frame.pack(fill="x")
btn_frame_brief = None
if buttons == "brief":
btn_frame_brief = ttk.Frame(win, padding=(12, 4, 12, 14))
btn_frame_brief.pack(fill="x")
def do_copy():
t = text_widget.get("1.0", "end").strip()
if t:
if not _win_clipboard_set(t):
self.clipboard_clear()
self.clipboard_append(t)
tw_status("Kopiert.")
# Diktat zuvorderst, gleiche runde Form wie die anderen Buttons (100x28)
if buttons in ("brief", "op_bericht"):
RoundedButton(
btn_frame, "Diktat", command=lambda: self._diktat_into_widget(win, text_widget, tw_status),
width=100, height=28, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0",
).pack(side="left", padx=(0, 8))
RoundedButton(
btn_frame, "Kopieren", command=do_copy,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
if buttons in ("brief", "rezept", "kogu", "kg"):
_cat = {"brief": "Briefe", "rezept": "Rezepte", "kogu": "Kostengutsprachen", "kg": "KG"}[buttons]
def on_window_close():
content = text_widget.get("1.0", "end").strip()
if content:
save_to_ablage(_cat, content)
try:
save_text_window_geometry(win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", on_window_close)
elif buttons == "op_bericht":
def on_window_close():
try:
save_text_window_geometry(win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", on_window_close)
if buttons in ("brief", "kogu", "kg", "op_bericht"):
action_label = "Brief" if buttons == "brief" else ("Kostengutsprache" if buttons == "kogu" else ("Krankengeschichte" if buttons == "kg" else "OP-Bericht"))
shorten_prompt = LETTER_SHORTEN_PROMPT if buttons == "brief" else (KOGU_SHORTEN_PROMPT if buttons == "kogu" else (KG_SHORTEN_PROMPT if buttons == "kg" else OP_BERICHT_SHORTEN_PROMPT))
expand_prompt = LETTER_EXPAND_PROMPT if buttons == "brief" else (KOGU_EXPAND_PROMPT if buttons == "kogu" else (KG_EXPAND_PROMPT if buttons == "kg" else OP_BERICHT_EXPAND_PROMPT))
def get_content_to_send():
if buttons == "kogu":
return _extract_kogu_main(text_widget.get("1.0", "end"))
return text_widget.get("1.0", "end").strip()
def append_kogu_footer(main_text: str) -> str:
if buttons != "kogu" and buttons != "op_bericht":
return main_text
if buttons == "op_bericht":
return main_text
g = gruss_var.get().strip()
s = sig_var.get().strip()
if not g and not s:
return main_text
parts = [main_text, ""]
if g:
parts.append(g)
if s:
parts.append(s)
return "\n".join(parts).rstrip()
def do_shorter():
t = get_content_to_send()
if not t:
return
if not self.ensure_ready():
return
tw_status(f"Kürze {action_label}")
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.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": shorten_prompt},
{"role": "user", "content": t},
],
)
result = resp.choices[0].message.content.strip()
result = re.sub(r"\*+", "", result)
result = re.sub(r"#+", "", result)
result = append_kogu_footer(result)
self.after(0, lambda: _update_text(result))
self.after(0, lambda: tw_status("Fertig."))
except Exception as e:
self.after(0, lambda: tw_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
def _update_text(new_text):
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
text_widget.insert("1.0", new_text)
threading.Thread(target=worker, daemon=True).start()
def do_longer():
t = get_content_to_send()
if not t:
return
if not self.ensure_ready():
return
tw_status(f"Schreibe {action_label} ausführlicher…")
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.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": expand_prompt},
{"role": "user", "content": t},
],
)
result = resp.choices[0].message.content.strip()
result = re.sub(r"\*+", "", result)
result = re.sub(r"#+", "", result)
result = append_kogu_footer(result)
self.after(0, lambda: _update_text(result))
self.after(0, lambda: tw_status("Fertig."))
except Exception as e:
self.after(0, lambda: tw_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
def _update_text(new_text):
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
text_widget.insert("1.0", new_text)
threading.Thread(target=worker, daemon=True).start()
RoundedButton(
btn_frame, "Kürzer", command=do_shorter,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
RoundedButton(
btn_frame, "Ausführlicher", command=do_longer,
width=120, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
if buttons == "brief":
def do_ki_ueberarbeitet():
t = text_widget.get("1.0", "end").strip()
if not t:
return
if not self.ensure_ready():
return
tw_status("KI überarbeitet Brief…")
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.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": LETTER_KI_UEBERARBEITET_PROMPT},
{"role": "user", "content": t},
],
)
result = resp.choices[0].message.content.strip()
result = re.sub(r"\*+", "", result)
result = re.sub(r"#+", "", result)
self.after(0, lambda: _update_brief(result))
self.after(0, lambda: tw_status("Fertig."))
except Exception as e:
self.after(0, lambda: tw_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
def _update_brief(new_text):
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
text_widget.insert("1.0", new_text)
threading.Thread(target=worker, daemon=True).start()
RoundedButton(
btn_frame_brief, "KI überarbeitet", command=do_ki_ueberarbeitet,
width=120, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
def do_vorlage_anpassen():
"""Dialog zum Bearbeiten der Arztbrief-Vorlage (Reihenfolge + Anweisungen)."""
tw = tk.Toplevel(win)
tw.title("Arztbrief Vorlage anpassen")
tw.transient(win)
tw.geometry("700x500")
tw.configure(bg="#B9ECFA")
tf = ttk.Frame(tw, padding=12)
tf.pack(fill="both", expand=True)
ttk.Label(tf, text="Reihenfolge der Abschnitte (z. B. 1. Diagnose, 2. Anlass, 3. Befunde, 4. Empfehlung, Therapie). Darunter Anweisungen, wie die Vorlage angewendet werden soll:").pack(anchor="w")
template_txt = ScrolledText(tf, wrap="word", font=self._text_font, bg="#F5FCFF", height=12)
template_txt.pack(fill="both", expand=True, pady=(4, 8))
template_txt.insert("1.0", load_arztbrief_vorlage())
self._bind_autotext(template_txt)
def save_template():
save_arztbrief_vorlage(template_txt.get("1.0", "end").strip())
tw_status("Arztbrief-Vorlage gespeichert.")
tw.destroy()
ttk.Button(tf, text="Speichern", command=save_template).pack(anchor="w")
def do_vorlage_uebernehmen():
"""Arztbrief zwingend nach der gespeicherten Vorlage umstrukturieren."""
t = text_widget.get("1.0", "end").strip()
if not t:
return
if not self.ensure_ready():
return
vorlage = load_arztbrief_vorlage().strip()
if not vorlage:
messagebox.showinfo("Hinweis", "Bitte zuerst eine Vorlage anlegen („Vorlage anpassen“).")
return
tw_status("Vorlage wird angewendet…")
prompt = f"""Passe den folgenden Arztbrief ZWINGEND der folgenden Vorlage an.
VORLAGE:
{vorlage}
Der Brief muss exakt der Reihenfolge und Struktur der Vorlage entsprechen. Keine Sternchen (*). Diagnosen mit ICD-10-Code beibehalten. Ausgabe: nur der angepasste Arztbrief."""
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.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": t},
],
)
result = resp.choices[0].message.content.strip()
result = re.sub(r"\*+", "", result)
result = re.sub(r"#+", "", result)
self.after(0, lambda: _update_brief(result))
self.after(0, lambda: tw_status("Vorlage angewendet."))
except Exception as e:
self.after(0, lambda: tw_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
def _update_brief(new_text):
text_widget.configure(state="normal")
text_widget.delete("1.0", "end")
text_widget.insert("1.0", new_text)
threading.Thread(target=worker, daemon=True).start()
RoundedButton(
btn_frame_brief, "Vorlage anwenden", command=do_vorlage_uebernehmen,
width=140, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
RoundedButton(
btn_frame_brief, "Vorlage anpassen", command=do_vorlage_anpassen,
width=130, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
def do_email():
t = text_widget.get("1.0", "end").strip()
if not t:
return
try:
import urllib.parse
body = urllib.parse.quote(t.replace("\n", "\r\n"))
import webbrowser
webbrowser.open("mailto:?body=" + body)
tw_status("E-Mail geöffnet.")
except Exception:
tw_status("E-Mail konnte nicht geöffnet werden.")
RoundedButton(
btn_frame, "E-Mail", command=do_email,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left")
elif buttons == "kg":
def do_accept_kg():
t = text_widget.get("1.0", "end").strip()
if not t:
return
cleaned_kg, comments_text = extract_kg_comments(t)
cleaned_kg = strip_kg_warnings(cleaned_kg)
self.txt_output.delete("1.0", "end")
self.txt_output.insert("1.0", cleaned_kg)
self._autocopy_kg(cleaned_kg)
tw_status("KG übernommen.")
win.destroy()
RoundedButton(
btn_frame, "In KG übernehmen", command=do_accept_kg,
width=140, height=28, canvas_bg="#B9ECFA",
).pack(side="left")
elif buttons == "op_bericht":
def do_op_template():
tw = tk.Toplevel(win)
tw.title("OP-Bericht Vorlage / Template")
tw.transient(win)
tw.geometry("660x420")
tw.configure(bg="#B9ECFA")
tf = ttk.Frame(tw, padding=12)
tf.pack(fill="both", expand=True)
ttk.Label(tf, text="Eigene Vorgaben, wie der OP-Bericht aussehen soll (z. B. Struktur, Formulierungen):").pack(anchor="w")
template_txt = ScrolledText(tf, wrap="word", font=self._text_font, bg="#F5FCFF", height=10)
template_txt.pack(fill="both", expand=True, pady=(4, 8))
template_txt.insert("1.0", load_op_bericht_template())
def save_template():
save_op_bericht_template(template_txt.get("1.0", "end").strip())
tw_status("OP-Bericht-Vorlage gespeichert.")
tw.destroy()
ttk.Button(tf, text="Speichern", command=save_template).pack(anchor="w")
RoundedButton(
btn_frame, "Template", command=do_op_template,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(8, 0))
elif buttons == "rezept":
def do_print():
t = text_widget.get("1.0", "end").strip()
if not t:
return
try:
import sys
fd, path = tempfile.mkstemp(suffix=".txt", prefix="rezept_")
os.close(fd)
with open(path, "w", encoding="utf-8") as f:
f.write(t)
if sys.platform == "win32":
try:
os.startfile(path, "print")
except OSError:
os.startfile(path)
tw_status("Druckdialog geöffnet.")
else:
import subprocess
subprocess.run(["xdg-open", path], check=False)
tw_status("Datei geöffnet zum Drucken Strg+P.")
except Exception as e:
messagebox.showerror("Drucken fehlgeschlagen", str(e))
RoundedButton(
btn_frame, "Drucken", command=do_print,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
elif buttons == "kogu":
def do_kogu_template():
tw = tk.Toplevel(win)
tw.title("Kostengutsprache Templates / Vorlage")
tw.transient(win)
tw.geometry("700x470")
tw.configure(bg="#B9ECFA")
tf = ttk.Frame(tw, padding=12)
tf.pack(fill="both", expand=True)
ttk.Label(tf, text="Beschreiben Sie hier, wie Ihre Kostengutsprache aussehen soll (Typ, Struktur, Formulierungen). Die KI liest dies zuerst und erstellt die Kostengutsprache daran orientiert:").pack(anchor="w")
template_txt = ScrolledText(tf, wrap="word", font=self._text_font, bg="#F5FCFF", height=12)
template_txt.pack(fill="both", expand=True, pady=(4, 8))
template_txt.insert("1.0", load_kogu_templates())
self._bind_autotext(template_txt)
def save_template():
save_kogu_templates(template_txt.get("1.0", "end").strip())
tw_status("KOGU-Vorlage gespeichert.")
tw.destroy()
ttk.Button(tf, text="Speichern", command=save_template).pack(anchor="w")
def do_kogu_diktat():
"""Diktiert direkt an die Cursorposition im Kostengutsprache-Text."""
if not self.ensure_ready():
return
rec_win = tk.Toplevel(win)
rec_win.title("Diktat an Cursorposition einfügen")
rec_win.transient(win)
rec_win.geometry("420x150")
rec_win.configure(bg="#B9ECFA")
rf = ttk.Frame(rec_win, padding=16)
rf.pack(fill="both", expand=True)
status_var = tk.StringVar(value="Bereit. Setzen Sie den Cursor in die Kostengutsprache.")
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)
tw_status("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()
# Diktat zuvorderst, gleiche runde Form wie die anderen (100x28)
RoundedButton(
btn_frame, "Diktat", command=do_kogu_diktat,
width=100, height=28, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0",
).pack(side="left", padx=(0, 8))
RoundedButton(
btn_frame, "Templates", command=do_kogu_template,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
def do_print_kogu():
t = text_widget.get("1.0", "end").strip()
if not t:
return
try:
import sys
fd, path = tempfile.mkstemp(suffix=".txt", prefix="kogu_")
os.close(fd)
with open(path, "w", encoding="utf-8") as f:
f.write(t)
if sys.platform == "win32":
try:
os.startfile(path, "print")
except OSError:
os.startfile(path)
tw_status("Druckdialog geöffnet.")
else:
import subprocess
subprocess.run(["xdg-open", path], check=False)
tw_status("Datei geöffnet zum Drucken Strg+P.")
except Exception as e:
messagebox.showerror("Drucken fehlgeschlagen", str(e))
RoundedButton(
btn_frame, "Drucken", command=do_print_kogu,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
def _request_async_document(self, system_prompt: str, user_text: str, status_msg: str, on_success) -> None:
"""Hilfsfunktion: ruft das Modell asynchron auf und zeigt bei Erfolg das Ergebnis."""
if not self.ensure_ready():
return
prev_status = self.status_var.get()
if status_msg:
self.set_status(status_msg)
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.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
)
result = resp.choices[0].message.content.strip()
self.after(0, lambda: on_success(result))
self.after(0, lambda: self.set_status(prev_status))
except Exception as e:
self.after(0, lambda: self.set_status("Fehler."))
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
threading.Thread(target=worker, daemon=True).start()
def open_brief_window(self):
kg_text = self.txt_output.get("1.0", "end").strip()
transcript = self.txt_transcript.get("1.0", "end").strip()
if not kg_text and not transcript:
messagebox.showinfo(
"Hinweis",
"Es liegt weder eine Krankengeschichte noch ein Transkript vor.",
)
return
user_text = (
"KRANKENGESCHICHTE (falls leer -> keine Daten):\n"
f"{kg_text or '(keine KG-Daten)'}\n\n"
"TRANSKRIPT (falls leer -> keine Daten):\n"
f"{transcript or '(kein Transkript)'}"
)
def on_success(result: str):
text = (result or "").strip()
self._last_brief_text = text
if text:
try:
save_to_ablage("Briefe", text)
self.set_status("Brief erstellt und automatisch gespeichert.")
except Exception:
pass
self._show_text_window("Arztbrief", result, buttons="brief")
self._request_async_document(
LETTER_PROMPT,
user_text,
"Erstelle Brief…",
on_success,
)
def open_rezept_window(self):
kg_text = self.txt_output.get("1.0", "end").strip()
transcript = self.txt_transcript.get("1.0", "end").strip()
comments = ""
if not kg_text and not transcript and not comments:
messagebox.showinfo(
"Hinweis",
"Keine Informationen vorhanden, um ein Rezept zu erstellen.",
)
return
user_text = (
"KRANKENGESCHICHTE (falls leer -> keine Daten):\n"
f"{kg_text or '(keine KG-Daten)'}\n\n"
"TRANSKRIPT (falls leer -> keine Daten):\n"
f"{transcript or '(kein Transkript)'}\n\n"
"VORSICHT / WARNZEICHEN:\n"
f"{comments or '(keine)'}"
)
def on_success(result: str):
raw = (result or "").strip()
self._last_rezept_text = raw
if raw:
try:
date_str = datetime.now().strftime("%d.%m.%Y")
sig_name = load_signature_name()
parts = ["Rezept", "", f"Datum: {date_str}", "", raw, "", f"Unterschrift: {sig_name or ''}"]
full = "\n".join(parts).rstrip()
save_to_ablage("Rezepte", full)
self.set_status("Rezept erstellt und automatisch gespeichert.")
except Exception:
pass
self._show_text_window("Rezept / Therapie", result, buttons="rezept")
self._request_async_document(
RECIPE_PROMPT,
user_text,
"Erstelle Rezept…",
on_success,
)
def open_kogu_window(self):
transcript = self.txt_transcript.get("1.0", "end").strip()
if not transcript:
messagebox.showinfo(
"Hinweis",
"Bitte zuerst ein Transkript aufnehmen oder Text eingeben.",
)
return
template = load_kogu_templates().strip()
system_prompt = KOGU_PROMPT
if template:
system_prompt = system_prompt + "\n\nZusätzliche Vorgaben des Arztes (Template bitte beachten):\n" + template
def on_success(result: str):
main = (result or "").strip()
self._last_kogu_text = main
if main:
try:
gruss = load_kogu_gruss()
sig_name = load_signature_name()
parts = [main]
if gruss:
parts.append("")
parts.append(gruss)
if sig_name:
parts.append(sig_name)
full = "\n".join(parts).rstrip()
save_to_ablage("Kostengutsprachen", full)
self.set_status("Kostengutsprache erstellt und automatisch gespeichert.")
except Exception:
pass
self._show_text_window("Kostengutsprache", result, buttons="kogu")
self._request_async_document(
system_prompt,
transcript,
"Erstelle Kostengutsprache…",
on_success,
)
def open_diskussion_window(self):
"""Fenster: Diskussion mit KI Wahl zwischen alles oder nur Medizin, Vorlage (wie KI diskutiert), Chat."""
if not self.ensure_ready():
return
win = tk.Toplevel(self)
win.title("Diskussion mit KI")
win.transient(self)
DISKUSSION_MIN_W, DISKUSSION_MIN_H = 750, 650
win.minsize(DISKUSSION_MIN_W, DISKUSSION_MIN_H)
saved_geom = load_diskussion_geometry()
if saved_geom:
try:
win.geometry(_clamp_geometry_str(saved_geom, DISKUSSION_MIN_W, DISKUSSION_MIN_H))
except Exception:
win.geometry("800x730")
else:
win.geometry("640x560")
win.configure(bg="#E8F4F8")
_diskussion_geometry_after_id = [None] # [id] für Debounce
def on_diskussion_close():
try:
if _diskussion_geometry_after_id[0] is not None:
win.after_cancel(_diskussion_geometry_after_id[0])
save_diskussion_geometry(win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", on_diskussion_close)
def _schedule_diskussion_geometry_save():
try:
save_diskussion_geometry(win.geometry())
except Exception:
pass
_diskussion_geometry_after_id[0] = None
def on_diskussion_configure(_event):
if _diskussion_geometry_after_id[0] is not None:
win.after_cancel(_diskussion_geometry_after_id[0])
_diskussion_geometry_after_id[0] = win.after(400, _schedule_diskussion_geometry_save)
win.bind("<Configure>", on_diskussion_configure)
add_resize_grip(win, DISKUSSION_MIN_W, DISKUSSION_MIN_H)
add_font_scale_control(win)
top_row = ttk.Frame(win, padding=(12, 10))
top_row.pack(fill="x")
ttk.Label(top_row, text="Thema:").pack(side="left", padx=(0, 8))
scope_var = tk.StringVar(value="all")
ttk.Radiobutton(
top_row, text="Über alles diskutieren", variable=scope_var, value="all",
).pack(side="left", padx=(0, 16))
ttk.Radiobutton(
top_row, text="Nur medizinischer Bereich", variable=scope_var, value="medical",
).pack(side="left", padx=(0, 12))
def open_vorlage_dialog():
tw = tk.Toplevel(win)
tw.title("Vorlage wie die KI mit Ihnen diskutiert")
tw.transient(win)
tw.geometry("700x420")
tw.configure(bg="#E8F4F8")
ttk.Label(tw, text="Diese Vorlage legt verbindlich fest, wie die KI mit Ihnen diskutiert (Ton, Stil, Regeln). Die KI hält sich daran.").pack(anchor="w", padx=12, pady=(12, 4))
tf = ttk.Frame(tw, padding=12)
tf.pack(fill="both", expand=True)
vorlage_txt = ScrolledText(tf, wrap="word", font=self._text_font, height=10, bg="#F5FCFF")
vorlage_txt.pack(fill="both", expand=True)
vorlage_txt.insert("1.0", load_diskussion_vorlage())
self._bind_autotext(vorlage_txt)
btn_f = ttk.Frame(tw, padding=(12, 8))
btn_f.pack(fill="x")
def save_and_close():
save_diskussion_vorlage(vorlage_txt.get("1.0", "end").strip())
tw.destroy()
ttk.Button(btn_f, text="Speichern und schließen", command=save_and_close).pack(side="left", padx=(0, 8))
ttk.Button(btn_f, text="Abbrechen", command=tw.destroy).pack(side="left")
ttk.Button(top_row, text="Vorlage", command=open_vorlage_dialog).pack(side="left", padx=(8, 0))
chat_frame = ttk.Frame(win, padding=(12, 4))
chat_frame.pack(fill="both", expand=True)
chat_display = ScrolledText(
chat_frame, wrap="word", font=self._text_font, bg="#F5FCFF", state="disabled", height=18,
)
chat_display.tag_configure("disk_bg_white", background="#FFFFFF")
chat_display.tag_configure("disk_bg_blue", background="#E0F2F7")
disk_insert_after_user = [None] # Einfügeposition für nächste KI-Antwort (direkt unter der Frage)
chat_display.pack(fill="both", expand=True)
self._bind_kg_section_copy(chat_display)
input_row = ttk.Frame(win, padding=(12, 8))
input_row.pack(fill="x")
input_txt = tk.Text(input_row, wrap="word", font=self._text_font, height=3, bg="#F5FCFF")
input_txt.pack(fill="x", pady=(0, 6))
status_disk = tk.StringVar(value="Bereit. Nachricht eingeben und Senden klicken.")
ttk.Label(input_row, textvariable=status_disk).pack(anchor="w")
btn_row_disk = ttk.Frame(input_row)
btn_row_disk.pack(fill="x", pady=(4, 0))
# Diktat zuvorderst (links), gleiche runde Form wie im Hauptprogramm (100x28)
RoundedButton(
btn_row_disk, "Diktat", command=lambda: self._diktat_into_widget(win, input_txt, status_disk.set),
width=100, height=28, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0",
).pack(side="left", padx=(0, 8))
btn_send = ttk.Button(btn_row_disk, text="Senden", command=lambda: None)
btn_send.pack(side="left", padx=(0, 8), pady=(4, 0))
messages = []
current_diskussion_path = [None] # bei Laden oder erstem Speichern gesetzt → weitere Speicherungen aktualisieren diese Datei
current_diskussion_titel = [None]
def get_chat_text_from_messages():
"""Erzeugt aus messages den sichtbaren Chat-Text (Sie: / KI:)."""
parts = []
for m in messages:
role = m.get("role")
content = (m.get("content") or "").strip()
if role == "user":
parts.append("Sie: " + content)
elif role == "assistant":
parts.append("KI: " + content)
return "\n\n".join(parts) if parts else ""
def build_system_content():
scope = scope_var.get()
if scope == "medical":
scope_text = "Du diskutierst ausschließlich über medizinische Themen. Bei anderen Themen weise höflich darauf hin und bleibe beim Medizinischen."
else:
scope_text = "Du diskutierst mit dem Nutzer über alles Denkbare sachlich, respektvoll und auf Augenhöhe."
scope_text += "\n\nAntworte in klarem Fließtext ohne Markdown: keine #, keine Sterne (*), keine Unterstriche für Überschriften oder Hervorhebungen. Kurze Absätze, übersichtlich."
vorlage = load_diskussion_vorlage().strip()
if vorlage:
return scope_text + "\n\nVorlage (verbindlich so sollst du mit dem Nutzer diskutieren):\n" + vorlage
return scope_text
def append_to_display(role: str, text: str):
"""Neuestes zuoberst: Frage (weiß), darunter KI-Antwort (blau). Ältere Paare darunter, abwechselnd weiss/blau."""
chat_display.configure(state="normal")
prefix = "Sie: " if role == "user" else "KI: "
block = prefix + (text or "").strip() + "\n\n"
tag = "disk_bg_white" if role == "user" else "disk_bg_blue"
if role == "user":
insert_pos = "1.0"
else:
insert_pos = disk_insert_after_user[0] if disk_insert_after_user[0] else "1.0"
start_idx = insert_pos
chat_display.insert(insert_pos, block)
end_idx = chat_display.index(f"{insert_pos}+{len(block)}c")
chat_display.tag_add(tag, start_idx, end_idx)
if role == "user":
disk_insert_after_user[0] = end_idx
chat_display.yview_moveto(0.0)
chat_display.configure(state="disabled")
def send_message():
user_text = input_txt.get("1.0", "end").strip()
if not user_text:
return
input_txt.delete("1.0", "end")
append_to_display("user", user_text)
status_disk.set("KI antwortet…")
btn_send.configure(state="disabled")
if not messages:
messages.append({"role": "system", "content": build_system_content()})
messages.append({"role": "user", "content": user_text})
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.client.chat.completions.create(
model=model,
messages=messages,
)
reply = (resp.choices[0].message.content or "").strip()
self.after(0, lambda: _on_reply(reply))
except Exception as e:
self.after(0, lambda: _on_error(str(e)))
def _clean_diskussion_reply(text: str) -> str:
"""Entfernt Markdown und unnötige Zeichen für übersichtliche Anzeige."""
if not text:
return ""
t = text
t = re.sub(r"#+", "", t) # alle # (Überschriften-Markdown)
t = re.sub(r"\*+", "", t)
t = re.sub(r"_{2,}", "", t)
t = re.sub(r"`+", "", t) # Backticks
t = re.sub(r"-{3,}", " ", t)
t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t) # [Text](url) → Text
t = re.sub(r"\n{3,}", "\n\n", t)
t = re.sub(r"[ \t]+", " ", t) # mehrere Leerzeichen/Tabs → eines
t = re.sub(r" *\n *", "\n", t) # Leerzeichen um Zeilenbruch
return t.strip()
def _on_reply(reply: str):
reply_clean = _clean_diskussion_reply(reply or "")
messages.append({"role": "assistant", "content": reply_clean})
append_to_display("assistant", reply_clean)
status_disk.set("Bereit.")
btn_send.configure(state="normal")
def _on_error(err: str):
append_to_display("assistant", "[Fehler: " + err + "]")
status_disk.set("Fehler.")
btn_send.configure(state="normal")
threading.Thread(target=worker, daemon=True).start()
def do_diskussion_speichern():
"""Diskussion als JSON speichern. Wenn geladen oder schon gespeichert: gleiche Datei aktualisieren, sonst neue Datei."""
chat_text = get_chat_text_from_messages()
if not chat_text.strip():
messagebox.showinfo("Diskussion speichern", "Keine Diskussion zum Speichern.")
return
status_disk.set("Speichere Diskussion…")
def worker():
try:
now = datetime.now()
datum = now.strftime("%d.%m.%Y")
uhrzeit = now.strftime("%H:%M")
path = current_diskussion_path[0]
titel = current_diskussion_titel[0]
if path and titel:
# Weitergeführtes Gespräch: bestehende Datei aktualisieren
data = {
"titel": titel,
"datum": datum,
"uhrzeit": uhrzeit,
"chat": chat_text,
"messages": messages.copy(),
}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
self.after(0, lambda: status_disk.set(f"Diskussion aktualisiert: {titel}"))
return
# Neue Diskussion: KI für Überschrift, neue Datei
r = self.client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Gib nur eine kurze deutsche Überschrift (38 Wörter) für diese Diskussion. Keine Anführungszeichen, nur die Überschrift, eine Zeile."},
{"role": "user", "content": chat_text[:3000]},
],
)
titel = (r.choices[0].message.content or "Diskussion").strip().strip('"\'')
if not titel:
titel = "Diskussion"
base_dir = os.path.dirname(os.path.abspath(__file__))
disk_dir = os.path.join(base_dir, "Lernmodus_Export", "Gespeicherte_Diskussionen")
os.makedirs(disk_dir, exist_ok=True)
safe_titel = "".join(c for c in titel[:50] if c.isalnum() or c in " _-") or "Diskussion"
fname = f"Diskussion_{now.strftime('%Y-%m-%d_%H-%M')}_{safe_titel}.json"
path = os.path.join(disk_dir, fname)
data = {
"titel": titel,
"datum": datum,
"uhrzeit": uhrzeit,
"chat": chat_text,
"messages": messages.copy(),
}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
current_diskussion_path[0] = path
current_diskussion_titel[0] = titel
self.after(0, lambda: status_disk.set(f"Diskussion gespeichert: {titel}"))
except Exception as e:
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
self.after(0, lambda: status_disk.set("Fehler beim Speichern."))
threading.Thread(target=worker, daemon=True).start()
def do_diskussion_laden():
"""Diskussion aus JSON-Datei laden."""
from tkinter import filedialog
base_dir = os.path.dirname(os.path.abspath(__file__))
disk_dir = os.path.join(base_dir, "Lernmodus_Export", "Gespeicherte_Diskussionen")
if not os.path.isdir(disk_dir):
os.makedirs(disk_dir, exist_ok=True)
path = filedialog.askopenfilename(
title="Diskussion laden",
initialdir=disk_dir,
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
chat_text = data.get("chat", "")
loaded_messages = data.get("messages", [])
messages.clear()
messages.extend(loaded_messages)
disk_insert_after_user[0] = None
chat_display.configure(state="normal")
chat_display.delete("1.0", "end")
pairs = []
last_u = None
for m in loaded_messages:
r, c = m.get("role"), (m.get("content") or "").strip()
if r == "user":
last_u = c
elif r == "assistant" and last_u is not None:
pairs.append((last_u, c))
last_u = None
for u, a in pairs:
append_to_display("user", u)
append_to_display("assistant", a)
chat_display.configure(state="disabled")
titel = data.get("titel", "")
datum = data.get("datum", "")
uhrzeit = data.get("uhrzeit", "")
current_diskussion_path[0] = path
current_diskussion_titel[0] = titel
status_disk.set(f"Geladen: {titel} ({datum} {uhrzeit})")
except Exception as e:
messagebox.showerror("Diskussion laden", str(e))
def do_diskussion_verlauf_loeschen():
"""Aktuellen Verlauf im Fenster leeren (Anzeige und Nachrichtenliste)."""
messages.clear()
disk_insert_after_user[0] = None
current_diskussion_path[0] = None
current_diskussion_titel[0] = None
chat_display.configure(state="normal")
chat_display.delete("1.0", "end")
chat_display.configure(state="disabled")
status_disk.set("Verlauf gelöscht.")
def do_diskussion_datei_loeschen():
"""Eine gespeicherte Diskussion (JSON-Datei) auswählen und endgültig löschen."""
from tkinter import filedialog
base_dir = os.path.dirname(os.path.abspath(__file__))
disk_dir = os.path.join(base_dir, "Lernmodus_Export", "Gespeicherte_Diskussionen")
if not os.path.isdir(disk_dir):
messagebox.showinfo("Diskussion löschen", "Kein Ordner mit gespeicherten Diskussionen.")
return
path = filedialog.askopenfilename(
title="Gespeicherte Diskussion zum Löschen wählen",
initialdir=disk_dir,
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
)
if not path:
return
if not messagebox.askyesno("Diskussion löschen", f"Diese Datei wirklich endgültig löschen?\n{os.path.basename(path)}"):
return
try:
os.remove(path)
status_disk.set("Diskussion gelöscht.")
except Exception as e:
messagebox.showerror("Fehler", str(e))
ttk.Button(btn_row_disk, text="Gespräch speichern", command=do_diskussion_speichern).pack(side="left", padx=(0, 8), pady=(4, 0))
ttk.Button(btn_row_disk, text="Gespräch laden", command=do_diskussion_laden).pack(side="left", padx=(0, 8), pady=(4, 0))
ttk.Button(btn_row_disk, text="Verlauf löschen", command=do_diskussion_verlauf_loeschen).pack(side="left", padx=(0, 8), pady=(4, 0))
ttk.Button(btn_row_disk, text="Diskussion löschen", command=do_diskussion_datei_loeschen).pack(side="left", pady=(4, 0))
btn_send.configure(command=send_message)
def on_diskussion_return(event):
"""Enter = Senden, Shift+Enter = neue Zeile."""
if event.state & 0x1: # Shift gedrückt → Zeilenumbruch wie üblich
return
send_message()
return "break"
input_txt.bind("<Return>", on_diskussion_return)
def open_op_bericht_window(self):
transcript = self.txt_transcript.get("1.0", "end").strip()
if not transcript:
messagebox.showinfo(
"Hinweis",
"Bitte zuerst ein Transkript aufnehmen oder Text eingeben.",
)
return
template = load_op_bericht_template().strip()
system_prompt = OP_BERICHT_PROMPT
if template:
system_prompt = system_prompt + "\n\nZusätzliche Vorgaben des Nutzers (Template):\n" + template
def on_success(result: str):
text = (result or "").strip()
text = re.sub(r"\*+", "", text)
text = re.sub(r"#+", "", text)
self._show_text_window("OP-Bericht", text, buttons="op_bericht")
self._request_async_document(
system_prompt,
transcript,
"Erstelle OP-Bericht…",
on_success,
)
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_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_diktat_window(self):
"""Unabhängiges Fenster: nur Diktat aufnehmen und transkribieren (keine KG). Text wird automatisch kopiert."""
if not self.ensure_ready():
return
DIKTAT_MIN_W, DIKTAT_MIN_H = 360, 330
win = tk.Toplevel(self)
win.title("Diktat nur Transkription")
win.transient(self)
win.minsize(DIKTAT_MIN_W, DIKTAT_MIN_H)
saved_geom = load_diktat_geometry()
if saved_geom:
try:
win.geometry(_clamp_geometry_str(saved_geom, DIKTAT_MIN_W, DIKTAT_MIN_H))
except Exception:
win.geometry("380x360")
else:
win.geometry("300x290")
win.configure(bg="#B9ECFA")
def on_diktat_close():
try:
save_diktat_geometry(win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", on_diktat_close)
add_resize_grip(win, DIKTAT_MIN_W, DIKTAT_MIN_H)
add_font_scale_control(win)
main_f = ttk.Frame(win, padding=12)
main_f.pack(fill="both", expand=True)
ttk.Label(main_f, text="Diktat (nur Transkription):").pack(anchor="w")
txt = ScrolledText(main_f, wrap="word", font=self._text_font, bg="#F5FCFF", height=8)
txt.pack(fill="both", expand=True, pady=(4, 4))
self._bind_textblock_pending(txt)
status_var = tk.StringVar(value="Bereit.")
status_bar = tk.Frame(main_f, bg="#FFE4CC", height=24, padx=8, pady=4)
status_bar.pack(fill="x", pady=(4, 0))
status_bar.pack_propagate(False)
lbl_status = tk.Label(
status_bar, textvariable=status_var, fg="#BD4500", bg="#FFE4CC",
font=self._text_font, anchor="w",
)
lbl_status.pack(side="left", fill="x", expand=True)
btn_row = ttk.Frame(main_f, padding=(0, 4, 0, 0))
btn_row.pack(fill="x")
btn_row2 = ttk.Frame(main_f, padding=(0, 2, 0, 0))
btn_row2.pack(fill="x")
diktat_recorder = [None]
is_recording = [False]
def toggle_diktat():
if not diktat_recorder[0]:
diktat_recorder[0] = AudioRecorder()
rec = diktat_recorder[0]
if not is_recording[0]:
try:
rec.start()
is_recording[0] = True
btn_diktat_record.configure(text="⏹ Aufnahme stoppen")
status_var.set("Aufnahme läuft…")
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
status_var.set("Bereit.")
else:
is_recording[0] = False
btn_diktat_record.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: _done(transcript_text))
except Exception as e:
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
self.after(0, lambda: status_var.set("Fehler."))
def _done(text):
diktat_recorder[0] = None
txt.configure(state="normal")
if text:
idx = txt.index(tk.INSERT)
txt.insert(idx, text)
full = txt.get("1.0", "end")
if not _win_clipboard_set(full):
self.clipboard_clear()
self.clipboard_append(full)
self.set_status("Diktat transkribiert und kopiert.")
status_var.set("Fertig. Text wurde ins Zwischenablage kopiert.")
threading.Thread(target=worker, daemon=True).start()
def do_neu():
txt.configure(state="normal")
txt.delete("1.0", "end")
status_var.set("Bereit.")
if is_recording[0]:
rec = diktat_recorder[0]
is_recording[0] = False
btn_diktat_record.configure(text="⏺ Aufnahme starten")
try:
wav_path = rec.stop_and_save_wav()
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
diktat_recorder[0] = None
toggle_diktat()
def do_kopieren():
t = txt.get("1.0", "end").strip()
if t:
if not _win_clipboard_set(t):
self.clipboard_clear()
self.clipboard_append(t)
self.set_status("Diktat kopiert.")
else:
self.set_status("Nichts zum Kopieren.")
btn_diktat_record = RoundedButton(
btn_row, "⏺ Aufnahme starten", command=toggle_diktat,
width=160, height=26, canvas_bg="#B9ECFA",
)
btn_diktat_record.pack(side="left")
RoundedButton(
btn_row, "Neu", command=do_neu,
width=70, height=26, canvas_bg="#B9ECFA",
).pack(side="left", padx=(6, 0))
RoundedButton(
btn_row2, "Kopieren", command=do_kopieren,
width=100, height=26, canvas_bg="#B9ECFA",
).pack(side="left")
if self._autotext_data.get("diktat_auto_start", True):
win.after(350, toggle_diktat)
def open_ordner_window(self):
"""Fenster für Ablage: KG, Briefe, Rezepte, Kostengutsprachen in Unterordnern; Export/Import. Bleibt sichtbar bis es geschlossen wird."""
ensure_ablage_dirs()
base_path = _ablage_base_path()
ORDNER_MIN_W, ORDNER_MIN_H = 650, 520
win = tk.Toplevel(self)
win.title("Ordner Ablage & Export/Import")
win.minsize(ORDNER_MIN_W, ORDNER_MIN_H)
saved_geom = load_ordner_geometry()
if saved_geom:
try:
win.geometry(_clamp_geometry_str(saved_geom, ORDNER_MIN_W, ORDNER_MIN_H))
except Exception:
win.geometry("800x650")
else:
win.geometry("640x500")
win.configure(bg="#B9ECFA")
def save_ordner_geom():
try:
save_ordner_geometry(win.geometry())
except Exception:
pass
_ordner_geom_after_id = [None]
def on_ordner_configure(e):
if e.widget is win and _ordner_geom_after_id[0]:
self.after_cancel(_ordner_geom_after_id[0])
if e.widget is win:
_ordner_geom_after_id[0] = self.after(400, save_ordner_geom)
win.bind("<Configure>", on_ordner_configure)
def on_ordner_close():
try:
save_ordner_geometry(win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", on_ordner_close)
add_resize_grip(win, ORDNER_MIN_W, ORDNER_MIN_H)
add_font_scale_control(win)
main_f = ttk.Frame(win, padding=12)
main_f.pack(fill="both", expand=True)
ttk.Label(main_f, text=f"Ablage: {base_path}").pack(anchor="w")
nb = ttk.Notebook(main_f)
nb.pack(fill="both", expand=True, pady=(8, 8))
def refresh_list(listbox, category):
listbox.delete(0, "end")
for f in list_ablage_files(category):
# Anzeigen ohne .txt, damit Einträge nicht als "txt-Datei" markiert wirken
display_name = f[:-4] if (f and str(f).endswith(".txt")) else f
listbox.insert("end", display_name)
def save_current(category):
if category == "KG":
content = self.txt_output.get("1.0", "end").strip()
if not content:
messagebox.showinfo("Hinweis", "Keine Krankengeschichte zum Speichern.")
return
elif category == "Briefe":
content = self._last_brief_text
if not content:
messagebox.showinfo("Hinweis", "Zuerst einen Brief erstellen (Button Brief).")
return
elif category == "Rezepte":
content = self._last_rezept_text
if not content:
messagebox.showinfo("Hinweis", "Zuerst ein Rezept erstellen (Button Rezept).")
return
elif category == "Kostengutsprachen":
content = self._last_kogu_text
if not content:
messagebox.showinfo("Hinweis", "Zuerst eine Kostengutsprache erstellen (Button KOGU).")
return
else:
return
try:
path = save_to_ablage(category, content)
if path:
messagebox.showinfo("Gespeichert", f"Gespeichert unter:\n{path}")
for lb in listboxes:
refresh_list(lb["listbox"], lb["category"])
else:
messagebox.showwarning("Hinweis", "Nichts gespeichert (Inhalt war leer).")
except Exception as e:
messagebox.showerror("Fehler", str(e))
def load_file_into_app(category, filename):
content = get_ablage_content(category, filename)
if not content:
messagebox.showinfo("Hinweis", "Datei ist leer oder nicht gefunden.")
return
# Immer in neuem Fenster öffnen; Ordner-Fenster bleibt offen
if category == "KG":
self._show_text_window("KG (geladen)", content, buttons="kg")
self.set_status("KG in neuem Fenster geöffnet.")
elif category == "Briefe":
self._last_brief_text = content
self._show_text_window("Brief (geladen)", content, buttons="brief")
self.set_status("Brief in neuem Fenster geöffnet.")
elif category == "Rezepte":
self._last_rezept_text = content
self._show_text_window("Rezept (geladen)", content, buttons="rezept")
self.set_status("Rezept in neuem Fenster geöffnet.")
elif category == "Kostengutsprachen":
self._last_kogu_text = content
self._show_text_window("KOGU (geladen)", content, buttons="kogu")
self.set_status("KOGU in neuem Fenster geöffnet.")
listboxes = []
for cat in ABLAGE_SUBFOLDERS:
frame = ttk.Frame(nb, padding=4)
nb.add(frame, text=cat)
ttk.Label(frame, text=f"Aktuelles als neue Datei speichern (Nummer + Datum/Uhrzeit):").pack(anchor="w")
btn_row = ttk.Frame(frame)
btn_row.pack(fill="x", pady=(0, 4))
RoundedButton(
btn_row, "Aktuelles speichern", command=lambda c=cat: save_current(c),
width=140, height=26, canvas_bg="#B9ECFA",
).pack(side="left")
ttk.Label(frame, text="Gespeicherte Dateien:").pack(anchor="w", pady=(4, 0))
lb = tk.Listbox(frame, height=10, font=("Segoe UI", 10))
lb.pack(fill="both", expand=True, pady=(2, 4))
refresh_list(lb, cat)
listboxes.append({"listbox": lb, "category": cat})
def on_select(evt, category=cat, listbox=lb):
sel = listbox.curselection()
if not sel:
return
idx = sel[0]
files = list_ablage_files(category)
if 0 <= idx < len(files):
load_file_into_app(category, files[idx])
lb.bind("<Double-Button-1>", on_select)
def load_selected(lbx=lb, c=cat):
sel = lbx.curselection()
if not sel:
messagebox.showinfo("Hinweis", "Bitte eine Datei auswählen.")
return
files = list_ablage_files(c)
if 0 <= sel[0] < len(files):
load_file_into_app(c, files[sel[0]])
RoundedButton(
frame, "Ausgewählte Datei in App laden", command=load_selected,
width=220, height=26, canvas_bg="#B9ECFA",
).pack(fill="x", pady=(0, 4))
btn_bottom = ttk.Frame(main_f)
btn_bottom.pack(fill="x", pady=(8, 0))
def do_export():
from tkinter import filedialog
import zipfile
dest = filedialog.asksaveasfilename(
title="Ablage exportieren (ZIP)",
defaultextension=".zip",
filetypes=[("ZIP", "*.zip"), ("Alle", "*.*")],
)
if not dest:
return
try:
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(base_path):
for f in files:
if not f.endswith(".txt"):
continue
path = os.path.join(root, f)
arcname = os.path.relpath(path, base_path)
zf.write(path, arcname)
messagebox.showinfo("Export", f"Exportiert nach:\n{dest}")
except Exception as e:
messagebox.showerror("Export fehlgeschlagen", str(e))
def do_import():
from tkinter import filedialog
import zipfile
src = filedialog.askopenfilename(
title="ZIP importieren (Inhalt in Ablage entpacken)",
filetypes=[("ZIP", "*.zip"), ("Alle", "*.*")],
)
if not src:
return
try:
with zipfile.ZipFile(src, "r") as zf:
zf.extractall(base_path)
messagebox.showinfo("Import", "Import abgeschlossen.")
for lb in listboxes:
refresh_list(lb["listbox"], lb["category"])
except Exception as e:
messagebox.showerror("Import fehlgeschlagen", str(e))
RoundedButton(btn_bottom, "Export (ZIP)", command=do_export, width=120, height=26, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
RoundedButton(btn_bottom, "Import (ZIP)", command=do_import, width=120, height=26, canvas_bg="#B9ECFA").pack(side="left")
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.client.chat.completions.create(
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.geometry("700x600")
win.configure(bg="#B9ECFA")
win.minsize(550, 450)
add_resize_grip(win, 550, 450)
add_font_scale_control(win)
f = ttk.Frame(win, padding=12)
f.pack(fill="both", expand=True)
ttk.Label(f, text="KI-Kontrolle (Logik, Diagnose/Therapie-Passung):").pack(anchor="w")
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=16)
txt.pack(fill="both", expand=True, pady=(8, 8))
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.client.chat.completions.create(
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)
default_pruefen_w, default_pruefen_h = 480, 550
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)}")
else:
win.geometry(f"{default_pruefen_w}x{default_pruefen_h}")
win.minsize(450, 500)
win.configure(bg="#B9ECFA")
def save_pruefen_size():
try:
w, h = win.winfo_width(), win.winfo_height()
x, y = win.winfo_x(), win.winfo_y()
if w > 200 and h > 150:
save_pruefen_geometry(w, h, x, y)
except Exception:
pass
win.bind("<Configure>", lambda e: win.after(500, save_pruefen_size) if e.widget is win else None)
add_resize_grip(win, 450, 500)
add_font_scale_control(win)
main_f = ttk.Frame(win, padding=12)
main_f.pack(fill="both", expand=True)
full_corrected = [corrected]
ttk.Label(main_f, text="Geprüfter Text (Diagnosen, Procedere, Therapie):").pack(anchor="w")
txt = ScrolledText(main_f, wrap="word", font=self._text_font, bg="#F5FCFF", height=6)
txt.pack(fill="both", expand=True, pady=(0, 8))
txt.insert("1.0", display_text)
self._bind_text_context_menu(txt)
listbox_corrections = []
def refresh_all_list():
k = load_korrekturen()
listbox.delete(0, "end")
listbox_corrections.clear()
for cat in ("medikamente", "diagnosen"):
for f, r in (k.get(cat) or {}).items():
listbox.insert("end", f"«{f}» → «{r}» ({cat})")
listbox_corrections.append((f, r, cat))
def refresh_display():
new_text, _ = apply_korrekturen(raw, load_korrekturen())
full_corrected[0] = new_text
disp = extract_diagnosen_therapie_procedere(new_text)
txt.delete("1.0", "end")
txt.insert("1.0", disp)
refresh_all_list()
ttk.Label(main_f, text="Alle gespeicherten Korrekturen (werden automatisch bei neuer KG angewendet):").pack(anchor="w", pady=(4, 0))
list_f = ttk.Frame(main_f)
list_f.pack(fill="x", pady=(0, 8))
listbox = tk.Listbox(list_f, height=4, font=("Segoe UI", 10))
listbox.pack(side="left", fill="both", expand=True)
refresh_all_list()
editing_entry = [None]
def on_listbox_double_click(evt):
sel = listbox.curselection()
if not sel or sel[0] >= len(listbox_corrections):
return
f, r, cat = listbox_corrections[sel[0]]
wrong_inline.set(f)
right_inline.set(r)
editing_entry[0] = (f, cat)
listbox.bind("<Double-Button-1>", on_listbox_double_click)
btn_row = ttk.Frame(main_f)
btn_row.pack(fill="x", pady=8)
def do_export():
from tkinter import filedialog
dest = filedialog.asksaveasfilename(
title="Korrekturen exportieren",
defaultextension=".json",
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
)
if not dest:
return
try:
with open(dest, "w", encoding="utf-8") as f:
json.dump(load_korrekturen(), f, ensure_ascii=False, indent=2)
self.set_status(f"Exportiert: {dest}")
messagebox.showinfo("Export", f"Korrekturen exportiert nach:\n{dest}")
except Exception as e:
messagebox.showerror("Export fehlgeschlagen", str(e))
def do_import():
from tkinter import filedialog
p = filedialog.askopenfilename(
title="Korrekturen importieren",
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
)
if not p:
return
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
existing = load_korrekturen()
for cat, mapping in data.items():
if isinstance(mapping, dict) and cat in existing:
existing[cat].update(mapping)
elif isinstance(mapping, dict):
existing[cat] = mapping
save_korrekturen(existing)
refresh_display()
self.set_status("Importiert.")
messagebox.showinfo("Import", "Korrekturen importiert.")
else:
messagebox.showerror("Import", "Ungültiges Format.")
except Exception as e:
messagebox.showerror("Import fehlgeschlagen", str(e))
def extract_meds_from_text(text: str):
meds = []
stopwords = ("dass", "diese", "oder", "und", "eine", "der", "die", "das", "therapie", "procedere", "diagnose", "keine", "sowie")
for m in re.findall(r"[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9\-]{3,}", text):
w = m.strip()
if w.lower() not in stopwords and not w.isdigit():
meds.append(w)
return list(dict.fromkeys(meds))[:12]
def do_interaktion():
text = full_corrected[0]
meds = extract_meds_from_text(text)
if not meds:
messagebox.showinfo("Interaktionscheck", "Keine Medikamentennamen erkannt. Bitte manuell prüfen.")
return
if not self.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.client.chat.completions.create(
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.")
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["medikamente"][w] = r
save_korrekturen(k)
refresh_display()
t = full_corrected[0]
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("Korrektur gespeichert und 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):
self.txt_transcript.delete("1.0", "end")
self.txt_transcript.insert("1.0", transcript)
def _fill_kg_and_finish(self, kg: str):
self._stop_timer()
self.set_status("Fertig.")
cleaned_kg, comments_text = extract_kg_comments(kg)
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")
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:
# Start recording
try:
self.recorder.start()
self.is_recording = True
self.btn_record.configure(text="⏹ Aufnahme stoppen")
self.set_status("Aufnahme läuft… (sprich jetzt)")
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
self.set_status("Bereit.")
else:
# Stop -> transcribe -> summarize
self.is_recording = False
self.btn_record.configure(text="⏺ Aufnahme starten")
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(prev_txt="", prev_kg=""):
try:
wav_path = self.recorder.stop_and_save_wav()
self.last_wav_path = wav_path
new_transcript = self.transcribe_wav(wav_path)
self.after(0, lambda: self._next_phase("kg"))
if prev_txt:
full_transcript = prev_txt + "\n\n" + new_transcript
if prev_kg:
kg = strip_kg_warnings(self.merge_kg(prev_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))
self.after(0, lambda: self._fill_transcript(full_transcript))
self.after(0, lambda: self._fill_kg_and_finish(kg))
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)))
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, args=(existing_transcript, existing_kg), daemon=True).start()
def transcribe_wav(self, wav_path: str) -> str:
with open(wav_path, "rb") as f:
# OpenAI Audio Transcriptions API
# https://platform.openai.com/docs/guides/speech-to-text
resp = self.client.audio.transcriptions.create(
model=TRANSCRIBE_MODEL,
file=f,
language="de",
)
# Schätze Token-Verbrauch für Transkription (ca. 1 Token pro 4 Zeichen)
text = getattr(resp, "text", "") or str(resp)
estimated_tokens = len(text) // 4
add_token_usage(estimated_tokens)
self.after(0, self.update_token_display)
return text
def call_chat_completion(self, **kwargs):
"""Wrapper für chat.completions.create mit automatischem Token-Tracking."""
resp = self.client.chat.completions.create(**kwargs)
# Extrahiere Token-Verbrauch aus Response
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ünftens)\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)
return t
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
template = load_templates_text().strip()
system_content = (f"Kontext/Einstellung (bei der Erstellung berücksichtigen):\n{template}\n\n{SYSTEM_PROMPT}") if template else SYSTEM_PROMPT
user_text = f"""TRANSKRIPT:
{transcript}
"""
resp = self.client.chat.completions.create(
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
template = load_templates_text().strip()
system_content = (f"Kontext/Einstellung (bei der Erstellung berücksichtigen):\n{template}\n\n{MERGE_PROMPT}") if template else 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.client.chat.completions.create(
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()
def _rebuild_textblock_buttons(self):
"""Erstellt die Textblock-Buttons neu basierend auf _textbloecke_data."""
for w in self._textbloecke_rows_frame.winfo_children():
w.destroy()
self._textbloecke_buttons = []
slots = sorted(self._textbloecke_data.keys(), key=int)
for i in range(0, len(slots), 2):
row_slots = slots[i : i + 2]
block_row = ttk.Frame(self._textbloecke_rows_frame, padding=(0, 4, 0, 0))
block_row.pack(fill="x")
block_inner = ttk.Frame(block_row)
block_inner.pack(anchor="center")
for slot_str in row_slots:
btn = RoundedButton(
block_inner, self._textblock_label(slot_str),
command=None,
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", width=70, height=26, canvas_bg="#B9ECFA",
radius=0,
)
btn._textblock_slot = slot_str
def make_click(s):
return lambda: self._copy_textblock(s)
btn.configure(command=make_click(slot_str))
def on_btn_press(e, s):
self._textblock_copy_to_clipboard(s)
btn.bind("<ButtonPress-1>", lambda e, s=slot_str: on_btn_press(e, s), add="+")
btn.pack(side="left", padx=(0, 4))
def make_ctx(s):
return lambda e: self._textblock_context(e, s)
def make_save_sel(s):
return lambda e: self._save_selection_to_textblock(e, s)
btn.bind("<Button-3>", make_ctx(slot_str))
btn.bind("<Shift-Button-1>", make_save_sel(slot_str))
self._textbloecke_buttons.append((slot_str, btn))
def _add_textblock(self):
"""Fügt einen Textblock hinzu wenn vorher mit - entfernt, wird der gespeicherte Inhalt wiederhergestellt."""
slots = sorted(self._textbloecke_data.keys(), key=int)
next_num = int(slots[-1]) + 1 if slots else 1
slot_str = str(next_num)
if self._removed_textbloecke:
restored = self._removed_textbloecke.pop()
self._textbloecke_data[slot_str] = {"name": restored.get("name", f"Textblock {slot_str}"), "content": restored.get("content", "")}
else:
self._textbloecke_data[slot_str] = {"name": f"Textblock {slot_str}", "content": ""}
save_textbloecke(self._textbloecke_data)
self._rebuild_textblock_buttons()
self.set_status(f"Textblock {slot_str} hinzugefügt.")
def _remove_textblock(self):
"""Entfernt den letzten Textblock. Inhalt wird gespeichert für spätere Wiederverwendung mit +."""
slots = sorted(self._textbloecke_data.keys(), key=int)
if len(slots) <= 2:
self.set_status("Mindestens 2 Textblöcke müssen bestehen bleiben.")
return
last = slots[-1]
self._removed_textbloecke.append(dict(self._textbloecke_data[last]))
del self._textbloecke_data[last]
save_textbloecke(self._textbloecke_data)
self._rebuild_textblock_buttons()
self.set_status(f"Textblock {last} entfernt (Inhalt für + gespeichert).")
def _textblock_label(self, slot: str) -> str:
"""Anzeigetext für einen Textblock-Button (Name oder gekürzter Inhalt)."""
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
name = (d.get("name") or "").strip()
content = (d.get("content") or "").strip()
max_len = 12
if name and name != f"Textblock {slot}":
return (name[:max_len] + "") if len(name) > max_len else name
if content:
return (content[:max_len] + "") if len(content) > max_len else content
return f"Textblock {slot}"
def _refresh_textblock_button(self, slot: str):
"""Aktualisiert den angezeigten Text eines Textblock-Buttons."""
for s, btn in self._textbloecke_buttons:
if s == slot:
btn.configure(text=self._textblock_label(slot))
break
# Typische SOAP-/KG-Überschriften für Abschnitts-Kopieren (Doppelklick)
_KG_SECTION_HEADERS = (
"subjektiv", "objektiv", "diagnose", "diagnosen", "beurteilung",
"therapie", "procedere", "anlass", "befunde", "empfehlung",
"assessment", "plan", "befund", "verlauf", "medikation",
)
def _get_kg_section_at_cursor(self, text_widget):
"""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()
# Überschrift: endet mit Doppelpunkt oder ist "N. Überschrift"
if line_lower.endswith(":"):
head = line_lower.rstrip(":")
elif "." in line_lower and line_lower.split(".", 1)[0].strip().isdigit():
head = line_lower.split(".", 1)[1].strip().rstrip(":")
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 = start_line
for i in range(line_no, len(lines) + 1):
if i == len(lines):
end_line = len(lines)
break
ln = (lines[i - 1] or "").strip().lower()
if i > start_line and ln and (ln.endswith(":") or (ln.split(".", 1)[0].strip().isdigit() if "." in ln else False)):
end_line = i
break
end_line = i
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}.0"
if end_line <= len(lines):
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("<Double-Button-1>", on_double_click, add="+")
def _bind_text_context_menu(self, text_widget):
"""Rechtsklick-Menü: Kopieren, Einfügen, Markierung in Textblock speichern."""
def on_right_click(event):
menu = tk.Menu(text_widget, tearoff=0)
menu.add_command(
label="Kopieren",
command=lambda: _do_copy(),
)
menu.add_command(
label="Einfügen",
command=lambda: _do_paste(),
)
slots = sorted(getattr(self, "_textbloecke_data", {}).keys())
if slots:
save_menu = tk.Menu(menu, tearoff=0)
for slot in slots:
lbl = self._textblock_label(slot)
save_menu.add_command(
label=lbl,
command=lambda s=slot, w=text_widget: _do_save_to_textblock(s, w),
)
menu.add_cascade(label="Markierung in Textblock speichern", menu=save_menu)
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _do_copy():
try:
if hasattr(text_widget, "selection_present") and text_widget.selection_present():
sel = text_widget.get(tk.SEL_FIRST, tk.SEL_LAST)
if not _win_clipboard_set(sel):
self.clipboard_clear()
self.clipboard_append(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.strip()
self._refresh_textblock_button(slot)
self.set_status("Auswahl in Textblock gespeichert.")
text_widget.bind("<Button-3>", on_right_click)
def _bind_textblock_pending(self, text_widget):
"""Wie textbloecke.py: ButtonPress-1 = Auswahl für Drag; Cursorposition ständig aktualisieren für Einfügen."""
if not hasattr(self, "_textblock_drag_data"):
self._textblock_drag_data = {"text": None, "active": False}
self._bind_text_context_menu(text_widget)
def save_cursor_position():
"""Speichert aktuelles Textfeld und Cursorposition so ist es immer die Stelle vor dem Klick auf den Textblock-Button."""
try:
if text_widget.winfo_exists() and text_widget.focus_get() == text_widget:
self._last_focused_text_widget = text_widget
self._last_insert_index = text_widget.index(tk.INSERT)
except (tk.TclError, AttributeError):
pass
def on_focus_out(event):
try:
self._last_focused_text_widget = text_widget
self._last_insert_index = text_widget.index(tk.INSERT)
except (tk.TclError, AttributeError):
pass
text_widget.bind("<FocusOut>", on_focus_out)
text_widget.bind("<KeyRelease>", lambda e: save_cursor_position(), add="+")
text_widget.bind("<ButtonRelease-1>", lambda e: save_cursor_position(), 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 or "").strip()
self._textblock_drag_data["active"] = False
else:
self._textblock_drag_data["text"] = None
self._textblock_drag_data["active"] = False
text_widget.bind("<ButtonPress-1>", start_drag, add="+")
text_widget.bind("<B1-Motion>", self._textblock_on_drag_motion, add="+")
text_widget.bind("<ButtonRelease-1>", self._on_global_drag_release, add="+")
self._bind_autotext(text_widget)
def _check_autotext_focus_out(self):
"""Prüft, ob der Fokus noch in unserer App ist (Hauptfenster oder Toplevel)."""
try:
w = self.focus_get()
if w is None:
self._autotext_focus_in_app[0] = False
return
top = w.winfo_toplevel()
if top == self:
self._autotext_focus_in_app[0] = True
return
if hasattr(top, "master") and top.master == self:
self._autotext_focus_in_app[0] = True
return
self._autotext_focus_in_app[0] = False
except (tk.TclError, AttributeError):
self._autotext_focus_in_app[0] = False
def _bind_autotext(self, text_widget):
"""Ersetzt Abkürzungen durch Autotext nach Leerzeichen/Zeilenumbruch etc. (dauerhaft gespeichert, wenn aktiviert)."""
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
def on_focus_in(event):
self._autotext_focus_in_app[0] = True
def on_focus_out(event):
self.after(50, self._check_autotext_focus_out)
text_widget.bind("<FocusIn>", on_focus_in, add="+")
text_widget.bind("<FocusOut>", on_focus_out, add="+")
def on_keyrelease(event):
if not getattr(self, "_autotext_data", {}).get("enabled", True):
return
entries = (self._autotext_data.get("entries") or {})
if not entries:
return
try:
text_widget.update_idletasks()
insert = text_widget.index(tk.INSERT)
text_before = text_widget.get("1.0", insert)
if not text_before:
return
last_char = text_before[-1]
if last_char not in AUTOTEXT_TERMINATORS:
return
word_end = len(text_before) - 1
i = word_end - 1
while i >= 0 and text_before[i] not in AUTOTEXT_TERMINATORS:
i -= 1
word_start = i + 1
word = text_before[word_start:word_end]
if not word or word not in entries:
return
expansion = entries[word]
start_idx = text_widget.index(f"{insert} - {len(word) + 1} chars")
text_widget.delete(start_idx, insert)
text_widget.insert(start_idx, expansion + last_char)
except (tk.TclError, AttributeError, IndexError):
pass
text_widget.bind("<KeyRelease>", lambda e: self.after(80, lambda: on_keyrelease(e)), add="+")
def _run_global_autotext_listener(self):
"""Hintergrund-Thread: Globaler Tastatur-Hook (Windows). Ersetzung in Worker-Thread mit Verzögerung, damit der Listener nicht blockiert."""
if not _HAS_PYNPUT:
return
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
controller = KbdController()
buffer = self._autotext_global_buffer
replace_queue = []
queue_lock = threading.Lock()
REPLACE_DELAY = 0.2
def key_to_char(key):
try:
if key == Key.space:
return " "
if key == Key.enter:
return "\n"
if key == Key.tab:
return "\t"
if hasattr(key, "char") and key.char:
return key.char
except Exception:
pass
return None
def key_to_terminator_char(key):
try:
if key == Key.space:
return " "
if key == Key.enter:
return "\n"
if key == Key.tab:
return "\t"
if hasattr(key, "vk") and key.vk == 13:
return "\n"
if hasattr(key, "char") and key.char and key.char in AUTOTEXT_TERMINATORS:
return key.char
except Exception:
pass
return None
def worker():
while True:
time.sleep(0.02)
with queue_lock:
if not replace_queue:
continue
n_back, text = replace_queue.pop(0)
injecting_ref = getattr(self, "_autotext_injecting", [False])
injecting_ref[0] = True
time.sleep(REPLACE_DELAY)
try:
data = load_autotext()
if not data.get("enabled", True):
injecting_ref[0] = False
continue
saved = _win_clipboard_get()
if not _win_clipboard_set(text):
injecting_ref[0] = False
continue
for _ in range(n_back):
controller.press(Key.backspace)
controller.release(Key.backspace)
time.sleep(0.1)
with controller.pressed(Key.ctrl):
controller.tap(KeyCode.from_char("v"))
time.sleep(0.05)
if saved:
_win_clipboard_set(saved)
except Exception:
pass
injecting_ref[0] = False
threading.Thread(target=worker, daemon=True).start()
def on_press(key):
if getattr(self, "_autotext_injecting", [False])[0]:
return
try:
if key == Key.backspace:
if buffer:
buffer.pop()
else:
ch = key_to_char(key)
if ch:
buffer.append(ch)
if len(buffer) > 200:
buffer.pop(0)
except Exception:
pass
def on_release(key):
if getattr(self, "_autotext_injecting", [False])[0]:
return
if getattr(self, "_autotext_focus_in_app", [False])[0]:
return
terminator_char = key_to_terminator_char(key)
if terminator_char is None:
return
try:
data = load_autotext()
if not data.get("enabled", True):
return
entries = data.get("entries") or {}
if not entries or len(buffer) < 2:
return
i = len(buffer) - 2
while i >= 0 and buffer[i] not in AUTOTEXT_TERMINATORS:
i -= 1
word = "".join(buffer[i + 1 : -1])
if not word:
return
expansion = entries.get(word)
if expansion is None:
for k, v in entries.items():
if k.lower() == word.lower():
expansion = v
break
if not expansion:
return
n_back = len(word) + 1
text = expansion + terminator_char
with queue_lock:
replace_queue.append((n_back, text))
del buffer[-(len(word) + 1) :]
buffer.extend(list(expansion + terminator_char))
while len(buffer) > 200:
buffer.pop(0)
except Exception:
pass
try:
with KbdListener(on_press=on_press, on_release=on_release) as listener:
listener.join()
except Exception:
pass
def _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 "").strip()
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. Text markieren, Maustaste gedrückt halten und 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)
idx = getattr(self, "_last_insert_index", tk.INSERT)
try:
if w is not None and w.winfo_exists() and hasattr(w, "insert"):
w.focus_set()
try:
w.insert(idx, content)
except tk.TclError:
w.insert(tk.INSERT, content)
w.see(idx)
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()
idx2 = getattr(self, "_last_insert_index", tk.INSERT)
try:
w2.insert(idx2, content)
except tk.TclError:
w2.insert(tk.INSERT, content)
w2.see(idx2)
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, Umbenennen, Textblock löschen."""
menu = tk.Menu(self, tearoff=0)
menu.add_command(
label="Einfügen",
command=lambda: self._textblock_insert_at_cursor(slot),
)
menu.add_command(
label="Umbenennen",
command=lambda: self._textblock_rename(slot),
)
menu.add_command(
label="Textblock löschen",
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 eingefügt.")
return
except (tk.TclError, AttributeError):
pass
if not _win_clipboard_set(content):
self.clipboard_clear()
self.clipboard_append(content)
self.set_status("Textblock in Zwischenablage mit Einfügen einfügen.")
def _textblock_clear(self, slot: str):
"""Löscht den Inhalt (und optional den Namen) des Textblocks."""
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = ""
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Textblock gelöscht.")
def _textblock_save_from_selection(self, slot: str):
"""Speichert die Auswahl des fokussierten Textfelds in den Textblock."""
text = self._get_selection_from_focus()
if not text.strip():
messagebox.showinfo("Hinweis", "Bitte zuerst Text markieren (z. B. im Transkript, in der KG oder im Diktat-Fenster).")
return
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Auswahl in Textblock gespeichert.")
def _textblock_save_from_clipboard(self, slot: str):
"""Speichert den Inhalt der Zwischenablage in den Textblock (überschreibt)."""
try:
text = self.clipboard_get()
except Exception:
text = ""
if isinstance(text, str):
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Textblock gespeichert.")
else:
self.set_status("Kein Text in Zwischenablage.")
def _textblock_rename(self, slot: str):
"""Umbenennen direkt am Button: Entry überlagert den Button, kein neues Fenster."""
def _do_rename():
btn = None
for s, b in self._textbloecke_buttons:
if s == slot:
btn = b
break
if not btn or not btn.winfo_exists():
return
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
current = (d.get("name") or "").strip() or f"Textblock {slot}"
parent = btn.nametowidget(btn.winfo_parent())
entry = ttk.Entry(parent, width=12, font=("Segoe UI", 11))
entry.insert(0, current)
entry.select_range(0, tk.END)
parent.update_idletasks()
entry.place(x=btn.winfo_x(), y=btn.winfo_y(), width=btn.winfo_width(), height=btn.winfo_height())
entry.focus_set()
def finish(save: bool):
if save:
new_name = entry.get().strip()
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["name"] = new_name or f"Textblock {slot}"
save_textbloecke(self._textbloecke_data)
self._refresh_textblock_button(slot)
self.set_status("Button umbenannt.")
entry.place_forget()
entry.destroy()
entry.bind("<Return>", lambda e: finish(True))
entry.bind("<Escape>", lambda e: finish(False))
self.after(150, _do_rename)
def _new_session(self):
# Vor dem Leeren: KG und (falls vorhanden) Brief/Rezept/KOGU zuverlässig in Ablage speichern (JSON + .txt)
def get_str(widget_or_str):
if widget_or_str is None:
return ""
if hasattr(widget_or_str, "get"):
try:
s = widget_or_str.get("1.0", "end")
return (s if isinstance(s, str) else str(s)).strip()
except Exception:
return ""
return (str(widget_or_str)).strip()
kg_text = get_str(self.txt_output)
brief_text = get_str(self._last_brief_text)
rezept_text = get_str(self._last_rezept_text)
kogu_text = get_str(self._last_kogu_text)
try:
if kg_text:
save_to_ablage("KG", kg_text)
if brief_text:
save_to_ablage("Briefe", brief_text)
if rezept_text:
save_to_ablage("Rezepte", rezept_text)
if kogu_text:
save_to_ablage("Kostengutsprachen", kogu_text)
except Exception as e:
try:
messagebox.showerror("Speichern bei Neu", str(e))
except Exception:
pass
self._last_brief_text = ""
self._last_rezept_text = ""
self._last_kogu_text = ""
self.txt_transcript.delete("1.0", "end")
self.txt_output.delete("1.0", "end")
self.set_status("Bereit.")
def copy_output(self):
text = self.txt_output.get("1.0", "end").strip()
if not text:
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()