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 ( " \n Dies 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 ( " \n Dies 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 ( " \n Dies 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 \n ZUSÄ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 \n ZUSÄ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 \n ZUSAETZLICHE 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 \n Zusä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 + = " \n Wenn der Nutzer eine konkrete Aufgabe gibt (z.B. etwas produzieren, formulieren, strukturieren, planen), liefere direkt ein umsetzbares Ergebnis. "
scope_text + = " \n \n Antworte 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 \n Vorlage (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 \n Zusatz des Nutzers: \n " + user_text
else :
user_text = befund_instr
if image_data_urls :
content_parts = [ ]
if user_text :
content_parts . append ( { " type " : " text " , " text " : user_text } )
else :
content_parts . append ( { " type " : " text " , " text " : " Bitte analysiere die angehängten Bilder. " } )
for image_url in image_data_urls :
content_parts . append ( { " type " : " image_url " , " image_url " : { " url " : image_url } } )
messages . append ( { " role " : " user " , " content " : content_parts } )
else :
messages . append ( { " role " : " user " , " content " : user_text } )
def worker ( ) :
try :
model = self . model_var . get ( ) . strip ( ) or DEFAULT_SUMMARY_MODEL
if model not in ALLOWED_SUMMARY_MODELS :
model = DEFAULT_SUMMARY_MODEL
resp = self . call_chat_completion (
model = model ,
messages = messages ,
)
reply = ( resp . choices [ 0 ] . message . content or " " ) . strip ( )
self . after ( 0 , lambda : _on_reply ( reply ) )
except Exception as e :
self . after ( 0 , lambda : _on_error ( str ( e ) ) )
def _clean_diskussion_reply ( text : str ) - > str :
""" Entfernt Markdown und unnötige Zeichen für übersichtliche Anzeige. """
if not text :
return " "
t = text
t = re . sub ( r " #+ " , " " , t ) # alle # (Überschriften-Markdown)
t = re . sub ( r " \ *+ " , " " , t )
t = re . sub ( r " _ { 2,} " , " " , t )
t = re . sub ( r " `+ " , " " , t ) # Backticks
t = re . sub ( r " - { 3,} " , " " , t )
t = re . sub ( r " \ [([^ \ ]]*) \ ] \ ([^)]* \ ) " , r " \ 1 " , t ) # [Text](url) → Text
t = re . sub ( r " \ n { 3,} " , " \n \n " , t )
t = re . sub ( r " [ \ t]+ " , " " , t ) # mehrere Leerzeichen/Tabs → eines
t = re . sub ( r " * \ n * " , " \n " , t ) # Leerzeichen um Zeilenbruch
return t . strip ( )
def _on_reply ( reply : str ) :
reply_clean = _clean_diskussion_reply ( reply or " " )
messages . append ( { " role " : " assistant " , " content " : reply_clean } )
append_to_display ( " assistant " , reply_clean )
status_disk . set ( " Bereit. " )
btn_send . configure ( state = " normal " )
btn_befund . configure ( state = " normal " )
def _on_error ( err : str ) :
append_to_display ( " assistant " , " [Fehler: " + err + " ] " )
status_disk . set ( " Fehler. " )
btn_send . configure ( state = " normal " )
btn_befund . configure ( state = " normal " )
threading . Thread ( target = worker , daemon = True ) . start ( )
def send_message ( ) :
_send_message_core ( befund_mode = False )
def send_befund_message ( ) :
_send_message_core ( befund_mode = True )
def do_diskussion_speichern ( ) :
""" Diskussion als JSON speichern. Wenn geladen oder schon gespeichert: gleiche Datei aktualisieren, sonst neue Datei. """
chat_text = get_chat_text_from_messages ( )
if not chat_text . strip ( ) :
messagebox . showinfo ( " Diskussion speichern " , " Keine Diskussion zum Speichern. " )
return
status_disk . set ( " Speichere Diskussion… " )
def worker ( ) :
try :
now = datetime . now ( )
datum = now . strftime ( " %d . % m. % Y " )
uhrzeit = now . strftime ( " % H: % M " )
path = current_diskussion_path [ 0 ]
titel = current_diskussion_titel [ 0 ]
if path and titel :
# Weitergeführtes Gespräch: bestehende Datei aktualisieren
data = {
" titel " : titel ,
" datum " : datum ,
" uhrzeit " : uhrzeit ,
" chat " : chat_text ,
" messages " : messages . copy ( ) ,
}
with open ( path , " w " , encoding = " utf-8 " ) as f :
json . dump ( data , f , indent = 2 , ensure_ascii = False )
self . after ( 0 , lambda : status_disk . set ( f " Diskussion aktualisiert: { titel } " ) )
return
# Neue Diskussion: KI für Überschrift, neue Datei
r = self . call_chat_completion (
model = " gpt-4o-mini " ,
messages = [
{ " role " : " system " , " content " : " Gib nur eine kurze deutsche Überschrift (3– 8 Wörter) für diese Diskussion. Keine Anführungszeichen, nur die Überschrift, eine Zeile. " } ,
{ " role " : " user " , " content " : chat_text [ : 3000 ] } ,
] ,
)
titel = ( r . choices [ 0 ] . message . content or " Diskussion " ) . strip ( ) . strip ( ' " \' ' )
if not titel :
titel = " Diskussion "
base_dir = os . path . dirname ( os . path . abspath ( __file__ ) )
disk_dir = os . path . join ( base_dir , " Lernmodus_Export " , " Gespeicherte_Diskussionen " )
os . makedirs ( disk_dir , exist_ok = True )
safe_titel = " " . join ( c for c in titel [ : 50 ] if c . isalnum ( ) or c in " _- " ) or " Diskussion "
fname = f " Diskussion_ { now . strftime ( ' % Y- % m- %d _ % H- % M ' ) } _ { safe_titel } .json "
path = os . path . join ( disk_dir , fname )
data = {
" titel " : titel ,
" datum " : datum ,
" uhrzeit " : uhrzeit ,
" chat " : chat_text ,
" messages " : messages . copy ( ) ,
}
with open ( path , " w " , encoding = " utf-8 " ) as f :
json . dump ( data , f , indent = 2 , ensure_ascii = False )
current_diskussion_path [ 0 ] = path
current_diskussion_titel [ 0 ] = titel
self . after ( 0 , lambda : status_disk . set ( f " Diskussion gespeichert: { titel } " ) )
except Exception as e :
self . after ( 0 , lambda : messagebox . showerror ( " Fehler " , str ( e ) ) )
self . after ( 0 , lambda : status_disk . set ( " Fehler beim Speichern. " ) )
threading . Thread ( target = worker , daemon = True ) . start ( )
def do_diskussion_laden ( ) :
""" Diskussion aus JSON-Datei laden. """
from tkinter import filedialog
base_dir = os . path . dirname ( os . path . abspath ( __file__ ) )
disk_dir = os . path . join ( base_dir , " Lernmodus_Export " , " Gespeicherte_Diskussionen " )
if not os . path . isdir ( disk_dir ) :
os . makedirs ( disk_dir , exist_ok = True )
path = filedialog . askopenfilename (
title = " Diskussion laden " ,
initialdir = disk_dir ,
filetypes = [ ( " JSON " , " *.json " ) , ( " Alle " , " *.* " ) ] ,
)
if not path :
return
try :
with open ( path , " r " , encoding = " utf-8 " ) as f :
data = json . load ( f )
chat_text = data . get ( " chat " , " " )
loaded_messages = data . get ( " messages " , [ ] )
messages . clear ( )
messages . extend ( loaded_messages )
disk_insert_after_user [ 0 ] = None
chat_display . configure ( state = " normal " )
chat_display . delete ( " 1.0 " , " end " )
chat_image_refs . clear ( )
pairs = [ ]
last_u = None
for m in loaded_messages :
r = m . get ( " role " )
txt , imgs = _content_to_text_and_images ( m . get ( " content " ) )
c = txt . strip ( )
if r == " user " :
last_u = ( c , imgs )
elif r == " assistant " and last_u is not None :
pairs . append ( ( last_u , c ) )
last_u = None
for u_pack , a in pairs :
u_text , u_imgs = u_pack
append_to_display ( " user " , u_text , image_data_urls = u_imgs )
append_to_display ( " assistant " , a )
chat_display . configure ( state = " disabled " )
titel = data . get ( " titel " , " – " )
datum = data . get ( " datum " , " " )
uhrzeit = data . get ( " uhrzeit " , " " )
current_diskussion_path [ 0 ] = path
current_diskussion_titel [ 0 ] = titel
status_disk . set ( f " Geladen: { titel } ( { datum } { uhrzeit } ) " )
except Exception as e :
messagebox . showerror ( " Diskussion laden " , str ( e ) )
def do_diskussion_verlauf_loeschen ( ) :
""" Aktuellen Verlauf im Fenster leeren (Anzeige und Nachrichtenliste). """
messages . clear ( )
disk_insert_after_user [ 0 ] = None
current_diskussion_path [ 0 ] = None
current_diskussion_titel [ 0 ] = None
pending_user_images . clear ( )
chat_image_refs . clear ( )
_refresh_attachment_label ( )
chat_display . configure ( state = " normal " )
chat_display . delete ( " 1.0 " , " end " )
chat_display . configure ( state = " disabled " )
status_disk . set ( " Verlauf gelöscht. " )
def do_diskussion_datei_loeschen ( ) :
""" Eine gespeicherte Diskussion (JSON-Datei) auswählen und endgültig löschen. """
from tkinter import filedialog
base_dir = os . path . dirname ( os . path . abspath ( __file__ ) )
disk_dir = os . path . join ( base_dir , " Lernmodus_Export " , " Gespeicherte_Diskussionen " )
if not os . path . isdir ( disk_dir ) :
messagebox . showinfo ( " Diskussion löschen " , " Kein Ordner mit gespeicherten Diskussionen. " )
return
path = filedialog . askopenfilename (
title = " Gespeicherte Diskussion zum Löschen wählen " ,
initialdir = disk_dir ,
filetypes = [ ( " JSON " , " *.json " ) , ( " Alle " , " *.* " ) ] ,
)
if not path :
return
if not messagebox . askyesno ( " Diskussion löschen " , f " Diese Datei wirklich endgültig löschen? \n { os . path . basename ( path ) } " ) :
return
try :
os . remove ( path )
status_disk . set ( " Diskussion gelöscht. " )
except Exception as e :
messagebox . showerror ( " Fehler " , str ( e ) )
ttk . Button ( btn_row_disk , text = " Gespräch speichern " , command = do_diskussion_speichern ) . pack ( side = " left " , padx = ( 0 , 8 ) , pady = ( 4 , 0 ) )
ttk . Button ( btn_row_disk , text = " Gespräch laden " , command = do_diskussion_laden ) . pack ( side = " left " , padx = ( 0 , 8 ) , pady = ( 4 , 0 ) )
ttk . Button ( btn_row_disk , text = " Verlauf löschen " , command = do_diskussion_verlauf_loeschen ) . pack ( side = " left " , padx = ( 0 , 8 ) , pady = ( 4 , 0 ) )
ttk . Button ( btn_row_disk , text = " Diskussion löschen " , command = do_diskussion_datei_loeschen ) . pack ( side = " left " , pady = ( 4 , 0 ) )
btn_send . configure ( command = send_message )
btn_upload . configure ( command = _upload_images )
btn_befund . configure ( command = send_befund_message )
def on_diskussion_return ( event ) :
""" Enter = Senden, Shift+Enter = neue Zeile. """
if event . state & 0x1 : # Shift gedrückt → Zeilenumbruch wie üblich
return
send_message ( )
return " break "
input_txt . bind ( " <Return> " , on_diskussion_return )
input_txt . bind ( " <Control-v> " , _paste_input_text_and_images )
input_txt . bind ( " <Control-V> " , _paste_input_text_and_images )
input_txt . bind ( " <Shift-Insert> " , _paste_input_text_and_images )
input_txt . bind ( " <Button-3> " , _show_input_context_menu )
# Fallback: Paste auch dann abfangen, wenn Fokus nicht exakt im Eingabefeld liegt.
win . bind ( " <Control-v> " , _paste_input_text_and_images )
win . bind ( " <Control-V> " , _paste_input_text_and_images )
win . bind ( " <Shift-Insert> " , _paste_input_text_and_images )
try :
if hasattr ( input_txt , " drop_target_register " ) and hasattr ( input_txt , " dnd_bind " ) :
from tkinterdnd2 import DND_FILES
input_txt . drop_target_register ( DND_FILES )
input_txt . dnd_bind ( " <<Drop>> " , _on_drop_files )
status_disk . set ( " Bereit. Nachricht eingeben oder Bild per Drag & Drop/Einfügen/Hochladen hinzufügen. " )
except Exception :
pass
def open_op_bericht_window ( self ) :
transcript = self . txt_transcript . get ( " 1.0 " , " end " ) . strip ( )
if not transcript :
messagebox . showinfo (
" Hinweis " ,
" Bitte zuerst ein Transkript aufnehmen oder Text eingeben. " ,
)
return
template = load_op_bericht_template ( ) . strip ( )
from datetime import date
heute = date . today ( ) . strftime ( " %d . % m. % Y " )
operateur = load_signature_name ( ) or " Siehe Transkript "
datum_operateur = f " Datum: { heute } \n Operateur: { 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 ) )