root 5f0b41161e feat(export): Volltranskript als Anhang in DOCX/PDF (meeting/phone-call/interview)
LLM komprimiert das Transkript bisher auf wenige Sätze + 20 Excerpt-Einträge
— der Rest fiel aus dem Dokument. Pipeline reicht Whisper-Segmente jetzt an
build_docx/build_pdf durch; Profile mit `docx.include_full_transcript: true`
hängen das komplette Transkript (9 pt) am Ende an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:35:34 +00:00

174 lines
6.8 KiB
Python

import asyncio
import logging
from pathlib import Path
import httpx
from .config import settings
log = logging.getLogger("voice-agent.mac_client")
# Transient-Fehler, bei denen ein einmaliger Retry sinnvoll ist
# (typisch bei Ollama-Reload oder kurzem Mac-Worker-Restart).
_RETRY_EXCEPTIONS = (
httpx.RemoteProtocolError,
httpx.ConnectError,
httpx.ReadError,
)
class MacClient:
def __init__(self, base_url: str | None = None, timeout: int | None = None):
self.base_url = (base_url or settings.mac_api_url).rstrip("/")
self.timeout = timeout or settings.mac_api_timeout
@staticmethod
def _hdrs(job_id: int | None) -> dict[str, str] | None:
if job_id is None:
return None
return {"X-Job-Id": str(job_id)}
@staticmethod
def _raise_for_status(r: httpx.Response, url: str) -> None:
"""Wie raise_for_status, aber zieht den 'error'-Feld aus JSON-Body in die Exception."""
if r.status_code < 400:
return
detail = ""
try:
j = r.json()
detail = j.get("error") or j.get("detail") or ""
except Exception: # noqa: BLE001
detail = (r.text or "").strip()[:300]
raise httpx.HTTPStatusError(
f"HTTP {r.status_code} {url}{detail}" if detail else f"HTTP {r.status_code} {url}",
request=r.request,
response=r,
)
async def _post_json_retry(self, url: str, payload: dict, job_id: int | None) -> dict:
"""JSON-POST mit einmaligem Retry bei transienten Verbindungsabbrüchen."""
for attempt in (0, 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(url, json=payload, headers=self._hdrs(job_id))
self._raise_for_status(r, url)
return r.json()
except _RETRY_EXCEPTIONS as e:
if attempt == 0:
log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url)
await asyncio.sleep(3.0)
continue
raise
async def _post_bytes_retry(self, url: str, payload: dict, job_id: int | None) -> bytes:
for attempt in (0, 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(url, json=payload, headers=self._hdrs(job_id))
self._raise_for_status(r, url)
return r.content
except _RETRY_EXCEPTIONS as e:
if attempt == 0:
log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url)
await asyncio.sleep(3.0)
continue
raise
async def health(self) -> dict:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/health")
r.raise_for_status()
return r.json()
async def list_profiles(self) -> list[dict]:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/api/profiles")
r.raise_for_status()
return r.json().get("profiles", [])
async def list_models(self) -> dict:
"""Liefert {models: [...], default: <worker .env default>}."""
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/api/models")
r.raise_for_status()
return r.json()
async def fetch_log(self, job_id: int, last: int = 200) -> list[str]:
"""Holt die letzten Mac-Worker-Logzeilen für einen Job (für die Diagnose-Anzeige)."""
try:
async with httpx.AsyncClient(timeout=8) as c:
r = await c.get(f"{self.base_url}/api/jobs/{job_id}/log", params={"last": last})
r.raise_for_status()
return r.json().get("lines", [])
except Exception as e: # noqa: BLE001
log.warning("fetch_log job=%s failed: %s", job_id, e)
return []
async def preload(self, profile: str | None = None, model: str | None = None,
job_id: int | None = None) -> dict:
"""Vor-Heizen: Ollama soll das Modell laden, bevor der echte Call läuft."""
return await self._post_json_retry(
f"{self.base_url}/api/preload",
{"profile": profile, "model": model},
job_id,
)
async def transcribe(self, audio_path: Path, language: str = "de", job_id: int | None = None) -> dict:
# Kein Retry: Datei-Upload ist nicht idempotent, und die Transkription
# ist der teuerste Schritt — Fehler hier sollen sichtbar bleiben.
async with httpx.AsyncClient(timeout=self.timeout) as c:
with audio_path.open("rb") as f:
files = {"audio": (audio_path.name, f, "application/octet-stream")}
data = {"language": language}
r = await c.post(
f"{self.base_url}/api/transcribe",
files=files,
data=data,
headers=self._hdrs(job_id),
)
r.raise_for_status()
return r.json()
async def summarize(self, transcript: str, title: str = "", profile: str | None = None,
model: str | None = None, job_id: int | None = None) -> dict:
return await self._post_json_retry(
f"{self.base_url}/api/summarize",
{"transcript": transcript, "title": title, "profile": profile, "model": model},
job_id,
)
async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None,
model: str | None = None, job_id: int | None = None) -> dict:
return await self._post_json_retry(
f"{self.base_url}/api/protocol",
{"transcript": transcript, "summary": summary, "title": title, "profile": profile,
"model": model},
job_id,
)
async def export_docx(
self,
data: dict,
profile: str | None = None,
job_id: int | None = None,
full_transcript: list[dict] | None = None,
) -> bytes:
return await self._post_bytes_retry(
f"{self.base_url}/api/export/docx",
{"data": data, "profile": profile, "full_transcript": full_transcript or []},
job_id,
)
async def export_pdf(
self,
data: dict,
profile: str | None = None,
job_id: int | None = None,
full_transcript: list[dict] | None = None,
) -> bytes:
return await self._post_bytes_retry(
f"{self.base_url}/api/export/pdf",
{"data": data, "profile": profile, "full_transcript": full_transcript or []},
job_id,
)