Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs), OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant), Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich. Runbook: docs/RUNBOOK_PARALLELTEST.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1638 lines
70 KiB
Python
1638 lines
70 KiB
Python
"""Tenant-aware FastAPI application.
|
||
|
||
The transcription / summarisation / delivery logic is identical for every
|
||
customer (the "core"). Everything brand- or customer-specific is pulled from
|
||
the active tenant config (core.config.CFG) and optional code hooks
|
||
(core.hooks). The tenant is selected once per process via the TENANT env var.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import html
|
||
import io
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import smtplib
|
||
import subprocess
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import uuid
|
||
import warnings
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime
|
||
from email.mime.application import MIMEApplication
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
from pathlib import Path
|
||
from urllib import error as urllib_error
|
||
from urllib.parse import quote, urlencode
|
||
from urllib import request as urllib_request
|
||
|
||
import requests
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
||
|
||
from core import contract, cost, retry
|
||
from core.config import CFG, CORE_TEMPLATES
|
||
from core.hooks import load_hooks
|
||
from core.docx_export import render_docx
|
||
from core.pdf import build_pdf_context, render_pdf
|
||
from core.protocol import (
|
||
normalize_protocol_text,
|
||
parse_protocol_sections,
|
||
project_label,
|
||
safe_filename,
|
||
)
|
||
|
||
warnings.filterwarnings("ignore", category=UserWarning)
|
||
|
||
HOOKS = load_hooks(CFG.dir)
|
||
|
||
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE_MB", "300")) * 1024 * 1024
|
||
ALLOWED_CONTENT_TYPES = {
|
||
"audio/mpeg", "audio/mp3", "audio/webm", "audio/ogg", "audio/wav",
|
||
"audio/flac", "audio/x-flac", "audio/aac", "audio/x-m4a", "audio/mp4",
|
||
"video/mp4", "video/webm", "video/quicktime", "video/x-m4v",
|
||
}
|
||
# Tuples, not sets: the order is user-visible in the "Erlaubt sind: ..." error
|
||
# messages and in the accept-attributes rendered into the frontend.
|
||
ALLOWED_UPLOAD_SUFFIXES = (
|
||
".mp3", ".webm", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".mp4", ".m4v", ".mov",
|
||
)
|
||
ALLOWED_TRANSCRIPT_SUFFIXES = (".txt", ".vtt", ".docx", ".md")
|
||
ALLOWED_VOCABULARY_SUFFIXES = (".txt",)
|
||
# Several recordings of one meeting (e.g. split into parts) can be uploaded
|
||
# together; they are joined in the given order and transcribed as one file.
|
||
# On by default, a tenant switches it off with "flags": {"multi_audio": false}.
|
||
MAX_AUDIO_FILES = 5 if CFG.flag("multi_audio", True) else 1
|
||
# Voxtral Mini Transcribe V2 takes up to 3 h of audio per request (Mistral
|
||
# docs). Longer recordings are rejected before the STT call with a clear
|
||
# message instead of a provider error after the upload.
|
||
MAX_AUDIO_SECONDS = int(os.getenv("STT_MAX_AUDIO_MINUTES", "180")) * 60
|
||
|
||
|
||
def _unsupported_format_detail(kind: str, suffix: str, allowed: tuple[str, ...]) -> str:
|
||
shown = suffix or "(ohne Dateiendung)"
|
||
return f"Nicht unterstütztes {kind}: {shown}. Erlaubt sind: {', '.join(allowed)}"
|
||
|
||
|
||
SEND_JOBS: dict[str, dict] = {}
|
||
SEND_JOBS_LOCK = threading.Lock()
|
||
# Background transcription jobs. The browser uploads, gets a job_id back
|
||
# immediately and then polls /transcribe-status — so no single HTTP request
|
||
# sits idle long enough to hit the ~10s reverse-proxy timeout. This in-memory
|
||
# store only works with a single worker process (see CMD in the Dockerfile).
|
||
TRANSCRIBE_JOBS: dict[str, dict] = {}
|
||
TRANSCRIBE_JOBS_LOCK = threading.Lock()
|
||
# Background summarisation jobs — same rationale as TRANSCRIBE_JOBS: the LLM call
|
||
# can run well past the ~10s reverse-proxy idle timeout (long protocols), so the
|
||
# browser uploads, gets a job_id and polls /summarize-status. Single worker only.
|
||
SUMMARIZE_JOBS: dict[str, dict] = {}
|
||
SUMMARIZE_JOBS_LOCK = threading.Lock()
|
||
DATA_DIR = Path(os.getenv("DATA_DIR", str(CFG.dir)))
|
||
VOCAB_FILE = DATA_DIR / "vocabulary.json"
|
||
VOCAB_LOCK = threading.Lock()
|
||
TEMP_DIR = Path(os.getenv("TEMP_DIR", tempfile.gettempdir())) / "meeting-transcript"
|
||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
AUTH_COOKIE = f"{CFG.id}_session"
|
||
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() == "true"
|
||
AUTH_COOKIE_SECURE = os.getenv("APP_COOKIE_SECURE", "false").lower() == "true"
|
||
SESSION_SECRET = os.getenv("SESSION_SECRET", os.getenv("APP_SESSION_SECRET", "")).strip()
|
||
OIDC_ISSUER = os.getenv("OIDC_ISSUER", "https://login.onyva.dev").strip()
|
||
OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "").strip()
|
||
OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "").strip()
|
||
OIDC_REDIRECT_URI = os.getenv("OIDC_REDIRECT_URI", "http://localhost:8000/auth/callback").strip()
|
||
OIDC_LOGOUT_REDIRECT_URI = os.getenv("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8000/").strip()
|
||
# The pending login (state + PKCE verifier) lives in a signed, short-lived cookie
|
||
# on the client — NOT in process memory, so a container restart or redeploy in the
|
||
# middle of a login no longer invalidates it.
|
||
OAUTH_STATE_COOKIE = f"{CFG.id}_oauth"
|
||
OAUTH_STATE_TTL = 10 * 60
|
||
# Marker appended to the state of an automatically retried login. The IdP echoes
|
||
# the state back verbatim, so it survives the round trip even when the browser
|
||
# keeps no cookie at all — which is what makes it a reliable loop breaker.
|
||
OAUTH_RETRY_MARKER = "~r"
|
||
|
||
|
||
class NoSpeechDetectedError(RuntimeError):
|
||
pass
|
||
|
||
|
||
class AudioTooLongError(RuntimeError):
|
||
pass
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
_stt = _health_stt()
|
||
print(f"Server startet – Tenant: {CFG.id} ({CFG.name}) – "
|
||
f"STT: Voxtral via {_stt['provider']} ({_stt.get('model', '?')})")
|
||
cost.start_worker()
|
||
yield
|
||
cost.flush_now()
|
||
print("Server fährt herunter.")
|
||
|
||
|
||
app = FastAPI(lifespan=lifespan)
|
||
|
||
|
||
# ── auth / session ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def _decode_jwt_claims(token: str) -> dict:
|
||
try:
|
||
payload_b64 = token.split(".")[1]
|
||
payload_b64 += "=" * (4 - len(payload_b64) % 4)
|
||
return json.loads(base64.urlsafe_b64decode(payload_b64).decode())
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _sign_payload(data: dict) -> str:
|
||
payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=")
|
||
sig = hmac.new(SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||
return f"{payload}.{sig}"
|
||
|
||
|
||
def _verify_payload(token: str) -> dict | None:
|
||
try:
|
||
payload, _, sig = token.partition(".")
|
||
if not sig or not SESSION_SECRET:
|
||
return None
|
||
expected = hmac.new(SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||
if not hmac.compare_digest(sig, expected):
|
||
return None
|
||
payload += "=" * (4 - len(payload) % 4)
|
||
return json.loads(base64.urlsafe_b64decode(payload).decode())
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _get_session_user(request: Request) -> dict | None:
|
||
if not AUTH_ENABLED:
|
||
return {"sub": "user:0", "email": "dev@local", "name": "Dev", "user_type": "employee", "app_roles": ["admin"]}
|
||
token = request.cookies.get(AUTH_COOKIE, "")
|
||
return _verify_payload(token) if token else None
|
||
|
||
|
||
def require_auth(request: Request) -> dict:
|
||
user = _get_session_user(request)
|
||
if user is None:
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
return user
|
||
|
||
|
||
def _set_session(response: Response, user: dict) -> None:
|
||
response.set_cookie(
|
||
AUTH_COOKIE, _sign_payload(user), max_age=12 * 60 * 60,
|
||
httponly=True, secure=AUTH_COOKIE_SECURE, samesite="lax",
|
||
)
|
||
|
||
|
||
def _set_pending_login(response: Response, state: str, code_verifier: str) -> None:
|
||
# SameSite=lax is required: the IdP sends the browser back via a top-level GET,
|
||
# which is exactly the case lax still allows.
|
||
payload = {"state": state, "cv": code_verifier, "exp": int(time.time()) + OAUTH_STATE_TTL}
|
||
response.set_cookie(
|
||
OAUTH_STATE_COOKIE, _sign_payload(payload), max_age=OAUTH_STATE_TTL,
|
||
httponly=True, secure=AUTH_COOKIE_SECURE, samesite="lax",
|
||
)
|
||
|
||
|
||
def _pending_login_verifier(request: Request, state: str) -> str | None:
|
||
"""PKCE verifier for `state`, or None if the cookie is missing, expired,
|
||
tampered with or belongs to a different login attempt."""
|
||
token = request.cookies.get(OAUTH_STATE_COOKIE, "")
|
||
data = _verify_payload(token) if token else None
|
||
if not data or int(data.get("exp", 0)) < time.time():
|
||
return None
|
||
if not hmac.compare_digest(str(data.get("state", "")), state):
|
||
return None
|
||
return data.get("cv") or None
|
||
|
||
|
||
# ── audio helpers ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _ffmpeg_executable() -> str:
|
||
import shutil
|
||
found = shutil.which("ffmpeg")
|
||
if found:
|
||
return found
|
||
winget_root = Path(os.environ.get("LOCALAPPDATA", "C:/Users")) / "Microsoft/WinGet/Packages"
|
||
for candidate in sorted(winget_root.glob("Gyan.FFmpeg_*/ffmpeg-*/bin/ffmpeg.exe"), reverse=True):
|
||
if candidate.exists():
|
||
return str(candidate)
|
||
raise RuntimeError("ffmpeg nicht gefunden. Bitte installieren: winget install Gyan.FFmpeg")
|
||
|
||
|
||
def _audio_duration_seconds(path: Path) -> float:
|
||
"""Media duration in seconds via ffprobe (best effort; 0.0 on failure).
|
||
|
||
Used for STT cost which is billed per audio minute — Mistral's response
|
||
carries no usage figure."""
|
||
ffprobe = (_ffmpeg_executable().replace("ffmpeg", "ffprobe")
|
||
if "ffmpeg" in _ffmpeg_executable() else "ffprobe")
|
||
try:
|
||
r = subprocess.run(
|
||
[ffprobe, "-v", "error", "-show_entries", "format=duration",
|
||
"-of", "default=noprint_wrappers=1:nokey=1", str(path)],
|
||
capture_output=True, text=True,
|
||
)
|
||
return float(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else 0.0
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
# Video containers whose audio track must be extracted before transcription.
|
||
_VIDEO_SUFFIXES = {".mp4", ".m4v", ".mov", ".webm"}
|
||
|
||
|
||
def extract_audio_if_video(input_path: Path) -> tuple[Path, bool]:
|
||
"""Mistral transcribes audio files directly. For video containers we extract
|
||
the audio track to mp3 first. Returns (path, created_temp)."""
|
||
if input_path.suffix.lower() not in _VIDEO_SUFFIXES:
|
||
return input_path, False
|
||
output_path = input_path.with_suffix(".extracted.mp3")
|
||
r = subprocess.run(
|
||
[_ffmpeg_executable(), "-y", "-i", str(input_path), "-vn", "-ac", "1",
|
||
"-ar", "16000", "-b:a", "96k", str(output_path)],
|
||
capture_output=True, text=True,
|
||
)
|
||
if r.returncode != 0:
|
||
raise RuntimeError(f"ffmpeg fehlgeschlagen:\n{r.stderr}")
|
||
return output_path, True
|
||
|
||
|
||
def concat_audio(paths: list[Path]) -> Path:
|
||
"""Join several audio/video files, in the given order, into one mono mp3.
|
||
|
||
The STT call then sees a single recording, so diarization keeps the same
|
||
speaker ids across all parts (transcribing the parts separately would
|
||
restart at SPRECHER_0 in every file). Every input is resampled to the same
|
||
format first because the concat filter needs matching streams."""
|
||
output_path = TEMP_DIR / f"{uuid.uuid4()}.merged.mp3"
|
||
cmd = [_ffmpeg_executable(), "-y"]
|
||
for p in paths:
|
||
cmd += ["-i", str(p)]
|
||
chains = "".join(
|
||
f"[{i}:a:0]aresample=16000,aformat=channel_layouts=mono[a{i}];"
|
||
for i in range(len(paths))
|
||
)
|
||
joined = "".join(f"[a{i}]" for i in range(len(paths)))
|
||
cmd += ["-filter_complex", f"{chains}{joined}concat=n={len(paths)}:v=0:a=1[out]",
|
||
"-map", "[out]", "-b:a", "96k", str(output_path)]
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
output_path.unlink(missing_ok=True)
|
||
raise RuntimeError(f"Zusammenfügen der Audiodateien fehlgeschlagen:\n{r.stderr[-1500:]}")
|
||
return output_path
|
||
|
||
|
||
def normalize_language(language: str) -> str:
|
||
lang = (language or "de").strip().lower()
|
||
return "en" if lang.startswith("en") else "de"
|
||
|
||
|
||
# ── Voxtral transcription (Mistral direkt oder via Cortecs) ──────────────────
|
||
#
|
||
# Beide Anbieter sprechen dieselbe Wire-API (Mistral-Format: multipart-Request
|
||
# mit diarize/timestamp_granularities/context_bias, Antwort mit segments +
|
||
# speaker_id) — Cortecs reicht die Voxtral-Aufrufe durch. Deshalb unterscheiden
|
||
# sich nur Endpunkt, API-Key und Modell-ID; Request-Bau und Parser sind geteilt.
|
||
#
|
||
# Auswahl pro Tenant über tenants/<id>/tenant.json:
|
||
# "stt": { "provider": "cortecs", "model": "voxtral-mini-transcribe-2602" }
|
||
# Ohne Eintrag bleibt es bei Mistral direkt. Env-Variablen (STT_PROVIDER,
|
||
# STT_MODEL bzw. MISTRAL_TRANSCRIBE_MODEL) überschreiben die tenant.json.
|
||
|
||
_STT_PROVIDERS = {
|
||
"mistral": {
|
||
"url": "https://api.mistral.ai/v1/audio/transcriptions",
|
||
"key_env": "MISTRAL_API_KEY",
|
||
"model": "voxtral-mini-latest",
|
||
# $0.003/min → €0.002625/min (1 USD ≈ €0.875)
|
||
"cost_per_minute": 0.002625,
|
||
},
|
||
"cortecs": {
|
||
"url": None, # aus CORTECS_BASE_URL abgeleitet
|
||
"key_env": "CORTECS_API_KEY",
|
||
"model": "voxtral-mini-transcribe-2602",
|
||
# Cortecs rechnet Voxtral nativ in EUR ab (Stand 12.08.2026).
|
||
"cost_per_minute": 0.003,
|
||
},
|
||
}
|
||
|
||
|
||
def _stt_config() -> dict:
|
||
"""Resolve the STT provider for this tenant.
|
||
|
||
Precedence: env > tenant.json "stt" > provider default.
|
||
"""
|
||
tenant_stt = CFG.stt or {}
|
||
provider = (os.getenv("STT_PROVIDER") or tenant_stt.get("provider") or "mistral").strip().lower()
|
||
spec = _STT_PROVIDERS.get(provider)
|
||
if spec is None:
|
||
raise RuntimeError(
|
||
f"Unbekannter STT-Provider '{provider}'. Erlaubt: {', '.join(sorted(_STT_PROVIDERS))}"
|
||
)
|
||
|
||
url = spec["url"]
|
||
if provider == "cortecs":
|
||
base = os.getenv("CORTECS_BASE_URL", "https://api.cortecs.ai/v1").strip().rstrip("/")
|
||
url = f"{base}/audio/transcriptions"
|
||
|
||
# MISTRAL_TRANSCRIBE_MODEL wird global an alle Container verteilt (SHARED_ENV)
|
||
# und gilt daher nur für den Mistral-Provider — sonst würde es die Modell-ID
|
||
# eines Cortecs-Tenants überschreiben. STT_MODEL wirkt providerübergreifend.
|
||
legacy_model = os.getenv("MISTRAL_TRANSCRIBE_MODEL") if provider == "mistral" else None
|
||
model = (os.getenv("STT_MODEL") or legacy_model
|
||
or tenant_stt.get("model") or spec["model"]).strip()
|
||
cost_per_minute = float(tenant_stt.get("cost_per_minute") or spec["cost_per_minute"])
|
||
|
||
return {
|
||
"provider": provider,
|
||
"url": url,
|
||
"key_env": spec["key_env"],
|
||
"model": model,
|
||
"diarize": os.getenv("STT_DIARIZE", os.getenv("MISTRAL_DIARIZE", "true")).lower() == "true",
|
||
"timeout": int(os.getenv("STT_TIMEOUT", os.getenv("MISTRAL_TIMEOUT", "600"))),
|
||
"cost_per_minute": cost_per_minute,
|
||
}
|
||
|
||
|
||
def _stt_api_key(cfg: dict) -> str:
|
||
key = os.getenv(cfg["key_env"], "").strip()
|
||
if not key:
|
||
raise RuntimeError(f"{cfg['key_env']} nicht gesetzt")
|
||
return key
|
||
|
||
|
||
def _load_vocabulary() -> list[str]:
|
||
with VOCAB_LOCK:
|
||
if not VOCAB_FILE.exists():
|
||
return []
|
||
try:
|
||
data = json.loads(VOCAB_FILE.read_text(encoding="utf-8"))
|
||
return [str(t).strip() for t in (data if isinstance(data, list) else []) if str(t).strip()]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _save_vocabulary(terms: list[str]) -> None:
|
||
with VOCAB_LOCK:
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
VOCAB_FILE.write_text(json.dumps(sorted(set(terms)), ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def _transcribe_with_voxtral(audio_path: Path, cfg: dict) -> dict:
|
||
"""Call the Voxtral transcription API (multipart) of the configured provider.
|
||
The language is auto-detected (no forced language); diarization + custom
|
||
vocabulary (context_bias) are enabled by default. The app's audio is
|
||
German/English, so auto-detection resolves to de/en."""
|
||
base_parts: list = [("model", (None, cfg["model"]))]
|
||
if cfg["diarize"]:
|
||
base_parts.append(("diarize", (None, "true")))
|
||
base_parts.append(("timestamp_granularities", (None, "segment")))
|
||
for term in _load_vocabulary()[:100]: # custom vocabulary → context_bias
|
||
base_parts.append(("context_bias", (None, term)))
|
||
|
||
def do_request():
|
||
# Re-open the file on every attempt: a retried multipart POST needs a
|
||
# fresh handle, otherwise the body would be empty (handle at EOF).
|
||
with audio_path.open("rb") as f:
|
||
parts = base_parts + [("file", (audio_path.name, f, "application/octet-stream"))]
|
||
return requests.post(
|
||
cfg["url"],
|
||
headers={"Authorization": f"Bearer {_stt_api_key(cfg)}"},
|
||
files=parts,
|
||
timeout=cfg["timeout"],
|
||
)
|
||
|
||
resp = retry.request_with_retry(do_request, label=f"{cfg['provider']}-stt")
|
||
if resp.status_code >= 400:
|
||
raise RuntimeError(
|
||
f"Transkription ({cfg['provider']}) HTTP {resp.status_code}: {resp.text[:500]}"
|
||
)
|
||
return resp.json()
|
||
|
||
|
||
def _format_voxtral_transcript(result: dict) -> str:
|
||
# Speaker label follows the auto-detected language (de → SPRECHER, en → SPEAKER).
|
||
lang = normalize_language(result.get("language") or "de")
|
||
speaker_label = "SPEAKER" if lang == "en" else "SPRECHER"
|
||
empty_label = "No speech detected" if lang == "en" else "Keine Sprache erkannt"
|
||
|
||
segments = result.get("segments") or []
|
||
lines: list[str] = []
|
||
last_speaker = object() # sentinel so the first segment never "merges"
|
||
for seg in segments:
|
||
text = (seg.get("text") or "").strip()
|
||
if not text:
|
||
continue
|
||
speaker = seg.get("speaker_id")
|
||
if lines and speaker == last_speaker:
|
||
lines[-1] += " " + text # merge consecutive same-speaker segments
|
||
continue
|
||
label = f"{speaker_label}_{speaker}" if speaker is not None else speaker_label
|
||
lines.append(f"{label}: {text}")
|
||
last_speaker = speaker
|
||
if lines:
|
||
return "\n".join(lines)
|
||
|
||
full_text = (result.get("text") or "").strip()
|
||
if full_text:
|
||
return f"{speaker_label}: {full_text}"
|
||
return f"{speaker_label}: {empty_label}"
|
||
|
||
|
||
def _format_duration(seconds: float) -> str:
|
||
minutes = int(round(seconds / 60))
|
||
return f"{minutes // 60}:{minutes % 60:02d} h"
|
||
|
||
|
||
def transcribe_audio(file_path: Path, duration_seconds: float = 0.0) -> str:
|
||
"""Transcribe an audio/video file via Voxtral (auto language detection),
|
||
Provider laut Tenant-Konfiguration. Returns the speaker-labelled transcript.
|
||
|
||
The request timeout grows with the audio length (STT_TIMEOUT + 5 min per
|
||
audio hour), so a long but valid recording is not cut off and retried."""
|
||
cfg = _stt_config()
|
||
cfg["timeout"] += int(300 * duration_seconds / 3600)
|
||
audio_path, created = extract_audio_if_video(file_path)
|
||
try:
|
||
result = _transcribe_with_voxtral(audio_path, cfg)
|
||
return _format_voxtral_transcript(result)
|
||
except Exception as exc:
|
||
print(f"Transkriptionsfehler ({cfg['provider']}): {exc}")
|
||
raise
|
||
finally:
|
||
if created:
|
||
audio_path.unlink(missing_ok=True)
|
||
|
||
|
||
def _track_stt_usage(seconds: float, *, account: str | None = None,
|
||
idempotency_key: str | None = None) -> None:
|
||
"""Report the EUR cost of an STT call from audio duration (billed per minute).
|
||
Best effort: tracking failures must never break the transcription flow."""
|
||
try:
|
||
if seconds <= 0:
|
||
return
|
||
minutes = seconds / 60.0
|
||
stt = _stt_config()
|
||
cost.track_usage(
|
||
"stt_audio",
|
||
amount_eur=cost.stt_cost_eur(minutes, per_minute=stt["cost_per_minute"]),
|
||
account=account,
|
||
quantity=round(minutes, 2),
|
||
idempotency_key=f"{idempotency_key}:stt" if idempotency_key else None,
|
||
metadata={"model": stt["model"], "provider": stt["provider"],
|
||
"seconds": round(seconds, 1)},
|
||
)
|
||
except Exception as exc:
|
||
print(f"[cost] stt usage tracking skipped: {exc}")
|
||
|
||
|
||
# ── transcript upload parsing (docx / vtt / txt) ─────────────────────────────
|
||
|
||
|
||
def _normalize_speaker_name(name: str) -> str:
|
||
"""Canonical speaker label so the same person never splits into several.
|
||
|
||
Word/Teams/Zoom exports frequently mix non-breaking spaces, double spaces
|
||
or trailing punctuation in the same name across utterances; without this
|
||
the dedup downstream would show "Ruben", "Ruben", "Ruben" as 3 speakers.
|
||
"""
|
||
name = re.sub(r"\s+", " ", name).strip()
|
||
return name.rstrip(":").strip()
|
||
|
||
|
||
def _extract_docx_text(content: bytes) -> str:
|
||
import zipfile
|
||
import xml.etree.ElementTree as ET
|
||
try:
|
||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||
xml_bytes = zf.read("word/document.xml")
|
||
except (zipfile.BadZipFile, KeyError) as exc:
|
||
raise HTTPException(status_code=415, detail=f"Ungültige .docx-Datei: {exc}")
|
||
ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
||
root = ET.fromstring(xml_bytes)
|
||
paragraphs: list[str] = []
|
||
for paragraph in root.iter(f"{ns}p"):
|
||
text = "".join((t.text or "") for t in paragraph.iter(f"{ns}t"))
|
||
paragraphs.append(text.strip())
|
||
return "\n".join(paragraphs)
|
||
|
||
|
||
def _parse_vtt_transcript(raw: str) -> str:
|
||
lines = raw.replace("\r\n", "\n").split("\n")
|
||
output: list[str] = []
|
||
current_speaker: str | None = None
|
||
current_text: list[str] = []
|
||
tag_pattern = re.compile(r"<[^>]+>")
|
||
voice_pattern = re.compile(r"^<v\s+([^>]+)>(.*?)(?:</v>)?\s*$", re.IGNORECASE)
|
||
cue_id_pattern = re.compile(r"^(\d+|[0-9a-f\-]{16,})$", re.IGNORECASE)
|
||
|
||
def flush():
|
||
nonlocal current_speaker, current_text
|
||
if current_text:
|
||
text = " ".join(current_text).strip()
|
||
if text:
|
||
output.append(f"{current_speaker or 'SPRECHER'}: {text}")
|
||
current_text = []
|
||
current_speaker = None
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
flush()
|
||
continue
|
||
if stripped.upper().startswith("WEBVTT") or stripped.startswith("NOTE"):
|
||
continue
|
||
if "-->" in stripped:
|
||
flush()
|
||
continue
|
||
if cue_id_pattern.match(stripped):
|
||
continue
|
||
match = voice_pattern.match(stripped)
|
||
if match:
|
||
current_speaker = _normalize_speaker_name(match.group(1))
|
||
current_text.append(tag_pattern.sub("", match.group(2)).strip())
|
||
else:
|
||
current_text.append(tag_pattern.sub("", stripped).strip())
|
||
flush()
|
||
return "\n".join(line for line in output if line.strip())
|
||
|
||
|
||
def _normalize_text_transcript(raw: str) -> str:
|
||
lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||
output: list[str] = []
|
||
current_speaker: str | None = None
|
||
current_text: list[str] = []
|
||
timestamp_pattern = re.compile(r"^(.+?)\s+\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\s*$")
|
||
# Teams/Zoom .docx export: "Name M:SS<spoken text glued to the timestamp>".
|
||
# The timestamp is followed immediately (no space) by the utterance, so the
|
||
# minute must not bleed into the speaker name (the "Olivia Kibele 0" bug).
|
||
teams_pattern = re.compile(r"^(.+?)\s+\d{1,2}:\d{2}(?::\d{2})?(\S.*)$")
|
||
inline_pattern = re.compile(r"^([^:\n]{1,80}):\s*(.+)$")
|
||
|
||
def looks_like_name(candidate: str) -> bool:
|
||
return bool(
|
||
candidate
|
||
and len(candidate.split()) <= 6
|
||
and not re.search(r"[.!?]", candidate)
|
||
and not candidate.lower().startswith("http")
|
||
)
|
||
|
||
def flush():
|
||
nonlocal current_text
|
||
if current_speaker is not None and current_text:
|
||
text = " ".join(current_text).strip()
|
||
if text:
|
||
output.append(f"{current_speaker}: {text}")
|
||
current_text = []
|
||
|
||
for raw_line in lines:
|
||
line = raw_line.strip()
|
||
if not line:
|
||
flush()
|
||
continue
|
||
timestamp_match = timestamp_pattern.match(line)
|
||
if timestamp_match and not timestamp_match.group(1).lower().startswith("http"):
|
||
flush()
|
||
current_speaker = _normalize_speaker_name(timestamp_match.group(1))
|
||
continue
|
||
teams_match = teams_pattern.match(line)
|
||
if teams_match and looks_like_name(teams_match.group(1).strip()):
|
||
flush()
|
||
current_speaker = _normalize_speaker_name(teams_match.group(1))
|
||
current_text.append(teams_match.group(2).strip())
|
||
continue
|
||
inline_match = inline_pattern.match(line)
|
||
if inline_match:
|
||
candidate = inline_match.group(1).strip()
|
||
if looks_like_name(candidate):
|
||
flush()
|
||
current_speaker = _normalize_speaker_name(candidate)
|
||
current_text.append(inline_match.group(2).strip())
|
||
continue
|
||
if current_speaker is None:
|
||
current_speaker = "SPRECHER"
|
||
current_text.append(line)
|
||
flush()
|
||
# Drop the leading header block (title/date/duration/"Transkription gestartet")
|
||
# that precedes the first real speaker — but keep SPRECHER if the whole
|
||
# transcript is unlabelled (no named speaker was ever found).
|
||
if any(not line.startswith("SPRECHER:") for line in output):
|
||
while output and output[0].startswith("SPRECHER:"):
|
||
output.pop(0)
|
||
if not output and raw.strip():
|
||
output.append(f"SPRECHER: {raw.strip()}")
|
||
return "\n".join(output)
|
||
|
||
|
||
def parse_transcript_upload(content: bytes, filename: str | None) -> str:
|
||
suffix = Path(filename or "").suffix.lower()
|
||
if suffix == ".docx":
|
||
text = _extract_docx_text(content)
|
||
else:
|
||
text = content.decode("utf-8", errors="replace")
|
||
if text.startswith(""):
|
||
text = text[1:]
|
||
if suffix == ".vtt":
|
||
return _parse_vtt_transcript(text)
|
||
return _normalize_text_transcript(text)
|
||
|
||
|
||
def _upload_suffix(content_type: str, filename: str | None) -> str:
|
||
ext_map = {
|
||
"audio/mpeg": ".mp3", "audio/mp3": ".mp3", "audio/webm": ".webm", "video/webm": ".webm",
|
||
"audio/ogg": ".ogg", "audio/wav": ".wav", "audio/flac": ".flac", "audio/x-flac": ".flac",
|
||
"audio/aac": ".aac", "audio/x-m4a": ".m4a", "audio/mp4": ".mp4", "video/mp4": ".mp4",
|
||
"video/quicktime": ".mov", "video/x-m4v": ".m4v",
|
||
}
|
||
# The filename comes from the client, so only a plain, short extension is
|
||
# carried over into the temp path — everything else falls back to the
|
||
# content type (ffmpeg/STT pick the decoder by extension).
|
||
guessed = Path(filename or "").suffix.strip().lower().lstrip(".")
|
||
if guessed.isalnum() and 1 <= len(guessed) <= 8:
|
||
return f".{guessed}"
|
||
return ext_map.get(content_type, ".audio")
|
||
|
||
|
||
async def _persist_upload_temporarily(file: UploadFile) -> tuple[Path, int]:
|
||
content_type = (file.content_type or "").split(";")[0].strip().lower()
|
||
suffix = Path(file.filename or "").suffix.lower()
|
||
supported_type = content_type in ALLOWED_CONTENT_TYPES or content_type.startswith("audio/")
|
||
supported_suffix = suffix in ALLOWED_UPLOAD_SUFFIXES
|
||
if not supported_type and not supported_suffix:
|
||
raise HTTPException(
|
||
status_code=415,
|
||
detail=_unsupported_format_detail("Dateiformat", suffix, ALLOWED_UPLOAD_SUFFIXES),
|
||
)
|
||
content = await file.read()
|
||
if len(content) > MAX_FILE_SIZE:
|
||
max_mb = MAX_FILE_SIZE // 1024 // 1024
|
||
raise HTTPException(status_code=413, detail=f"Datei zu groß ({len(content) // 1024 // 1024} MB). Max {max_mb} MB")
|
||
if len(content) == 0:
|
||
raise HTTPException(status_code=400, detail="Leere Datei")
|
||
temp_path = TEMP_DIR / f"{uuid.uuid4()}{_upload_suffix(content_type, file.filename)}"
|
||
temp_path.write_bytes(content)
|
||
return temp_path, len(content)
|
||
|
||
|
||
# ── AI summarisation ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def _llm_client():
|
||
try:
|
||
from openai import OpenAI
|
||
except ModuleNotFoundError as exc:
|
||
raise RuntimeError(
|
||
"Python-Paket 'openai' nicht installiert. Bitte Abhaengigkeiten neu installieren."
|
||
) from exc
|
||
api_key = os.getenv("CORTECS_API_KEY", "").strip()
|
||
if not api_key:
|
||
raise RuntimeError("CORTECS_API_KEY nicht gesetzt")
|
||
# Cortecs speaks the OpenAI wire format; the SDK appends /chat/completions to
|
||
# the base URL, so it must include the /v1 prefix.
|
||
# The SDK retries connection errors + 408/409/429/5xx with exponential
|
||
# backoff; bump the default (2) so a brief provider blip doesn't fail the job.
|
||
return OpenAI(
|
||
api_key=api_key,
|
||
base_url=os.getenv("CORTECS_BASE_URL", "https://api.cortecs.ai/v1").strip(),
|
||
max_retries=int(os.getenv("LLM_MAX_RETRIES", "4")),
|
||
timeout=float(os.getenv("LLM_TIMEOUT", "600")),
|
||
)
|
||
|
||
|
||
def _summary_prompt() -> str:
|
||
"""The summary prompt with the tenant's identity filled in.
|
||
|
||
The shared default prompt is written with @@ORG@@/@@ORG_SHORT@@/@@ORG_PROFILE@@
|
||
placeholders so every tenant gets a protocol that names *its own* company —
|
||
a tenant-specific prompt.txt simply contains no placeholders.
|
||
"""
|
||
path = CFG.prompt_path
|
||
if not path.exists():
|
||
raise RuntimeError(f"{path} nicht gefunden")
|
||
prompt = path.read_text(encoding="utf-8").strip()
|
||
if not prompt:
|
||
raise RuntimeError(f"{path} ist leer")
|
||
for token, value in (
|
||
("@@ORG@@", CFG.ai_detect_org),
|
||
("@@ORG_SHORT@@", CFG.org_short),
|
||
("@@ORG_PROFILE@@", CFG.org_profile),
|
||
):
|
||
prompt = prompt.replace(token, value)
|
||
# An empty @@ORG_PROFILE@@ must not leave a dangling space behind.
|
||
return re.sub(r"[ \t]+(?=\n)", "", prompt).strip()
|
||
|
||
|
||
def _llm_text(response) -> str:
|
||
parts = []
|
||
for choice in getattr(response, "choices", []) or []:
|
||
text = getattr(getattr(choice, "message", None), "content", None)
|
||
if text:
|
||
parts.append(text)
|
||
return "\n".join(parts).strip()
|
||
|
||
|
||
def _track_llm_usage(cost_point: str, model: str, response, *, account: str | None = None,
|
||
idempotency_key: str | None = None) -> None:
|
||
"""Report the EUR cost of a Cortecs/Claude call from its token usage.
|
||
Best effort: tracking failures must never break the summarisation flow."""
|
||
try:
|
||
usage = getattr(response, "usage", None)
|
||
if usage is None:
|
||
return
|
||
in_tok = int(getattr(usage, "prompt_tokens", 0) or 0)
|
||
out_tok = int(getattr(usage, "completion_tokens", 0) or 0)
|
||
cost.track_usage(
|
||
cost_point,
|
||
amount_eur=cost.llm_cost_eur(model, in_tok, out_tok),
|
||
account=account,
|
||
quantity=in_tok + out_tok,
|
||
idempotency_key=idempotency_key,
|
||
metadata={"model": model, "input_tokens": in_tok, "output_tokens": out_tok},
|
||
)
|
||
except Exception as exc:
|
||
print(f"[cost] llm usage tracking skipped: {exc}")
|
||
|
||
|
||
def _set_send_job(job_id: str, **updates):
|
||
with SEND_JOBS_LOCK:
|
||
job = SEND_JOBS.setdefault(job_id, {})
|
||
job.update(updates)
|
||
|
||
|
||
def _set_summarize_job(job_id: str, **updates):
|
||
with SUMMARIZE_JOBS_LOCK:
|
||
SUMMARIZE_JOBS.setdefault(job_id, {}).update(updates)
|
||
|
||
|
||
def _prune_summarize_jobs(keep: int = 50):
|
||
with SUMMARIZE_JOBS_LOCK:
|
||
overflow = len(SUMMARIZE_JOBS) - keep
|
||
if overflow <= 0:
|
||
return
|
||
stale = [
|
||
jid for jid, d in SUMMARIZE_JOBS.items()
|
||
if d.get("status") in ("completed", "error")
|
||
][:overflow]
|
||
for jid in stale:
|
||
SUMMARIZE_JOBS.pop(jid, None)
|
||
|
||
|
||
def _run_summarize_job(job_id: str, transcript: str, title: str, insurance_type: str,
|
||
customer_2: str, customer_name: str, customer_address: str,
|
||
account: str | None = None):
|
||
started = time.monotonic()
|
||
try:
|
||
_set_summarize_job(job_id, status="running")
|
||
print(f"[summarize {job_id}] Start")
|
||
summary = summarize_with_ai(
|
||
transcript, title, insurance_type, customer_2, customer_name, customer_address,
|
||
account=account, idempotency_key=job_id,
|
||
)
|
||
_set_summarize_job(job_id, status="completed", summary=summary,
|
||
completed_at=datetime.now().isoformat())
|
||
print(f"[summarize {job_id}] Fertig nach {time.monotonic() - started:.1f}s")
|
||
except Exception as exc:
|
||
_set_summarize_job(job_id, status="error", error=str(exc),
|
||
completed_at=datetime.now().isoformat())
|
||
print(f"[summarize {job_id}] Fehlgeschlagen nach {time.monotonic() - started:.1f}s: {exc}")
|
||
|
||
|
||
def _set_transcribe_job(job_id: str, **updates):
|
||
with TRANSCRIBE_JOBS_LOCK:
|
||
TRANSCRIBE_JOBS.setdefault(job_id, {}).update(updates)
|
||
|
||
|
||
def _prune_transcribe_jobs(keep: int = 50):
|
||
# Finished/failed jobs hold the full transcript, so cap how many we retain
|
||
# to avoid unbounded memory growth on a long-running process.
|
||
with TRANSCRIBE_JOBS_LOCK:
|
||
overflow = len(TRANSCRIBE_JOBS) - keep
|
||
if overflow <= 0:
|
||
return
|
||
stale = [
|
||
jid for jid, d in TRANSCRIBE_JOBS.items()
|
||
if d.get("status") in ("completed", "error")
|
||
][:overflow]
|
||
for jid in stale:
|
||
TRANSCRIBE_JOBS.pop(jid, None)
|
||
|
||
|
||
def _run_transcribe_job(job_id: str, temp_paths: list[Path], filename: str, size: int,
|
||
account: str | None = None):
|
||
started = time.monotonic()
|
||
merged: Path | None = None
|
||
try:
|
||
_set_transcribe_job(job_id, status="running")
|
||
print(f"[transcribe {job_id}] Start ({filename}, {len(temp_paths)} Datei(en), {size} Bytes)")
|
||
if len(temp_paths) > 1:
|
||
merged = concat_audio(temp_paths)
|
||
temp_path = merged or temp_paths[0]
|
||
seconds = _audio_duration_seconds(temp_path)
|
||
# 0.0 = ffprobe could not read the duration → let the provider decide.
|
||
if seconds > MAX_AUDIO_SECONDS:
|
||
raise AudioTooLongError(
|
||
f"Die Aufnahme ist insgesamt {_format_duration(seconds)} lang – möglich sind "
|
||
f"höchstens {_format_duration(MAX_AUDIO_SECONDS)} pro Transkription. "
|
||
"Bitte kürzen oder auf mehrere Protokolle aufteilen."
|
||
)
|
||
transcript = transcribe_audio(temp_path, seconds)
|
||
_track_stt_usage(seconds, account=account, idempotency_key=job_id)
|
||
_set_transcribe_job(
|
||
job_id, status="completed", transcription=transcript,
|
||
filename=filename, size=size, completed_at=datetime.now().isoformat(),
|
||
)
|
||
print(f"[transcribe {job_id}] Fertig nach {time.monotonic() - started:.1f}s")
|
||
except NoSpeechDetectedError as exc:
|
||
_set_transcribe_job(job_id, status="error", error=str(exc),
|
||
completed_at=datetime.now().isoformat())
|
||
print(f"[transcribe {job_id}] Keine Sprache erkannt nach {time.monotonic() - started:.1f}s")
|
||
except AudioTooLongError as exc:
|
||
_set_transcribe_job(job_id, status="error", error=str(exc),
|
||
completed_at=datetime.now().isoformat())
|
||
print(f"[transcribe {job_id}] Zu lang ({exc})")
|
||
except Exception as exc:
|
||
_set_transcribe_job(
|
||
job_id, status="error",
|
||
error=f"Transkription fehlgeschlagen: {exc}",
|
||
completed_at=datetime.now().isoformat(),
|
||
)
|
||
print(f"[transcribe {job_id}] Fehlgeschlagen nach {time.monotonic() - started:.1f}s: {exc}")
|
||
finally:
|
||
for p in (*temp_paths, merged):
|
||
if p is not None:
|
||
p.unlink(missing_ok=True)
|
||
|
||
|
||
def summarize_with_ai(
|
||
transcript: str, title: str, insurance_type: str = "", customer_2: str = "",
|
||
customer_name: str = "", customer_address: str = "",
|
||
account: str | None = None, idempotency_key: str | None = None,
|
||
) -> str:
|
||
client = _llm_client()
|
||
base_prompt = _summary_prompt()
|
||
ctx = {
|
||
"title": title, "insurance_type": insurance_type, "customer_2": customer_2,
|
||
"customer_name": customer_name, "customer_address": customer_address,
|
||
}
|
||
prompt = HOOKS.build_prompt(base_prompt, ctx)
|
||
model = os.getenv("LLM_MODEL", "claude-4-6-sonnet")
|
||
|
||
terminart_val = insurance_type.strip() or "(aus Gespräch erkennen)"
|
||
projekt_val = customer_2.strip() or "(keiner)"
|
||
kunde_val = customer_name.strip() or "(aus Gespräch erkennen)"
|
||
kontakt_val = customer_address.strip() or "(aus Gespräch erkennen)"
|
||
title_val = title.strip() or "(ohne Titel)"
|
||
|
||
user_content = (
|
||
f"Termin-Art: {terminart_val}\n"
|
||
f"Projektname: {projekt_val}\n"
|
||
f"Kunde / Unternehmen: {kunde_val}\n"
|
||
f"Ansprechpartner Kunde: {kontakt_val}\n"
|
||
f"Meeting-Titel: {title_val}\n\n"
|
||
f"Gesprächsprotokoll:\n{transcript}"
|
||
)
|
||
def complete(system_prompt: str, cost_suffix: str):
|
||
response = client.chat.completions.create(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
max_tokens=int(os.getenv("LLM_MAX_TOKENS", "16000")),
|
||
temperature=float(os.getenv("LLM_TEMPERATURE", "0.2")),
|
||
stream=False,
|
||
)
|
||
_track_llm_usage("llm_summarize", model, response, account=account,
|
||
idempotency_key=f"{idempotency_key}:{cost_suffix}" if idempotency_key else None)
|
||
return _llm_text(response), _hit_token_limit(response)
|
||
|
||
content, truncated = complete(prompt, "summarize")
|
||
if truncated:
|
||
# The protocol ran into max_tokens and stops mid-sentence. Instead of
|
||
# an error, silently ask once more for a shorter version that keeps
|
||
# the tenant's structure (the tenant prompt itself stays unchanged).
|
||
print(f"[summarize] max_tokens erreicht ({len(content)} Zeichen) – zweiter Versuch, gekürzt")
|
||
content, truncated = complete(prompt + _SHORTER_RETRY_INSTRUCTION, "summarize-short")
|
||
if truncated:
|
||
print("[summarize] auch der gekürzte Versuch hat max_tokens erreicht")
|
||
content = _drop_unfinished_tail(content)
|
||
if not content:
|
||
raise RuntimeError("Claude hat keine Dokumentation zurückgegeben")
|
||
return normalize_protocol_text(content)
|
||
|
||
|
||
_SHORTER_RETRY_INSTRUCTION = """
|
||
|
||
Wichtiger Zusatz zur Länge:
|
||
Ein erster Versuch mit diesen Anweisungen ist zu lang geworden und wurde abgeschnitten. Erstelle das Dokument deshalb in einer deutlich kürzeren Fassung, etwa halb so lang wie üblich.
|
||
- Behalte den Aufbau exakt bei: dieselben Metadatenblöcke, dieselben Abschnitte und Überschriften in derselben Reihenfolge, dieselben Formatregeln und dieselbe Tonalität.
|
||
- Kein Abschnitt darf fehlen, auch die letzten Abschnitte müssen vollständig sein.
|
||
- Kürze durch Verdichten: weniger Details, Beispiele und Nebenaspekte, kürzere Absätze, weniger Zwischenüberschriften. Zahlen, Termine, Namen und Entscheidungen bleiben erhalten.
|
||
- Alle übrigen Regeln, insbesondere "erfinde nichts", gelten unverändert."""
|
||
|
||
|
||
def _hit_token_limit(response) -> bool:
|
||
"""True if the model stopped because max_tokens was reached (the text is
|
||
then cut off mid-sentence). OpenAI-compatible: finish_reason "length"."""
|
||
for choice in getattr(response, "choices", []) or []:
|
||
if getattr(choice, "finish_reason", None) in ("length", "max_tokens"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _drop_unfinished_tail(text: str) -> str:
|
||
"""Last resort for a still truncated protocol: drop the half-written last
|
||
line, which the PDF parser would otherwise render as a heading, and any
|
||
heading left without content above it."""
|
||
lines = text.rstrip().split("\n")
|
||
unfinished = lambda ln: not re.search(r"[.!?:…\"“”»)\]]$", ln.strip())
|
||
if len(lines) > 1 and unfinished(lines[-1]):
|
||
lines.pop()
|
||
while len(lines) > 1 and (not lines[-1].strip() or unfinished(lines[-1])):
|
||
lines.pop()
|
||
return "\n".join(lines).rstrip()
|
||
|
||
|
||
# ── e-mail / delivery ────────────────────────────────────────────────────────
|
||
|
||
|
||
def build_email_html(title: str, summary: str) -> str:
|
||
escaped_title = html.escape(title)
|
||
c = CFG.colors
|
||
org = CFG.office.get("name", CFG.name)
|
||
subtitle = CFG.office.get("subtitle", "")
|
||
org_line = f"{org} {subtitle}".strip()
|
||
return f"""\
|
||
<!doctype html>
|
||
<html lang="de">
|
||
<body style="margin:0;padding:24px;background:{c['bg']};font-family:Arial,Helvetica,sans-serif;">
|
||
<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid {c['border']};border-radius:16px;overflow:hidden;">
|
||
<div style="padding:22px 26px;background:{c['accent']};">
|
||
<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:{c['accent_light']};margin-bottom:8px;">{html.escape(CFG.name)}</div>
|
||
<div style="font-size:22px;line-height:1.3;font-weight:700;color:#ffffff;">{escaped_title}</div>
|
||
</div>
|
||
<div style="padding:26px;">
|
||
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.7;color:{c['text']};">Im Anhang finden Sie das Gesprächsprotokoll als PDF.</p>
|
||
<p style="margin:0;font-size:15px;line-height:1.7;color:{c['text']};">Freundliche Grüße<br>{html.escape(org_line)}</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def build_pdf(
|
||
title: str, summary: str, transcript: str, insurance_type: str = "", customer_2: str = "",
|
||
customer_name: str = "", customer_address: str = "",
|
||
) -> bytes:
|
||
context = build_pdf_context(
|
||
CFG, title, summary, insurance_type, customer_2, customer_name, customer_address
|
||
)
|
||
context = HOOKS.pdf_context(context)
|
||
return render_pdf(context, CFG)
|
||
|
||
|
||
def build_docx(
|
||
title: str, summary: str, transcript: str, insurance_type: str = "", customer_2: str = "",
|
||
customer_name: str = "", customer_address: str = "",
|
||
) -> bytes:
|
||
# Reuses the exact same structured context as the PDF, so the Word document
|
||
# carries identical content for every tenant.
|
||
context = build_pdf_context(
|
||
CFG, title, summary, insurance_type, customer_2, customer_name, customer_address
|
||
)
|
||
context = HOOKS.pdf_context(context)
|
||
return render_docx(context, CFG)
|
||
|
||
|
||
def deliver(
|
||
recipients: list[str], title: str, summary: str, transcript: str, insurance_type: str = "",
|
||
customer_2: str = "", customer_name: str = "", customer_address: str = "",
|
||
) -> bool:
|
||
clean_summary = normalize_protocol_text(summary)
|
||
pdf_bytes = build_pdf(title, clean_summary, transcript, insurance_type, customer_2, customer_name, customer_address)
|
||
|
||
parsed = parse_protocol_sections(clean_summary)
|
||
projekt_lines = parsed["projekt"]
|
||
if customer_name.strip():
|
||
projekt_lines = [customer_name.strip()] + ([customer_2.strip()] if customer_2.strip() else [])
|
||
project_lbl = project_label(projekt_lines) if projekt_lines else "Projekt"
|
||
terminart = insurance_type.strip() or parsed["terminart"] or "Kundengespräch"
|
||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||
pdf_name = f"{date_str}_{safe_filename(terminart)}_{safe_filename(project_lbl)}.pdf"
|
||
subject_title = f"Gesprächsprotokoll {terminart} – {project_lbl}"
|
||
|
||
smtp_host = os.getenv("SMTP_HOST")
|
||
smtp_port = int(os.getenv("SMTP_PORT", 587))
|
||
smtp_user = os.getenv("SMTP_USER")
|
||
smtp_password = os.getenv("SMTP_PASSWORD")
|
||
smtp_from_raw = os.getenv("SMTP_FROM", "").strip()
|
||
if smtp_from_raw and "@" not in smtp_from_raw and smtp_user:
|
||
smtp_from = f"{smtp_from_raw} <{smtp_user}>"
|
||
elif smtp_from_raw:
|
||
smtp_from = smtp_from_raw
|
||
else:
|
||
smtp_from = smtp_user or ""
|
||
|
||
if not all([smtp_host, smtp_user, smtp_password]):
|
||
raise RuntimeError("SMTP nicht konfiguriert. Das PDF wurde nicht gespeichert und nicht versendet.")
|
||
|
||
html_body = build_email_html(subject_title, clean_summary)
|
||
body = (
|
||
f"Termin-Art: {terminart}\n"
|
||
f"Projekt: {project_lbl}\n"
|
||
f"Datum: {datetime.now().strftime('%d.%m.%Y')}\n\n"
|
||
f"Im Anhang finden Sie das Gesprächsprotokoll als PDF."
|
||
)
|
||
msg = MIMEMultipart("mixed")
|
||
msg["Subject"] = subject_title
|
||
msg["From"] = smtp_from
|
||
msg["To"] = ", ".join(recipients)
|
||
alt = MIMEMultipart("alternative")
|
||
alt.attach(MIMEText(body, "plain", "utf-8"))
|
||
alt.attach(MIMEText(html_body, "html", "utf-8"))
|
||
msg.attach(alt)
|
||
attachment = MIMEApplication(pdf_bytes, _subtype="pdf")
|
||
attachment.add_header("Content-Disposition", "attachment", filename=pdf_name)
|
||
msg.attach(attachment)
|
||
|
||
print(f"Sende E-Mail an {recipients} via {smtp_host}:{smtp_port} ...")
|
||
|
||
def _send():
|
||
with smtplib.SMTP(str(smtp_host), smtp_port, timeout=30) as server:
|
||
server.ehlo()
|
||
server.starttls()
|
||
server.ehlo()
|
||
auth_str = f"\x00{smtp_user}\x00{smtp_password}"
|
||
auth_b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii")
|
||
code, resp = server.docmd("AUTH PLAIN", auth_b64)
|
||
if code != 235:
|
||
raise smtplib.SMTPAuthenticationError(code, resp)
|
||
server.send_message(msg, from_addr=str(smtp_from), to_addrs=recipients)
|
||
|
||
try:
|
||
# Retry only transient connection failures (OSError covers connection
|
||
# reset / timeout / DNS). Auth and recipient errors are not OSError, so
|
||
# they fail fast without pointless retries.
|
||
retry.call_with_retry(
|
||
_send,
|
||
retry_on=(smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected, OSError),
|
||
label="smtp-send",
|
||
)
|
||
print("E-Mail erfolgreich gesendet.")
|
||
except Exception as e:
|
||
print(f"SMTP-Fehler: {e}")
|
||
raise RuntimeError(f"E-Mail konnte nicht gesendet werden: {e}") from e
|
||
return True
|
||
|
||
|
||
# ── frontend templating ──────────────────────────────────────────────────────
|
||
|
||
|
||
def _favicon_data_uri() -> str:
|
||
# A tenant can ship its own brand favicon (favicon.svg). Otherwise we
|
||
# generate a compact mark from the tenant's accent + red colours.
|
||
fav = CFG.favicon_path
|
||
if fav:
|
||
svg = re.sub(r"<\?xml.*?\?>", "", fav.read_text(encoding="utf-8"), flags=re.DOTALL).strip()
|
||
return "data:image/svg+xml," + quote(svg, safe="")
|
||
c = CFG.colors
|
||
accent = c["accent"].replace("#", "%23")
|
||
red = c["red"].replace("#", "%23")
|
||
return (
|
||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E"
|
||
"%3Crect width='64' height='64' rx='14' fill='%23FFFFFF'/%3E"
|
||
f"%3Ccircle cx='39' cy='15' r='6.5' fill='{accent}'/%3E"
|
||
f"%3Cpath d='M17 23 L30 35 L17 47' fill='none' stroke='{accent}' stroke-width='6.5' stroke-linecap='round' stroke-linejoin='round'/%3E"
|
||
f"%3Cpath d='M31 29 L44 41 L31 53' fill='none' stroke='{red}' stroke-width='6.5' stroke-linecap='round' stroke-linejoin='round'/%3E"
|
||
"%3C/svg%3E"
|
||
)
|
||
|
||
|
||
def _topbar_logo_markup() -> str:
|
||
p = CFG.logo_path
|
||
label = html.escape(CFG.name)
|
||
if p and p.suffix.lower() == ".svg":
|
||
svg = p.read_text(encoding="utf-8")
|
||
svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.DOTALL)
|
||
svg = re.sub(r"<!--.*?-->", "", svg, flags=re.DOTALL)
|
||
svg = re.sub(r"<svg\b", f'<svg class="topbar-logo" role="img" aria-label="{label}"', svg, count=1)
|
||
return svg.strip()
|
||
if p: # png/other → reference the /brand/logo route
|
||
return f'<img class="topbar-logo" src="/brand/logo" alt="{label}">'
|
||
return f'<div class="topbar-logo" style="font-size:28px;font-weight:700;color:var(--accent)">{label}</div>'
|
||
|
||
|
||
_INDEX_TEMPLATE = (CORE_TEMPLATES / "index.template.html").read_text(encoding="utf-8")
|
||
|
||
|
||
def _render_index() -> str:
|
||
c = CFG.colors
|
||
replacements = {
|
||
"@@TITLE@@": html.escape(CFG.title),
|
||
"@@DESCRIPTION@@": html.escape(CFG.description),
|
||
"@@FAVICON@@": _favicon_data_uri(),
|
||
# The frontend rejects unsupported files before the upload starts; these
|
||
# tokens keep its check in sync with the server-side validation above.
|
||
"@@ACCEPT_MEDIA@@": ",".join((*ALLOWED_UPLOAD_SUFFIXES, "audio/*", "video/mp4", "video/quicktime")),
|
||
"@@ACCEPT_TRANSCRIPT@@": ",".join(ALLOWED_TRANSCRIPT_SUFFIXES),
|
||
"@@ACCEPT_VOCABULARY@@": ",".join(ALLOWED_VOCABULARY_SUFFIXES),
|
||
"@@ALLOWED_MEDIA_SUFFIXES_JSON@@": json.dumps(list(ALLOWED_UPLOAD_SUFFIXES)),
|
||
"@@ALLOWED_MEDIA_TYPES_JSON@@": json.dumps(sorted(ALLOWED_CONTENT_TYPES)),
|
||
"@@ALLOWED_TRANSCRIPT_SUFFIXES_JSON@@": json.dumps(list(ALLOWED_TRANSCRIPT_SUFFIXES)),
|
||
"@@ALLOWED_VOCABULARY_SUFFIXES_JSON@@": json.dumps(list(ALLOWED_VOCABULARY_SUFFIXES)),
|
||
"@@MAX_FILE_SIZE_MB@@": str(MAX_FILE_SIZE // 1024 // 1024),
|
||
"@@MAX_AUDIO_FILES@@": str(MAX_AUDIO_FILES),
|
||
"@@MULTIPLE_MEDIA@@": "multiple" if MAX_AUDIO_FILES > 1 else "",
|
||
"@@LOGO_SVG@@": _topbar_logo_markup(),
|
||
"@@COLOR_ACCENT@@": c["accent"],
|
||
"@@COLOR_ACCENT_DARK@@": c["accent_dark"],
|
||
"@@COLOR_ACCENT_MID@@": c["accent_mid"],
|
||
"@@COLOR_ACCENT_LIGHT@@": c["accent_light"],
|
||
"@@COLOR_ACCENT_XLIGHT@@": c["accent_xlight"],
|
||
"@@COLOR_BG@@": c["bg"],
|
||
"@@COLOR_SURFACE@@": c["surface"],
|
||
"@@COLOR_SURFACE2@@": c["surface2"],
|
||
"@@COLOR_BORDER@@": c["border"],
|
||
"@@COLOR_BORDER2@@": c["border2"],
|
||
"@@COLOR_TEXT@@": c["text"],
|
||
"@@COLOR_TEXT_2@@": c["text_2"],
|
||
"@@COLOR_TEXT_3@@": c["text_3"],
|
||
"@@COLOR_RED@@": c["red"],
|
||
"@@COLOR_PROGRESS_TO@@": c["progress_to"],
|
||
# The input stays in the DOM (JS reads it unconditionally) — tenants that
|
||
# don't print an "Ansprechpartner Kunde" block just hide the field.
|
||
"@@STYLE_KONTAKT_FIELD@@": "" if CFG.flag("ui_kontakt_field", True) else "display:none;",
|
||
}
|
||
out = _INDEX_TEMPLATE
|
||
for token, value in replacements.items():
|
||
out = out.replace(token, value)
|
||
return out
|
||
|
||
|
||
# ── routes ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def get_index(request: Request):
|
||
if _get_session_user(request) is None:
|
||
return RedirectResponse("/auth/login", status_code=302)
|
||
return _render_index()
|
||
|
||
|
||
@app.get("/auth/login")
|
||
async def auth_login(retry: str = None):
|
||
state = secrets.token_urlsafe(24) + (OAUTH_RETRY_MARKER if retry else "")
|
||
code_verifier = secrets.token_urlsafe(64)
|
||
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
|
||
params = urlencode({
|
||
"response_type": "code", "client_id": OIDC_CLIENT_ID, "redirect_uri": OIDC_REDIRECT_URI,
|
||
"scope": "openid profile email", "state": state,
|
||
"code_challenge": code_challenge, "code_challenge_method": "S256",
|
||
})
|
||
response = RedirectResponse(f"{OIDC_ISSUER}/oauth/authorize?{params}", status_code=302)
|
||
_set_pending_login(response, state, code_verifier)
|
||
return response
|
||
|
||
|
||
@app.get("/auth/callback")
|
||
async def auth_callback(request: Request, code: str = None, state: str = None, auth_disabled: str = None):
|
||
if auth_disabled:
|
||
user = {"sub": "user:0", "email": "dev@local", "name": "Dev", "user_type": "employee", "app_roles": []}
|
||
response = RedirectResponse("/", status_code=303)
|
||
_set_session(response, user)
|
||
return response
|
||
if not code:
|
||
raise HTTPException(status_code=400, detail="Ungültiger Callback: code fehlt")
|
||
idp_initiated = not state
|
||
code_verifier = None
|
||
if not idp_initiated:
|
||
code_verifier = _pending_login_verifier(request, state)
|
||
if code_verifier is None:
|
||
# No pending login for this state. That is almost never an attack: the
|
||
# same callback URL gets opened a second time (reload, back button,
|
||
# restored tab, address-bar autocomplete from history, or a mail/AV
|
||
# scanner prefetching the link) — the authorization code is single-use,
|
||
# so replaying it could never succeed anyway. Whoever already has a
|
||
# session is simply let in; everyone else starts a fresh login instead
|
||
# of hitting a dead-end error page.
|
||
if _get_session_user(request) is not None:
|
||
return RedirectResponse("/", status_code=303)
|
||
if not state.endswith(OAUTH_RETRY_MARKER):
|
||
return RedirectResponse("/auth/login?retry=1", status_code=303)
|
||
# Second failure in a row: the browser is not keeping our cookie, so
|
||
# another retry would only loop.
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Login konnte nicht abgeschlossen werden – der Browser speichert keine "
|
||
"Cookies für diese Seite. Bitte Cookies für diese Adresse erlauben oder "
|
||
"es in einem normalen (nicht privaten) Fenster erneut versuchen.",
|
||
)
|
||
token_fields = {
|
||
"grant_type": "authorization_code", "code": code, "redirect_uri": OIDC_REDIRECT_URI,
|
||
"client_id": OIDC_CLIENT_ID, "client_secret": OIDC_CLIENT_SECRET,
|
||
}
|
||
if code_verifier is not None:
|
||
token_fields["code_verifier"] = code_verifier
|
||
token_body = urlencode(token_fields).encode()
|
||
req = urllib_request.Request(
|
||
f"{OIDC_ISSUER}/oauth/token", data=token_body,
|
||
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST",
|
||
)
|
||
try:
|
||
with urllib_request.urlopen(req) as resp:
|
||
tokens = json.loads(resp.read())
|
||
except urllib_error.HTTPError as e:
|
||
raise HTTPException(status_code=502, detail=f"Token-Tausch fehlgeschlagen ({e.code})")
|
||
claims = _decode_jwt_claims(tokens.get("id_token", ""))
|
||
user = {
|
||
"sub": claims.get("sub", ""), "email": claims.get("email", ""), "name": claims.get("name", ""),
|
||
"user_type": claims.get("user_type", ""), "app_roles": claims.get("app_roles", []),
|
||
}
|
||
response = RedirectResponse("/", status_code=303)
|
||
_set_session(response, user)
|
||
response.delete_cookie(OAUTH_STATE_COOKIE)
|
||
return response
|
||
|
||
|
||
@app.get("/auth/logout")
|
||
@app.post("/logout")
|
||
async def logout():
|
||
response = RedirectResponse(
|
||
f"{OIDC_ISSUER}/logout?{urlencode({'post_logout_redirect_uri': OIDC_LOGOUT_REDIRECT_URI})}",
|
||
status_code=302,
|
||
)
|
||
response.delete_cookie(AUTH_COOKIE)
|
||
return response
|
||
|
||
|
||
def _health_stt() -> dict:
|
||
"""STT block for /health — never raises, so a misconfigured provider shows up
|
||
in the payload instead of turning the health check into a 500."""
|
||
try:
|
||
cfg = _stt_config()
|
||
except Exception as exc:
|
||
return {"provider": "unknown", "error": str(exc)}
|
||
return {
|
||
"provider": cfg["provider"],
|
||
"model": cfg["model"],
|
||
"diarize": cfg["diarize"],
|
||
"api_key_configured": bool(os.getenv(cfg["key_env"], "").strip()),
|
||
}
|
||
|
||
|
||
@app.get("/health")
|
||
async def health():
|
||
"""Non-secret status: which STT/LLM provider + model the running container
|
||
uses, and whether the API keys are configured. No secret values exposed."""
|
||
return {
|
||
"status": "ok",
|
||
"tenant": CFG.id,
|
||
# Vertrag mit der Admin-Plattform: welcher Core läuft, und woher stammen die Tenant-Daten (siehe docs/CONTRACT.md)
|
||
"core": {"version": contract.build_info()["sha"], "built_at": contract.build_info()["built_at"],
|
||
"contract": contract.CONTRACT_VERSION, "hook_api": contract.HOOK_API},
|
||
"bundle": {k: CFG.bundle.get(k) for k in ("source", "version", "stale", "error")},
|
||
"stt": _health_stt(),
|
||
"llm": {
|
||
"provider": "cortecs",
|
||
"model": os.getenv("LLM_MODEL", "claude-4-6-sonnet"),
|
||
"api_key_configured": bool(os.getenv("CORTECS_API_KEY", "").strip()),
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/brand/logo")
|
||
async def get_logo(_: None = Depends(require_auth)):
|
||
p = CFG.logo_path
|
||
if not p or not p.exists():
|
||
raise HTTPException(status_code=404, detail="Logo nicht gefunden")
|
||
mime = "image/svg+xml" if p.suffix.lower() == ".svg" else "image/png"
|
||
return FileResponse(p, media_type=mime)
|
||
|
||
|
||
@app.post("/upload")
|
||
async def upload_audio(_: None = Depends(require_auth)):
|
||
raise HTTPException(
|
||
status_code=410,
|
||
detail="Persistente Audio-Uploads wurden entfernt. Bitte /transcribe direkt mit multipart/form-data aufrufen.",
|
||
)
|
||
|
||
|
||
@app.post("/transcribe")
|
||
async def transcribe(
|
||
background_tasks: BackgroundTasks,
|
||
file: list[UploadFile] = File(...),
|
||
user: dict = Depends(require_auth),
|
||
):
|
||
# Persist the upload(s) to disk and hand the slow STT call off to a
|
||
# background task; the temp files are deleted in the job's finally (NOT
|
||
# here). The client polls /transcribe-status so each request stays short.
|
||
# Several "file" fields = parts of one meeting, in upload order.
|
||
if len(file) > MAX_AUDIO_FILES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(f"Höchstens {MAX_AUDIO_FILES} Audiodateien pro Transkription."
|
||
if MAX_AUDIO_FILES > 1 else "Bitte nur eine Audiodatei hochladen."),
|
||
)
|
||
temp_paths: list[Path] = []
|
||
size = 0
|
||
try:
|
||
for f in file:
|
||
path, n = await _persist_upload_temporarily(f)
|
||
temp_paths.append(path)
|
||
size += n
|
||
except Exception:
|
||
for p in temp_paths:
|
||
p.unlink(missing_ok=True)
|
||
raise
|
||
_prune_transcribe_jobs()
|
||
job_id = uuid.uuid4().hex
|
||
filename = ", ".join(f.filename or "audio.webm" for f in file)
|
||
_set_transcribe_job(job_id, status="queued", filename=filename, size=size,
|
||
created_at=datetime.now().isoformat())
|
||
background_tasks.add_task(_run_transcribe_job, job_id, temp_paths, filename, size, user.get("sub"))
|
||
return {"job_id": job_id, "status": "queued"}
|
||
|
||
|
||
@app.get("/transcribe-status/{job_id}")
|
||
async def transcribe_status(job_id: str, _: None = Depends(require_auth)):
|
||
with TRANSCRIBE_JOBS_LOCK:
|
||
job = TRANSCRIBE_JOBS.get(job_id)
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="Transkriptions-Job nicht gefunden")
|
||
return job
|
||
|
||
|
||
@app.post("/upload-transcript")
|
||
async def upload_transcript(file: UploadFile = File(...), _: None = Depends(require_auth)):
|
||
suffix = Path(file.filename or "").suffix.lower()
|
||
if suffix not in ALLOWED_TRANSCRIPT_SUFFIXES:
|
||
raise HTTPException(
|
||
status_code=415,
|
||
detail=_unsupported_format_detail("Transkript-Format", suffix, ALLOWED_TRANSCRIPT_SUFFIXES),
|
||
)
|
||
content = await file.read()
|
||
if len(content) > MAX_FILE_SIZE:
|
||
max_mb = MAX_FILE_SIZE // 1024 // 1024
|
||
raise HTTPException(status_code=413, detail=f"Datei zu groß ({len(content) // 1024 // 1024} MB). Max {max_mb} MB")
|
||
if not content:
|
||
raise HTTPException(status_code=400, detail="Leere Datei")
|
||
try:
|
||
text = parse_transcript_upload(content, file.filename)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=422, detail=f"Transkript konnte nicht gelesen werden: {exc}")
|
||
if not text.strip():
|
||
raise HTTPException(status_code=422, detail="Transkript ist leer oder konnte nicht erkannt werden.")
|
||
return {"transcription": text, "filename": file.filename, "size": len(content)}
|
||
|
||
|
||
def _run_send_job(
|
||
job_id: str, recipients: list[str], title: str, transcript: str, insurance_type: str,
|
||
customer_2: str, customer_name: str = "", customer_address: str = "", pre_summary: str = "",
|
||
account: str | None = None,
|
||
):
|
||
try:
|
||
_set_send_job(job_id, status="running")
|
||
summary = pre_summary.strip() or summarize_with_ai(
|
||
transcript, title, insurance_type, customer_2, customer_name, customer_address,
|
||
account=account, idempotency_key=job_id,
|
||
)
|
||
deliver(recipients, title, summary, transcript, insurance_type, customer_2, customer_name, customer_address)
|
||
_set_send_job(job_id, status="completed", summary=summary, recipients=len(recipients),
|
||
completed_at=datetime.now().isoformat())
|
||
except Exception as exc:
|
||
_set_send_job(job_id, status="error", error=str(exc), completed_at=datetime.now().isoformat())
|
||
|
||
|
||
@app.post("/summarize")
|
||
async def summarize(
|
||
background_tasks: BackgroundTasks,
|
||
transcript: str = Form(...), title: str = Form(...), insurance_type: str = Form(""),
|
||
customer_2: str = Form(""), customer_name: str = Form(""), customer_address: str = Form(""),
|
||
user: dict = Depends(require_auth),
|
||
):
|
||
# The LLM call can exceed the reverse-proxy idle timeout, so run it in the
|
||
# background and let the client poll /summarize-status (short requests).
|
||
if not transcript.strip():
|
||
raise HTTPException(status_code=400, detail="Kein Transkript")
|
||
_prune_summarize_jobs()
|
||
job_id = uuid.uuid4().hex
|
||
_set_summarize_job(job_id, status="queued", created_at=datetime.now().isoformat())
|
||
background_tasks.add_task(
|
||
_run_summarize_job, job_id, transcript, title or "Kundengespräch",
|
||
insurance_type, customer_2, customer_name, customer_address, user.get("sub"),
|
||
)
|
||
return {"job_id": job_id, "status": "queued"}
|
||
|
||
|
||
@app.get("/summarize-status/{job_id}")
|
||
async def summarize_status(job_id: str, _: None = Depends(require_auth)):
|
||
with SUMMARIZE_JOBS_LOCK:
|
||
job = SUMMARIZE_JOBS.get(job_id)
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="Summarize-Job nicht gefunden")
|
||
return job
|
||
|
||
|
||
@app.post("/download-pdf")
|
||
async def download_pdf(
|
||
summary: str = Form(...), transcript: str = Form(...), title: str = Form(""),
|
||
insurance_type: str = Form(""), customer_2: str = Form(""), customer_name: str = Form(""),
|
||
customer_address: str = Form(""), _: None = Depends(require_auth),
|
||
):
|
||
try:
|
||
clean_summary = normalize_protocol_text(summary)
|
||
pdf_bytes = build_pdf(title, clean_summary, transcript, insurance_type, customer_2, customer_name, customer_address)
|
||
parsed = parse_protocol_sections(clean_summary)
|
||
projekt_lines = parsed["projekt"]
|
||
if customer_name.strip():
|
||
projekt_lines = [customer_name.strip()] + ([customer_2.strip()] if customer_2.strip() else [])
|
||
project_lbl = project_label(projekt_lines) if projekt_lines else "Protokoll"
|
||
terminart = insurance_type.strip() or parsed["terminart"] or "Kundengespräch"
|
||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||
pdf_name = f"{date_str}_{safe_filename(terminart)}_{safe_filename(project_lbl)}.pdf"
|
||
content_disposition = f"attachment; filename=\"protokoll.pdf\"; filename*=UTF-8''{quote(pdf_name)}"
|
||
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": content_disposition})
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.post("/download-docx")
|
||
async def download_docx(
|
||
summary: str = Form(...), transcript: str = Form(...), title: str = Form(""),
|
||
insurance_type: str = Form(""), customer_2: str = Form(""), customer_name: str = Form(""),
|
||
customer_address: str = Form(""), _: None = Depends(require_auth),
|
||
):
|
||
try:
|
||
clean_summary = normalize_protocol_text(summary)
|
||
docx_bytes = build_docx(title, clean_summary, transcript, insurance_type, customer_2, customer_name, customer_address)
|
||
parsed = parse_protocol_sections(clean_summary)
|
||
projekt_lines = parsed["projekt"]
|
||
if customer_name.strip():
|
||
projekt_lines = [customer_name.strip()] + ([customer_2.strip()] if customer_2.strip() else [])
|
||
project_lbl = project_label(projekt_lines) if projekt_lines else "Protokoll"
|
||
terminart = insurance_type.strip() or parsed["terminart"] or "Kundengespräch"
|
||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||
docx_name = f"{date_str}_{safe_filename(terminart)}_{safe_filename(project_lbl)}.docx"
|
||
content_disposition = f"attachment; filename=\"protokoll.docx\"; filename*=UTF-8''{quote(docx_name)}"
|
||
return Response(
|
||
content=docx_bytes,
|
||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
headers={"Content-Disposition": content_disposition},
|
||
)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.post("/ai-detect")
|
||
async def ai_detect(request: Request, user: dict = Depends(require_auth)):
|
||
body = await request.json()
|
||
transcript = (body.get("transcript") or "").strip()
|
||
if not transcript:
|
||
raise HTTPException(status_code=400, detail="Kein Transkript übergeben")
|
||
client = _llm_client()
|
||
model = os.getenv("LLM_MODEL", "claude-4-6-sonnet")
|
||
org = CFG.ai_detect_org
|
||
system_prompt = (
|
||
f'Du bist ein Assistent der Firma "{org}". '
|
||
"Du analysierst Transkripte von Kundengesprächen und extrahierst strukturierte Metadaten. "
|
||
"Antworte AUSSCHLIESSLICH mit einem JSON-Objekt – kein Markdown, keine Erklärungen, kein Fließtext."
|
||
)
|
||
user_content = f"""\
|
||
Lies das folgende Gesprächstranskript und extrahiere die vier Metadatenfelder.
|
||
Antworte nur mit einem JSON-Objekt in exakt dieser Struktur:
|
||
|
||
{{
|
||
"kunde": "<Name des Kundenunternehmens. Nicht '{org}' oder eine Agentur – sondern die Gegenseite, also der Auftraggeber. Leer-String wenn nicht eindeutig erkennbar.>",
|
||
"projekt": "<Projektname oder konkrete Projektbezeichnung, falls im Gespräch explizit genannt (z.B. 'Relaunch 2026', 'Kampagne Herbst'). Leer-String wenn kein Name genannt.>",
|
||
"ansprechpartner": ["<Vorname Nachname - Funktion>"],
|
||
"terminart": "<Exakt einer dieser Werte: Kick-off | Briefing | Jour fixe | Status-Update | Konzept-Review | Workshop | Strategie-Meeting | Abnahme | Pitch / Erstgespräch | Kundengespräch. Leer-String wenn unklar.>"
|
||
}}
|
||
|
||
Regeln:
|
||
- "kunde": Das Unternehmen der Gegenseite (Auftraggeber/Kunde), NICHT die Agentur.
|
||
- "ansprechpartner": Nur Personen auf Kundenseite. Format je Eintrag: "Vorname Nachname - Funktion" oder "Vorname Nachname" wenn die Funktion unbekannt ist. Leeres Array [] wenn niemand namentlich erkennbar.
|
||
- "terminart": Nur exakt einen der oben genannten Werte übernehmen. Kein eigener Text.
|
||
- Lieber Leer-String als eine unsichere Schätzung.
|
||
|
||
Transkript:
|
||
{transcript}"""
|
||
response = client.chat.completions.create(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
max_tokens=512, temperature=0.0,
|
||
)
|
||
_track_llm_usage("llm_detect", model, response, account=user.get("sub"),
|
||
idempotency_key=f"detect:{hashlib.sha256(transcript.encode()).hexdigest()[:16]}")
|
||
raw = (_llm_text(response) or "").strip()
|
||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||
raw = re.sub(r"\s*```$", "", raw)
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
raise HTTPException(status_code=500, detail="KI-Antwort konnte nicht geparst werden")
|
||
return {
|
||
"kunde": str(data.get("kunde") or ""),
|
||
"projekt": str(data.get("projekt") or ""),
|
||
"ansprechpartner": [str(a) for a in (data.get("ansprechpartner") or []) if a],
|
||
"terminart": str(data.get("terminart") or ""),
|
||
}
|
||
|
||
|
||
@app.post("/send")
|
||
async def send(
|
||
background_tasks: BackgroundTasks, emails: str = Form(...), title: str = Form(...),
|
||
transcript: str = Form(...), summary: str = Form(""), insurance_type: str = Form(""),
|
||
customer_2: str = Form(""), customer_name: str = Form(""), customer_address: str = Form(""),
|
||
user: dict = Depends(require_auth),
|
||
):
|
||
recipients = [item.strip() for item in emails.split(",") if item.strip()]
|
||
if not recipients or any("@" not in email for email in recipients):
|
||
raise HTTPException(status_code=400, detail="Ungültige E-Mail-Liste")
|
||
if not transcript.strip():
|
||
raise HTTPException(status_code=400, detail="Kein Transkript")
|
||
job_id = uuid.uuid4().hex
|
||
_set_send_job(job_id, status="queued", recipients=len(recipients), created_at=datetime.now().isoformat())
|
||
background_tasks.add_task(
|
||
_run_send_job, job_id, recipients, title or "Kundengespräch", transcript, insurance_type,
|
||
customer_2, customer_name, customer_address, summary, user.get("sub"),
|
||
)
|
||
return {"success": True, "job_id": job_id, "status": "queued"}
|
||
|
||
|
||
@app.get("/send-status/{job_id}")
|
||
async def send_status(job_id: str, _: None = Depends(require_auth)):
|
||
with SEND_JOBS_LOCK:
|
||
job = SEND_JOBS.get(job_id)
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="Send-Job nicht gefunden")
|
||
return job
|
||
|
||
|
||
@app.get("/vocabulary")
|
||
async def get_vocabulary(_: None = Depends(require_auth)):
|
||
return {"terms": _load_vocabulary()}
|
||
|
||
|
||
@app.post("/vocabulary")
|
||
async def add_vocabulary(term: str = Form(...), _: None = Depends(require_auth)):
|
||
terms = _load_vocabulary()
|
||
if term.strip() and term.strip() not in terms:
|
||
_save_vocabulary(terms + [term.strip()])
|
||
return {"terms": _load_vocabulary()}
|
||
|
||
|
||
@app.delete("/vocabulary")
|
||
async def delete_vocabulary(term: str = Form(...), _: None = Depends(require_auth)):
|
||
_save_vocabulary([t for t in _load_vocabulary() if t != term])
|
||
return {"terms": _load_vocabulary()}
|
||
|
||
|
||
@app.post("/vocabulary/import")
|
||
async def import_vocabulary(file: UploadFile = File(...), _: None = Depends(require_auth)):
|
||
suffix = Path(file.filename or "").suffix.lower()
|
||
if suffix not in ALLOWED_VOCABULARY_SUFFIXES:
|
||
raise HTTPException(
|
||
status_code=415,
|
||
detail=_unsupported_format_detail("Vokabular-Format", suffix, ALLOWED_VOCABULARY_SUFFIXES),
|
||
)
|
||
raw = await file.read()
|
||
if not raw:
|
||
raise HTTPException(status_code=400, detail="Leere Datei")
|
||
try:
|
||
content = raw.decode("utf-8-sig")
|
||
except UnicodeDecodeError:
|
||
raise HTTPException(
|
||
status_code=415,
|
||
detail="Die Datei ist keine UTF-8-Textdatei. Bitte eine .txt-Datei mit einem Begriff pro Zeile hochladen.",
|
||
)
|
||
new_terms = [ln.strip() for ln in content.splitlines() if ln.strip()]
|
||
_save_vocabulary(_load_vocabulary() + new_terms)
|
||
return {"terms": _load_vocabulary(), "imported": len(new_terms)}
|
||
|
||
|
||
# tenant-specific extra routes (no-op unless overrides.py defines them)
|
||
HOOKS.extra_routes(app)
|