2602 lines
118 KiB
Python
2602 lines
118 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
TextWindowsMixin – Fenster für Brief, Rezept, KOGU, Diskussion, OP-Bericht.
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import json
|
||
import base64
|
||
import io
|
||
import threading
|
||
import tempfile
|
||
from datetime import datetime
|
||
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox
|
||
from tkinter.scrolledtext import ScrolledText
|
||
|
||
from aza_persistence import (
|
||
load_text_window_geometry,
|
||
save_text_window_geometry,
|
||
load_diskussion_geometry,
|
||
save_diskussion_geometry,
|
||
load_kogu_gruss,
|
||
save_kogu_gruss,
|
||
load_kogu_templates,
|
||
save_kogu_templates,
|
||
load_diskussion_vorlage,
|
||
save_diskussion_vorlage,
|
||
load_op_bericht_template,
|
||
save_op_bericht_template,
|
||
load_arztbrief_vorlage,
|
||
save_arztbrief_vorlage,
|
||
load_signature_name,
|
||
save_signature_name,
|
||
save_to_ablage,
|
||
_clamp_geometry_str,
|
||
_win_clipboard_set,
|
||
sanitize_markdown_for_plain_text,
|
||
extract_kg_comments,
|
||
load_brief_style_profiles,
|
||
save_brief_style_profiles,
|
||
get_active_brief_style_profile_name,
|
||
set_active_brief_style_profile,
|
||
get_active_brief_style_prompt,
|
||
extract_texts_from_docx_files,
|
||
SYSTEM_STYLE_PROFILES,
|
||
get_all_style_profile_choices,
|
||
strip_kg_warnings,
|
||
get_brief_order_instruction,
|
||
load_brief_presets,
|
||
save_brief_presets,
|
||
)
|
||
from aza_ui_helpers import (
|
||
center_window,
|
||
add_resize_grip,
|
||
add_text_font_size_control,
|
||
apply_initial_scaling_to_window,
|
||
RoundedButton,
|
||
add_font_scale_control,
|
||
load_toplevel_geometry,
|
||
save_toplevel_geometry,
|
||
)
|
||
from aza_prompts import (
|
||
LETTER_PROMPT,
|
||
KOGU_PROMPT,
|
||
OP_BERICHT_PROMPT,
|
||
LETTER_SHORTEN_PROMPT,
|
||
LETTER_EXPAND_PROMPT,
|
||
LETTER_KI_UEBERARBEITET_PROMPT,
|
||
KG_SHORTEN_PROMPT,
|
||
KG_EXPAND_PROMPT,
|
||
KOGU_SHORTEN_PROMPT,
|
||
KOGU_EXPAND_PROMPT,
|
||
OP_BERICHT_SHORTEN_PROMPT,
|
||
OP_BERICHT_EXPAND_PROMPT,
|
||
RECIPE_PROMPT,
|
||
)
|
||
from aza_config import (
|
||
KOGU_GRUSS_OPTIONS, DEFAULT_SUMMARY_MODEL, ALLOWED_SUMMARY_MODELS,
|
||
NUM_BRIEF_PRESETS, BRIEF_PROFILE_DEFAULTS,
|
||
)
|
||
from aza_audio import AudioRecorder
|
||
|
||
|
||
class TextWindowsMixin:
|
||
"""Mixin für Text-Fenster (Brief, Rezept, KOGU, Diskussion, OP-Bericht)."""
|
||
|
||
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 _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 = 350, 300
|
||
win = tk.Toplevel(self)
|
||
win.title(title)
|
||
win.transient(self)
|
||
win.minsize(TEXT_WIN_MIN_W, TEXT_WIN_MIN_H)
|
||
win.configure(bg="#B9ECFA")
|
||
win.attributes("-topmost", True)
|
||
if hasattr(self, "_aza_windows"):
|
||
self._aza_windows.add(win)
|
||
self._register_window(win)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
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")
|
||
center_window(win, 780, 740)
|
||
else:
|
||
# Keine gespeicherte Position → zentrieren
|
||
win.geometry("620x600")
|
||
center_window(win, 620, 600)
|
||
|
||
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_header = ttk.Frame(text_frame)
|
||
text_header.pack(fill="x", anchor="w")
|
||
ttk.Label(text_header, text=f"{title}:").pack(side="left")
|
||
text_widget = ScrolledText(
|
||
text_frame, wrap="word", font=self._text_font, bg="#F5FCFF", state="normal"
|
||
)
|
||
text_widget.pack(fill="both", expand=True)
|
||
add_text_font_size_control(text_header, text_widget, initial_size=10, bg_color="#B9ECFA", save_key="text_window")
|
||
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":
|
||
self._last_brief_text_widget = text_widget
|
||
|
||
btn_frame_brief = ttk.Frame(win, padding=(12, 4, 12, 14))
|
||
btn_frame_brief.pack(fill="x")
|
||
|
||
profile_frame = tk.Frame(win, bg="#E8F4F8", padx=8, pady=4)
|
||
profile_frame.pack(fill="x")
|
||
|
||
sp_enabled = self._autotext_data.get("stilprofil_enabled", False)
|
||
sp_name = self._autotext_data.get("stilprofil_name", "")
|
||
sp_default = self._autotext_data.get("stilprofil_default_brief", False)
|
||
|
||
sp_enabled_var = tk.BooleanVar(value=sp_enabled)
|
||
sp_default_var = tk.BooleanVar(value=sp_default)
|
||
|
||
profile_choices = get_all_style_profile_choices()
|
||
sp_name_var = tk.StringVar(value=sp_name if sp_name and sp_name in profile_choices else "(keins)")
|
||
|
||
row1 = tk.Frame(profile_frame, bg="#E8F4F8")
|
||
row1.pack(fill="x", pady=(0, 2))
|
||
|
||
tk.Checkbutton(row1, text="Stilprofil anwenden", variable=sp_enabled_var,
|
||
font=("Segoe UI", 9), bg="#E8F4F8", fg="#1a4d6d",
|
||
activebackground="#E8F4F8", selectcolor="#E8F4F8",
|
||
command=lambda: _on_sp_toggle()).pack(side="left")
|
||
|
||
sp_combo = ttk.Combobox(row1, textvariable=sp_name_var,
|
||
values=profile_choices, state="readonly", width=24)
|
||
sp_combo.pack(side="left", padx=(6, 0))
|
||
|
||
profile_status = tk.Label(row1, font=("Segoe UI", 8), bg="#E8F4F8", fg="#5B8DB3")
|
||
profile_status.pack(side="left", padx=(8, 0))
|
||
|
||
row2 = tk.Frame(profile_frame, bg="#E8F4F8")
|
||
row2.pack(fill="x")
|
||
|
||
tk.Checkbutton(row2, text="Standard fuer Arztbriefe", variable=sp_default_var,
|
||
font=("Segoe UI", 8), bg="#E8F4F8", fg="#5B8DB3",
|
||
activebackground="#E8F4F8", selectcolor="#E8F4F8",
|
||
command=lambda: _on_sp_default_change()).pack(side="left")
|
||
|
||
def _save_sp_state():
|
||
self._autotext_data["stilprofil_enabled"] = sp_enabled_var.get()
|
||
sel = sp_name_var.get()
|
||
name_val = "" if sel == "(keins)" else sel
|
||
self._autotext_data["stilprofil_name"] = name_val
|
||
self._autotext_data["active_brief_profile"] = name_val
|
||
self._autotext_data["stilprofil_default_brief"] = sp_default_var.get()
|
||
try:
|
||
from aza_persistence import save_autotext
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
|
||
def _update_status():
|
||
if not sp_enabled_var.get():
|
||
profile_status.configure(text="Standardbrief (kein Stilprofil)")
|
||
else:
|
||
sel = sp_name_var.get()
|
||
if sel == "(keins)":
|
||
profile_status.configure(text="Standardbrief (kein Stilprofil)")
|
||
else:
|
||
profile_status.configure(text=f"Aktiv: {sel}")
|
||
|
||
def _on_sp_toggle():
|
||
_save_sp_state()
|
||
_update_status()
|
||
if sp_enabled_var.get() and sp_name_var.get() != "(keins)":
|
||
self._regenerate_brief_live(text_widget, tw_status)
|
||
|
||
def _on_sp_change(*_):
|
||
_save_sp_state()
|
||
_update_status()
|
||
if sp_enabled_var.get() and sp_name_var.get() != "(keins)":
|
||
self._regenerate_brief_live(text_widget, tw_status)
|
||
|
||
def _on_sp_default_change():
|
||
_save_sp_state()
|
||
|
||
sp_combo.bind("<<ComboboxSelected>>", _on_sp_change)
|
||
_update_status()
|
||
|
||
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(sanitize_markdown_for_plain_text(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
|
||
if hasattr(self, "_aza_windows"):
|
||
self._aza_windows.discard(win)
|
||
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
|
||
if hasattr(self, "_aza_windows"):
|
||
self._aza_windows.discard(win)
|
||
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()
|
||
|
||
_shorten_level = [0]
|
||
|
||
def _shorten_level_instruction(level: int) -> str:
|
||
if level <= 1:
|
||
return ""
|
||
if level == 2:
|
||
return ("\nDies ist bereits die 2. Kürzung. Kürze DEUTLICH stärker als beim letzten Mal. "
|
||
"Entferne weniger wichtige Details, Hintergrundinformationen und Erläuterungen. "
|
||
"Nur das Wesentlichste behalten.")
|
||
if level == 3:
|
||
return ("\nDies ist bereits die 3. Kürzung. Kürze SEHR aggressiv. "
|
||
"Nur noch die wichtigsten Diagnosen, Befunde und Empfehlungen. "
|
||
"Keine Erläuterungen, keine Nebensächlichkeiten, keine Einleitungssätze.")
|
||
return ("\nDies ist die {}. Kürzung. MAXIMAL komprimieren! "
|
||
"Nur noch Schlüsseldiagnosen mit ICD-10, zentrale Befunde und "
|
||
"die wichtigste Empfehlung. Alles andere weglassen. "
|
||
"So kurz wie möglich.").format(level)
|
||
|
||
def do_shorter():
|
||
t = get_content_to_send()
|
||
if not t:
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
_shorten_level[0] += 1
|
||
level = _shorten_level[0]
|
||
tw_status(f"Kürze {action_label}… (Stufe {level})")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
prompt = shorten_prompt + _shorten_level_instruction(level)
|
||
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)
|
||
result = append_kogu_footer(result)
|
||
self.after(0, lambda: _update_text(result))
|
||
self.after(0, lambda: tw_status(f"Fertig – Kürzung Stufe {level}."))
|
||
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 _regenerate_brief_with_vorlage():
|
||
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:
|
||
return
|
||
user_content = (
|
||
"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)'}"
|
||
)
|
||
bi = get_brief_order_instruction()
|
||
ve = load_arztbrief_vorlage()
|
||
prompt = LETTER_PROMPT + bi
|
||
if ve:
|
||
prompt += (
|
||
"\n\nZUSÄTZLICHE ANWEISUNGEN DES ARZTES (ZWINGEND EINHALTEN):\n"
|
||
+ ve
|
||
)
|
||
tw_status("Aktualisiere Brief mit neuen Anweisungen…")
|
||
|
||
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": user_content},
|
||
],
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
result = re.sub(r"\*+", "", result)
|
||
result = re.sub(r"#+", "", result)
|
||
self.after(0, lambda: _update_brief_from_vorlage(result))
|
||
self.after(0, lambda: tw_status("Brief aktualisiert."))
|
||
except Exception as e:
|
||
self.after(0, lambda: tw_status("Fehler beim Aktualisieren."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
|
||
def _update_brief_from_vorlage(new_text):
|
||
text_widget.configure(state="normal")
|
||
text_widget.delete("1.0", "end")
|
||
text_widget.insert("1.0", new_text)
|
||
self._last_brief_text = new_text
|
||
try:
|
||
save_to_ablage("Briefe", new_text)
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def do_open_brief_vorlage():
|
||
self._open_brief_vorlage(win, on_save_callback=_regenerate_brief_with_vorlage)
|
||
|
||
RoundedButton(
|
||
btn_frame_brief, "Vorlage", command=do_open_brief_vorlage,
|
||
width=90, height=28, canvas_bg="#B9ECFA",
|
||
).pack(side="left", padx=(0, 8))
|
||
|
||
def do_open_stilprofil():
|
||
self._open_brief_stilprofil_dialog(win)
|
||
|
||
RoundedButton(
|
||
btn_frame_brief, "Stilprofil", command=do_open_stilprofil,
|
||
width=90, height=28, canvas_bg="#E8F4F8", bg="#E8F4F8", fg="#1a4d6d",
|
||
active_bg="#D0E8F0",
|
||
).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")
|
||
|
||
# ─── Brief-Section-Controls (+/−) ───
|
||
from aza_persistence import get_active_brief_preset
|
||
brief_preset = get_active_brief_preset()
|
||
b_order = brief_preset.get("order", [])
|
||
b_labels = brief_preset.get("labels", {})
|
||
b_vis = brief_preset.get("visibility", {})
|
||
_brief_section_levels = {}
|
||
_brief_section_labels = {}
|
||
|
||
brief_sec_frame = tk.Frame(win, bg="#B9ECFA", padx=8, pady=4)
|
||
brief_sec_frame.pack(fill="x")
|
||
brief_sec_inner = tk.Frame(brief_sec_frame, bg="#B9ECFA")
|
||
brief_sec_inner.pack()
|
||
|
||
_FG = "#1a4d6d"
|
||
_ARROW_FG = "#7AAFC8"
|
||
_ARROW_HOVER = "#1a4d6d"
|
||
|
||
_KEEP_TWO = {"BF", "BE"}
|
||
_visible_keys = [k for k in b_order if b_vis.get(k, True)]
|
||
_first_letter_counts = {}
|
||
for _k in _visible_keys:
|
||
fl = _k[0]
|
||
_first_letter_counts[fl] = _first_letter_counts.get(fl, 0) + 1
|
||
|
||
def _brief_short(sec_key):
|
||
if sec_key in _KEEP_TWO:
|
||
return sec_key
|
||
if _first_letter_counts.get(sec_key[0], 0) > 1:
|
||
return sec_key[:2]
|
||
return sec_key[0]
|
||
|
||
_brief_shorts = {k: _brief_short(k) for k in _visible_keys}
|
||
|
||
def _apply_brief_section_edit(sec_key, direction):
|
||
lv = _brief_section_levels.get(sec_key, 0) + direction
|
||
lv = max(-3, min(3, lv))
|
||
_brief_section_levels[sec_key] = lv
|
||
lbl = _brief_section_labels.get(sec_key)
|
||
short = _brief_shorts.get(sec_key, sec_key[:2])
|
||
if lbl:
|
||
lbl.configure(text=short if lv == 0 else f"{short}{lv:+d}")
|
||
t = text_widget.get("1.0", "end").strip()
|
||
if not t:
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
name = b_labels.get(sec_key, sec_key)
|
||
if lv == 0:
|
||
return
|
||
elif lv < 0:
|
||
instr = f"Kürze den Abschnitt '{name}' – gleiche Fakten, aber knapper formuliert. KEINE Informationen entfernen."
|
||
else:
|
||
instr = f"Formuliere den Abschnitt '{name}' ausführlicher – gleiche Fakten in vollständigeren Sätzen. KEINE neuen Fakten erfinden."
|
||
tw_status(f"{name} wird angepasst…")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
prompt = (
|
||
"Du bist ärztliche Dokumentationsassistenz. "
|
||
f"{instr} "
|
||
"Gib den gesamten Brief zurück. Keine Sternchen (*), kein Markdown."
|
||
)
|
||
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)
|
||
def _update():
|
||
text_widget.configure(state="normal")
|
||
text_widget.delete("1.0", "end")
|
||
text_widget.insert("1.0", result)
|
||
tw_status("Fertig.")
|
||
self.after(0, _update)
|
||
except Exception as ex:
|
||
self.after(0, lambda: tw_status("Fehler."))
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
for sec_key in b_order:
|
||
if not b_vis.get(sec_key, True):
|
||
continue
|
||
sf = tk.Frame(brief_sec_inner, bg="#B9ECFA")
|
||
sf.pack(side="left", padx=(0, 8))
|
||
short = _brief_shorts.get(sec_key, sec_key[:2])
|
||
lv = _brief_section_levels.get(sec_key, 0)
|
||
lbl_text = short if lv == 0 else f"{short}{lv:+d}"
|
||
w_chars = 3 if len(short) <= 1 else 4
|
||
sec_label = tk.Label(sf, text=lbl_text, font=("Segoe UI", 10, "bold"),
|
||
bg="#B9ECFA", fg=_FG, anchor="center", width=w_chars, pady=1)
|
||
sec_label.pack(side="left")
|
||
_brief_section_labels[sec_key] = sec_label
|
||
btn_up = tk.Label(sf, text="\u25B2", font=("Segoe UI", 8),
|
||
bg="#B9ECFA", fg=_ARROW_FG, cursor="hand2",
|
||
padx=1, pady=0)
|
||
btn_up.pack(side="left")
|
||
btn_down = tk.Label(sf, text="\u25BC", font=("Segoe UI", 8),
|
||
bg="#B9ECFA", fg=_ARROW_FG, cursor="hand2",
|
||
padx=1, pady=0)
|
||
btn_down.pack(side="left")
|
||
btn_up.bind("<Button-1>", lambda e, k=sec_key: _apply_brief_section_edit(k, 1))
|
||
btn_down.bind("<Button-1>", lambda e, k=sec_key: _apply_brief_section_edit(k, -1))
|
||
btn_up.bind("<Enter>", lambda e, w=btn_up: w.configure(fg=_ARROW_HOVER))
|
||
btn_up.bind("<Leave>", lambda e, w=btn_up: w.configure(fg=_ARROW_FG))
|
||
btn_down.bind("<Enter>", lambda e, w=btn_down: w.configure(fg=_ARROW_HOVER))
|
||
btn_down.bind("<Leave>", lambda e, w=btn_down: w.configure(fg=_ARROW_FG))
|
||
|
||
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)
|
||
tw.attributes("-topmost", True)
|
||
self._register_window(tw)
|
||
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)
|
||
tw.attributes("-topmost", True)
|
||
self._register_window(tw)
|
||
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)
|
||
rec_win.attributes("-topmost", True)
|
||
self._register_window(rec_win)
|
||
add_resize_grip(rec_win, 350, 130)
|
||
apply_initial_scaling_to_window(rec_win)
|
||
rf = ttk.Frame(rec_win, padding=16)
|
||
rf.pack(fill="both", expand=True)
|
||
status_var = tk.StringVar(value="Bereit. 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 _open_brief_vorlage(self, parent=None, on_save_callback=None):
|
||
"""Vorlage-Fenster für Arztbrief – Profile mit variablen Sections + editierbare Überschriften."""
|
||
import copy
|
||
VOR_W, VOR_H = 560, 700
|
||
win = tk.Toplevel(parent or self)
|
||
win.title("Vorlage – Arztbrief")
|
||
win.transient(parent or self)
|
||
win.minsize(440, 500)
|
||
win.configure(bg="#E8F4FA")
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
|
||
saved_geom = load_toplevel_geometry("brief_vorlage")
|
||
if saved_geom:
|
||
win.geometry(saved_geom)
|
||
else:
|
||
win.geometry(f"{VOR_W}x{VOR_H}")
|
||
center_window(win, VOR_W, VOR_H)
|
||
|
||
def _on_close():
|
||
try:
|
||
save_toplevel_geometry("brief_vorlage", win.geometry())
|
||
except Exception:
|
||
pass
|
||
win.destroy()
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _on_close)
|
||
win.bind("<Configure>", lambda e: save_toplevel_geometry("brief_vorlage", win.geometry()))
|
||
|
||
header = tk.Frame(win, bg="#C8E8C8")
|
||
header.pack(fill="x")
|
||
tk.Label(header, text="📋 Vorlage für Arztbrief", font=("Segoe UI", 12, "bold"),
|
||
bg="#C8E8C8", fg="#2A5A2A").pack(padx=12, pady=8)
|
||
|
||
order_frame = tk.LabelFrame(win, text=" Abschnitts-Reihenfolge ", font=("Segoe UI", 9, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d", padx=10, pady=6)
|
||
order_frame.pack(fill="x", padx=14, pady=(10, 4))
|
||
|
||
presets_data = load_brief_presets()
|
||
active_idx = [presets_data.get("active", 0)]
|
||
|
||
profile_bar = tk.Frame(order_frame, bg="#E8F4FA")
|
||
profile_bar.pack(fill="x", pady=(0, 4))
|
||
|
||
_PROF_ACTIVE = {"bg": "#1a4d6d", "fg": "white", "font": ("Segoe UI", 9, "bold")}
|
||
_PROF_INACTIVE = {"bg": "#D4EEF7", "fg": "#1a4d6d", "font": ("Segoe UI", 9)}
|
||
|
||
_profile_btns = []
|
||
order_list = []
|
||
current_labels = {}
|
||
current_visibility = {}
|
||
visibility_vars = {}
|
||
row_widgets = []
|
||
drag_info = {"active": False, "src_idx": -1, "num_labels": []}
|
||
|
||
list_frame = tk.Frame(order_frame, bg="#E8F4FA")
|
||
list_frame.pack(fill="x")
|
||
|
||
def _load_preset(idx):
|
||
active_idx[0] = idx
|
||
preset = presets_data["presets"][idx]
|
||
order_list.clear()
|
||
order_list.extend(preset.get("order", []))
|
||
current_labels.clear()
|
||
current_labels.update(preset.get("labels", {}))
|
||
current_visibility.clear()
|
||
vis = preset.get("visibility", {})
|
||
current_visibility.update({k: vis.get(k, True) for k in order_list})
|
||
visibility_vars.clear()
|
||
for k in order_list:
|
||
visibility_vars[k] = tk.BooleanVar(value=current_visibility.get(k, True))
|
||
for i, btn in enumerate(_profile_btns):
|
||
pn = presets_data["presets"][i].get("name", f"Profil {i+1}")
|
||
marker = "\u25cf " if i == idx else "\u25cb "
|
||
btn.configure(text=f" {marker}{pn} ",
|
||
**(_PROF_ACTIVE if i == idx else _PROF_INACTIVE))
|
||
_rebuild_order_ui()
|
||
|
||
def _save_current_to_preset():
|
||
idx = active_idx[0]
|
||
presets_data["presets"][idx]["order"] = list(order_list)
|
||
presets_data["presets"][idx]["labels"] = dict(current_labels)
|
||
presets_data["presets"][idx]["visibility"] = dict(current_visibility)
|
||
presets_data["active"] = idx
|
||
|
||
for pi in range(NUM_BRIEF_PRESETS):
|
||
pname = presets_data["presets"][pi].get("name", f"Profil {pi+1}")
|
||
is_active = (pi == active_idx[0])
|
||
style = _PROF_ACTIVE if is_active else _PROF_INACTIVE
|
||
marker = "\u25cf " if is_active else "\u25cb "
|
||
btn = tk.Label(profile_bar, text=f" {marker}{pname} ", cursor="hand2",
|
||
padx=10, pady=3, **style)
|
||
btn.pack(side="left", padx=(0, 4))
|
||
btn.bind("<Button-1>", lambda e, i=pi: _load_preset(i))
|
||
btn.bind("<Enter>", lambda e, b=btn, i=pi: b.configure(
|
||
bg="#2a6a8d" if i == active_idx[0] else "#B8DDE8"))
|
||
btn.bind("<Leave>", lambda e, b=btn, i=pi: b.configure(
|
||
**(_PROF_ACTIVE if i == active_idx[0] else _PROF_INACTIVE)))
|
||
_profile_btns.append(btn)
|
||
|
||
tk.Label(order_frame,
|
||
text="Drag-and-Drop · Häkchen = im Brief anzeigen · Doppelklick = Überschrift umbenennen:",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#4a8aaa").pack(anchor="w", pady=(0, 4))
|
||
|
||
def _on_vis_toggle(key):
|
||
current_visibility[key] = visibility_vars[key].get()
|
||
_rebuild_order_ui()
|
||
|
||
def _rename_section(key):
|
||
old_name = current_labels.get(key, key)
|
||
rename_win = tk.Toplevel(win)
|
||
rename_win.title("Überschrift umbenennen")
|
||
rename_win.transient(win)
|
||
rename_win.geometry("350x100")
|
||
rename_win.configure(bg="#E8F4FA")
|
||
rename_win.attributes("-topmost", True)
|
||
self._register_window(rename_win)
|
||
center_window(rename_win, 350, 100)
|
||
tk.Label(rename_win, text=f"Neue Überschrift für «{old_name}»:",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#1a4d6d").pack(padx=12, pady=(10, 4))
|
||
name_var = tk.StringVar(value=old_name)
|
||
entry = tk.Entry(rename_win, textvariable=name_var, font=("Segoe UI", 11),
|
||
bg="white", fg="#1a4d6d", relief="flat", bd=1)
|
||
entry.pack(fill="x", padx=12, pady=(0, 8))
|
||
entry.select_range(0, "end")
|
||
entry.focus_set()
|
||
|
||
def _do_rename(event=None):
|
||
new_name = name_var.get().strip()
|
||
if new_name:
|
||
current_labels[key] = new_name
|
||
_rebuild_order_ui()
|
||
rename_win.destroy()
|
||
|
||
entry.bind("<Return>", _do_rename)
|
||
tk.Button(rename_win, text="OK", command=_do_rename,
|
||
font=("Segoe UI", 9), bg="#5BDB7B", fg="#1a4d6d",
|
||
relief="flat", padx=12, cursor="hand2").pack(pady=(0, 8))
|
||
|
||
def _drag_start(event, idx):
|
||
drag_info["active"] = True
|
||
drag_info["src_idx"] = idx
|
||
row_widgets[idx].configure(highlightbackground="#FFA500", highlightthickness=2)
|
||
|
||
def _drag_motion(event):
|
||
if not drag_info["active"]:
|
||
return
|
||
src = drag_info["src_idx"]
|
||
y = event.y_root
|
||
target = src
|
||
for i, row in enumerate(row_widgets):
|
||
try:
|
||
ry = row.winfo_rooty()
|
||
rh = row.winfo_height()
|
||
if ry <= y < ry + rh:
|
||
target = i
|
||
break
|
||
except Exception:
|
||
pass
|
||
if target == src:
|
||
return
|
||
item = order_list.pop(src)
|
||
order_list.insert(target, item)
|
||
row = row_widgets.pop(src)
|
||
row_widgets.insert(target, row)
|
||
nlbl = drag_info["num_labels"].pop(src)
|
||
drag_info["num_labels"].insert(target, nlbl)
|
||
for w in row_widgets:
|
||
w.pack_forget()
|
||
for i, w in enumerate(row_widgets):
|
||
w.pack(fill="x", pady=1)
|
||
for i, lbl in enumerate(drag_info["num_labels"]):
|
||
lbl.configure(text=f"{i + 1}.")
|
||
for w in row_widgets:
|
||
w.configure(highlightbackground="#B0D4E8", highlightthickness=1)
|
||
row_widgets[target].configure(highlightbackground="#FFA500", highlightthickness=2)
|
||
drag_info["src_idx"] = target
|
||
|
||
def _drag_end(event):
|
||
if drag_info["active"]:
|
||
drag_info["active"] = False
|
||
_rebuild_order_ui()
|
||
|
||
def _rebuild_order_ui():
|
||
for w in list_frame.winfo_children():
|
||
w.destroy()
|
||
row_widgets.clear()
|
||
drag_info["num_labels"] = []
|
||
for idx, key in enumerate(order_list):
|
||
vis = visibility_vars.get(key, tk.BooleanVar(value=True)).get()
|
||
row_bg = "white" if vis else "#F0F0F0"
|
||
fg_color = "#1a4d6d" if vis else "#AAAAAA"
|
||
row = tk.Frame(list_frame, bg=row_bg, highlightbackground="#B0D4E8",
|
||
highlightthickness=1, padx=4, pady=2)
|
||
row.pack(fill="x", pady=1)
|
||
handle = tk.Label(row, text="☰", font=("Segoe UI", 10),
|
||
bg=row_bg, fg="#B0D4E8", cursor="fleur", padx=2)
|
||
handle.pack(side="left", padx=(2, 4))
|
||
cb = tk.Checkbutton(row, variable=visibility_vars.get(key),
|
||
bg=row_bg, activebackground=row_bg,
|
||
command=lambda k=key: _on_vis_toggle(k))
|
||
cb.pack(side="left", padx=(0, 0))
|
||
num_lbl = tk.Label(row, text=f"{idx + 1}.", font=("Segoe UI", 10, "bold"),
|
||
bg=row_bg, fg=fg_color, width=2)
|
||
num_lbl.pack(side="left", padx=(2, 2))
|
||
drag_info["num_labels"].append(num_lbl)
|
||
label_text = current_labels.get(key, key)
|
||
name_lbl = tk.Label(row, text=label_text,
|
||
font=("Segoe UI", 10), bg=row_bg, fg=fg_color, anchor="w")
|
||
name_lbl.pack(side="left", fill="x", expand=True, padx=4)
|
||
name_lbl.bind("<Double-Button-1>", lambda e, k=key: _rename_section(k))
|
||
for widget in (handle, num_lbl, row):
|
||
widget.bind("<ButtonPress-1>", lambda e, i=idx: _drag_start(e, i))
|
||
widget.bind("<B1-Motion>", _drag_motion)
|
||
widget.bind("<ButtonRelease-1>", _drag_end)
|
||
handle.bind("<Enter>", lambda e, h=handle: h.configure(fg="#1a4d6d"))
|
||
handle.bind("<Leave>", lambda e, h=handle: h.configure(fg="#B0D4E8"))
|
||
row_widgets.append(row)
|
||
|
||
_load_preset(active_idx[0])
|
||
|
||
def _reset_order():
|
||
idx = active_idx[0]
|
||
defaults = BRIEF_PROFILE_DEFAULTS[idx] if idx < len(BRIEF_PROFILE_DEFAULTS) else BRIEF_PROFILE_DEFAULTS[0]
|
||
order_list.clear()
|
||
order_list.extend(defaults["order"])
|
||
current_labels.clear()
|
||
current_labels.update(defaults["labels"])
|
||
current_visibility.clear()
|
||
current_visibility.update(defaults["visibility"])
|
||
visibility_vars.clear()
|
||
for k in order_list:
|
||
visibility_vars[k] = tk.BooleanVar(value=True)
|
||
_rebuild_order_ui()
|
||
|
||
reset_btn = tk.Label(order_frame, text="↺ Standard-Reihenfolge", font=("Segoe UI", 8),
|
||
bg="#E8F4FA", fg="#7EC8E3", cursor="hand2")
|
||
reset_btn.pack(anchor="e", pady=(4, 0))
|
||
reset_btn.bind("<Button-1>", lambda e: _reset_order())
|
||
reset_btn.bind("<Enter>", lambda e: reset_btn.configure(fg="#1a4d6d"))
|
||
reset_btn.bind("<Leave>", lambda e: reset_btn.configure(fg="#7EC8E3"))
|
||
|
||
# ─── Freitext-Vorlage ───
|
||
tk.Label(win, text="Zusätzliche Anweisungen für den Brief (optional):",
|
||
font=("Segoe UI", 9, "bold"), bg="#E8F4FA", fg="#1a4d6d"
|
||
).pack(anchor="w", padx=14, pady=(8, 2))
|
||
|
||
txt_frame = tk.Frame(win, bg="#E8F4FA", padx=14)
|
||
txt_frame.pack(fill="both", expand=True, pady=(0, 4))
|
||
vorlage_text = tk.Text(txt_frame, font=("Segoe UI", 11), bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0, wrap="word",
|
||
insertbackground="#1a4d6d", padx=8, pady=6, height=4)
|
||
vorlage_text.pack(fill="both", expand=True)
|
||
|
||
current = load_arztbrief_vorlage()
|
||
if current:
|
||
vorlage_text.insert("1.0", current)
|
||
|
||
status_var = tk.StringVar(value="")
|
||
|
||
btn_frame = tk.Frame(win, bg="#D4EEF7", padx=14, pady=8)
|
||
btn_frame.pack(fill="x")
|
||
|
||
def _save():
|
||
text = vorlage_text.get("1.0", "end-1c").strip()
|
||
save_arztbrief_vorlage(text)
|
||
_save_current_to_preset()
|
||
save_brief_presets(presets_data)
|
||
pname = presets_data["presets"][active_idx[0]].get("name", f"Profil {active_idx[0]+1}")
|
||
status_var.set(f"✓ {pname} + Vorlage gespeichert.")
|
||
|
||
def _clear():
|
||
vorlage_text.delete("1.0", "end")
|
||
save_arztbrief_vorlage("")
|
||
_reset_order()
|
||
_save_current_to_preset()
|
||
save_brief_presets(presets_data)
|
||
status_var.set("Alles zurückgesetzt.")
|
||
|
||
def _save_and_close():
|
||
_save()
|
||
_on_close()
|
||
if on_save_callback:
|
||
on_save_callback()
|
||
|
||
tk.Button(btn_frame, text="💾 Speichern", font=("Segoe UI", 11, "bold"),
|
||
bg="#5BDB7B", fg="#1a4d6d", activebackground="#4BCB6B",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=_save).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Speichern & Schliessen", font=("Segoe UI", 9),
|
||
bg="#7EC8E3", fg="#1a4d6d", activebackground="#6CB8D3",
|
||
relief="flat", bd=0, padx=12, pady=4, cursor="hand2",
|
||
command=_save_and_close).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Zurücksetzen", font=("Segoe UI", 9),
|
||
bg="#E0E0E0", fg="#666", activebackground="#D0D0D0",
|
||
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
|
||
command=_clear).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 9),
|
||
bg="#C8DDE6", fg="#1a4d6d", activebackground="#B8CDD6",
|
||
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
|
||
command=_on_close).pack(side="right")
|
||
|
||
tk.Label(win, textvariable=status_var, font=("Segoe UI", 8, "bold"),
|
||
bg="#E8F4FA", fg="#2A7A2A").pack(fill="x", padx=14, pady=(0, 4))
|
||
|
||
def 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)'}"
|
||
)
|
||
|
||
self._last_brief_user_text = user_text
|
||
|
||
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")
|
||
|
||
sp_enabled = self._autotext_data.get("stilprofil_enabled", False)
|
||
sp_name = self._autotext_data.get("stilprofil_name", "")
|
||
|
||
if sp_enabled and sp_name == "KISIM Bericht":
|
||
from aza_prompts import KISIM_BRIEF_PROFILE_PROMPT
|
||
sig_name = load_signature_name()
|
||
from datetime import date
|
||
datum_str = date.today().strftime("%d.%m.%Y")
|
||
profile_prompt = KISIM_BRIEF_PROFILE_PROMPT.replace(
|
||
"{datum}", datum_str).replace(
|
||
"{arztname}", sig_name or "Arzt/Ärztin").replace(
|
||
"{signaturname}", sig_name or "")
|
||
full_letter_prompt = profile_prompt
|
||
elif sp_enabled and sp_name == "Klinischer Bericht":
|
||
from aza_prompts import KLINISCHER_BRIEF_PROFILE_PROMPT
|
||
sig_name = load_signature_name()
|
||
from datetime import date
|
||
datum_str = date.today().strftime("%d.%m.%Y")
|
||
profile_prompt = KLINISCHER_BRIEF_PROFILE_PROMPT.replace(
|
||
"{datum}", datum_str).replace(
|
||
"{arztname}", sig_name or "Arzt/Ärztin").replace(
|
||
"{signaturname}", sig_name or "")
|
||
full_letter_prompt = profile_prompt
|
||
else:
|
||
brief_instruction = get_brief_order_instruction()
|
||
full_letter_prompt = LETTER_PROMPT + brief_instruction
|
||
|
||
vorlage_extra = load_arztbrief_vorlage()
|
||
if vorlage_extra:
|
||
full_letter_prompt += (
|
||
"\n\nZUSÄTZLICHE ANWEISUNGEN DES ARZTES (ZWINGEND EINHALTEN):\n"
|
||
+ vorlage_extra
|
||
)
|
||
|
||
if sp_enabled and sp_name and sp_name not in SYSTEM_STYLE_PROFILES:
|
||
style_prompt = get_active_brief_style_prompt()
|
||
if style_prompt:
|
||
full_letter_prompt = (
|
||
"STIL-ANWEISUNG (aus frueheren Briefen gelernt – NUR Stil/Struktur/Formulierung anwenden, "
|
||
"KEINE Patientendaten/Diagnosen/Therapien aus dem Stilprofil uebernehmen, "
|
||
"medizinischen Inhalt NUR aus dem aktuellen Fall entnehmen):\n"
|
||
+ style_prompt
|
||
+ "\n\n--- ENDE STIL-ANWEISUNG ---\n\n"
|
||
+ full_letter_prompt
|
||
)
|
||
elif not sp_enabled:
|
||
pass
|
||
|
||
self._request_async_document(
|
||
full_letter_prompt,
|
||
user_text,
|
||
"Erstelle Brief…",
|
||
on_success,
|
||
)
|
||
|
||
def _build_brief_prompt_for_profile(self):
|
||
"""Baut den Brief-Prompt basierend auf den aktuellen Stilprofil-Einstellungen."""
|
||
sp_enabled = self._autotext_data.get("stilprofil_enabled", False)
|
||
sp_name = self._autotext_data.get("stilprofil_name", "")
|
||
|
||
if sp_enabled and sp_name == "KISIM Bericht":
|
||
from aza_prompts import KISIM_BRIEF_PROFILE_PROMPT
|
||
sig_name = load_signature_name()
|
||
from datetime import date
|
||
datum_str = date.today().strftime("%d.%m.%Y")
|
||
full_letter_prompt = KISIM_BRIEF_PROFILE_PROMPT.replace(
|
||
"{datum}", datum_str).replace(
|
||
"{arztname}", sig_name or "Arzt/Ärztin").replace(
|
||
"{signaturname}", sig_name or "")
|
||
elif sp_enabled and sp_name == "Klinischer Bericht":
|
||
from aza_prompts import KLINISCHER_BRIEF_PROFILE_PROMPT
|
||
sig_name = load_signature_name()
|
||
from datetime import date
|
||
datum_str = date.today().strftime("%d.%m.%Y")
|
||
full_letter_prompt = KLINISCHER_BRIEF_PROFILE_PROMPT.replace(
|
||
"{datum}", datum_str).replace(
|
||
"{arztname}", sig_name or "Arzt/Ärztin").replace(
|
||
"{signaturname}", sig_name or "")
|
||
else:
|
||
brief_instruction = get_brief_order_instruction()
|
||
full_letter_prompt = LETTER_PROMPT + brief_instruction
|
||
|
||
vorlage_extra = load_arztbrief_vorlage()
|
||
if vorlage_extra:
|
||
full_letter_prompt += (
|
||
"\n\nZUSAETZLICHE ANWEISUNGEN DES ARZTES (ZWINGEND EINHALTEN):\n"
|
||
+ vorlage_extra
|
||
)
|
||
|
||
if sp_enabled and sp_name and sp_name not in SYSTEM_STYLE_PROFILES:
|
||
style_prompt = get_active_brief_style_prompt()
|
||
if style_prompt:
|
||
full_letter_prompt = (
|
||
"STIL-ANWEISUNG (aus frueheren Briefen gelernt):\n"
|
||
+ style_prompt
|
||
+ "\n\n--- ENDE STIL-ANWEISUNG ---\n\n"
|
||
+ full_letter_prompt
|
||
)
|
||
return full_letter_prompt
|
||
|
||
def _regenerate_brief_live(self, text_widget, tw_status):
|
||
"""Generiert den aktuellen Brief live mit dem geaenderten Stilprofil neu."""
|
||
user_text = getattr(self, "_last_brief_user_text", None)
|
||
if not user_text:
|
||
tw_status("Kein Quelltext fuer Regenerierung vorhanden.")
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
tw_status("Brief wird mit neuem Stilprofil neu generiert...")
|
||
full_letter_prompt = self._build_brief_prompt_for_profile()
|
||
|
||
def on_done(result: str):
|
||
text = (result or "").strip()
|
||
text = re.sub(r"\*+", "", text)
|
||
text = re.sub(r"#+", "", text)
|
||
self._last_brief_text = text
|
||
try:
|
||
text_widget.configure(state="normal")
|
||
text_widget.delete("1.0", "end")
|
||
text_widget.insert("1.0", text)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
if text:
|
||
try:
|
||
save_to_ablage("Briefe", text)
|
||
except Exception:
|
||
pass
|
||
tw_status("Brief mit neuem Stilprofil aktualisiert.")
|
||
|
||
self._request_async_document(
|
||
full_letter_prompt,
|
||
user_text,
|
||
"Generiere Brief mit neuem Stilprofil...",
|
||
on_done,
|
||
)
|
||
|
||
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)
|
||
win.configure(bg="#E8F4F8")
|
||
win.attributes("-topmost", True)
|
||
if hasattr(self, "_aza_windows"):
|
||
self._aza_windows.add(win)
|
||
self._register_window(win)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
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")
|
||
center_window(win, 800, 730)
|
||
else:
|
||
# Keine gespeicherte Position → zentrieren
|
||
win.geometry("640x560")
|
||
center_window(win, 640, 560)
|
||
|
||
_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
|
||
if hasattr(self, "_aza_windows"):
|
||
self._aza_windows.discard(win)
|
||
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)
|
||
tw.attributes("-topmost", True)
|
||
self._register_window(tw)
|
||
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_header = ttk.Frame(tf)
|
||
vorlage_header.pack(fill="x", anchor="w")
|
||
ttk.Label(vorlage_header, text="Vorlage:").pack(side="left")
|
||
vorlage_txt = ScrolledText(tf, wrap="word", font=self._text_font, height=10, bg="#F5FCFF")
|
||
vorlage_txt.pack(fill="both", expand=True)
|
||
add_text_font_size_control(vorlage_header, vorlage_txt, initial_size=10, bg_color="#E8F4F8", save_key="diskussion_vorlage")
|
||
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)
|
||
|
||
# Header für Chat mit Schriftgrößen-Spinbox
|
||
chat_header = tk.Frame(chat_frame, bg="#F5FCFF")
|
||
chat_header.pack(fill="x")
|
||
|
||
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)
|
||
|
||
# Schriftgrößen-Spinbox für Diskussion
|
||
add_text_font_size_control(chat_header, chat_display, initial_size=10, bg_color="#F5FCFF", save_key="diskussion_window")
|
||
|
||
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))
|
||
input_attach_var = tk.StringVar(value="Anhänge: keine")
|
||
ttk.Label(input_row, textvariable=input_attach_var).pack(anchor="w", pady=(0, 2))
|
||
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))
|
||
btn_upload = ttk.Button(btn_row_disk, text="Bild hochladen", command=lambda: None)
|
||
btn_upload.pack(side="left", padx=(0, 8), pady=(4, 0))
|
||
btn_befund = ttk.Button(btn_row_disk, text="Befund beurteilen", command=lambda: None)
|
||
btn_befund.pack(side="left", padx=(0, 8), pady=(4, 0))
|
||
|
||
messages = []
|
||
pending_user_images = []
|
||
chat_image_refs = []
|
||
current_diskussion_path = [None] # bei Laden oder erstem Speichern gesetzt → weitere Speicherungen aktualisieren diese Datei
|
||
current_diskussion_titel = [None]
|
||
|
||
def _content_to_text_and_images(content):
|
||
text_parts = []
|
||
image_urls = []
|
||
if isinstance(content, str):
|
||
text_parts.append(content)
|
||
elif isinstance(content, list):
|
||
for part in content:
|
||
if not isinstance(part, dict):
|
||
continue
|
||
ptype = (part.get("type") or "").strip().lower()
|
||
if ptype in ("text", "input_text"):
|
||
txt = part.get("text")
|
||
if isinstance(txt, str) and txt.strip():
|
||
text_parts.append(txt.strip())
|
||
elif ptype in ("image_url", "input_image"):
|
||
image_url = part.get("image_url")
|
||
if isinstance(image_url, dict):
|
||
url = image_url.get("url")
|
||
elif isinstance(image_url, str):
|
||
url = image_url
|
||
else:
|
||
url = part.get("url")
|
||
if isinstance(url, str) and url.strip():
|
||
image_urls.append(url.strip())
|
||
text = "\n".join(text_parts).strip()
|
||
return text, image_urls
|
||
|
||
def _decode_data_url_to_photo(data_url: str, max_w: int = 360, max_h: int = 240):
|
||
try:
|
||
if not isinstance(data_url, str) or not data_url.startswith("data:image"):
|
||
return None
|
||
if "," not in data_url:
|
||
return None
|
||
_, b64_data = data_url.split(",", 1)
|
||
raw = base64.b64decode(b64_data)
|
||
from PIL import Image, ImageTk
|
||
img = Image.open(io.BytesIO(raw))
|
||
img.thumbnail((max_w, max_h))
|
||
return ImageTk.PhotoImage(img)
|
||
except Exception:
|
||
return None
|
||
|
||
def _refresh_attachment_label():
|
||
cnt = len(pending_user_images)
|
||
if cnt <= 0:
|
||
input_attach_var.set("Anhänge: keine")
|
||
elif cnt == 1:
|
||
input_attach_var.set("Anhänge: 1 Bild")
|
||
else:
|
||
input_attach_var.set(f"Anhänge: {cnt} Bilder")
|
||
|
||
def _grab_clipboard_image_data_url():
|
||
try:
|
||
from PIL import Image, ImageGrab
|
||
except Exception:
|
||
return None
|
||
try:
|
||
clip = ImageGrab.grabclipboard()
|
||
except Exception:
|
||
return None
|
||
img = None
|
||
if isinstance(clip, Image.Image):
|
||
img = clip
|
||
elif isinstance(clip, list) and clip:
|
||
# Manche Windows-Clipboard-Inhalte liefern Dateipfade.
|
||
for p in clip:
|
||
try:
|
||
if isinstance(p, str) and os.path.isfile(p):
|
||
img = Image.open(p)
|
||
break
|
||
except Exception:
|
||
continue
|
||
if img is None:
|
||
return None
|
||
try:
|
||
if img.mode not in ("RGB", "RGBA"):
|
||
img = img.convert("RGB")
|
||
bio = io.BytesIO()
|
||
img.save(bio, format="PNG")
|
||
b64 = base64.b64encode(bio.getvalue()).decode("ascii")
|
||
return "data:image/png;base64," + b64
|
||
except Exception:
|
||
return None
|
||
|
||
def _image_file_to_data_url(path: str):
|
||
try:
|
||
from PIL import Image
|
||
img = Image.open(path)
|
||
if img.mode not in ("RGB", "RGBA"):
|
||
img = img.convert("RGB")
|
||
ext = os.path.splitext(path)[1].lower()
|
||
fmt = "PNG"
|
||
mime = "image/png"
|
||
if ext in (".jpg", ".jpeg"):
|
||
fmt = "JPEG"
|
||
mime = "image/jpeg"
|
||
elif ext == ".webp":
|
||
fmt = "WEBP"
|
||
mime = "image/webp"
|
||
bio = io.BytesIO()
|
||
img.save(bio, format=fmt)
|
||
b64 = base64.b64encode(bio.getvalue()).decode("ascii")
|
||
return f"data:{mime};base64,{b64}"
|
||
except Exception:
|
||
return None
|
||
|
||
def _attach_image_file(path: str) -> bool:
|
||
if not path:
|
||
return False
|
||
ext = os.path.splitext(path)[1].lower()
|
||
if ext not in (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"):
|
||
return False
|
||
if not os.path.isfile(path):
|
||
return False
|
||
data_url = _image_file_to_data_url(path)
|
||
if not data_url:
|
||
return False
|
||
pending_user_images.append(data_url)
|
||
return True
|
||
|
||
def _extract_paths_from_drop_data(raw_data: str):
|
||
if not isinstance(raw_data, str):
|
||
return []
|
||
try:
|
||
items = list(win.tk.splitlist(raw_data))
|
||
except Exception:
|
||
items = re.findall(r"\{[^}]+\}|[^\s]+", raw_data)
|
||
out = []
|
||
for item in items:
|
||
p = (item or "").strip().strip("{}").strip()
|
||
if p:
|
||
out.append(p)
|
||
return out
|
||
|
||
def _extract_image_paths_from_text(text: str):
|
||
if not isinstance(text, str):
|
||
return []
|
||
parts = re.findall(r"[A-Za-z]:\\[^\n\r\t]+", text)
|
||
seen = set()
|
||
out = []
|
||
for p in parts:
|
||
p2 = p.strip().strip('"').strip("'")
|
||
if p2 in seen:
|
||
continue
|
||
seen.add(p2)
|
||
if _attach_image_file(p2):
|
||
out.append(p2)
|
||
return out
|
||
|
||
def _upload_images():
|
||
from tkinter import filedialog
|
||
paths = filedialog.askopenfilenames(
|
||
title="Befund-Bilder auswählen",
|
||
filetypes=[
|
||
("Bilder", "*.png *.jpg *.jpeg *.bmp *.gif *.webp"),
|
||
("Alle Dateien", "*.*"),
|
||
],
|
||
)
|
||
if not paths:
|
||
return
|
||
added = 0
|
||
for p in paths:
|
||
if _attach_image_file(p):
|
||
added += 1
|
||
_refresh_attachment_label()
|
||
if added:
|
||
status_disk.set(f"{added} Bild(er) angehängt.")
|
||
else:
|
||
status_disk.set("Keine unterstützten Bilddateien ausgewählt.")
|
||
|
||
def _on_drop_files(event):
|
||
raw = getattr(event, "data", "")
|
||
paths = _extract_paths_from_drop_data(raw)
|
||
added = 0
|
||
for p in paths:
|
||
if _attach_image_file(p):
|
||
added += 1
|
||
if added:
|
||
_refresh_attachment_label()
|
||
status_disk.set(f"{added} Bild(er) per Drag & Drop angehängt.")
|
||
return "break"
|
||
return None
|
||
|
||
def _paste_input_text_and_images(event=None):
|
||
inserted_any = False
|
||
try:
|
||
txt = input_txt.clipboard_get()
|
||
if isinstance(txt, str) and txt:
|
||
input_txt.insert(tk.INSERT, txt)
|
||
inserted_any = True
|
||
except Exception:
|
||
pass
|
||
image_data_url = _grab_clipboard_image_data_url()
|
||
if image_data_url:
|
||
pending_user_images.append(image_data_url)
|
||
_refresh_attachment_label()
|
||
inserted_any = True
|
||
# Falls Dateipfade (z.B. aus Explorer) als Text in der Zwischenablage sind, ebenfalls anhängen.
|
||
if not image_data_url:
|
||
try:
|
||
clip_text = input_txt.clipboard_get()
|
||
except Exception:
|
||
clip_text = ""
|
||
attached_paths = _extract_image_paths_from_text(clip_text or "")
|
||
if attached_paths:
|
||
_refresh_attachment_label()
|
||
inserted_any = True
|
||
if inserted_any:
|
||
if pending_user_images:
|
||
status_disk.set("Text/Bild eingefügt. Jetzt Senden klicken.")
|
||
else:
|
||
status_disk.set("Text eingefügt.")
|
||
else:
|
||
status_disk.set("Zwischenablage enthält keinen einfügbaren Text oder kein Bild.")
|
||
return "break"
|
||
|
||
def _remove_last_attachment():
|
||
if pending_user_images:
|
||
pending_user_images.pop()
|
||
_refresh_attachment_label()
|
||
status_disk.set("Letzter Bild-Anhang entfernt.")
|
||
else:
|
||
status_disk.set("Keine Bild-Anhänge vorhanden.")
|
||
|
||
def _show_input_context_menu(event):
|
||
menu = tk.Menu(input_txt, tearoff=0)
|
||
menu.add_command(label="Einfügen (Text + Bild)", command=lambda: _paste_input_text_and_images())
|
||
menu.add_command(label="Bild hochladen", command=_upload_images)
|
||
menu.add_command(label="Letzten Bild-Anhang entfernen", command=_remove_last_attachment)
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
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_text, content_images = _content_to_text_and_images(m.get("content"))
|
||
content = content_text.strip()
|
||
if content_images:
|
||
img_hint = "[Bilder: " + str(len(content_images)) + "]"
|
||
content = (content + "\n" + img_hint).strip() if content else img_hint
|
||
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 += "\nWenn der Nutzer eine konkrete Aufgabe gibt (z.B. etwas produzieren, formulieren, strukturieren, planen), liefere direkt ein umsetzbares Ergebnis."
|
||
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, image_data_urls=None):
|
||
"""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: "
|
||
body = (text or "").strip()
|
||
if not body and image_data_urls:
|
||
body = "[Bild]"
|
||
block = prefix + body + "\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"
|
||
chat_display.mark_set(tk.INSERT, insert_pos)
|
||
start_idx = chat_display.index(tk.INSERT)
|
||
chat_display.insert(tk.INSERT, block)
|
||
if image_data_urls:
|
||
for url in image_data_urls:
|
||
photo = _decode_data_url_to_photo(url)
|
||
if photo is not None:
|
||
chat_image_refs.append(photo)
|
||
chat_display.image_create(tk.INSERT, image=photo, padx=4, pady=2)
|
||
chat_display.insert(tk.INSERT, "\n")
|
||
else:
|
||
chat_display.insert(tk.INSERT, "[Bild konnte nicht angezeigt werden]\n")
|
||
chat_display.insert(tk.INSERT, "\n")
|
||
end_idx = chat_display.index(tk.INSERT)
|
||
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_core(befund_mode: bool = False):
|
||
user_text = input_txt.get("1.0", "end").strip()
|
||
_extract_image_paths_from_text(user_text)
|
||
image_data_urls = list(pending_user_images)
|
||
if not user_text and not image_data_urls:
|
||
return
|
||
if befund_mode and not image_data_urls:
|
||
status_disk.set("Für 'Befund beurteilen' bitte mindestens ein Bild anhängen.")
|
||
return
|
||
input_txt.delete("1.0", "end")
|
||
pending_user_images.clear()
|
||
_refresh_attachment_label()
|
||
append_to_display("user", user_text, image_data_urls=image_data_urls)
|
||
status_disk.set("KI antwortet…")
|
||
btn_send.configure(state="disabled")
|
||
btn_befund.configure(state="disabled")
|
||
|
||
if not messages:
|
||
messages.append({"role": "system", "content": build_system_content()})
|
||
if befund_mode:
|
||
befund_instr = (
|
||
"Bitte beurteile den Befund anhand der angehängten Daten.\n"
|
||
"Liefere strukturiert:\n"
|
||
"1) Kurzbeschreibung des sichtbaren Befunds\n"
|
||
"2) Mögliche Einordnung / Differenzialdiagnosen\n"
|
||
"3) Dringlichkeit / Red Flags\n"
|
||
"4) Nächste sinnvolle Abklärungen oder Therapieoptionen\n"
|
||
"5) Kurze patientenverständliche Erklärung\n"
|
||
"Wenn etwas auf den Bildern nicht sicher erkennbar ist, schreibe das klar."
|
||
)
|
||
if user_text:
|
||
user_text = befund_instr + "\n\nZusatz des Nutzers:\n" + user_text
|
||
else:
|
||
user_text = befund_instr
|
||
if image_data_urls:
|
||
content_parts = []
|
||
if user_text:
|
||
content_parts.append({"type": "text", "text": user_text})
|
||
else:
|
||
content_parts.append({"type": "text", "text": "Bitte analysiere die angehängten Bilder."})
|
||
for image_url in image_data_urls:
|
||
content_parts.append({"type": "image_url", "image_url": {"url": image_url}})
|
||
messages.append({"role": "user", "content": content_parts})
|
||
else:
|
||
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")
|
||
btn_befund.configure(state="normal")
|
||
|
||
def _on_error(err: str):
|
||
append_to_display("assistant", "[Fehler: " + err + "]")
|
||
status_disk.set("Fehler.")
|
||
btn_send.configure(state="normal")
|
||
btn_befund.configure(state="normal")
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def send_message():
|
||
_send_message_core(befund_mode=False)
|
||
|
||
def send_befund_message():
|
||
_send_message_core(befund_mode=True)
|
||
|
||
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")
|
||
chat_image_refs.clear()
|
||
pairs = []
|
||
last_u = None
|
||
for m in loaded_messages:
|
||
r = m.get("role")
|
||
txt, imgs = _content_to_text_and_images(m.get("content"))
|
||
c = txt.strip()
|
||
if r == "user":
|
||
last_u = (c, imgs)
|
||
elif r == "assistant" and last_u is not None:
|
||
pairs.append((last_u, c))
|
||
last_u = None
|
||
for u_pack, a in pairs:
|
||
u_text, u_imgs = u_pack
|
||
append_to_display("user", u_text, image_data_urls=u_imgs)
|
||
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
|
||
pending_user_images.clear()
|
||
chat_image_refs.clear()
|
||
_refresh_attachment_label()
|
||
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)
|
||
btn_upload.configure(command=_upload_images)
|
||
btn_befund.configure(command=send_befund_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)
|
||
input_txt.bind("<Control-v>", _paste_input_text_and_images)
|
||
input_txt.bind("<Control-V>", _paste_input_text_and_images)
|
||
input_txt.bind("<Shift-Insert>", _paste_input_text_and_images)
|
||
input_txt.bind("<Button-3>", _show_input_context_menu)
|
||
# Fallback: Paste auch dann abfangen, wenn Fokus nicht exakt im Eingabefeld liegt.
|
||
win.bind("<Control-v>", _paste_input_text_and_images)
|
||
win.bind("<Control-V>", _paste_input_text_and_images)
|
||
win.bind("<Shift-Insert>", _paste_input_text_and_images)
|
||
try:
|
||
if hasattr(input_txt, "drop_target_register") and hasattr(input_txt, "dnd_bind"):
|
||
from tkinterdnd2 import DND_FILES
|
||
input_txt.drop_target_register(DND_FILES)
|
||
input_txt.dnd_bind("<<Drop>>", _on_drop_files)
|
||
status_disk.set("Bereit. Nachricht eingeben oder Bild per Drag & Drop/Einfügen/Hochladen hinzufügen.")
|
||
except Exception:
|
||
pass
|
||
|
||
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()
|
||
from datetime import date
|
||
heute = date.today().strftime("%d.%m.%Y")
|
||
operateur = load_signature_name() or "Siehe Transkript"
|
||
datum_operateur = f"Datum: {heute}\nOperateur: {operateur}"
|
||
system_prompt = OP_BERICHT_PROMPT.replace("{op_datum_operateur}", datum_operateur)
|
||
if template:
|
||
system_prompt = (
|
||
f"ZWINGENDE VORLAGE DES ARZTES (hat höchste Priorität, MUSS vollständig eingehalten werden):\n"
|
||
f"{template}\n\n"
|
||
f"{system_prompt}"
|
||
)
|
||
|
||
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,
|
||
)
|
||
|
||
# ─── Briefstil-Profile ───
|
||
|
||
def _open_brief_stilprofil_dialog(self, parent_win=None):
|
||
"""Dialog zur Verwaltung und Erstellung von Briefstil-Profilen."""
|
||
dlg = tk.Toplevel(parent_win or self)
|
||
dlg.title("Briefstil-Profile")
|
||
dlg.configure(bg="#FFFFFF")
|
||
dlg.minsize(520, 520)
|
||
dlg.geometry("560x560")
|
||
dlg.attributes("-topmost", True)
|
||
self._register_window(dlg)
|
||
|
||
profiles = load_brief_style_profiles()
|
||
active_name = self._autotext_data.get("stilprofil_name", "") or profiles.get("_active_profile", "")
|
||
user_profile_names = [k for k in profiles if k != "_active_profile"]
|
||
|
||
header = tk.Frame(dlg, bg="#E8F4F8")
|
||
header.pack(fill="x")
|
||
tk.Label(header, text="Briefstil-Profile verwalten", font=("Segoe UI Semibold", 12),
|
||
bg="#E8F4F8", fg="#1a4d6d").pack(side="left", padx=10, pady=8)
|
||
|
||
main = tk.Frame(dlg, bg="#FFFFFF", padx=12, pady=8)
|
||
main.pack(fill="both", expand=True)
|
||
|
||
active_info = tk.Frame(main, bg="#F0F7FA", padx=8, pady=6)
|
||
active_info.pack(fill="x", pady=(0, 6))
|
||
if active_name and self._autotext_data.get("stilprofil_enabled", False):
|
||
if active_name in SYSTEM_STYLE_PROFILES:
|
||
active_txt = f"Aktives Stilprofil: {active_name} (Systemprofil)"
|
||
else:
|
||
active_txt = f"Aktives Stilprofil: {active_name} (Benutzerprofil)"
|
||
else:
|
||
active_txt = "Kein Stilprofil aktiv – Briefe werden im Standardstil generiert."
|
||
tk.Label(active_info, text=active_txt, font=("Segoe UI", 9),
|
||
bg="#F0F7FA", fg="#1a4d6d", wraplength=480).pack(anchor="w")
|
||
tk.Label(active_info, text="Tipp: Stilprofil-Auswahl erfolgt im Arztbrief-Fenster.",
|
||
font=("Segoe UI", 8), bg="#F0F7FA", fg="#888").pack(anchor="w")
|
||
|
||
tk.Label(main, text="Verfuegbare Profile:", font=("Segoe UI", 10),
|
||
bg="#FFFFFF", fg="#333").pack(anchor="w", pady=(4, 2))
|
||
|
||
tk.Label(main, text="Systemprofile (fest eingebaut, immer verfuegbar):",
|
||
font=("Segoe UI", 8), bg="#FFFFFF", fg="#888").pack(anchor="w")
|
||
for sp in SYSTEM_STYLE_PROFILES:
|
||
marker = " \u2713" if sp == active_name else ""
|
||
tk.Label(main, text=f" \u2022 {sp}{marker}", font=("Segoe UI", 9),
|
||
bg="#FFFFFF", fg="#5B8DB3").pack(anchor="w")
|
||
|
||
if user_profile_names:
|
||
tk.Label(main, text="Benutzerprofile (aus Word-Briefen gelernt):",
|
||
font=("Segoe UI", 8), bg="#FFFFFF", fg="#888").pack(anchor="w", pady=(4, 0))
|
||
for up in user_profile_names:
|
||
marker = " \u2713" if up == active_name else ""
|
||
tk.Label(main, text=f" \u2022 {up}{marker}", font=("Segoe UI", 9),
|
||
bg="#FFFFFF", fg="#333").pack(anchor="w")
|
||
|
||
delete_var = tk.StringVar(value="(keins)")
|
||
all_user_for_delete = ["(keins)"] + user_profile_names
|
||
|
||
sep = ttk.Separator(main)
|
||
sep.pack(fill="x", pady=8)
|
||
|
||
tk.Label(main, text="Neues Stilprofil aus Word-Briefen erstellen:",
|
||
font=("Segoe UI Semibold", 10), bg="#FFFFFF", fg="#1a4d6d").pack(anchor="w")
|
||
|
||
name_frame = tk.Frame(main, bg="#FFFFFF")
|
||
name_frame.pack(fill="x", pady=(4, 2))
|
||
tk.Label(name_frame, text="Profilname:", font=("Segoe UI", 9),
|
||
bg="#FFFFFF").pack(side="left")
|
||
name_var = tk.StringVar(value="")
|
||
tk.Entry(name_frame, textvariable=name_var, width=30, font=("Segoe UI", 9)).pack(
|
||
side="left", padx=(8, 0))
|
||
|
||
file_list_var = tk.StringVar(value="Keine Dateien ausgewaehlt")
|
||
selected_files = []
|
||
|
||
def do_select_files():
|
||
from tkinter import filedialog
|
||
files = filedialog.askopenfilenames(
|
||
parent=dlg,
|
||
title="Word-Briefe auswaehlen",
|
||
filetypes=[("Word-Dateien", "*.docx"), ("Alle Dateien", "*.*")],
|
||
)
|
||
if files:
|
||
selected_files.clear()
|
||
selected_files.extend(files)
|
||
names = [os.path.basename(f) for f in files]
|
||
file_list_var.set(f"{len(files)} Datei(en): " + ", ".join(names[:5]))
|
||
if len(names) > 5:
|
||
file_list_var.set(file_list_var.get() + f" (+{len(names)-5} weitere)")
|
||
|
||
tk.Button(main, text="Word-Dateien auswaehlen", font=("Segoe UI", 9),
|
||
bg="#5B8DB3", fg="white", relief="flat", cursor="hand2",
|
||
command=do_select_files).pack(anchor="w", pady=(4, 2))
|
||
|
||
tk.Label(main, textvariable=file_list_var, font=("Segoe UI", 8),
|
||
bg="#FFFFFF", fg="#888").pack(anchor="w", pady=(0, 4))
|
||
|
||
progress_var = tk.StringVar(value="")
|
||
progress_lbl = tk.Label(main, textvariable=progress_var, font=("Segoe UI", 9),
|
||
bg="#FFFFFF", fg="#5B8DB3", wraplength=480)
|
||
progress_lbl.pack(anchor="w", pady=(0, 4))
|
||
|
||
def do_learn():
|
||
pname = name_var.get().strip()
|
||
if not pname:
|
||
messagebox.showwarning("Profilname fehlt", "Bitte einen Namen eingeben.", parent=dlg)
|
||
return
|
||
if pname in SYSTEM_STYLE_PROFILES:
|
||
messagebox.showwarning("Name reserviert",
|
||
f"'{pname}' ist ein Systemprofil und kann nicht ueberschrieben werden.", parent=dlg)
|
||
return
|
||
if not selected_files:
|
||
messagebox.showwarning("Keine Dateien", "Bitte mindestens eine Word-Datei auswaehlen.", parent=dlg)
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
|
||
progress_var.set("Lese Word-Dateien...")
|
||
dlg.update_idletasks()
|
||
|
||
def worker():
|
||
try:
|
||
texts = extract_texts_from_docx_files(selected_files)
|
||
valid = [(n, t) for n, t in texts if not t.startswith("[FEHLER")]
|
||
errors = [(n, t) for n, t in texts if t.startswith("[FEHLER")]
|
||
|
||
if not valid:
|
||
self.after(0, lambda: progress_var.set("Keine lesbaren Dateien gefunden."))
|
||
return
|
||
|
||
self.after(0, lambda: progress_var.set(
|
||
f"{len(valid)} Datei(en) gelesen. Analysiere Stil..."))
|
||
|
||
combined = ""
|
||
for name, text in valid:
|
||
combined += f"\n--- Brief: {name} ---\n{text[:3000]}\n"
|
||
|
||
resp = self._backend_chat_completion(
|
||
model="gpt-5-mini",
|
||
messages=[
|
||
{"role": "system", "content": (
|
||
"Du bist ein Analysewerkzeug fuer aerztliche Briefstile. "
|
||
"Analysiere die folgenden Arztbriefe und erstelle ein STILPROFIL. "
|
||
"Das Stilprofil beschreibt NUR den Schreibstil, NICHT den medizinischen Inhalt. "
|
||
"WICHTIG: Keine Patientennamen, Diagnosen oder persoenliche Daten aus den Briefen uebernehmen.\n\n"
|
||
"Analysiere folgende Aspekte und formuliere sie als klare Anweisungen:\n"
|
||
"A) Diagnose-Stil: kurz/ausfuehrlich, getrennt/zusammengefasst, mit/ohne ICD, stichwortartig/formuliert\n"
|
||
"B) Befund-Stil: kurz/mittel/ausfuehrlich, Fliesstext/Liste, typische Sprache\n"
|
||
"C) Therapie-/Procedere-Stil: kurz/pragmatisch/erklaerend, typische Formulierungen\n"
|
||
"D) Briefstruktur: Abschnittsreihenfolge, Ueberschriften, Einleitung/Schluss, Laenge\n"
|
||
"E) Fachrichtung: falls erkennbar (Dermatologie, Hausarzt, Chirurgie etc.)\n"
|
||
"F) Sprachstil: formell/nuechtern/kompakt, typische Wendungen, Detailgrad\n\n"
|
||
"Ausgabeformat: Ein klarer Stilbeschreibungstext, der als Anweisung fuer die Briefgenerierung dient. "
|
||
"Maximal 400 Woerter. Deutsch."
|
||
)},
|
||
{"role": "user", "content": f"Bitte analysiere den Stil dieser Arztbriefe:\n{combined}"}
|
||
],
|
||
temperature=0.3,
|
||
max_tokens=800,
|
||
)
|
||
style_prompt = resp.choices[0].message.content.strip()
|
||
|
||
profs = load_brief_style_profiles()
|
||
profs[pname] = {
|
||
"style_prompt": style_prompt,
|
||
"source_files": [os.path.basename(f) for f in selected_files],
|
||
"source_count": len(valid),
|
||
"created": datetime.now().isoformat(),
|
||
}
|
||
save_brief_style_profiles(profs)
|
||
|
||
def on_done():
|
||
err_msg = ""
|
||
if errors:
|
||
err_msg = f"\n({len(errors)} Datei(en) konnten nicht gelesen werden)"
|
||
progress_var.set(f"Stilprofil '{pname}' erstellt!{err_msg}")
|
||
status_lbl.configure(
|
||
text=f"Profil '{pname}' wurde erstellt. Bitte im Arztbrief-Fenster aktivieren.")
|
||
|
||
self.after(0, on_done)
|
||
|
||
except Exception as e:
|
||
self.after(0, lambda err=str(e): progress_var.set(f"Fehler: {err}"))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
tk.Button(main, text="Stil lernen und Profil erstellen", font=("Segoe UI", 10, "bold"),
|
||
bg="#1a4d6d", fg="white", relief="flat", cursor="hand2",
|
||
command=do_learn).pack(anchor="w", pady=(6, 4))
|
||
|
||
sep2 = ttk.Separator(main)
|
||
sep2.pack(fill="x", pady=8)
|
||
|
||
if user_profile_names:
|
||
del_frame = tk.Frame(main, bg="#FFFFFF")
|
||
del_frame.pack(fill="x", pady=(6, 2))
|
||
tk.Label(del_frame, text="Benutzerprofil loeschen:", font=("Segoe UI", 9),
|
||
bg="#FFFFFF", fg="#333").pack(side="left")
|
||
del_combo = ttk.Combobox(del_frame, textvariable=delete_var,
|
||
values=all_user_for_delete, state="readonly", width=28)
|
||
del_combo.pack(side="left", padx=(6, 0))
|
||
|
||
def do_delete():
|
||
sel = delete_var.get()
|
||
if sel == "(keins)":
|
||
return
|
||
if messagebox.askyesno("Profil loeschen", f"Benutzerprofil '{sel}' wirklich loeschen?", parent=dlg):
|
||
profs = load_brief_style_profiles()
|
||
profs.pop(sel, None)
|
||
if profs.get("_active_profile") == sel:
|
||
profs["_active_profile"] = ""
|
||
save_brief_style_profiles(profs)
|
||
if self._autotext_data.get("stilprofil_name") == sel:
|
||
self._autotext_data["stilprofil_name"] = ""
|
||
self._autotext_data["stilprofil_enabled"] = False
|
||
self._autotext_data["active_brief_profile"] = ""
|
||
try:
|
||
from aza_persistence import save_autotext
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
delete_var.set("(keins)")
|
||
status_lbl.configure(text=f"Profil '{sel}' geloescht.")
|
||
|
||
tk.Button(del_frame, text="Loeschen", font=("Segoe UI", 9),
|
||
bg="#E74C3C", fg="white", relief="flat", cursor="hand2",
|
||
command=do_delete).pack(side="left", padx=(8, 0))
|
||
|
||
status_lbl = tk.Label(main, text="", font=("Segoe UI", 9), bg="#FFFFFF", fg="#5B8DB3",
|
||
wraplength=480)
|
||
status_lbl.pack(anchor="w", pady=(8, 0))
|
||
|
||
tk.Button(dlg, text="Schliessen", font=("Segoe UI", 10), bg="#EBEDF0", fg="#333",
|
||
relief="flat", command=dlg.destroy).pack(pady=(0, 8))
|