6211 lines
262 KiB
Python
6211 lines
262 KiB
Python
# -*- 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
|
||
|
||
|
||
# -----------------------------
|
||
# Tooltip-Funktion
|
||
# -----------------------------
|
||
class ToolTip:
|
||
"""Zeigt Tooltips beim Überfahren mit der Maus."""
|
||
def __init__(self, widget, text):
|
||
self.widget = widget
|
||
self.text = text
|
||
self.tooltip = None
|
||
self.widget.bind("<Enter>", self.show_tooltip)
|
||
self.widget.bind("<Leave>", self.hide_tooltip)
|
||
|
||
def show_tooltip(self, event=None):
|
||
try:
|
||
x = self.widget.winfo_rootx() + 25
|
||
y = self.widget.winfo_rooty() + 25
|
||
|
||
self.tooltip = tk.Toplevel(self.widget)
|
||
self.tooltip.wm_overrideredirect(True)
|
||
self.tooltip.wm_geometry(f"+{x}+{y}")
|
||
|
||
label = tk.Label(
|
||
self.tooltip,
|
||
text=self.text,
|
||
background="#FFFACD",
|
||
foreground="#000000",
|
||
relief="solid",
|
||
borderwidth=1,
|
||
font=("Segoe UI", 9),
|
||
padx=8,
|
||
pady=4
|
||
)
|
||
label.pack()
|
||
except Exception:
|
||
pass
|
||
|
||
def hide_tooltip(self, event=None):
|
||
if self.tooltip:
|
||
self.tooltip.destroy()
|
||
self.tooltip = None
|
||
|
||
|
||
def add_tooltip(widget, text):
|
||
"""Fügt einem Widget einen Tooltip hinzu."""
|
||
return ToolTip(widget, text)
|
||
|
||
|
||
# -----------------------------
|
||
# 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)
|
||
|
||
# Button-Scale-Variable vorher definieren für Callback
|
||
current_button_scale = load_button_scale()
|
||
button_scale_var = tk.DoubleVar(value=current_button_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))
|
||
|
||
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):
|
||
# Basis-Größe 16, damit bei Scale 0.3-0.8 die Schrift lesbar bleibt
|
||
# 16 * 0.3 = 4.8 → 5pt (sehr klein)
|
||
# 16 * 0.6 = 9.6 → 10pt (normal)
|
||
# 16 * 0.8 = 12.8 → 13pt (größer)
|
||
base_size = 16
|
||
new_size = max(5, 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 = 16
|
||
new_size = max(5, 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)
|
||
# Basis-Font-Größe 16, damit bei Scale 0.3-0.8 die Schrift lesbar ist
|
||
font_size = max(5, int(16 * 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 = 0.6 # Angepasst an neue Range (Mitte zwischen 0.3 und 0.8)
|
||
MIN_FONT_SCALE = 0.3 # Sehr klein für große Bildschirme
|
||
MAX_FONT_SCALE = 0.8 # Begrenzt, damit Schrift nie zu groß für Buttons wird
|
||
DEFAULT_BUTTON_SCALE = 1.4
|
||
MIN_BUTTON_SCALE = 1.4 # Buttons nie kleiner als 140%
|
||
MAX_BUTTON_SCALE = 2.0
|
||
# Optimale Reset-Werte
|
||
OPTIMAL_FONT_SCALE = 0.6 # Angepasst an neue Range
|
||
OPTIMAL_BUTTON_SCALE = 1.4
|
||
|
||
|
||
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.4–1.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.3–0.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.8–2.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, 'budget_dollars': float, 'used_dollars': float}"""
|
||
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, "budget_dollars": 0, "used_dollars": 0}
|
||
|
||
|
||
def save_token_usage(used: int = None, total: int = None, budget_dollars: float = None, used_dollars: float = None) -> None:
|
||
"""Speichert die Token-Nutzung."""
|
||
try:
|
||
current = load_token_usage()
|
||
if used is not None:
|
||
current["used"] = used
|
||
if total is not None:
|
||
current["total"] = total
|
||
if budget_dollars is not None:
|
||
current["budget_dollars"] = budget_dollars
|
||
if used_dollars is not None:
|
||
current["used_dollars"] = used_dollars
|
||
with open(_token_usage_config_path(), "w", encoding="utf-8") as f:
|
||
json.dump(current, 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(used=data["used"])
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def fetch_openai_usage(client) -> dict:
|
||
"""Ruft echte Verbrauchs-Daten von OpenAI ab."""
|
||
try:
|
||
# OpenAI API Key aus Client extrahieren
|
||
api_key = client.api_key if hasattr(client, 'api_key') else None
|
||
if not api_key:
|
||
return None
|
||
|
||
# Verwende httpx (bereits von openai installiert)
|
||
import httpx
|
||
|
||
headers = {
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
# Verbrauch der letzten 30 Tage abrufen
|
||
end_date = datetime.now()
|
||
start_date = end_date - timedelta(days=30)
|
||
|
||
url = f"https://api.openai.com/v1/usage?start_date={start_date.strftime('%Y-%m-%d')}&end_date={end_date.strftime('%Y-%m-%d')}"
|
||
|
||
with httpx.Client(timeout=10.0) as http_client:
|
||
response = http_client.get(url, headers=headers)
|
||
|
||
if response.status_code == 200:
|
||
data = response.json()
|
||
# Summiere Verbrauch aus allen Tagen
|
||
total_cost = 0
|
||
for day_data in data.get("data", []):
|
||
total_cost += day_data.get("cost", 0) / 100 # Cent to Dollar
|
||
|
||
return {
|
||
"used_dollars": total_cost,
|
||
"success": True
|
||
}
|
||
else:
|
||
return {
|
||
"error": f"API returned status {response.status_code}",
|
||
"success": False
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"error": str(e),
|
||
"success": False
|
||
}
|
||
|
||
return None
|
||
|
||
|
||
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. 3–5 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 = 850
|
||
DEFAULT_WINDOW_HEIGHT = 920
|
||
|
||
# Globale Liste aller offenen Fenster für Skalierung
|
||
_ALL_WINDOWS = []
|
||
|
||
|
||
def apply_initial_scaling_to_window(win):
|
||
"""Wendet initiale Skalierung auf ein neu erstelltes Fenster an."""
|
||
try:
|
||
if win not in _ALL_WINDOWS:
|
||
_ALL_WINDOWS.append(win)
|
||
|
||
# Lade gespeicherte Skalierungen
|
||
font_scale = load_font_scale()
|
||
button_scale = load_button_scale()
|
||
|
||
# Wende Skalierungen an
|
||
win.after(100, lambda: scale_window_fonts(win, font_scale))
|
||
win.after(150, lambda: scale_window_buttons(win, button_scale))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class KGDesktopApp(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("KI Assistent PRAXIS LINDENGUT AG")
|
||
MAIN_MIN_W, MAIN_MIN_H = 750, 650 # Mindestgröße: alle Buttons sichtbar (angepasst für unterschiedliche DPI)
|
||
self.minsize(MAIN_MIN_W, MAIN_MIN_H)
|
||
|
||
# Gespeicherte Größe/Position, Sash und Transkript-Höhe laden oder Standard
|
||
saved = load_window_geometry()
|
||
self._saved_sash = None
|
||
self._saved_sash_transcript = None
|
||
if saved:
|
||
w = max(MAIN_MIN_W, saved[0])
|
||
h = max(MAIN_MIN_H, saved[1])
|
||
x, y = saved[2], saved[3]
|
||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||
if len(saved) >= 5 and saved[4] is not None:
|
||
self._saved_sash = saved[4]
|
||
if len(saved) >= 6 and saved[5] is not None:
|
||
self._saved_sash_transcript = saved[5]
|
||
else:
|
||
self.geometry(f"{DEFAULT_WINDOW_WIDTH}x{DEFAULT_WINDOW_HEIGHT}")
|
||
|
||
# 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)
|
||
|
||
# Automatische Aktualisierung der Token-Nutzung beim Start (nach 3 Sekunden)
|
||
self.after(3000, lambda: threading.Thread(target=self._fetch_and_update_usage, daemon=True).start())
|
||
|
||
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 (zeigt echten OpenAI-Verbrauch)
|
||
token_data = load_token_usage()
|
||
used_dollars = token_data.get("used_dollars", 0)
|
||
budget_dollars = token_data.get("budget_dollars", 50) # Standard: $50
|
||
|
||
if budget_dollars > 0:
|
||
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
|
||
remaining_percent = max(0, min(100, remaining_percent))
|
||
else:
|
||
remaining_percent = 100
|
||
|
||
self._token_label = ttk.Label(
|
||
self._top_right,
|
||
text=f"📊 {remaining_percent}%",
|
||
font=("Segoe UI", 9),
|
||
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d"),
|
||
cursor="hand2"
|
||
)
|
||
self._token_label.pack(side="left", padx=(0, 8))
|
||
remaining = max(0, budget_dollars - used_dollars)
|
||
tooltip_text = f"Guthaben: {remaining_percent}% (${remaining:.2f} von ${budget_dollars:.2f})\n\n"
|
||
tooltip_text += f"Verbraucht: ${used_dollars:.2f}\n\n"
|
||
tooltip_text += "100% = Volles Budget verfügbar\n0% = Budget aufgebraucht\n\n"
|
||
tooltip_text += "Linksklick: Echte Daten von OpenAI laden\nRechtsklick: Budget einstellen"
|
||
add_tooltip(self._token_label, tooltip_text)
|
||
|
||
# Token-Label anklickbar machen
|
||
def refresh_usage(e=None):
|
||
self.set_status("Lade Verbrauch von OpenAI...")
|
||
threading.Thread(target=self._fetch_and_update_usage, daemon=True).start()
|
||
|
||
def open_budget(e=None):
|
||
self._open_budget_settings()
|
||
|
||
self._token_label.bind("<Button-1>", refresh_usage)
|
||
self._token_label.bind("<Button-3>", open_budget)
|
||
|
||
# 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")
|
||
add_tooltip(self.btn_reset_scales, "Reset: Stellt optimale Größen ein\n(Schrift: 60%, Buttons: 140%)")
|
||
|
||
# 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)
|
||
|
||
font_label = ttk.Label(self._top_right, text="Aa", font=("Segoe UI", 10))
|
||
font_label.pack(side="left", padx=(0, 4))
|
||
add_tooltip(font_label, "Schriftgröße: Steuert die Textgröße\nin allen Fenstern (30%-80%)")
|
||
|
||
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))
|
||
add_tooltip(self._font_scale_slider, "Schriftgröße anpassen:\nLinks = kleiner, Rechts = größer")
|
||
|
||
# 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)
|
||
|
||
button_label = ttk.Label(self._top_right, text="⚏", font=("Segoe UI", 12))
|
||
button_label.pack(side="left", padx=(0, 4))
|
||
add_tooltip(button_label, "Button-Größe: Steuert die Größe\naller Buttons (140%-200%)")
|
||
|
||
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))
|
||
add_tooltip(self._button_scale_slider, "Button-Größe anpassen:\nLinks = kleiner, Rechts = größer")
|
||
|
||
# Transparenz-Regler
|
||
self._opacity_var_main = tk.DoubleVar(value=round(load_opacity() * 100))
|
||
|
||
def on_opacity_main(val):
|
||
try:
|
||
alpha = float(val) / 100.0
|
||
alpha = max(MIN_OPACITY, min(1.0, alpha))
|
||
self.attributes("-alpha", alpha)
|
||
save_opacity(alpha)
|
||
except Exception:
|
||
pass
|
||
|
||
opacity_label = ttk.Label(self._top_right, text="◐", font=("Segoe UI", 10))
|
||
opacity_label.pack(side="left", padx=(0, 4))
|
||
add_tooltip(opacity_label, "Transparenz: Steuert die Durchsichtigkeit\ndes Fensters (40%-100%)")
|
||
|
||
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))
|
||
add_tooltip(self._opacity_scale_main, "Transparenz anpassen:\nLinks = durchsichtig, Rechts = undurchsichtig")
|
||
|
||
self.btn_settings = RoundedButton(
|
||
self._top_right, "⚙", command=self._open_settings, width=36, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_settings.pack(side="right", anchor="n")
|
||
self._scalable_widgets.append(self.btn_settings)
|
||
add_tooltip(self.btn_settings, "Einstellungen öffnen")
|
||
|
||
self._status_row = ttk.Frame(self, padding=(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)
|
||
# Basis-Größe 16, damit bei Scale 0.3-0.8 die Schrift lesbar bleibt
|
||
base_size = 16
|
||
new_size = max(5, int(base_size * scale))
|
||
new_font = (self._text_font[0], new_size)
|
||
|
||
# Aktualisiere Status-Label
|
||
if hasattr(self, 'lbl_status'):
|
||
self.lbl_status.configure(font=new_font)
|
||
|
||
# Aktualisiere Text-Widgets
|
||
for txt_widget in [self.txt_transcript, self.txt_output]:
|
||
if txt_widget and txt_widget.winfo_exists():
|
||
txt_widget.configure(font=new_font)
|
||
|
||
# Skaliere Schrift in allen RoundedButtons
|
||
for widget in self.winfo_children():
|
||
self._scale_font_recursive(widget, scale)
|
||
|
||
except Exception as e:
|
||
pass
|
||
|
||
def _apply_font_scale_global(self, scale: float):
|
||
"""Wendet Schriftgrößen-Skalierung auf ALLE offenen Fenster an."""
|
||
self._apply_font_scale(scale)
|
||
for win in _ALL_WINDOWS:
|
||
try:
|
||
if win and win.winfo_exists():
|
||
scale_window_fonts(win, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_button_scale(self, scale: float):
|
||
"""Wendet nur den Button-Größen-Skalierungsfaktor an."""
|
||
try:
|
||
for widget in self.winfo_children():
|
||
self._scale_button_size_recursive(widget, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_button_scale_global(self, scale: float):
|
||
"""Wendet Button-Größen-Skalierung auf ALLE offenen Fenster an."""
|
||
self._apply_button_scale(scale)
|
||
for win in _ALL_WINDOWS:
|
||
try:
|
||
if win and win.winfo_exists():
|
||
scale_window_buttons(win, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def update_token_display(self):
|
||
"""Aktualisiert die Token-Anzeige (Budget-basiert)."""
|
||
try:
|
||
token_data = load_token_usage()
|
||
used_dollars = token_data.get("used_dollars", 0)
|
||
budget_dollars = token_data.get("budget_dollars", 50)
|
||
|
||
if budget_dollars > 0:
|
||
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
|
||
remaining_percent = max(0, min(100, remaining_percent))
|
||
else:
|
||
remaining_percent = 100
|
||
|
||
self._token_label.configure(
|
||
text=f"📊 {remaining_percent}%",
|
||
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d")
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _fetch_and_update_usage(self):
|
||
"""Ruft echte Verbrauchs-Daten von OpenAI ab (läuft in Thread)."""
|
||
try:
|
||
usage_data = fetch_openai_usage(self.client)
|
||
|
||
if usage_data and usage_data.get("success"):
|
||
used_dollars = usage_data.get("used_dollars", 0)
|
||
save_token_usage(used_dollars=used_dollars)
|
||
self.after(0, lambda: self.set_status(f"✓ OpenAI-Verbrauch aktualisiert: ${used_dollars:.2f} (letzte 30 Tage)"))
|
||
self.after(0, self.update_token_display)
|
||
elif usage_data and not usage_data.get("success"):
|
||
error_msg = usage_data.get("error", "Unbekannter Fehler")
|
||
self.after(0, lambda: self.set_status(f"⚠ OpenAI Usage API: {error_msg}"))
|
||
else:
|
||
self.after(0, lambda: self.set_status("⚠ Konnte OpenAI-Verbrauch nicht abrufen. Prüfen Sie die API-Verbindung."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status(f"⚠ Fehler beim Abrufen: {str(e)}"))
|
||
|
||
def _open_budget_settings(self):
|
||
"""Dialog zum Einstellen des monatlichen Budgets."""
|
||
token_data = load_token_usage()
|
||
current_budget = token_data.get("budget_dollars", 50)
|
||
|
||
new_budget = simpledialog.askfloat(
|
||
"Monatliches Budget",
|
||
"Ihr monatliches OpenAI-Budget in Dollar:\n(z.B. 50 für $50/Monat)",
|
||
initialvalue=current_budget,
|
||
minvalue=1,
|
||
maxvalue=10000
|
||
)
|
||
|
||
if new_budget:
|
||
save_token_usage(budget_dollars=new_budget)
|
||
self.update_token_display()
|
||
self.set_status(f"Budget auf ${new_budget:.2f} gesetzt.")
|
||
|
||
def _scale_font_recursive(self, widget, scale: float):
|
||
"""Rekursive Funktion zum Skalieren der Schriftgröße in RoundedButtons."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_font_size_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_font_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _scale_button_size_recursive(self, widget, scale: float):
|
||
"""Rekursive Funktion zum Skalieren der Button-Größe in RoundedButtons."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_button_size_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_button_size_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _scale_widget_recursive(self, widget, scale: float):
|
||
"""Legacy-Methode für Rückwärtskompatibilität."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_font_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_widget_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _check_old_kg_entries(self):
|
||
"""Prüft KG-Einträge älter als 2 Wochen: bei Bestätigung löschen (oder automatisch, wenn Einstellung aktiv)."""
|
||
try:
|
||
old = get_old_kg_entries(14)
|
||
if not old:
|
||
return
|
||
auto = getattr(self, "_autotext_data", {}).get("kg_auto_delete_old", False)
|
||
if auto:
|
||
n = delete_kg_entries_older_than(14)
|
||
if n > 0:
|
||
self.set_status(f"{n} KG-Einträge älter als 2 Wochen automatisch gelöscht.")
|
||
return
|
||
if messagebox.askyesno(
|
||
"KG-Aufräumen",
|
||
f"Es gibt {len(old)} KG-Einträge älter als 2 Wochen.\n\nSollen diese gelöscht werden, um Speicher zu schonen?",
|
||
):
|
||
n = delete_kg_entries_older_than(14)
|
||
if n > 0:
|
||
self.set_status(f"{n} KG-Einträge gelöscht.")
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_close(self):
|
||
"""Beim Schließen: KG/Brief/Rezept/KOGU lesen und in Ablage speichern (JSON + .txt), dann schließen."""
|
||
def _str(w):
|
||
if w is None:
|
||
return ""
|
||
if hasattr(w, "get"):
|
||
try:
|
||
s = w.get("1.0", "end")
|
||
return (s if isinstance(s, str) else str(s)).strip()
|
||
except Exception:
|
||
return ""
|
||
return (str(w)).strip()
|
||
kg_text = _str(self.txt_output)
|
||
brief_text = _str(getattr(self, "_last_brief_text", None))
|
||
rezept_text = _str(getattr(self, "_last_rezept_text", None))
|
||
kogu_text = _str(getattr(self, "_last_kogu_text", None))
|
||
try:
|
||
if kg_text:
|
||
save_to_ablage("KG", kg_text)
|
||
if brief_text:
|
||
save_to_ablage("Briefe", brief_text)
|
||
if rezept_text:
|
||
save_to_ablage("Rezepte", rezept_text)
|
||
if kogu_text:
|
||
save_to_ablage("Kostengutsprachen", kogu_text)
|
||
except Exception:
|
||
pass
|
||
self._save_window_geometry()
|
||
self.destroy()
|
||
|
||
def _open_settings(self):
|
||
SETTINGS_MIN_W, SETTINGS_MIN_H = 680, 520
|
||
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")
|
||
aw.minsize(500, 400)
|
||
add_resize_grip(aw, 500, 400)
|
||
add_font_scale_control(aw)
|
||
af = ttk.Frame(aw, padding=12)
|
||
af.pack(fill="both", expand=True)
|
||
ttk.Label(af, text="Abkürzungen: Tippen Sie z. B. „mfg“ + Leerzeichen → wird zu „mit freundlichen Grüßen“. Mehrzeilige Ersetzungen möglich.").pack(anchor="w")
|
||
list_f = ttk.Frame(af)
|
||
list_f.pack(fill="both", expand=True, pady=(8, 8))
|
||
listbox = tk.Listbox(list_f, height=8, font=("Segoe UI", 11))
|
||
listbox.pack(side="left", fill="both", expand=True)
|
||
scroll = ttk.Scrollbar(list_f, orient="vertical", command=listbox.yview)
|
||
scroll.pack(side="right", fill="y")
|
||
listbox.configure(yscrollcommand=scroll.set)
|
||
entries = dict(self._autotext_data.get("entries") or {})
|
||
|
||
def refresh_list():
|
||
listbox.delete(0, "end")
|
||
for k in sorted(entries.keys()):
|
||
listbox.insert("end", f"«{k}» → {((entries[k] or '').replace(chr(10), ' ↵ ')[:40])}…" if len((entries[k] or "").replace("\n", " ")) > 40 else f"«{k}» → {((entries[k] or '').replace(chr(10), ' ↵ '))}")
|
||
|
||
refresh_list()
|
||
|
||
edit_f = ttk.Frame(af)
|
||
edit_f.pack(fill="x", pady=(0, 8))
|
||
ttk.Label(edit_f, text="Abkürzung:").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||
abbrev_var = tk.StringVar()
|
||
ttk.Entry(edit_f, textvariable=abbrev_var, width=18).grid(row=0, column=1, sticky="w", padx=(0, 16))
|
||
ttk.Label(edit_f, text="Ersetzung (mehrzeilig möglich):").grid(row=1, column=0, sticky="nw", padx=(0, 8), pady=(4, 0))
|
||
repl_text = ScrolledText(edit_f, wrap="word", height=4, width=42, font=self._text_font, bg="#F5FCFF")
|
||
repl_text.grid(row=1, column=1, sticky="ew", pady=(4, 0))
|
||
edit_f.columnconfigure(1, weight=1)
|
||
|
||
def on_select(evt):
|
||
sel = listbox.curselection()
|
||
if not sel:
|
||
return
|
||
idx = sel[0]
|
||
keys = sorted(entries.keys())
|
||
if idx >= len(keys):
|
||
return
|
||
k = keys[idx]
|
||
abbrev_var.set(k)
|
||
repl_text.delete("1.0", "end")
|
||
repl_text.insert("1.0", entries.get(k, ""))
|
||
|
||
listbox.bind("<<ListboxSelect>>", on_select)
|
||
|
||
def do_add():
|
||
ab = abbrev_var.get().strip()
|
||
if not ab:
|
||
messagebox.showinfo("Hinweis", "Bitte eine Abkürzung eingeben.")
|
||
return
|
||
entries[ab] = repl_text.get("1.0", "end").rstrip("\n")
|
||
self._autotext_data["entries"] = entries
|
||
save_autotext(self._autotext_data)
|
||
refresh_list()
|
||
abbrev_var.set("")
|
||
repl_text.delete("1.0", "end")
|
||
self.set_status("Autotext-Eintrag hinzugefügt/aktualisiert.")
|
||
|
||
def do_delete():
|
||
sel = listbox.curselection()
|
||
if not sel:
|
||
messagebox.showinfo("Hinweis", "Bitte einen Eintrag in der Liste auswählen.")
|
||
return
|
||
keys = sorted(entries.keys())
|
||
idx = sel[0]
|
||
if idx >= len(keys):
|
||
return
|
||
k = keys[idx]
|
||
del entries[k]
|
||
self._autotext_data["entries"] = entries
|
||
save_autotext(self._autotext_data)
|
||
refresh_list()
|
||
abbrev_var.set("")
|
||
repl_text.delete("1.0", "end")
|
||
self.set_status("Autotext-Eintrag gelöscht.")
|
||
|
||
def do_diktieren():
|
||
if not self.ensure_ready():
|
||
messagebox.showwarning("Diktieren", "Kein API-Key konfiguriert. Bitte in den Einstellungen eintragen.")
|
||
return
|
||
rec_win = tk.Toplevel(aw)
|
||
rec_win.title("Autotext – Diktieren")
|
||
rec_win.transient(aw)
|
||
rec_win.geometry("420x130")
|
||
rec_win.configure(bg="#B9ECFA")
|
||
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(680, 560)
|
||
try:
|
||
g = getattr(self, "_geometry_before_minimize", None)
|
||
if g and len(g) >= 4:
|
||
self.geometry(f"{g[0]}x{g[1]}+{g[2]}+{g[3]}")
|
||
except Exception:
|
||
pass
|
||
else:
|
||
self._geometry_before_minimize = (
|
||
self.winfo_width(), self.winfo_height(), self.winfo_x(), self.winfo_y()
|
||
)
|
||
self.paned.pack_forget()
|
||
self._bottom_frame.pack_forget()
|
||
self._top_right.pack_forget()
|
||
self._btn_row_left.pack_forget()
|
||
self._btn_minimize.configure(text="□")
|
||
self._minimized = True
|
||
self.minsize(360, 120)
|
||
top = self._btn_row_left.master
|
||
self._mini_frame = ttk.Frame(top, style="TopBar.TFrame", padding=(0, 0, 0, 4))
|
||
self._mini_frame.pack(fill="x")
|
||
top_row = ttk.Frame(self._mini_frame)
|
||
top_row.pack(fill="x")
|
||
|
||
# Buttons mit aktueller Skalierung
|
||
button_scale = load_button_scale()
|
||
font_scale = load_font_scale()
|
||
|
||
btn_neu = RoundedButton(top_row, "Neu", command=self._new_session, width=50, height=26, canvas_bg="#B9ECFA")
|
||
btn_neu.set_button_size_scale(button_scale)
|
||
btn_neu.set_font_size_scale(font_scale)
|
||
btn_neu.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_brief = RoundedButton(top_row, "Brief", command=self.open_brief_window, width=70, height=26, canvas_bg="#e1f6fc", bg="#e1f6fc", fg="#1a4d6d", active_bg="#c8ecf8")
|
||
btn_brief.set_button_size_scale(button_scale)
|
||
btn_brief.set_font_size_scale(font_scale)
|
||
btn_brief.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_op = RoundedButton(top_row, "OP-Bericht", command=self.open_op_bericht_window, width=90, height=26, canvas_bg="#b0e4ff", bg="#b0e4ff", fg="#1a4d6d", active_bg="#92d8f5")
|
||
btn_op.set_button_size_scale(button_scale)
|
||
btn_op.set_font_size_scale(font_scale)
|
||
btn_op.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_diktat = RoundedButton(top_row, "Diktat", command=self.open_diktat_window, width=70, height=26, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0")
|
||
btn_diktat.set_button_size_scale(button_scale)
|
||
btn_diktat.set_font_size_scale(font_scale)
|
||
btn_diktat.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_min = RoundedButton(top_row, "−", command=self._toggle_minimize, width=28, height=26, canvas_bg="#B9ECFA")
|
||
btn_min.set_button_size_scale(button_scale)
|
||
btn_min.set_font_size_scale(font_scale)
|
||
btn_min.pack(side="left", padx=(4, 0), anchor="n")
|
||
|
||
aufnahme_row = ttk.Frame(self._mini_frame)
|
||
aufnahme_row.pack(fill="x", pady=(6, 0))
|
||
btn_aufnahme = RoundedButton(
|
||
aufnahme_row, "⏺ Aufnahme starten", command=self.toggle_record,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E", width=208, height=47,
|
||
canvas_bg="#B9ECFA",
|
||
)
|
||
btn_aufnahme.set_button_size_scale(button_scale)
|
||
btn_aufnahme.set_font_size_scale(font_scale)
|
||
btn_aufnahme.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")
|
||
|
||
# Fenstergröße dynamisch basierend auf Button-Skalierung
|
||
base_width = 540
|
||
base_height = 210
|
||
scaled_width = int(base_width * button_scale)
|
||
scaled_height = int(base_height * button_scale)
|
||
self.geometry(f"{scaled_width}x{scaled_height}")
|
||
|
||
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 = 800, 650
|
||
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.call_chat_completion(
|
||
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.call_chat_completion(
|
||
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.call_chat_completion(
|
||
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")
|
||
tw.minsize(500, 400)
|
||
add_resize_grip(tw, 500, 400)
|
||
add_font_scale_control(tw)
|
||
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.call_chat_completion(
|
||
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")
|
||
tw.minsize(480, 350)
|
||
add_resize_grip(tw, 480, 350)
|
||
add_font_scale_control(tw)
|
||
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")
|
||
tw.minsize(520, 380)
|
||
add_resize_grip(tw, 520, 380)
|
||
add_font_scale_control(tw)
|
||
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")
|
||
rec_win.minsize(350, 130)
|
||
add_resize_grip(rec_win, 350, 130)
|
||
apply_initial_scaling_to_window(rec_win)
|
||
rf = ttk.Frame(rec_win, padding=16)
|
||
rf.pack(fill="both", expand=True)
|
||
status_var = tk.StringVar(value="Bereit. 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.call_chat_completion(
|
||
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 = 850, 730
|
||
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")
|
||
tw.minsize(520, 350)
|
||
add_resize_grip(tw, 520, 350)
|
||
add_font_scale_control(tw)
|
||
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))
|
||
|
||
# Diktieren-Funktion direkt ohne separates Fenster
|
||
diskussion_rec = [None]
|
||
is_diskussion_rec = [False]
|
||
|
||
def toggle_diskussion_diktat():
|
||
if not self.ensure_ready():
|
||
return
|
||
if not diskussion_rec[0]:
|
||
diskussion_rec[0] = AudioRecorder()
|
||
rec = diskussion_rec[0]
|
||
|
||
if not is_diskussion_rec[0]:
|
||
# Starte Aufnahme
|
||
try:
|
||
rec.start()
|
||
is_diskussion_rec[0] = True
|
||
btn_diktat.configure(text="⏹ Stoppen")
|
||
status_disk.set("Aufnahme läuft… Sprechen Sie jetzt.")
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
is_diskussion_rec[0] = False
|
||
else:
|
||
# Stoppe Aufnahme und transkribiere
|
||
is_diskussion_rec[0] = False
|
||
btn_diktat.configure(text="Diktieren")
|
||
status_disk.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
|
||
|
||
def insert_text():
|
||
if win.winfo_exists() and input_txt.winfo_exists():
|
||
idx = input_txt.index(tk.INSERT)
|
||
input_txt.insert(idx, transcript_text)
|
||
status_disk.set("Text eingefügt. Jetzt Senden klicken.")
|
||
|
||
self.after(0, insert_text)
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
self.after(0, lambda: status_disk.set("Fehler beim Diktieren."))
|
||
self.after(0, lambda: btn_diktat.configure(text="Diktieren"))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
btn_diktat = RoundedButton(
|
||
btn_row_disk, "Diktieren", command=toggle_diskussion_diktat,
|
||
width=100, height=28, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0",
|
||
)
|
||
btn_diktat.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.call_chat_completion(
|
||
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.call_chat_completion(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": "Gib nur eine kurze deutsche Überschrift (3–8 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 = 420, 380
|
||
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 = 750, 600
|
||
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.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": KI_PRUEFEN_PROMPT},
|
||
{"role": "user", "content": kg},
|
||
],
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self.after(0, lambda: self._show_ki_pruefen_window(result, kg))
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _show_ki_pruefen_window(self, result: str, original_kg: str) -> None:
|
||
"""Zeigt das KI-Prüfergebnis in einem Fenster; Button „Übernehme Korrektur“ erzeugt korrigierte KG."""
|
||
win = tk.Toplevel(self)
|
||
win.title("KI-Kontrolle – Logik & Zusammenhänge")
|
||
win.transient(self)
|
||
win.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.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": prompt},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
)
|
||
corrected = resp.choices[0].message.content.strip()
|
||
corrected = re.sub(r"\*+", "", corrected)
|
||
corrected = re.sub(r"#+", "", corrected)
|
||
for prefix in ("Aktuelle Krankengeschichte:", "Aktuelle Krankengeschichte", "Korrigierte Krankengeschichte:", "Korrigierte Krankengeschichte"):
|
||
if corrected.startswith(prefix):
|
||
corrected = corrected[len(prefix):].strip()
|
||
break
|
||
self.after(0, lambda: _apply_corrected(corrected))
|
||
self.after(0, lambda: self.set_status("Korrektur übernommen."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
|
||
|
||
def _apply_corrected(text):
|
||
try:
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", text)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
import threading
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
RoundedButton(
|
||
btn_row, "Übernehme Korrektur",
|
||
command=do_uebernehme_korrektur,
|
||
width=180, height=28, canvas_bg="#B9ECFA",
|
||
).pack(side="left")
|
||
|
||
def open_pruefen_window(self):
|
||
"""Öffnet das Korrektur-Fenster mit Korrekturen und Interaktionscheck."""
|
||
kg = self.txt_output.get("1.0", "end").strip()
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
raw = ""
|
||
if kg:
|
||
raw += "KRANKENGESCHICHTE:\n" + kg + "\n\n"
|
||
if transcript:
|
||
raw += "TRANSKRIPT:\n" + transcript + "\n\n"
|
||
raw = raw.strip() or "(Kein Text zum Prüfen)"
|
||
|
||
korrekturen = load_korrekturen()
|
||
corrected, applied = apply_korrekturen(raw, korrekturen)
|
||
display_text = extract_diagnosen_therapie_procedere(corrected)
|
||
|
||
win = tk.Toplevel(self)
|
||
win.title("Korrektur – Korrekturen & Interaktionscheck")
|
||
win.transient(self)
|
||
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(520, 560)
|
||
win.configure(bg="#B9ECFA")
|
||
|
||
def save_pruefen_size():
|
||
try:
|
||
w, h = win.winfo_width(), win.winfo_height()
|
||
x, y = win.winfo_x(), win.winfo_y()
|
||
if w > 200 and h > 150:
|
||
save_pruefen_geometry(w, h, x, y)
|
||
except Exception:
|
||
pass
|
||
|
||
win.bind("<Configure>", lambda e: win.after(500, save_pruefen_size) if e.widget is win else None)
|
||
add_resize_grip(win, 520, 560)
|
||
add_font_scale_control(win)
|
||
|
||
main_f = ttk.Frame(win, padding=12)
|
||
main_f.pack(fill="both", expand=True)
|
||
|
||
full_corrected = [corrected]
|
||
|
||
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.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": INTERACTION_PROMPT},
|
||
{"role": "user", "content": f"Medikamente/Therapien: {med_list}"},
|
||
],
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self.after(0, lambda: self._show_interaktion_window(meds, result))
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler – Websuche als Fallback."))
|
||
self.after(0, lambda: messagebox.showwarning("Interaktionscheck", f"KI-Prüfung fehlgeschlagen.\n{str(e)}\n\nWebsuche wird geöffnet."))
|
||
self.after(0, lambda: _fallback_google(meds))
|
||
|
||
def _fallback_google(meds):
|
||
import webbrowser
|
||
q = "+".join(meds[:6]) + "+Interaktion"
|
||
webbrowser.open(f"https://www.google.com/search?q={q}")
|
||
self.set_status("Websuche geöffnet (Fallback).")
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def do_korrigieren():
|
||
t = full_corrected[0]
|
||
if "KRANKENGESCHICHTE:" in t:
|
||
kg_part = t.split("TRANSKRIPT:")[0].replace("KRANKENGESCHICHTE:", "").strip()
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", kg_part)
|
||
self.set_status("Korrekturen in KG übernommen.")
|
||
elif "TRANSKRIPT:" in t:
|
||
trans_part = t.split("TRANSKRIPT:")[1].split("VORSICHT:")[0].strip()
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_transcript.insert("1.0", trans_part)
|
||
self.set_status("Korrekturen in Transkript übernommen.")
|
||
elif t and t != "(Kein Text zum Prüfen)":
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", t)
|
||
self.set_status("Korrekturen in KG übernommen.")
|
||
|
||
RoundedButton(btn_row, "Korrigieren", command=do_korrigieren, width=100, height=28, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
|
||
RoundedButton(btn_row, "Interaktionscheck", command=do_interaktion, width=140, height=28, canvas_bg="#B9ECFA").pack(side="left")
|
||
|
||
btn_row2 = ttk.Frame(main_f, padding=(0, 4, 0, 0))
|
||
btn_row2.pack(fill="x")
|
||
RoundedButton(btn_row2, "Export", command=do_export, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
|
||
RoundedButton(btn_row2, "Import", command=do_import, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left")
|
||
|
||
bottom_add = ttk.Frame(win, padding=(12, 0, 12, 12))
|
||
bottom_add.pack(fill="x")
|
||
ttk.Label(bottom_add, text="Falsch:").pack(side="left", padx=(0, 4))
|
||
wrong_inline = tk.StringVar()
|
||
ttk.Entry(bottom_add, textvariable=wrong_inline, width=22).pack(side="left", padx=(0, 12))
|
||
ttk.Label(bottom_add, text="→ Richtig:").pack(side="left", padx=4)
|
||
right_inline = tk.StringVar()
|
||
ttk.Entry(bottom_add, textvariable=right_inline, width=22).pack(side="left", padx=(0, 12))
|
||
|
||
def add_inline_and_update():
|
||
w, r = wrong_inline.get().strip(), right_inline.get().strip()
|
||
if not w or not r:
|
||
messagebox.showinfo("Hinweis", "Bitte Falsch- und Richtig-Feld ausfüllen.")
|
||
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")
|
||
rec_win.minsize(350, 130)
|
||
add_resize_grip(rec_win, 350, 130)
|
||
apply_initial_scaling_to_window(rec_win)
|
||
rf = ttk.Frame(rec_win, padding=16)
|
||
rf.pack(fill="both", expand=True)
|
||
status_var = tk.StringVar(value="Bereit. Cursor im Text setzen, dann Aufnahme starten.")
|
||
ttk.Label(rf, textvariable=status_var).pack(pady=(0, 12))
|
||
diktat_rec = [None]
|
||
is_rec = [False]
|
||
|
||
def toggle_rec():
|
||
if not diktat_rec[0]:
|
||
diktat_rec[0] = AudioRecorder()
|
||
rec = diktat_rec[0]
|
||
if not is_rec[0]:
|
||
try:
|
||
rec.start()
|
||
is_rec[0] = True
|
||
btn_rec.configure(text="⏹ Stoppen")
|
||
status_var.set("Aufnahme läuft…")
|
||
except Exception as e:
|
||
messagebox.showerror("Aufnahme-Fehler", str(e))
|
||
rec_win.destroy()
|
||
else:
|
||
is_rec[0] = False
|
||
btn_rec.configure(text="⏺ Aufnahme starten")
|
||
status_var.set("Transkribiere…")
|
||
|
||
def worker():
|
||
try:
|
||
wav_path = rec.stop_and_save_wav()
|
||
transcript_text = self.transcribe_wav(wav_path)
|
||
transcript_text = self._diktat_apply_punctuation(transcript_text)
|
||
try:
|
||
if os.path.exists(wav_path):
|
||
os.remove(wav_path)
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda: _insert_done(transcript_text))
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
self.after(0, lambda: rec_win.destroy())
|
||
|
||
def _insert_done(text):
|
||
diktat_rec[0] = None
|
||
if text:
|
||
idx = text_widget.index(tk.INSERT)
|
||
text_widget.insert(idx, text)
|
||
if status_callback:
|
||
status_callback("Diktat an Cursorposition eingefügt.")
|
||
status_var.set("Fertig.")
|
||
rec_win.destroy()
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
btn_rec = RoundedButton(
|
||
rf, "⏺ Aufnahme starten", command=toggle_rec,
|
||
width=160, height=32, canvas_bg="#B9ECFA",
|
||
)
|
||
btn_rec.pack()
|
||
|
||
def ensure_ready(self):
|
||
if not self.client:
|
||
messagebox.showerror(
|
||
"API-Key fehlt",
|
||
"OPENAI_API_KEY ist nicht gesetzt.\n\n"
|
||
"Lege eine '.env' Datei an (im gleichen Ordner wie dieses Script):\n"
|
||
"OPENAI_API_KEY=sk-...\n"
|
||
)
|
||
return False
|
||
return True
|
||
|
||
def toggle_record(self):
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
if not self.is_recording:
|
||
# 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.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_content},
|
||
{"role": "user", "content": user_text},
|
||
],
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
def merge_kg(self, existing_kg: str, full_transcript: str) -> str:
|
||
"""Ergänzt die bestehende KG um neue Informationen aus dem Transkript."""
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
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.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_content},
|
||
{"role": "user", "content": user_text},
|
||
],
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
def make_kg_from_text(self):
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
if not transcript:
|
||
messagebox.showinfo("Hinweis", "Bitte zuerst diktieren oder Text in das Transkript-Feld einfügen.")
|
||
return
|
||
|
||
self._start_timer("kg")
|
||
|
||
def worker():
|
||
try:
|
||
kg = strip_kg_warnings(self.summarize_text(transcript))
|
||
self.after(0, lambda: self._stop_timer())
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
def _save_kg_and_show(k):
|
||
if k and str(k).strip():
|
||
try:
|
||
save_to_ablage("KG", str(k).strip())
|
||
except Exception:
|
||
pass
|
||
self._show_text_window("KG erneut erstellen", k, buttons="kg")
|
||
self.after(0, lambda k=kg: _save_kg_and_show(k))
|
||
except Exception as e:
|
||
self.after(0, lambda: self._stop_timer())
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
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()
|