# -*- 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("", 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("<>", _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("", lambda e, k=sec_key: _apply_brief_section_edit(k, 1)) btn_down.bind("", lambda e, k=sec_key: _apply_brief_section_edit(k, -1)) btn_up.bind("", lambda e, w=btn_up: w.configure(fg=_ARROW_HOVER)) btn_up.bind("", lambda e, w=btn_up: w.configure(fg=_ARROW_FG)) btn_down.bind("", lambda e, w=btn_down: w.configure(fg=_ARROW_HOVER)) btn_down.bind("", 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("", 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("", lambda e, i=pi: _load_preset(i)) btn.bind("", lambda e, b=btn, i=pi: b.configure( bg="#2a6a8d" if i == active_idx[0] else "#B8DDE8")) btn.bind("", 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("", _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("", lambda e, k=key: _rename_section(k)) for widget in (handle, num_lbl, row): widget.bind("", lambda e, i=idx: _drag_start(e, i)) widget.bind("", _drag_motion) widget.bind("", _drag_end) handle.bind("", lambda e, h=handle: h.configure(fg="#1a4d6d")) handle.bind("", lambda e, h=handle: h.configure(fg="#B0D4E8")) row_widgets.append(row) _load_preset(active_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("", lambda e: _reset_order()) reset_btn.bind("", lambda e: reset_btn.configure(fg="#1a4d6d")) reset_btn.bind("", 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("", 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("", on_diskussion_return) input_txt.bind("", _paste_input_text_and_images) input_txt.bind("", _paste_input_text_and_images) input_txt.bind("", _paste_input_text_and_images) input_txt.bind("", _show_input_context_menu) # Fallback: Paste auch dann abfangen, wenn Fokus nicht exakt im Eingabefeld liegt. win.bind("", _paste_input_text_and_images) win.bind("", _paste_input_text_and_images) win.bind("", _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("<>", _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))