# -*- coding: utf-8 -*-
"""
Standalone persistence/load-save utilities extracted from basis14.py.
"""
import os
import json
import sys
import time
import re
import html
import hashlib
import threading
from difflib import SequenceMatcher
from datetime import datetime, timedelta
from tkinter import messagebox
from aza_config import (
CONFIG_FILENAME,
WINDOW_CONFIG_FILENAME,
SIGNATURE_CONFIG_FILENAME,
KORREKTUREN_CONFIG_FILENAME,
ABLAGE_BASE_DIR,
ABLAGE_SUBFOLDERS,
ABLAGE_LABELS,
PRUEFEN_WINDOW_CONFIG_FILENAME,
ORDNER_WINDOW_CONFIG_FILENAME,
TEXT_WINDOW_CONFIG_FILENAME,
DIKTAT_WINDOW_CONFIG_FILENAME,
DISKUSSION_WINDOW_CONFIG_FILENAME,
SETTINGS_WINDOW_CONFIG_FILENAME,
TEXTBLOECKE_CONFIG_FILENAME,
TEMPLATES_CONFIG_FILENAME,
OP_BERICHT_TEMPLATE_CONFIG_FILENAME,
ARZTBRIEF_VORLAGE_CONFIG_FILENAME,
TODO_CONFIG_FILENAME,
TODO_WINDOW_CONFIG_FILENAME,
TODO_INBOX_CONFIG_FILENAME,
TODO_SETTINGS_CONFIG_FILENAME,
NOTES_CONFIG_FILENAME,
CHECKLIST_CONFIG_FILENAME,
USER_PROFILE_CONFIG_FILENAME,
OPACITY_CONFIG_FILENAME,
AUTOTEXT_CONFIG_FILENAME,
FONT_SCALE_CONFIG_FILENAME,
BUTTON_SCALE_CONFIG_FILENAME,
TOKEN_USAGE_CONFIG_FILENAME,
KG_DETAIL_LEVEL_CONFIG_FILENAME,
SOAP_SECTION_LEVELS_CONFIG_FILENAME,
FONT_SIZES_CONFIG_FILENAME,
PANED_POSITIONS_CONFIG_FILENAME,
DEFAULT_OPACITY,
MIN_OPACITY,
DEFAULT_FONT_SCALE,
MIN_FONT_SCALE,
MAX_FONT_SCALE,
DEFAULT_BUTTON_SCALE,
MIN_BUTTON_SCALE,
MAX_BUTTON_SCALE,
_SOAP_SECTIONS,
_SOAP_LABELS,
_DEFAULT_KORREKTUREN,
ARZTBRIEF_VORLAGE_DEFAULT,
KOGU_GRUSS_OPTIONS,
KOGU_GRUSS_CONFIG_FILENAME,
KOGU_TEMPLATES_CONFIG_FILENAME,
DISKUSSION_VORLAGE_CONFIG_FILENAME,
ALLOWED_SUMMARY_MODELS,
DEFAULT_SUMMARY_MODEL,
COMMENT_KEYWORDS,
_SUPABASE_URL,
_SUPABASE_ANON_KEY,
SOAP_ORDER_CONFIG_FILENAME,
SOAP_VISIBILITY_CONFIG_FILENAME,
SOAP_PRESETS_CONFIG_FILENAME,
DEFAULT_SOAP_ORDER,
NUM_SOAP_PRESETS,
BRIEF_PRESETS_CONFIG_FILENAME,
NUM_BRIEF_PRESETS,
BRIEF_PROFILE_DEFAULTS,
get_writable_data_dir,
)
def _config_path():
return os.path.join(get_writable_data_dir(), CONFIG_FILENAME)
def _window_config_path():
return os.path.join(get_writable_data_dir(), WINDOW_CONFIG_FILENAME)
def _clamp_geometry_str(geom: str, min_w: int, min_h: int) -> str:
"""Begrenzt gespeicherte Geometrie-String auf Mindestgröße (alle Buttons sichtbar)."""
if not geom or "x" not in geom:
return f"{min_w}x{min_h}"
parts = geom.replace("+", "x").split("x")
try:
w = max(min_w, int(parts[0].strip()))
h = max(min_h, int(parts[1].strip()))
if len(parts) >= 4:
return f"{w}x{h}+{parts[2].strip()}+{parts[3].strip()}"
return f"{w}x{h}"
except (ValueError, IndexError):
return f"{min_w}x{min_h}"
def load_window_geometry():
"""Liest gespeicherte Fenstergröße, Position, Sash (Breite) und Transkript-Höhe. Rückgabe: (w, h, x, y, sash_h, sash_v) oder None."""
try:
path = _window_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
parts = f.read().strip().split()
if len(parts) >= 4:
w, h, x, y = int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])
if w >= 400 and h >= 300:
sash_h = int(parts[4]) if len(parts) >= 5 else None
sash_v = int(parts[5]) if len(parts) >= 6 else None
return (w, h, x, y, sash_h, sash_v)
except Exception:
pass
return None
def save_window_geometry(
width: int, height: int, x: int, y: int, sash: int = None, sash_transcript: int = None
) -> None:
"""Speichert Fenstergröße, Position, Sash (Breite) und Transkript-Höhe dauerhaft."""
try:
with open(_window_config_path(), "w", encoding="utf-8") as f:
if sash is not None and sash_transcript is not None:
f.write(f"{width} {height} {x} {y} {sash} {sash_transcript}\n")
elif sash is not None:
f.write(f"{width} {height} {x} {y} {sash}\n")
else:
f.write(f"{width} {height} {x} {y}\n")
except Exception:
pass
def reset_all_window_positions() -> None:
"""Löscht alle gespeicherten Fensterpositionen, damit beim nächsten Start alle Fenster zentriert öffnen."""
base_dir = get_writable_data_dir()
# Alle Fenster-Geometrie-Dateien
geometry_files = [
WINDOW_CONFIG_FILENAME,
PRUEFEN_WINDOW_CONFIG_FILENAME,
ORDNER_WINDOW_CONFIG_FILENAME,
TEXT_WINDOW_CONFIG_FILENAME,
DIKTAT_WINDOW_CONFIG_FILENAME,
DISKUSSION_WINDOW_CONFIG_FILENAME,
SETTINGS_WINDOW_CONFIG_FILENAME,
TODO_WINDOW_CONFIG_FILENAME,
PANED_POSITIONS_CONFIG_FILENAME,
]
# Auch generische Toplevel-Geometrie-Dateien (autotext, interaktionscheck, ki_kontrolle, etc.)
for fname in os.listdir(base_dir):
if fname.startswith("kg_diktat_") and fname.endswith("_geometry.txt"):
geometry_files.append(fname)
# KG Detail-Level und SOAP-Section-Levels zurücksetzen
for cfg_name in (KG_DETAIL_LEVEL_CONFIG_FILENAME, SOAP_SECTION_LEVELS_CONFIG_FILENAME):
cfg_path = os.path.join(base_dir, cfg_name)
try:
if os.path.isfile(cfg_path):
os.remove(cfg_path)
except Exception:
pass
# Alle löschen
deleted = 0
for fname in geometry_files:
path = os.path.join(base_dir, fname)
try:
if os.path.isfile(path):
os.remove(path)
deleted += 1
except Exception:
pass
return deleted
def _opacity_config_path():
return os.path.join(get_writable_data_dir(), OPACITY_CONFIG_FILENAME)
def load_opacity() -> float:
"""Liest die Fenster-Transparenz (0.4–1.0). Standard 0.9."""
try:
path = _opacity_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
v = float(f.read().strip())
return max(MIN_OPACITY, min(1.0, v))
except Exception:
pass
return DEFAULT_OPACITY
def save_opacity(value: float) -> None:
"""Speichert die Fenster-Transparenz."""
try:
v = max(MIN_OPACITY, min(1.0, value))
with open(_opacity_config_path(), "w", encoding="utf-8") as f:
f.write(str(v))
except Exception:
pass
def _autotext_config_path():
return os.path.join(get_writable_data_dir(), AUTOTEXT_CONFIG_FILENAME)
def _font_scale_config_path():
return os.path.join(get_writable_data_dir(), FONT_SCALE_CONFIG_FILENAME)
def _button_scale_config_path():
return os.path.join(get_writable_data_dir(), BUTTON_SCALE_CONFIG_FILENAME)
def load_font_scale() -> float:
"""Liest den Schriftgrößen-Skalierungsfaktor (0.3–0.8). Standard 1.0."""
try:
path = _font_scale_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
v = float(f.read().strip())
return max(MIN_FONT_SCALE, min(MAX_FONT_SCALE, v))
except Exception:
pass
return DEFAULT_FONT_SCALE
def save_font_scale(value: float) -> None:
"""Speichert den Schriftgrößen-Skalierungsfaktor."""
try:
v = max(MIN_FONT_SCALE, min(MAX_FONT_SCALE, value))
with open(_font_scale_config_path(), "w", encoding="utf-8") as f:
f.write(str(v))
except Exception:
pass
def load_button_scale() -> float:
"""Liest den Button-Größen-Skalierungsfaktor (0.8–2.0). Standard 1.0."""
try:
path = _button_scale_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
v = float(f.read().strip())
return max(MIN_BUTTON_SCALE, min(MAX_BUTTON_SCALE, v))
except Exception:
pass
return DEFAULT_BUTTON_SCALE
def save_button_scale(value: float) -> None:
"""Speichert den Button-Größen-Skalierungsfaktor."""
try:
v = max(MIN_BUTTON_SCALE, min(MAX_BUTTON_SCALE, value))
with open(_button_scale_config_path(), "w", encoding="utf-8") as f:
f.write(str(v))
except Exception:
pass
# (Textfeld-Schriftgrößen + add_text_font_size_control sind in aza_ui_helpers.py)
def _token_usage_config_path():
return os.path.join(get_writable_data_dir(), TOKEN_USAGE_CONFIG_FILENAME)
def load_token_usage() -> dict:
"""Liest die Token-Nutzung. Format: {'used': int, 'total': int, 'budget_dollars': float, 'used_dollars': float}"""
try:
path = _token_usage_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.loads(f.read().strip())
return data
except Exception:
pass
return {"used": 0, "total": 1000000, "budget_dollars": 0, "used_dollars": 0}
def save_token_usage(used: int = None, total: int = None, budget_dollars: float = None, used_dollars: float = None) -> None:
"""Speichert die Token-Nutzung."""
try:
current = load_token_usage()
if used is not None:
current["used"] = used
if total is not None:
current["total"] = total
if budget_dollars is not None:
current["budget_dollars"] = budget_dollars
if used_dollars is not None:
current["used_dollars"] = used_dollars
with open(_token_usage_config_path(), "w", encoding="utf-8") as f:
json.dump(current, f)
except Exception:
pass
def add_token_usage(tokens: int) -> None:
"""Fügt verbrauchte Tokens hinzu."""
try:
data = load_token_usage()
data["used"] = data.get("used", 0) + tokens
save_token_usage(used=data["used"])
except Exception:
pass
def fetch_openai_usage(client) -> dict:
"""Ruft echte Verbrauchs-Daten von OpenAI ab."""
try:
# OpenAI API Key aus Client extrahieren
api_key = client.api_key if hasattr(client, 'api_key') else None
if not api_key:
return None
# Verwende httpx (bereits von openai installiert)
import httpx
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
# Verbrauch der letzten 30 Tage abrufen
end_date = datetime.now()
start_date = end_date - timedelta(days=30)
url = f"https://api.openai.com/v1/usage?start_date={start_date.strftime('%Y-%m-%d')}&end_date={end_date.strftime('%Y-%m-%d')}"
with httpx.Client(timeout=10.0) as http_client:
response = http_client.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
# Summiere Verbrauch aus allen Tagen
total_cost = 0
for day_data in data.get("data", []):
total_cost += day_data.get("cost", 0) / 100 # Cent to Dollar
return {
"used_dollars": total_cost,
"success": True
}
else:
return {
"error": f"API returned status {response.status_code}",
"success": False
}
except Exception as e:
return {
"error": str(e),
"success": False
}
return None
def load_autotext() -> dict:
"""Lädt Autotext und Einstellungen inkl. News/Event-Filter."""
try:
path = _autotext_config_path()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return {
"enabled": data.get("enabled", True),
"entries": data.get("entries") if isinstance(data.get("entries"), dict) else {},
"diktat_auto_start": data.get("diktat_auto_start", True),
"notizen_open_on_start": data.get("notizen_open_on_start", data.get("diktat_open_on_start", True)),
"textbloecke_visible": data.get("textbloecke_visible", True),
"addon_visible": data.get("addon_visible", True),
"addon_buttons": data.get("addon_buttons", {
"uebersetzer": True,
"email": True,
"autotext": True,
"whatsapp": True,
"docapp": True
}),
"kg_auto_delete_old": data.get("kg_auto_delete_old", False),
"textbloecke_collapsed": data.get("textbloecke_collapsed", False),
"status_color": data.get("status_color", "#BD4500"),
"soap_collapsed": data.get("soap_collapsed", False),
"autoOpenNews": data.get("autoOpenNews", False),
"autoOpenEvents": data.get("autoOpenEvents", True),
"newsTemplate": data.get("newsTemplate", "all"),
"newsSelectedSpecialties": data.get("newsSelectedSpecialties", []),
"newsSelectedRegions": data.get("newsSelectedRegions", ["CH", "EU"]),
"newsSort": data.get("newsSort", "newest"),
"eventsSelectedSpecialties": data.get("eventsSelectedSpecialties", ["general-medicine"]),
"eventsSelectedRegions": data.get("eventsSelectedRegions", ["CH", "EU"]),
"eventsTemplate": data.get("eventsTemplate", "general_ch_eu"),
"eventsSort": data.get("eventsSort", "soonest"),
"eventsMonthsAhead": int(data.get("eventsMonthsAhead", 13)),
"selectedLanguage": data.get("selectedLanguage", "system"),
"user_specialty_default": data.get("user_specialty_default", "dermatology"),
"user_specialties_selected": data.get("user_specialties_selected", []),
"ui_font_delta": int(data.get("ui_font_delta", -2)),
}
except Exception:
pass
return {
"enabled": True, "entries": {}, "diktat_auto_start": True,
"notizen_open_on_start": True,
"textbloecke_visible": True, "addon_visible": True,
"addon_buttons": {
"uebersetzer": True,
"email": True,
"autotext": True,
"whatsapp": True,
"docapp": True
},
"kg_auto_delete_old": False,
"textbloecke_collapsed": False,
"status_color": "#BD4500",
"soap_collapsed": False,
"autoOpenNews": False,
"autoOpenEvents": True,
"newsTemplate": "all",
"newsSelectedSpecialties": [],
"newsSelectedRegions": ["CH", "EU"],
"newsSort": "newest",
"eventsSelectedSpecialties": ["general-medicine"],
"eventsSelectedRegions": ["CH", "EU"],
"eventsTemplate": "general_ch_eu",
"eventsSort": "soonest",
"eventsMonthsAhead": 13,
"selectedLanguage": "system",
"user_specialty_default": "dermatology",
"user_specialties_selected": [],
"ui_font_delta": -2,
}
def save_autotext(data: dict) -> None:
"""Speichert Autotext inkl. News/Event-Settings dauerhaft."""
try:
with open(_autotext_config_path(), "w", encoding="utf-8") as f:
json.dump(
{
"enabled": data.get("enabled", True),
"entries": data.get("entries") or {},
"diktat_auto_start": data.get("diktat_auto_start", True),
"notizen_open_on_start": data.get("notizen_open_on_start", data.get("diktat_open_on_start", True)),
"textbloecke_visible": data.get("textbloecke_visible", True),
"addon_visible": data.get("addon_visible", True),
"kg_auto_delete_old": data.get("kg_auto_delete_old", False),
"addon_buttons": data.get("addon_buttons", {}),
"textbloecke_collapsed": data.get("textbloecke_collapsed", False),
"status_color": data.get("status_color", "#BD4500"),
"soap_collapsed": data.get("soap_collapsed", False),
"autoOpenNews": bool(data.get("autoOpenNews", False)),
"autoOpenEvents": bool(data.get("autoOpenEvents", True)),
"newsTemplate": data.get("newsTemplate", "all"),
"newsSelectedSpecialties": data.get("newsSelectedSpecialties", []),
"newsSelectedRegions": data.get("newsSelectedRegions", ["CH", "EU"]),
"newsSort": data.get("newsSort", "newest"),
"eventsSelectedSpecialties": data.get("eventsSelectedSpecialties", ["general-medicine"]),
"eventsSelectedRegions": data.get("eventsSelectedRegions", ["CH", "EU"]),
"eventsTemplate": data.get("eventsTemplate", "general_ch_eu"),
"eventsSort": data.get("eventsSort", "soonest"),
"eventsMonthsAhead": int(data.get("eventsMonthsAhead", 13)),
"selectedLanguage": data.get("selectedLanguage", "system"),
"user_specialty_default": data.get("user_specialty_default", "dermatology"),
"user_specialties_selected": data.get("user_specialties_selected", []),
"ui_font_delta": int(data.get("ui_font_delta", -2)),
},
f, ensure_ascii=False, indent=2,
)
except Exception:
pass
def _is_admin() -> bool:
"""Prüft, ob die Anwendung mit Administratorrechten läuft (für globalen Autotext)."""
if sys.platform != "win32":
return False
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def _run_as_admin() -> bool:
"""Startet die Anwendung mit Administratorrechten neu. Beendet die aktuelle Instanz."""
if sys.platform != "win32":
return False
try:
import ctypes
args = " ".join([f'"{a}"' if " " in a else a for a in sys.argv])
ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, args, None, 1)
sys.exit(0)
return True
except Exception:
return False
def sanitize_markdown_for_plain_text(raw_text: str) -> str:
"""Entfernt Markdown-Syntax für sauberen Plain-Text (ohne *, #, etc.)."""
lines = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
out_lines = []
for raw_line in lines:
line = raw_line
line = re.sub(r"^\s*#{1,6}\s+", "", line)
line = re.sub(r"^\s*\d+\.\s+", "", line)
line = re.sub(r"^\s*[-*•]\s+", "", line)
line = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
line = re.sub(r"__(.+?)__", r"\1", line)
line = re.sub(r"(? bool:
"""Text in Windows-Zwischenablage (für globalen Autotext per Strg+V)."""
if sys.platform != "win32":
return False
try:
import ctypes
from ctypes import wintypes
CF_UNICODETEXT = 13
GMEM_MOVEABLE = 0x0002
GMEM_DDESHARE = 0x2000
alloc_flags = GMEM_MOVEABLE | GMEM_DDESHARE
kernel32 = ctypes.WinDLL("kernel32")
user32 = ctypes.WinDLL("user32")
user32.OpenClipboard.argtypes = [wintypes.HWND]
user32.OpenClipboard.restype = wintypes.BOOL
user32.CloseClipboard.argtypes = []
user32.EmptyClipboard.argtypes = []
user32.RegisterClipboardFormatW.argtypes = [wintypes.LPCWSTR]
user32.RegisterClipboardFormatW.restype = wintypes.UINT
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
user32.SetClipboardData.restype = wintypes.HANDLE
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
def _set_clipboard_data(fmt: int, payload: bytes) -> bool:
h = kernel32.GlobalAlloc(alloc_flags, len(payload))
if not h:
return False
ptr = kernel32.GlobalLock(h)
if not ptr:
return False
ctypes.memmove(ptr, payload, len(payload))
kernel32.GlobalUnlock(h)
return bool(user32.SetClipboardData(fmt, h))
def _inline_markdown_to_html(line: str) -> str:
escaped = html.escape(line)
escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped)
escaped = re.sub(r"__(.+?)__", r"\1", escaped)
escaped = re.sub(r"(?\1", escaped)
escaped = re.sub(r"(?\1", escaped)
return escaped
def _markdown_like_to_html(raw_text: str) -> str:
lines = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
html_parts = []
in_ul = False
in_ol = False
def _close_lists():
nonlocal in_ul, in_ol
if in_ul:
html_parts.append("")
in_ul = False
if in_ol:
html_parts.append("")
in_ol = False
for raw_line in lines:
line = raw_line.rstrip()
stripped = line.strip()
if not stripped:
_close_lists()
html_parts.append("
")
continue
m_head = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if m_head:
_close_lists()
level = min(6, len(m_head.group(1)))
html_parts.append(f"
{_inline_markdown_to_html(stripped)}
") _close_lists() return "".join(html_parts) if html_parts else "" def _build_cf_html_payload(fragment_html: str) -> bytes: full_html = ( "" + fragment_html + "" ) marker_start = b"" marker_end = b"" header_template = ( "Version:0.9\r\n" "StartHTML:{:010d}\r\n" "EndHTML:{:010d}\r\n" "StartFragment:{:010d}\r\n" "EndFragment:{:010d}\r\n" ) dummy_header = header_template.format(0, 0, 0, 0) html_bytes = full_html.encode("utf-8") start_html = len(dummy_header.encode("ascii")) end_html = start_html + len(html_bytes) start_fragment = start_html + html_bytes.index(marker_start) + len(marker_start) end_fragment = start_html + html_bytes.index(marker_end) header = header_template.format(start_html, end_html, start_fragment, end_fragment) return header.encode("ascii") + html_bytes + b"\0" for _ in range(5): if user32.OpenClipboard(None): break time.sleep(0.03) else: return False try: user32.EmptyClipboard() plain_text = sanitize_markdown_for_plain_text(text or "") text_data = (plain_text + "\0").encode("utf-16-le") ok_text = _set_clipboard_data(CF_UNICODETEXT, text_data) html_format = user32.RegisterClipboardFormatW("HTML Format") ok_html = False if html_format: fragment = html_fragment if html_fragment is not None else _markdown_like_to_html(text or "") html_payload = _build_cf_html_payload(fragment) ok_html = _set_clipboard_data(html_format, html_payload) return bool(ok_text or ok_html) finally: user32.CloseClipboard() except Exception: return False def _win_clipboard_get() -> str: """Text aus Windows-Zwischenablage lesen.""" if sys.platform != "win32": return "" try: import ctypes from ctypes import wintypes CF_UNICODETEXT = 13 user32 = ctypes.WinDLL("user32") kernel32 = ctypes.WinDLL("kernel32") if not user32.OpenClipboard(None): return "" h = user32.GetClipboardData(CF_UNICODETEXT) user32.CloseClipboard() if not h: return "" ptr = kernel32.GlobalLock(h) if not ptr: return "" buf = (ctypes.c_char * 131072).from_address(ptr) data = bytearray() for i in range(0, 131070, 2): if buf[i] == 0 and buf[i + 1] == 0: break data.extend([buf[i], buf[i + 1]]) kernel32.GlobalUnlock(h) return data.decode("utf-16-le", errors="ignore") except Exception: return "" def _signature_config_path(): return os.path.join(get_writable_data_dir(), SIGNATURE_CONFIG_FILENAME) def load_signature_name() -> str: """Liest den gespeicherten Namen für die Unterschrift.""" try: path = _signature_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return f.read().strip() except Exception: pass return "" def save_signature_name(name: str) -> None: """Speichert den Namen für die Unterschrift.""" try: with open(_signature_config_path(), "w", encoding="utf-8") as f: f.write((name or "").strip()) except Exception: pass def _korrekturen_config_path(): return os.path.join(get_writable_data_dir(), KORREKTUREN_CONFIG_FILENAME) def load_korrekturen() -> dict: """Lädt die Korrekturen-Datenbank: {'medikamente': {falsch: richtig}, 'diagnosen': {...}}.""" try: path = _korrekturen_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): for cat, defaults in _DEFAULT_KORREKTUREN.items(): if cat not in data: data[cat] = {} for falsch, richtig in defaults.items(): if falsch not in data[cat]: data[cat][falsch] = richtig return data except Exception: pass return {cat: dict(mapping) for cat, mapping in _DEFAULT_KORREKTUREN.items()} def save_korrekturen(data: dict) -> None: """Speichert die Korrekturen-Datenbank.""" try: with open(_korrekturen_config_path(), "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception: pass def _ablage_base_path(): """Pfad zum Ablage-Basisordner: Dokumente/KG_Diktat_Ablage.""" docs = os.path.join(os.path.expanduser("~"), "Documents") if not os.path.isdir(docs): docs = os.path.expanduser("~") return os.path.join(docs, "KG_Diktat_Ablage") def _ablage_json_path(): """Eine zentrale JSON-Datei – Inhalt wird hier zuverlässig gespeichert.""" return os.path.join(_ablage_base_path(), "ablage.json") def ensure_ablage_dirs(): """Erstellt Basisordner und alle Unterordner.""" base = _ablage_base_path() os.makedirs(base, exist_ok=True) for sub in ABLAGE_SUBFOLDERS: os.makedirs(os.path.join(base, sub), exist_ok=True) def _load_ablage_json(): """Lädt ablage.json. Rückgabe: {"KG": [{"content": "...", "name": "..."}], ...}.""" path = _ablage_json_path() if not os.path.isfile(path): return {c: [] for c in ABLAGE_SUBFOLDERS} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): return {c: [] for c in ABLAGE_SUBFOLDERS} for c in ABLAGE_SUBFOLDERS: if c not in data or not isinstance(data[c], list): data[c] = [] return data except Exception: return {c: [] for c in ABLAGE_SUBFOLDERS} def _save_ablage_json(data: dict) -> bool: """Schreibt ablage.json. Rückgabe: True bei Erfolg.""" try: ensure_ablage_dirs() path = _ablage_json_path() with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) return True except Exception: return False def save_to_ablage(category: str, content: str): """ Speichert in ablage.json (eine zentrale JSON-Datei). Laden erfolgt über die App (Ordner → Auswählen → „Ausgewählte Datei in App laden“). Rückgabe: Pfad der ablage.json bei Erfolg, sonst None. """ if category not in ABLAGE_SUBFOLDERS: try: messagebox.showerror("Speichern", f"Unbekannte Kategorie: {category}") except Exception: pass return None raw = content if isinstance(content, str) else (str(content) if content is not None else "") content = raw.strip() if not content: return None try: ensure_ablage_dirs() label = ABLAGE_LABELS.get(category, category) now = datetime.now() date_str = now.strftime("%d.%m.%Y") time_str = now.strftime("%H:%M") data = _load_ablage_json() n = len(data.get(category, [])) + 1 name = f"{n} {label} {date_str} {time_str}.txt" entry = {"content": content, "name": name} data.setdefault(category, []).append(entry) if not _save_ablage_json(data): raise RuntimeError("ablage.json konnte nicht geschrieben werden.") return _ablage_json_path() except Exception as e: try: messagebox.showerror("Speichern fehlgeschlagen", f"Pfad: {_ablage_base_path()}\nFehler: {e}") except Exception: pass return None def list_ablage_files(category: str): """Listet Einträge aus ablage.json, neueste zuoberst (höchste Nummer zuerst).""" data = _load_ablage_json() names = [e.get("name", "") for e in data.get(category, []) if isinstance(e, dict) and e.get("name")] def sort_key(name): m = re.match(r"^(\d+)", str(name)) return (-int(m.group(1)), name) if m else (0, name) names.sort(key=sort_key) return names def _parse_entry_date(name: str): """Parst Datum aus Eintragsnamen (z.B. '1 KG 04.02.2026 10.txt') → datetime oder None.""" if not name: return None m = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", str(name)) if not m: return None try: d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) return datetime(y, mo, d) except (ValueError, IndexError): return None def get_old_kg_entries(days: int = 14): """Liefert KG-Einträge, die älter als days Tage sind.""" data = _load_ablage_json() entries = data.get("KG", []) if not isinstance(entries, list): return [] cutoff = datetime.now() - timedelta(days=days) return [e for e in entries if isinstance(e, dict) and _parse_entry_date(e.get("name", "")) and _parse_entry_date(e.get("name", "")) < cutoff] def delete_kg_entries_older_than(days: int = 14) -> int: """Löscht KG-Einträge älter als days Tage. Rückgabe: Anzahl gelöschter Einträge.""" return delete_entries_older_than("KG", days=days) def count_entries_older_than(category: str, days: int = 14) -> int: """Zählt Einträge einer Kategorie, die älter als days Tage sind.""" if category not in ABLAGE_SUBFOLDERS: return 0 data = _load_ablage_json() entries = data.get(category, []) if not isinstance(entries, list): return 0 cutoff = datetime.now() - timedelta(days=days) old_entries = [ e for e in entries if isinstance(e, dict) and _parse_entry_date(e.get("name", "")) and _parse_entry_date(e.get("name", "")) < cutoff ] return len(old_entries) def delete_entries_older_than(category: str, days: int = 14) -> int: """Löscht Einträge einer Kategorie, die älter als days Tage sind. Rückgabe: Anzahl gelöschter Einträge.""" if category not in ABLAGE_SUBFOLDERS: return 0 data = _load_ablage_json() entries = data.get(category, []) if not isinstance(entries, list): return 0 cutoff = datetime.now() - timedelta(days=days) kept = [e for e in entries if not isinstance(e, dict) or not _parse_entry_date(e.get("name", "")) or _parse_entry_date(e.get("name", "")) >= cutoff] deleted = len(entries) - len(kept) if deleted > 0: data[category] = kept _save_ablage_json(data) return deleted def delete_all_ablage_entries(category: str) -> int: """Löscht alle Einträge einer Kategorie. Rückgabe: Anzahl gelöschter Einträge.""" if category not in ABLAGE_SUBFOLDERS: return 0 data = _load_ablage_json() count = len(data.get(category, [])) if count > 0: data[category] = [] _save_ablage_json(data) return count def get_ablage_content(category: str, filename: str) -> str: """Liest Inhalt aus ablage.json (Eintrag anhand name). Liefert nur Text, nie JSON-Rohdaten.""" if not filename or filename == "ablage.json": return "" data = _load_ablage_json() for e in data.get(category, []): if isinstance(e, dict) and e.get("name") == filename: return (e.get("content") or "").strip() or "" return "" def _pruefen_window_config_path(): return os.path.join(get_writable_data_dir(), PRUEFEN_WINDOW_CONFIG_FILENAME) def _ordner_window_config_path(): return os.path.join(get_writable_data_dir(), ORDNER_WINDOW_CONFIG_FILENAME) def _text_window_config_path(): return os.path.join(get_writable_data_dir(), TEXT_WINDOW_CONFIG_FILENAME) def _diktat_window_config_path(): return os.path.join(get_writable_data_dir(), DIKTAT_WINDOW_CONFIG_FILENAME) def _diskussion_window_config_path(): return os.path.join(get_writable_data_dir(), DISKUSSION_WINDOW_CONFIG_FILENAME) def _settings_window_config_path(): return os.path.join(get_writable_data_dir(), SETTINGS_WINDOW_CONFIG_FILENAME) def load_settings_geometry() -> str: """Liest gespeicherte Geometry des Einstellungs-Fensters (z. B. '460x300+100+50').""" try: path = _settings_window_config_path() if os.path.isfile(path): geom = open(path, "r", encoding="utf-8").read().strip() if geom: return geom except Exception: pass return "" def save_settings_geometry(geom: str) -> None: """Speichert Größe und Position des Einstellungs-Fensters.""" try: if geom: with open(_settings_window_config_path(), "w", encoding="utf-8") as f: f.write(geom + "\n") except Exception: pass def _textbloecke_config_path(): return os.path.join(get_writable_data_dir(), TEXTBLOECKE_CONFIG_FILENAME) def load_textbloecke(): """Lädt die Textblöcke: {"1": {"name": "...", "content": "..."}, ...}. Mindestens 2 Slots.""" try: path = _textbloecke_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): out = {} for k, v in data.items(): if isinstance(k, str) and k.isdigit() and isinstance(v, dict): out[k] = {"name": (v.get("name") or "").strip(), "content": v.get("content") or ""} slots = sorted(out.keys(), key=int) if len(slots) >= 2: return {s: out[s] for s in slots} except Exception: pass return {"1": {"name": "Textblock 1", "content": ""}, "2": {"name": "Textblock 2", "content": ""}} def save_textbloecke(data: dict) -> None: """Speichert die Textblöcke dauerhaft. Alle Slots werden gespeichert.""" try: full = {} for k, v in (data or {}).items(): if isinstance(k, str) and isinstance(v, dict): full[k] = {"name": (v.get("name") or "").strip(), "content": v.get("content") or ""} if len(full) < 2: return with open(_textbloecke_config_path(), "w", encoding="utf-8") as f: json.dump(full, f, ensure_ascii=False, indent=2) f.flush() try: os.fsync(f.fileno()) except Exception: pass except Exception: pass def load_pruefen_geometry(): """Liest gespeicherte Größe und Position des Prüfen-Fensters. Rückgabe: (w, h, x, y) oder (w, h) oder None.""" try: path = _pruefen_window_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: parts = f.read().strip().split() if len(parts) >= 2: w, h = int(parts[0]), int(parts[1]) if w >= 300 and h >= 250: if len(parts) >= 4: x, y = int(parts[2]), int(parts[3]) return (w, h, x, y) return (w, h) except Exception: pass return None def save_pruefen_geometry(width: int, height: int, x: int = None, y: int = None) -> None: """Speichert Größe und Position des Prüfen-Fensters.""" try: with open(_pruefen_window_config_path(), "w", encoding="utf-8") as f: if x is not None and y is not None: f.write(f"{width} {height} {x} {y}\n") else: f.write(f"{width} {height}\n") except Exception: pass def load_ordner_geometry() -> str: """Liest gespeicherte Geometry des Ordner-Fensters (z. B. '640x500+100+50').""" try: path = _ordner_window_config_path() if os.path.isfile(path): geom = open(path, "r", encoding="utf-8").read().strip() if geom: return geom except Exception: pass return "" def save_ordner_geometry(geom: str) -> None: try: with open(_ordner_window_config_path(), "w", encoding="utf-8") as f: f.write(geom) except Exception: pass def load_text_window_geometry() -> str: try: path = _text_window_config_path() if os.path.isfile(path): return open(path, "r", encoding="utf-8").read().strip() except Exception: pass return "" def save_text_window_geometry(geom: str) -> None: try: with open(_text_window_config_path(), "w", encoding="utf-8") as f: f.write(geom) except Exception: pass def load_diktat_geometry() -> str: try: path = _diktat_window_config_path() if os.path.isfile(path): return open(path, "r", encoding="utf-8").read().strip() except Exception: pass return "" def save_diktat_geometry(geom: str) -> None: try: with open(_diktat_window_config_path(), "w", encoding="utf-8") as f: f.write(geom) except Exception: pass def load_diskussion_geometry() -> str: try: path = _diskussion_window_config_path() if os.path.isfile(path): return open(path, "r", encoding="utf-8").read().strip() except Exception: pass return "" def save_diskussion_geometry(geom: str) -> None: try: with open(_diskussion_window_config_path(), "w", encoding="utf-8") as f: f.write(geom) except Exception: pass def extract_diagnosen_therapie_procedere(text: str) -> str: """Extrahiert nur Diagnosen, Therapie und Procedere aus dem Text.""" if "KRANKENGESCHICHTE:" in text: kg_part = text.split("TRANSKRIPT:")[0].replace("KRANKENGESCHICHTE:", "").strip() else: kg_part = text lines = kg_part.split("\n") result = [] in_block = False target_headers = ("Diagnose:", "Diagnosen:", "Therapie:", "Procedere:") for line in lines: stripped = line.strip() if any(stripped.startswith(h) for h in target_headers): if result: result.append("") result.append(line) in_block = True elif in_block: if stripped and stripped.endswith(":"): in_block = False else: result.append(line) out = "\n".join(result).strip() return out if out else kg_part def _similarity(a: str, b: str) -> float: return SequenceMatcher(None, a.lower(), b.lower()).ratio() def apply_korrekturen(text: str, korrekturen: dict) -> tuple: """Wendet Korrekturen an. Rückgabe: (korrigierter_text, [(falsch, richtig), ...]).""" result = text applied = [] FUZZY_THRESHOLD = 0.85 for kategorie, mapping in korrekturen.items(): if not isinstance(mapping, dict): continue for falsch, richtig in mapping.items(): if not falsch or not richtig: continue pattern = r"\b" + re.escape(falsch) + r"\b" if re.search(pattern, result, re.IGNORECASE): result = re.sub(pattern, richtig, result, flags=re.IGNORECASE) applied.append((falsch, richtig)) words = re.findall(r"[A-Za-zÄÖÜäöüß0-9\-]+", result) for kategorie, mapping in korrekturen.items(): if not isinstance(mapping, dict): continue for falsch, richtig in mapping.items(): if not falsch or not richtig or (falsch, richtig) in applied: continue for w in set(words): if len(w) < 4: continue if _similarity(w, falsch) >= FUZZY_THRESHOLD: pattern = r"\b" + re.escape(w) + r"\b" result = re.sub(pattern, richtig, result) applied.append((falsch, richtig)) words = re.findall(r"[A-Za-zÄÖÜäöüß0-9\-]+", result) break result = result.replace("ß", "ss") return result, applied def _kogu_gruss_config_path(): return os.path.join(get_writable_data_dir(), KOGU_GRUSS_CONFIG_FILENAME) def load_kogu_gruss() -> str: """Liest den gespeicherten Schlusssatz für KOGU.""" try: path = _kogu_gruss_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: s = f.read().strip() if s in KOGU_GRUSS_OPTIONS: return s except Exception: pass return KOGU_GRUSS_OPTIONS[0] def save_kogu_gruss(gruss: str) -> None: """Speichert den Schlusssatz für KOGU.""" try: with open(_kogu_gruss_config_path(), "w", encoding="utf-8") as f: f.write((gruss or "").strip()) except Exception: pass def _kogu_templates_config_path(): return os.path.join(get_writable_data_dir(), KOGU_TEMPLATES_CONFIG_FILENAME) def load_kogu_templates() -> str: """Liest die gespeicherte Vorlage für Kostengutsprachen (eigene Wünsche an Typ/Format/Inhalt).""" try: path = _kogu_templates_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return f.read().strip() except Exception: pass return "" def save_kogu_templates(text: str) -> None: """Speichert die Vorlage für Kostengutsprachen.""" try: with open(_kogu_templates_config_path(), "w", encoding="utf-8") as f: f.write((text or "").strip()) except Exception: pass def _diskussion_vorlage_config_path(): return os.path.join(get_writable_data_dir(), DISKUSSION_VORLAGE_CONFIG_FILENAME) def load_diskussion_vorlage() -> str: """Liest die Vorlage für die KI-Diskussion (legt fest, wie die KI mit dem Nutzer diskutiert).""" try: path = _diskussion_vorlage_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return f.read().strip() except Exception: pass return "" def save_diskussion_vorlage(text: str) -> None: """Speichert die Vorlage für die KI-Diskussion.""" try: with open(_diskussion_vorlage_config_path(), "w", encoding="utf-8") as f: f.write((text or "").strip()) except Exception: pass def _op_bericht_template_config_path(): return os.path.join(get_writable_data_dir(), OP_BERICHT_TEMPLATE_CONFIG_FILENAME) def load_op_bericht_template() -> str: """Liest die gespeicherte Vorlage für den OP-Bericht (eigene Wünsche an Format/Inhalt).""" try: path = _op_bericht_template_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return f.read().strip() except Exception: pass return "" def save_op_bericht_template(text: str) -> None: """Speichert die Vorlage für den OP-Bericht.""" try: with open(_op_bericht_template_config_path(), "w", encoding="utf-8") as f: f.write((text or "").strip()) except Exception: pass def _arztbrief_vorlage_config_path(): return os.path.join(get_writable_data_dir(), ARZTBRIEF_VORLAGE_CONFIG_FILENAME) def load_arztbrief_vorlage() -> str: """Liest die gespeicherte Vorlage für den Arztbrief (Reihenfolge + Anweisungen).""" try: path = _arztbrief_vorlage_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return f.read().strip() except Exception: pass return ARZTBRIEF_VORLAGE_DEFAULT def save_arztbrief_vorlage(text: str) -> None: """Speichert die Vorlage für den Arztbrief.""" try: with open(_arztbrief_vorlage_config_path(), "w", encoding="utf-8") as f: f.write((text or "").strip()) except Exception: pass def _todo_config_path(): return os.path.join(get_writable_data_dir(), TODO_CONFIG_FILENAME) def _todo_window_config_path(): return os.path.join(get_writable_data_dir(), TODO_WINDOW_CONFIG_FILENAME) def load_todos() -> list: """Lädt die To-do-Liste. Jedes Item: {id, text, done, date (optional, 'YYYY-MM-DD'), created}.""" try: path = _todo_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return [] def save_todos(todos: list) -> None: """Speichert die To-do-Liste lokal UND pusht in die Cloud.""" try: with open(_todo_config_path(), "w", encoding="utf-8") as f: json.dump(todos, f, indent=2, ensure_ascii=False) except Exception: pass import threading threading.Thread(target=cloud_push_todos, args=(todos,), daemon=True).start() def _notes_config_path(): return os.path.join(get_writable_data_dir(), NOTES_CONFIG_FILENAME) def load_notes() -> list: """Lädt die Notizen-Liste. Jedes Item: {id, title, text, created}.""" try: path = _notes_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return [] def save_notes(notes: list) -> None: """Speichert die Notizen-Liste lokal UND pusht in die Cloud.""" try: with open(_notes_config_path(), "w", encoding="utf-8") as f: json.dump(notes, f, indent=2, ensure_ascii=False) except Exception: pass import threading threading.Thread(target=cloud_push_notes, args=(notes,), daemon=True).start() def _checklist_config_path(): return os.path.join(get_writable_data_dir(), CHECKLIST_CONFIG_FILENAME) def load_checklists() -> list: """Lädt die Checklisten. Jede: {id, title, items: [{text, done}], created}.""" try: path = _checklist_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return [] def save_checklists(checklists: list) -> None: try: with open(_checklist_config_path(), "w", encoding="utf-8") as f: json.dump(checklists, f, indent=2, ensure_ascii=False) except Exception: pass def load_todo_geometry() -> str: try: path = _todo_window_config_path() if os.path.isfile(path): return open(path, "r", encoding="utf-8").read().strip() except Exception: pass return "" def save_todo_geometry(geom: str) -> None: try: with open(_todo_window_config_path(), "w", encoding="utf-8") as f: f.write(geom) except Exception: pass def _todo_settings_path(): return os.path.join(get_writable_data_dir(), TODO_SETTINGS_CONFIG_FILENAME) def load_todo_settings() -> dict: """Lädt Todo-Fenster-Einstellungen (aktiver Tab, aktive Kategorie, benutzerdefinierte Kategorien).""" try: path = _todo_settings_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} def save_todo_settings(settings: dict) -> None: """Speichert Todo-Fenster-Einstellungen.""" try: with open(_todo_settings_path(), "w", encoding="utf-8") as f: json.dump(settings, f, indent=2, ensure_ascii=False) except Exception: pass def _todo_inbox_path(): return os.path.join(get_writable_data_dir(), TODO_INBOX_CONFIG_FILENAME) def load_todo_inbox() -> list: """Lädt die Inbox (empfangene To-dos von anderen Benutzern).""" try: path = _todo_inbox_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return [] def save_todo_inbox(inbox: list) -> None: """Speichert die Inbox.""" try: with open(_todo_inbox_path(), "w", encoding="utf-8") as f: json.dump(inbox, f, indent=2, ensure_ascii=False) except Exception: pass def send_todo_to_inbox(todo_item: dict, sender_name: str, recipient: str) -> None: """Sendet ein To-do in die Inbox (Datei-basiert, für lokale Nutzung).""" inbox = load_todo_inbox() from datetime import datetime as _dt entry = { "id": int(_dt.now().timestamp() * 1000), "text": todo_item.get("text", ""), "date": todo_item.get("date"), "priority": todo_item.get("priority", 0), "notes": todo_item.get("notes", ""), "done": False, "sender": sender_name, "recipient": recipient, "sent_at": _dt.now().isoformat(), } inbox.append(entry) save_todo_inbox(inbox) # ─── Cloud-Sync (Supabase – kostenlose Cloud-DB) ─── def cloud_push_todos(todos: list) -> bool: """Schreibt die To-do-Liste nach Supabase.""" import urllib.request payload = json.dumps({"data": todos}).encode("utf-8") req = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync?id=eq.1", data=payload, method="PATCH", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", "Content-Type": "application/json", } ) try: urllib.request.urlopen(req, timeout=10) return True except Exception: return False def cloud_pull_todos() -> list: """Liest die To-do-Liste aus Supabase. Gibt None zurück bei Fehler.""" import urllib.request req = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync?id=eq.1&select=data", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", } ) try: resp = urllib.request.urlopen(req, timeout=10) rows = json.loads(resp.read().decode("utf-8")) if rows and len(rows) > 0: return rows[0].get("data", []) return [] except Exception: return None def cloud_get_status() -> str: """Gibt den Cloud-Status zurück.""" try: pulled = cloud_pull_todos() if pulled is not None: return "Supabase verbunden" except Exception: pass return "" def cloud_push_notes(notes: list) -> bool: """Schreibt die Notizen-Liste nach Supabase (id=2 in todo_sync).""" import urllib.request payload = json.dumps({"id": 2, "data": notes}).encode("utf-8") req = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync?id=eq.2", data=payload, method="PATCH", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", "Content-Type": "application/json", } ) try: urllib.request.urlopen(req, timeout=10) return True except Exception: # Wenn Zeile noch nicht existiert, INSERT try: req2 = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync", data=payload, method="POST", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", "Content-Type": "application/json", "Prefer": "return=minimal", } ) urllib.request.urlopen(req2, timeout=10) return True except Exception: return False def cloud_pull_notes() -> list: """Liest die Notizen-Liste aus Supabase (id=2).""" import urllib.request req = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync?id=eq.2&select=data", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", } ) try: resp = urllib.request.urlopen(req, timeout=10) rows = json.loads(resp.read().decode("utf-8")) if rows and len(rows) > 0: return rows[0].get("data", []) return [] except Exception: return None def _user_profile_config_path(): return os.path.join(get_writable_data_dir(), USER_PROFILE_CONFIG_FILENAME) def load_user_profile() -> dict: """Lädt das gespeicherte Benutzerprofil. Rückgabe: {name, specialty, clinic} oder leeres Dict.""" try: path = _user_profile_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} def save_user_profile(profile: dict) -> None: """Speichert das Benutzerprofil.""" try: with open(_user_profile_config_path(), "w", encoding="utf-8") as f: json.dump(profile, f, indent=2, ensure_ascii=False) except Exception: pass def extract_date_from_todo_text(text: str): """Erkennt deutsche Datumsangaben im diktierten Text und gibt (bereinigter_text, date_obj_or_None) zurück. Erkannte Muster: - "heute", "morgen", "übermorgen" - "nächsten Montag/Dienstag/…", "am Montag", "kommenden Freitag" - "in 3 Tagen", "in einer Woche", "in zwei Wochen", "in einem Monat" - "bis 20. März", "am 15. Februar 2026", "bis 3.4.", "bis 03.04.2026" - "bis Ende Woche", "bis Ende Monat" """ import re from datetime import date, timedelta if not text or not text.strip(): return text, None original = text today = date.today() WOCHENTAGE = { "montag": 0, "dienstag": 1, "mittwoch": 2, "donnerstag": 3, "freitag": 4, "samstag": 5, "sonntag": 6, } MONATE = { "januar": 1, "februar": 2, "märz": 3, "maerz": 3, "april": 4, "mai": 5, "juni": 6, "juli": 7, "august": 8, "september": 9, "oktober": 10, "november": 11, "dezember": 12, "jan": 1, "feb": 2, "mär": 3, "mar": 3, "apr": 4, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "okt": 10, "nov": 11, "dez": 12, } ZAHLWOERTER = { "einem": 1, "einer": 1, "eins": 1, "ein": 1, "zwei": 2, "drei": 3, "vier": 4, "fünf": 5, "fuenf": 5, "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10, "elf": 11, "zwölf": 12, "zwoelf": 12, } lowered = text.lower().strip() found_date = None pattern_match = None # "heute" m = re.search(r'\b(bis\s+)?heute\b', lowered) if m: found_date = today pattern_match = m # "morgen" if not found_date: m = re.search(r'\b(bis\s+)?morgen\b', lowered) if m: found_date = today + timedelta(days=1) pattern_match = m # "übermorgen" if not found_date: m = re.search(r'\b(bis\s+)?[üu]bermorgen\b', lowered) if m: found_date = today + timedelta(days=2) pattern_match = m # "bis Ende Woche" if not found_date: m = re.search(r'\bbis\s+ende\s+(?:der\s+)?woche\b', lowered) if m: days_until_friday = (4 - today.weekday()) % 7 if days_until_friday == 0: days_until_friday = 7 found_date = today + timedelta(days=days_until_friday) pattern_match = m # "bis Ende Monat" if not found_date: m = re.search(r'\bbis\s+ende\s+(?:des\s+)?monat[s]?\b', lowered) if m: import calendar as cal_m last_day = cal_m.monthrange(today.year, today.month)[1] found_date = date(today.year, today.month, last_day) pattern_match = m # "nächsten/kommenden/am Montag/Dienstag/…" if not found_date: wt_pattern = "|".join(WOCHENTAGE.keys()) m = re.search( r'\b(?:(?:bis\s+)?(?:n[äa]chsten?|kommenden?|am)\s+)(' + wt_pattern + r')\b', lowered, ) if m: target_wd = WOCHENTAGE[m.group(1)] days_ahead = (target_wd - today.weekday()) % 7 if days_ahead == 0: days_ahead = 7 found_date = today + timedelta(days=days_ahead) pattern_match = m # "in X Tagen/Wochen/Monaten" if not found_date: m = re.search( r'\bin\s+(\d+|' + '|'.join(ZAHLWOERTER.keys()) + r')\s+(tag(?:en?)?|woche[n]?|monat(?:en?)?)\b', lowered, ) if m: num_str = m.group(1) num = ZAHLWOERTER.get(num_str, None) if num is None: try: num = int(num_str) except ValueError: num = 1 unit = m.group(2) if "tag" in unit: found_date = today + timedelta(days=num) elif "woche" in unit: found_date = today + timedelta(weeks=num) elif "monat" in unit: new_month = today.month + num new_year = today.year + (new_month - 1) // 12 new_month = ((new_month - 1) % 12) + 1 import calendar as cal_m2 max_day = cal_m2.monthrange(new_year, new_month)[1] found_date = date(new_year, new_month, min(today.day, max_day)) pattern_match = m # "bis/am 20. März (2026)" oder "bis/am 20. 3. (2026)" if not found_date: monat_pattern = "|".join(MONATE.keys()) m = re.search( r'\b(?:bis|am|vom)\s+(\d{1,2})\.\s*(' + monat_pattern + r')(?:\s+(\d{2,4}))?\b', lowered, ) if m: day = int(m.group(1)) month = MONATE.get(m.group(2), None) year = today.year if m.group(3): year = int(m.group(3)) if year < 100: year += 2000 if month and 1 <= day <= 31: try: found_date = date(year, month, day) if found_date < today and not m.group(3): found_date = date(year + 1, month, day) pattern_match = m except ValueError: found_date = None # "bis/am 20.3." oder "bis/am 20.03.2026" if not found_date: m = re.search( r'\b(?:bis|am|vom)\s+(\d{1,2})\.(\d{1,2})\.(?:(\d{2,4}))?\b', lowered, ) if m: day = int(m.group(1)) month = int(m.group(2)) year = today.year if m.group(3): year = int(m.group(3)) if year < 100: year += 2000 if 1 <= month <= 12 and 1 <= day <= 31: try: found_date = date(year, month, day) if found_date < today and not m.group(3): found_date = date(year + 1, month, day) pattern_match = m except ValueError: found_date = None # Standalone Datum: "20. März", "15. Februar 2026" (ohne bis/am Präfix) if not found_date: monat_pattern = "|".join(MONATE.keys()) m = re.search( r'\b(\d{1,2})\.\s*(' + monat_pattern + r')(?:\s+(\d{2,4}))?\b', lowered, ) if m: day = int(m.group(1)) month = MONATE.get(m.group(2), None) year = today.year if m.group(3): year = int(m.group(3)) if year < 100: year += 2000 if month and 1 <= day <= 31: try: found_date = date(year, month, day) if found_date < today and not m.group(3): found_date = date(year + 1, month, day) pattern_match = m except ValueError: found_date = None if found_date and pattern_match: cleaned = original[:pattern_match.start()] + original[pattern_match.end():] cleaned = re.sub(r'\s{2,}', ' ', cleaned).strip() cleaned = re.sub(r'^[,.\s]+|[,.\s]+$', '', cleaned).strip() return cleaned, found_date return text, None def load_saved_model() -> str: """Liest die zuletzt gewählte KG-Modell-ID aus der Config-Datei.""" try: path = _config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: model = f.read().strip() if model in ALLOWED_SUMMARY_MODELS: return model except Exception: pass return DEFAULT_SUMMARY_MODEL def save_model(model: str) -> None: """Speichert die gewählte KG-Modell-ID in der Config-Datei.""" if model not in ALLOWED_SUMMARY_MODELS: return try: with open(_config_path(), "w", encoding="utf-8") as f: f.write(model) except Exception: pass def _templates_config_path(): return os.path.join(get_writable_data_dir(), TEMPLATES_CONFIG_FILENAME) # ─── KG Detail-Level (Kürzer/Ausführlicher-Stufe) ─── def _kg_detail_level_path(): return os.path.join(get_writable_data_dir(), KG_DETAIL_LEVEL_CONFIG_FILENAME) def load_kg_detail_level() -> int: """Lädt die gespeicherte KG-Detailstufe. 0=Standard, negativ=kürzer, positiv=ausführlicher.""" try: path = _kg_detail_level_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return int(f.read().strip()) except Exception: pass return 0 def save_kg_detail_level(level: int) -> None: """Speichert die KG-Detailstufe.""" try: with open(_kg_detail_level_path(), "w", encoding="utf-8") as f: f.write(str(level)) except Exception: pass def get_kg_detail_instruction(level: int) -> str: """Gibt die passende Anweisung für die KG-Erstellung basierend auf dem Detail-Level zurück.""" if level == 0: return "" if level <= -3: return ("\n\nWICHTIG – STIL: Extrem knapp und kompakt. Nur Schlüsselwörter und Diagnosen mit ICD-10. " "Keine ganzen Sätze, nur Stichpunkte. Maximal komprimiert.") if level == -2: return ("\n\nWICHTIG – STIL: Sehr kurz und kompakt. Kurze Stichpunkte, " "keine ausführlichen Beschreibungen. Nur das Wesentliche.") if level == -1: return ("\n\nWICHTIG – STIL: Eher kurz und prägnant. Knapp formulieren, " "auf das Wesentliche beschränken.") if level == 1: return ("\n\nWICHTIG – STIL: Etwas ausführlicher als normal. Stichpunkte zu kurzen Sätzen ausformulieren, " "klinische Details ergänzen wo sinnvoll.") if level == 2: return ("\n\nWICHTIG – STIL: Ausführlich. Vollständige Sätze, detaillierte klinische Beschreibungen, " "differentialdiagnostische Überlegungen wo relevant.") if level >= 3: return ("\n\nWICHTIG – STIL: Sehr ausführlich und detailliert. Vollständige Sätze, " "ausführliche klinische Beschreibungen, differentialdiagnostische Überlegungen, " "detaillierte Therapiebegründungen. Umfassende Dokumentation.") return "" # ─── SOAP-Abschnitts-Detailstufen (S, O, D einzeln steuerbar) ─── def _soap_section_levels_path(): return os.path.join(get_writable_data_dir(), SOAP_SECTION_LEVELS_CONFIG_FILENAME) def load_soap_section_levels() -> dict: """Lädt die individuellen SOAP-Detailstufen: {"S": 0, "O": 0, "D": 0}.""" try: path = _soap_section_levels_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return {k: int(data.get(k, 0)) for k in _SOAP_SECTIONS} except Exception: pass return {k: 0 for k in _SOAP_SECTIONS} def save_soap_section_levels(levels: dict) -> None: """Speichert die individuellen SOAP-Detailstufen.""" try: with open(_soap_section_levels_path(), "w", encoding="utf-8") as f: json.dump({k: int(levels.get(k, 0)) for k in _SOAP_SECTIONS}, f) except Exception: pass def get_soap_section_instruction(levels: dict) -> str: """Erzeugt eine Prompt-Anweisung basierend auf individuellen SOAP-Section-Levels.""" parts = [] for key in _SOAP_SECTIONS: lv = levels.get(key, 0) if lv == 0: continue name = _SOAP_LABELS[key] if lv <= -3: parts.append(f"- {name}: Maximal komprimiert – nur die wichtigsten 1-2 Stichworte pro Punkt. Vorhandene Informationen beibehalten, nur kürzer formulieren.") elif lv == -2: parts.append(f"- {name}: Deutlich kürzer formulieren – gleiche Fakten, aber knapper auf den Punkt gebracht.") elif lv == -1: parts.append(f"- {name}: Leicht kürzer formulieren – gleicher Inhalt, etwas knappere Wortwahl.") elif lv == 1: parts.append(f"- {name}: Leicht ausführlicher formulieren – gleiche Fakten in vollständigeren Sätzen statt Stichpunkten. KEINE neuen Informationen erfinden.") elif lv == 2: parts.append(f"- {name}: Ausführlicher formulieren – vorhandene Stichpunkte in ganzen Sätzen ausformulieren. NUR vorhandene Informationen verwenden, NICHTS dazuerfinden.") elif lv >= 3: parts.append(f"- {name}: In vollständigen, ausführlichen Sätzen ausformulieren. Alle vorhandenen Punkte in Fliesstext umwandeln. STRIKT NUR vorhandene Informationen verwenden – KEINE neuen Fakten, Befunde oder Details erfinden.") if not parts: return "" return ("\n\nWICHTIG – INDIVIDUELLE ABSCHNITTSLÄNGEN (zwingend einhalten):\n" "ACHTUNG: Ausführlicher bedeutet NUR längere/vollständigere Formulierungen – NIEMALS neue Fakten, " "Befunde oder Details erfinden, die nicht im Original stehen!\n" + "\n".join(parts)) def load_templates_text() -> str: """Liest den gespeicherten Template-Text (z. B. Fachrichtung/Kontext für die KI).""" try: path = _templates_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return f.read().strip() except Exception: pass return "" def save_templates_text(text: str) -> None: """Speichert den Template-Text.""" try: with open(_templates_config_path(), "w", encoding="utf-8") as f: f.write((text or "").strip()) except Exception: pass def strip_kg_warnings(text: str) -> str: """Entfernt typische Warn-/Hinweisphrasen aus der KG-Ausgabe (z. B. Verordnung/Beginn dokumentiert).""" import re def remove_warning_block(m): content = m.group(1) if any( x in content for x in ( "dokumentiert", "Weiterverordnung", "Überprüfung erforderlich", ) ): return "" return m.group(0) result = re.sub(r"\(([^)]*)\)", remove_warning_block, text) result = re.sub(r"(?<=\S) +", " ", result) result = re.sub(r"\n{3,}", "\n\n", result) # Therapie/Procedere-Format bereinigen result = re.sub(r"Therapie/Procedere\s*:", "Therapie:", result, flags=re.IGNORECASE) result = re.sub(r"^-\s*Therapie\s*:\s*", "- ", result, flags=re.MULTILINE | re.IGNORECASE) result = re.sub(r"^-\s*Procedere\s*:\s*", "- ", result, flags=re.MULTILINE | re.IGNORECASE) result = re.sub(r"\n{3,}", "\n\n", result) # Leerzeilen zwischen Aufzählungspunkten entfernen (• direkt untereinander) for _ in range(5): result = re.sub(r"(^ {0,6}[\u2022\-\u2013].*)\n\n+( {0,6}[\u2022\-\u2013])", r"\1\n\2", result, flags=re.MULTILINE) # Keine Leerzeile zwischen Abschnittsüberschrift und zugehöriger Aufzählung result = re.sub( r"(?im)^((?:Diagnose|Diagnosen|Therapie|Procedere)\s*:)\n\s*\n(?= {0,6}[\u2022\-\u2013])", r"\1\n", result, ) # Vor einer neuen Überschrift genau eine Leerzeile lassen result = re.sub( r"(?im)([^\n])\n(?=(?:Diagnose|Diagnosen|Therapie|Procedere)\s*:)", r"\1\n\n", result, ) # Aufzählungspunkte (•) immer mit 3 Leerzeichen einrücken result = re.sub(r"^[ \t]*(\u2022)", r" \1", result, flags=re.MULTILINE) return result.strip() def _is_warning_comment(text: str) -> bool: """True, wenn der Klammer-Text eine Vorsicht/Warnung für den Arzt darstellt.""" t = text.lower().strip() return any(kw in t for kw in COMMENT_KEYWORDS) def _is_icd10_code(text: str) -> bool: """True, wenn der Klammer-Text ein ICD-10-GM-Code ist (z. B. L57.0, M79.1). Diese bleiben in der KG.""" import re t = text.strip() return bool(re.match(r"^[A-Z][0-9]{2}(\.[0-9]{1,2})?$", t, re.IGNORECASE)) def extract_kg_comments(text: str) -> tuple: """Entfernt Klammer-Inhalte aus der KG, außer ICD-10-Codes. Nur Vorsicht/Warnzeichen kommen ins graue Kommentarfeld.""" import re lines = text.split("\n") cleaned_lines = [] comments = [] for line in lines: rest = line line_comments = [] new_rest = "" last_end = 0 for m in re.finditer(r"\(([^)]*)\)", rest): content = m.group(1).strip() if _is_icd10_code(content): new_rest += rest[last_end : m.end()] else: new_rest += rest[last_end : m.start()] if content: line_comments.append(content) last_end = m.end() new_rest += rest[last_end:] new_rest = re.sub(r" +", " ", new_rest).strip() if line_comments: context = new_rest.strip() if context.startswith("- "): context = context[2:].strip() for c in line_comments: if _is_warning_comment(c): comments.append(f"- {context}: {c}") cleaned_lines.append(new_rest) cleaned = "\n".join(cleaned_lines) cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip() comments_text = "\n".join(comments) if comments else "" return cleaned, comments_text # ─── SOAP-Reihenfolge ─── def _soap_order_config_path(): return os.path.join(get_writable_data_dir(), SOAP_ORDER_CONFIG_FILENAME) def _legacy_load_soap_order() -> list: """Migriert alte Einzeldatei-Konfiguration (nur einmalig beim ersten Start mit Presets).""" try: path = _soap_order_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, list): return data except Exception: pass return None def get_soap_order_instruction(order: list, visibility=None) -> str: """Erzeugt eine Prompt-Anweisung für die benutzerdefinierte SOAP-Reihenfolge (unter Berücksichtigung der Sichtbarkeit).""" visible_order = order if visibility: visible_order = [k for k in order if visibility.get(k, True)] if visible_order == DEFAULT_SOAP_ORDER: return "" names = [_SOAP_LABELS.get(k, k) for k in visible_order] numbered = "\n".join(f" {i+1}. {n}" for i, n in enumerate(names)) return ( "\n\nWICHTIG – BENUTZERDEFINIERTE ABSCHNITTS-REIHENFOLGE (zwingend einhalten):\n" "Ordne die Abschnitte der Krankengeschichte EXAKT in folgender Reihenfolge:\n" f"{numbered}\n" "Abschnitte, die nicht vorhanden sind, weglassen – aber die Reihenfolge der vorhandenen Abschnitte MUSS dieser Vorgabe entsprechen." ) # ─── SOAP-Sichtbarkeit ─── def _soap_visibility_config_path(): return os.path.join(get_writable_data_dir(), SOAP_VISIBILITY_CONFIG_FILENAME) def _legacy_load_soap_visibility() -> dict: """Migriert alte Einzeldatei-Konfiguration.""" try: path = _soap_visibility_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return {k: bool(data.get(k, True)) for k in DEFAULT_SOAP_ORDER} except Exception: pass return None def get_soap_visibility_instruction(visibility: dict) -> str: """Erzeugt eine Prompt-Anweisung für ausgeblendete SOAP-Abschnitte.""" hidden = [_SOAP_LABELS.get(k, k) for k in DEFAULT_SOAP_ORDER if not visibility.get(k, True)] if not hidden: return "" hidden_str = ", ".join(hidden) return ( f"\n\nWICHTIG – AUSGEBLENDETE ABSCHNITTE (zwingend einhalten):\n" f"Folgende Abschnitte dürfen NICHT in der Krankengeschichte erscheinen: {hidden_str}.\n" f"Lasse diese Abschnitte komplett weg – keine Überschrift, kein Inhalt." ) # ─── SOAP-Profile (KG) ─── def _soap_presets_path(): return os.path.join(get_writable_data_dir(), SOAP_PRESETS_CONFIG_FILENAME) def _default_soap_presets(): return { "active": 0, "presets": [ {"name": f"Profil {i+1}", "order": list(DEFAULT_SOAP_ORDER), "visibility": {k: True for k in DEFAULT_SOAP_ORDER}} for i in range(NUM_SOAP_PRESETS) ], } def load_soap_presets() -> dict: try: path = _soap_presets_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict) and "presets" in data: for p in data["presets"]: existing = set(p.get("order", [])) for k in DEFAULT_SOAP_ORDER: if k not in existing: p["order"].insert(0, k) if k not in p.get("visibility", {}): p["visibility"][k] = True return data except Exception: pass defaults = _default_soap_presets() legacy_order = _legacy_load_soap_order() legacy_vis = _legacy_load_soap_visibility() if legacy_order or legacy_vis: p0 = defaults["presets"][0] if legacy_order: for k in DEFAULT_SOAP_ORDER: if k not in legacy_order: legacy_order.insert(0, k) p0["order"] = legacy_order if legacy_vis: p0["visibility"] = legacy_vis return defaults def save_soap_presets(data: dict) -> None: try: with open(_soap_presets_path(), "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception: pass def get_active_soap_preset(data: dict = None) -> dict: if data is None: data = load_soap_presets() idx = data.get("active", 0) presets = data.get("presets", []) if 0 <= idx < len(presets): return presets[idx] return {"order": list(DEFAULT_SOAP_ORDER), "visibility": {k: True for k in DEFAULT_SOAP_ORDER}} def load_soap_order() -> list: """Lädt die SOAP-Reihenfolge des aktiven Profils.""" preset = get_active_soap_preset() return list(preset.get("order", DEFAULT_SOAP_ORDER)) def load_soap_visibility() -> dict: """Lädt die SOAP-Sichtbarkeit des aktiven Profils.""" preset = get_active_soap_preset() vis = preset.get("visibility", {}) return {k: bool(vis.get(k, True)) for k in DEFAULT_SOAP_ORDER} # ─── Brief-Profile ─── def _brief_presets_path(): return os.path.join(get_writable_data_dir(), BRIEF_PRESETS_CONFIG_FILENAME) def _default_brief_presets(): import copy return { "active": 0, "presets": [copy.deepcopy(p) for p in BRIEF_PROFILE_DEFAULTS], } def load_brief_presets() -> dict: try: path = _brief_presets_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict) and "presets" in data: return data except Exception: pass return _default_brief_presets() def save_brief_presets(data: dict) -> None: try: with open(_brief_presets_path(), "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) except Exception: pass def get_active_brief_preset() -> dict: data = load_brief_presets() idx = data.get("active", 0) presets = data.get("presets", []) if 0 <= idx < len(presets): return presets[idx] import copy return copy.deepcopy(BRIEF_PROFILE_DEFAULTS[0]) def get_brief_order_instruction() -> str: preset = get_active_brief_preset() order = preset.get("order", []) labels = preset.get("labels", {}) vis = preset.get("visibility", {}) visible = [k for k in order if vis.get(k, True)] if not visible: return "" names = [labels.get(k, k) for k in visible] numbered = "\n".join(f" {i+1}. {n}" for i, n in enumerate(names)) hidden = [labels.get(k, k) for k in order if not vis.get(k, True)] diag_keys = {"DI"} sentence_keys = {"AN", "BE", "ZF", "EP", "AE", "VL"} diag_names = [labels.get(k, k) for k in visible if k in diag_keys] sentence_names = [labels.get(k, k) for k in visible if k in sentence_keys] parts = [ "\n\nWICHTIG – BRIEF-ABSCHNITTSREIHENFOLGE UND FORMATIERUNG (zwingend einhalten):\n" "Verwende EXAKT folgende Abschnitte in dieser Reihenfolge als Überschriften:\n" f"{numbered}\n" "Abschnitte, die nicht vorhanden sind, weglassen." ] if hidden: parts.append( f"Folgende Abschnitte NICHT im Brief verwenden: {', '.join(hidden)}." ) if diag_names: parts.append( f"FORMATIERUNG Diagnosen ({', '.join(diag_names)}): Stichwortartig als Aufzählung – " "jede Diagnose eine Zeile mit ICD-10-GM-Code in eckigen Klammern." ) if sentence_names: parts.append( f"FORMATIERUNG ({', '.join(sentence_names)}): In vollständigen, ausformulierten Sätzen schreiben – " "wie in einem ärztlichen Brief üblich. Keine reinen Stichpunkte." ) return "\n".join(parts)