Files
Onyscribe-Admin/vendor/platform/core/app.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
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>
2026-10-15 15:30:26 +02:00

1638 lines
70 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)