Files
aza/AzA march 2026/aza_chat_desktop_host.py
2026-06-13 22:47:31 +02:00

403 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""AzA Chat-only Desktop-Host — unsichtbarer Controller ohne AzA Office."""
from __future__ import annotations
import os
import sys
import threading
import time
import unicodedata
from typing import Optional
import requests
import tkinter as tk
from tkinter import messagebox
from aza_audit_log import log_event
from aza_consent import has_valid_consent
from aza_empfang_desktop_core import EmpfangDesktopCoreMixin, init_empfang_desktop_core_state
from aza_empfang_host_poll import EmpfangHostPollMixin
from aza_empfang_incoming_popup import empfang_popup_host
from aza_persistence import load_autotext, load_user_profile, migrate_legacy_writable_data_if_needed, save_autotext, save_user_profile
from openai_runtime_config import has_openai_api_key
def _empfang_identity_key(label: str) -> str:
t = (label or "").strip().lower()
t = unicodedata.normalize("NFKD", t)
return "".join(ch for ch in t if unicodedata.combining(ch) == 0)
def _office_desktop_running() -> bool:
if sys.platform != "win32":
return False
try:
import subprocess as sp
r = sp.run(
["tasklist", "/FI", "IMAGENAME eq aza_desktop.exe", "/NH"],
capture_output=True,
text=True,
timeout=5,
)
return "aza_desktop.exe" in (r.stdout or "").lower()
except Exception:
return False
class ChatDesktopHost(
tk.Tk,
EmpfangDesktopCoreMixin,
EmpfangHostPollMixin,
):
"""Unsichtbarer Tk-Host fuer Chat-only: Poll, Popup, Singleton Huelle/Kontakt."""
def __init__(self) -> None:
super().__init__()
self.withdraw()
try:
self.wm_attributes("-alpha", 0.0)
except Exception:
pass
self.title("AzA Chat")
self._shutdown_in_progress = False
self._chat_host_mode = True
self._autotext_data = load_autotext()
try:
migrate_legacy_writable_data_if_needed()
except Exception:
pass
self._user_profile = load_user_profile()
if not self._user_profile.get("name"):
self._user_profile["name"] = "Benutzer"
save_user_profile(self._user_profile)
init_empfang_desktop_core_state(self)
self._empfang_last_seen_ids: set = set()
self._empfang_dismissed_notification_ids: set = set()
self._empfang_native_popup_shown_ids: set = set()
self._empfang_poll_autopopup_last_ts = 0.0
self._empfang_last_device_poll_wall = 0.0
self._empfang_shell_dm_open_pending = None
empfang_popup_host(self)
self.protocol("WM_DELETE_WINDOW", self.shutdown)
self.after(800, self._bootstrap_chat_host)
if not _office_desktop_running():
self.after(5000, self._empfang_background_poll)
self.after(12000, self._chat_update_poll_loop)
self.after(15000, self._start_empfang_presence_ping_loop)
def _bootstrap_chat_host(self) -> None:
try:
self._sync_desktop_profile_from_empfang_metadata(force=False)
except Exception:
pass
try:
self._empfang_self_user_id_resolve_now()
except Exception:
pass
self._prepare_empfang_prefs_for_webview()
self._maybe_autostart_kontakt_panel()
try:
from desktop_update_check import prompt_chat_update_if_required, check_for_chat_updates
prompt_chat_update_if_required(parent=self)
check_for_chat_updates()
except Exception:
pass
def _chat_update_poll_loop(self) -> None:
if getattr(self, "_shutdown_in_progress", False):
return
try:
from desktop_update_check import check_for_chat_updates, consume_chat_update_request_flag, manual_check_for_chat_updates
check_for_chat_updates()
if consume_chat_update_request_flag():
manual_check_for_chat_updates(parent=self)
except Exception:
pass
try:
self.after(3600000, self._chat_update_poll_loop)
except Exception:
pass
def shutdown(self) -> None:
self._shutdown_in_progress = True
self._empfang_presence_stop = True
try:
self._shutdown_tracked_child_processes()
except Exception:
pass
try:
self.destroy()
except Exception:
pass
def set_status(self, msg: str) -> None:
pass
def _debug_log(self, msg: str) -> None:
pass
@staticmethod
def _clean_backend_value(value: str):
if value is None:
return None
v = str(value).replace("\ufeff", "").strip(" \t\r\n")
return v if v else None
def _read_backend_value_file(self, filename: str) -> Optional[str]:
search_dirs: list[str] = []
if getattr(sys, "frozen", False):
search_dirs.append(os.path.dirname(sys.executable))
meipass = getattr(sys, "_MEIPASS", "")
if meipass:
search_dirs.append(meipass)
search_dirs.append(os.path.dirname(os.path.abspath(__file__)))
search_dirs.append(os.getcwd())
seen: set[str] = set()
for base in search_dirs:
if not base or base in seen:
continue
seen.add(base)
p = os.path.join(base, filename)
if not os.path.isfile(p):
continue
try:
with open(p, "r", encoding="utf-8-sig") as f:
for ln in f:
s = ln.strip()
if s and not s.startswith("#"):
return self._clean_backend_value(s)
except Exception:
pass
return None
def get_backend_url(self):
url = self._clean_backend_value(os.getenv("MEDWORK_BACKEND_URL"))
if url:
return url.rstrip("/")
url = self._read_backend_value_file("backend_url.txt")
if url:
return url.rstrip("/")
raise RuntimeError("Backend-URL fehlt.")
def get_backend_token(self):
token = self._read_backend_value_file("backend_token.txt")
if token:
return token
tokens_env = os.getenv("MEDWORK_API_TOKENS")
if tokens_env and tokens_env.strip():
token = self._clean_backend_value(tokens_env.split(",")[0].strip())
else:
token = self._clean_backend_value(os.getenv("MEDWORK_API_TOKEN"))
if token:
return token
raise RuntimeError("Backend-Token fehlt.")
def get_practice_id(self):
return (self._user_profile.get("practice_id") or "").strip()
def _empfang_self_display_name(self) -> str:
dn = (self._user_profile.get("empfang_display_name") or "").strip()
if dn:
return dn
return (self._user_profile.get("name") or "").strip()
def _empfang_self_user_id(self) -> str:
return (self._user_profile.get("empfang_user_id") or "").strip()
def _empfang_self_user_id_resolve_now(self, users_full: Optional[list] = None) -> str:
my_dn = self._empfang_self_display_name()
if not my_dn:
return self._empfang_self_user_id()
full = users_full
if full is None:
try:
bu = self.get_backend_url()
r = requests.get(f"{bu}/empfang/users", headers=self._empfang_headers(), timeout=5)
if r.status_code == 200:
full = (r.json() or {}).get("users_full") or []
except Exception:
full = []
if not isinstance(full, list):
return self._empfang_self_user_id()
target = _empfang_identity_key(my_dn)
candidates = []
for u in full:
if not isinstance(u, dict):
continue
if _empfang_identity_key(u.get("display_name") or "") != target:
continue
uid = str(u.get("user_id") or "").strip()
if uid:
role = str(u.get("role") or "").strip().lower()
status = str(u.get("status") or "active").strip().lower()
candidates.append({"user_id": uid, "role": role, "status": status})
if not candidates:
return self._empfang_self_user_id()
role_rank = {"admin": 0, "arzt": 1, "empfang": 2, "mpa": 3}
def _score(c):
return (0 if c["status"] == "active" else 1, role_rank.get(c["role"], 9))
candidates.sort(key=_score)
chosen = candidates[0]["user_id"]
if self._empfang_self_user_id() != chosen:
self._user_profile["empfang_user_id"] = chosen
try:
save_user_profile(self._user_profile)
except Exception:
pass
return chosen
def _empfang_headers(self) -> dict:
hdrs = {
"X-API-Token": self.get_backend_token(),
"X-Practice-Id": self.get_practice_id(),
}
uid = self._empfang_self_user_id()
if uid:
hdrs["X-AzA-Empfang-User-Id"] = uid
return hdrs
def _prepare_empfang_prefs_for_webview(self) -> None:
prefs = self._autotext_data.setdefault("empfang_prefs", {})
self._autotext_data["empfang_prefs"] = prefs
try:
save_autotext(self._autotext_data)
except Exception:
pass
def _send_to_empfang(self) -> None:
try:
self._sync_desktop_profile_from_empfang_metadata(force=False)
except Exception:
pass
try:
self._empfang_self_user_id_resolve_now()
except Exception:
pass
self._prepare_empfang_prefs_for_webview()
self._empfang_open_webview_singleton(gui_parent=self, silent_status=True, offer_tk_on_shell_fail=False)
def _empfang_schedule_context_autopopup(self, reason: str) -> None:
return
def _empfang_maybe_autopopup_from_poll(self, new_ids: set, msgs: list) -> None:
return
def _empfang_show_pending_device_notice(self, snap: dict) -> None:
return
def _sync_desktop_profile_from_empfang_metadata(self, force: bool = False) -> None:
try:
self._empfang_self_user_id_resolve_now()
except Exception:
pass
def ensure_ready(self) -> bool:
if has_openai_api_key() or os.getenv("MEDWORK_BACKEND_URL"):
return True
messagebox.showinfo(
"KI-Verbindung",
"Die KI-Verbindung ist noch nicht eingerichtet.\n"
"Bitte OpenAI-Schlüssel oder Backend konfigurieren.",
parent=empfang_popup_host(self),
)
return False
def _check_ai_consent(self) -> bool:
try:
uid = getattr(self, "_get_consent_user_id", lambda: "chat-host")()
except Exception:
uid = "chat-host"
if has_valid_consent(uid):
return True
messagebox.showinfo("KI-Einwilligung", "Bitte KI-Einwilligung in AzA Office erteilen.", parent=empfang_popup_host(self))
return False
def _get_consent_user_id(self) -> str:
return (self.get_practice_id() or "chat-host").strip() or "chat-host"
def _ensure_microphone_ready(self) -> bool:
try:
from aza_audio import check_microphone
ok, msg = check_microphone()
if not ok:
messagebox.showwarning("Mikrofon", msg or "Mikrofon nicht verfügbar.", parent=empfang_popup_host(self))
return bool(ok)
except Exception:
return True
def transcribe_wav(self, wav_path: str) -> str:
uid = self._get_consent_user_id()
if not has_valid_consent(uid):
raise RuntimeError("KI-Einwilligung fehlt.")
log_event("AI_TRANSCRIBE", uid)
try:
import basis14
return basis14.KGDesktopApp.transcribe_file_via_backend_with_fallback(self, wav_path)
except Exception as exc:
raise RuntimeError(str(exc)) from exc
EMPFANG_PRESENCE_PING_INTERVAL_S = 28
def _start_empfang_presence_ping_loop(self) -> None:
try:
existing = getattr(self, "_empfang_presence_thread", None)
if isinstance(existing, threading.Thread) and existing.is_alive():
return
except Exception:
pass
self._empfang_presence_stop = False
t = threading.Thread(target=self._empfang_presence_ping_loop_worker, name="aza-chat-presence", daemon=True)
self._empfang_presence_thread = t
t.start()
def _empfang_presence_ping_loop_worker(self) -> None:
interval = max(10, int(self.EMPFANG_PRESENCE_PING_INTERVAL_S))
while not getattr(self, "_empfang_presence_stop", False):
try:
bu = self.get_backend_url()
requests.post(f"{bu}/empfang/presence/ping", headers=self._empfang_headers(), json={}, timeout=5)
except Exception:
pass
for _ in range(interval):
if getattr(self, "_empfang_presence_stop", False):
return
time.sleep(1)
def try_acquire_chat_host_singleton() -> bool:
if sys.platform != "win32":
return True
try:
import ctypes
from ctypes import wintypes
kernel32 = ctypes.windll.kernel32
ERROR_ALREADY_EXISTS = 183
mutex = kernel32.CreateMutexW(None, False, "Global\\AZA_ChatDesktopHost_v1")
if kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
return False
return True
except Exception:
return True
def focus_existing_kontakt_panel() -> bool:
if sys.platform != "win32":
return False
try:
helper = object.__new__(ChatDesktopHost)
init_empfang_desktop_core_state(helper)
EmpfangDesktopCoreMixin._kontakt_panel_try_bring_to_front(helper, 0)
EmpfangDesktopCoreMixin._kontakt_panel_try_bring_to_front(helper, 400)
EmpfangDesktopCoreMixin._empfang_shell_focus_existing_instances(helper)
return True
except Exception:
return False