Files
2026-04-19 20:41:37 +02:00

267 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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