"""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//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"^]+)>(.*?)(?:)?\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". # 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"""\
{html.escape(CFG.name)}
{escaped_title}

Im Anhang finden Sie das Gesprächsprotokoll als PDF.

Freundliche Grüße
{html.escape(org_line)}

""" 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"