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
|