Files
aza/AzA march 2026/aza_text_windows_mixin.py

2622 lines
120 KiB
Python
Raw Permalink Normal View History

2026-03-25 22:03:39 +01:00
# -*- 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")
2026-04-19 20:41:37 +02:00
if buttons in ("brief", "rezept", "kogu"):
def _send_doc_to_empfang():
doc_text = text_widget.get("1.0", "end").strip()
if not doc_text:
tw_status("Kein Inhalt zum Senden.")
return
doc_label = {"brief": "Arztbrief", "rezept": "Rezept",
"kogu": "Kostengutsprache"}.get(buttons, title)
try:
self._empfang_send_document(doc_label, doc_text)
tw_status(f"{doc_label} an Empfang gesendet.")
except Exception as exc:
tw_status(f"Senden fehlgeschlagen: {exc}")
RoundedButton(
btn_frame, "An Empfang", command=_send_doc_to_empfang,
width=100, height=28, canvas_bg="#B9ECFA",
bg="#d4eef7", fg="#1a4d6d", active_bg="#c4dee7",
).pack(side="right", padx=(8, 0))
2026-03-25 22:03:39 +01:00
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 (38 Wörter) für diese Diskussion. Keine Anführungszeichen, nur die Überschrift, eine Zeile."},
{"role": "user", "content": chat_text[:3000]},
],
)
titel = (r.choices[0].message.content or "Diskussion").strip().strip('"\'')
if not titel:
titel = "Diskussion"
base_dir = os.path.dirname(os.path.abspath(__file__))
disk_dir = os.path.join(base_dir, "Lernmodus_Export", "Gespeicherte_Diskussionen")
os.makedirs(disk_dir, exist_ok=True)
safe_titel = "".join(c for c in titel[:50] if c.isalnum() or c in " _-") or "Diskussion"
fname = f"Diskussion_{now.strftime('%Y-%m-%d_%H-%M')}_{safe_titel}.json"
path = os.path.join(disk_dir, fname)
data = {
"titel": titel,
"datum": datum,
"uhrzeit": uhrzeit,
"chat": chat_text,
"messages": messages.copy(),
}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
current_diskussion_path[0] = path
current_diskussion_titel[0] = titel
self.after(0, lambda: status_disk.set(f"Diskussion gespeichert: {titel}"))
except Exception as e:
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
self.after(0, lambda: status_disk.set("Fehler beim Speichern."))
threading.Thread(target=worker, daemon=True).start()
def do_diskussion_laden():
"""Diskussion aus JSON-Datei laden."""
from tkinter import filedialog
base_dir = os.path.dirname(os.path.abspath(__file__))
disk_dir = os.path.join(base_dir, "Lernmodus_Export", "Gespeicherte_Diskussionen")
if not os.path.isdir(disk_dir):
os.makedirs(disk_dir, exist_ok=True)
path = filedialog.askopenfilename(
title="Diskussion laden",
initialdir=disk_dir,
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
chat_text = data.get("chat", "")
loaded_messages = data.get("messages", [])
messages.clear()
messages.extend(loaded_messages)
disk_insert_after_user[0] = None
chat_display.configure(state="normal")
chat_display.delete("1.0", "end")
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"
2026-03-30 07:59:11 +02:00
resp = self._backend_chat_completion(
2026-03-25 22:03:39 +01:00
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))