267 lines
9.0 KiB
Python
267 lines
9.0 KiB
Python
# -*- 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
|