Files

267 lines
9.0 KiB
Python
Raw Permalink Normal View History

2026-03-25 22:03:39 +01:00
# -*- coding: utf-8 -*-
"""
API-Client für Desktop-Anwendungen.
Stellt alle Backend-Aufrufe als einfache Funktionen bereit.
Hybrid-Modus: versucht Backend, fällt auf lokalen OpenAI-Call zurück.
from workforce_planner.api_client import WorkforceClient
client = WorkforceClient("http://localhost:8000")
client.login("admin@praxis.ch", "admin123")
result = client.transcribe_audio("recording.wav")
"""
import os
import threading
from typing import Optional, Callable
import requests
from requests.exceptions import ConnectionError, Timeout, RequestException
_BACKEND_TIMEOUT_TRANSCRIBE = 20
_BACKEND_TIMEOUT_DEFAULT = 12
class WorkforceClient:
"""HTTP-Client für das workforce_planner Backend."""
def __init__(self, base_url: str = "http://localhost:8000"):
self.base_url = base_url.rstrip("/")
self.token: Optional[str] = None
self.employee: Optional[dict] = None
self._session = requests.Session()
@property
def _headers(self) -> dict:
h = {}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
return h
@property
def is_authenticated(self) -> bool:
return self.token is not None
def is_backend_available(self) -> bool:
try:
r = self._session.get(
f"{self.base_url}/api/v1/health", timeout=3
)
return r.status_code == 200
except RequestException:
return False
# ─── Auth ───────────────────────────────────
def login(self, email: str, password: str) -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/auth/login",
json={"email": email, "password": password},
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
data = r.json()
self.token = data["access_token"]
self.employee = data["employee"]
return data
def logout(self):
self.token = None
self.employee = None
# ─── Employees ──────────────────────────────
def list_employees(self, active_only: bool = True) -> list[dict]:
r = self._session.get(
f"{self.base_url}/api/v1/employees/",
headers=self._headers,
params={"active_only": active_only},
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def create_employee(self, data: dict) -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/employees/",
headers=self._headers,
json=data,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def update_employee(self, employee_id: str, data: dict) -> dict:
r = self._session.patch(
f"{self.base_url}/api/v1/employees/{employee_id}",
headers=self._headers,
json=data,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def delete_employee(self, employee_id: str):
r = self._session.delete(
f"{self.base_url}/api/v1/employees/{employee_id}",
headers=self._headers,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
# ─── Absences ───────────────────────────────
def list_absences(self, year: Optional[int] = None, employee_id: Optional[str] = None) -> list[dict]:
params = {}
if year:
params["year"] = year
if employee_id:
params["employee_id"] = employee_id
r = self._session.get(
f"{self.base_url}/api/v1/absences/",
headers=self._headers,
params=params,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def create_absence(self, data: dict) -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/absences/",
headers=self._headers,
json=data,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
if r.status_code == 422:
detail = r.json().get("detail", {})
raise ValueError(detail.get("message", str(detail)))
r.raise_for_status()
return r.json()
def delete_absence(self, absence_id: str):
r = self._session.delete(
f"{self.base_url}/api/v1/absences/{absence_id}",
headers=self._headers,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
def get_balance(self, employee_id: str, year: int) -> dict:
r = self._session.get(
f"{self.base_url}/api/v1/balance/{employee_id}/{year}",
headers=self._headers,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
# ─── KI-Service (Hybrid) ────────────────────
def transcribe_audio(self, wav_path: str, language: str = "de") -> dict:
"""
Sendet WAV an Backend. Bei Fehler: wirft Exception
(Fallback wird vom Caller gehandhabt).
"""
with open(wav_path, "rb") as f:
r = self._session.post(
f"{self.base_url}/api/v1/ai/transcribe",
headers=self._headers,
files={"file": (os.path.basename(wav_path), f, "audio/wav")},
data={"language": language},
timeout=_BACKEND_TIMEOUT_TRANSCRIBE,
)
r.raise_for_status()
return r.json()
def summarize(self, transcript: str, system_prompt: str = "", model: str = "gpt-4o") -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/ai/summarize",
headers=self._headers,
json={"transcript": transcript, "system_prompt": system_prompt, "model": model},
timeout=30,
)
r.raise_for_status()
return r.json()
def merge_kg(self, existing_kg: str, full_transcript: str,
system_prompt: str = "", model: str = "gpt-4o") -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/ai/merge",
headers=self._headers,
json={
"existing_kg": existing_kg,
"full_transcript": full_transcript,
"system_prompt": system_prompt,
"model": model,
},
timeout=30,
)
r.raise_for_status()
return r.json()
def chat(self, messages: list[dict], model: str = "gpt-4o") -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/ai/chat",
headers=self._headers,
json={"messages": messages, "model": model},
timeout=30,
)
r.raise_for_status()
return r.json()
# ═══════════════════════════════════════════════════════════
# HYBRID-TRANSKRIPTION: Backend mit lokalem Fallback
# ═══════════════════════════════════════════════════════════
def hybrid_transcribe(
wav_path: str,
api_client: Optional[WorkforceClient],
local_transcribe_fn: Callable[[str], str],
on_success: Callable[[str, str], None],
on_error: Optional[Callable[[str], None]] = None,
):
"""
Hybrid-Transkription: versucht Backend, fällt auf lokal zurück.
Läuft in eigenem Thread UI blockiert nie.
Args:
wav_path: Pfad zur WAV-Datei
api_client: WorkforceClient (oder None = immer lokal)
local_transcribe_fn: Bestehende lokale Transkription (self.transcribe_wav)
on_success: Callback(text, source) source = "backend" oder "local"
on_error: Callback(error_message) bei totalem Fehler
"""
def _worker():
source = "local"
text = ""
# ── Versuch 1: Backend ──
if api_client and api_client.is_authenticated:
try:
result = api_client.transcribe_audio(wav_path)
text = result.get("text", "")
source = "backend"
except (ConnectionError, Timeout):
pass
except RequestException:
pass
# ── Versuch 2: Lokal (Fallback) ──
if not text and source == "local":
try:
text = local_transcribe_fn(wav_path)
source = "local"
except Exception as exc:
if on_error:
on_error(f"Transkription fehlgeschlagen: {exc}")
return
on_success(text, source)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t