import io import json import html import os import re import smtplib import subprocess import tempfile import threading 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 import request as urllib_request from dotenv import load_dotenv load_dotenv() from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks from fastapi.responses import HTMLResponse warnings.filterwarnings("ignore", category=UserWarning) 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/webm", } OUTPUT_DIR = Path("output") OUTPUT_DIR.mkdir(exist_ok=True) PROMPT_FILE = Path("prompt.txt") ROTARY_LOGO_FILE = Path("rotary_logo.png") SEND_JOBS: dict[str, dict] = {} SEND_JOBS_LOCK = threading.Lock() TEMP_DIR = Path(os.getenv("TEMP_DIR", tempfile.gettempdir())) / "meeting-transcript" TEMP_DIR.mkdir(parents=True, exist_ok=True) @asynccontextmanager async def lifespan(app: FastAPI): print("Server startet – Azure Speech Transkription aktiv.") yield print("Server fährt herunter.") app = FastAPI(lifespan=lifespan) # ── Audio helpers ────────────────────────────────────────────────────────────── def convert_to_wav(input_path: Path) -> Path: output_path = input_path.with_suffix(".converted.wav") r = subprocess.run( [ "ffmpeg", "-y", "-i", str(input_path), "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", str(output_path), ], capture_output=True, text=True, ) if r.returncode != 0: raise RuntimeError(f"ffmpeg fehlgeschlagen:\n{r.stderr}") return output_path def detect_audio_format(data: bytes) -> str: if data[:3] == b"ID3" or data[:2] in (b"\xff\xfb", b"\xff\xf3"): return "mp3" if data[:4] == b"fLaC": return "flac" if data[:4] == b"OggS": return "ogg" if data[:4] == b"RIFF": return "wav" if data[:4] == b"\x1aE\xdf\xa3": return "webm" return "unknown" def is_azure_direct_upload_supported(file_path: Path, fmt: str) -> bool: supported_exts = { ".wav", ".mp3", ".ogg", ".flac", ".webm", ".wma", ".aac", ".amr", ".speex", ".m4a", ".mp4", } supported_formats = {"wav", "mp3", "ogg", "flac", "webm"} return file_path.suffix.lower() in supported_exts or fmt in supported_formats # ── Transcription ────────────────────────────────────────────────────────────── def _azure_speech_config() -> tuple[str, str]: endpoint = os.getenv("AZURE_SPEECH_ENDPOINT", "").strip() key = ( os.getenv("AZURE_SPEECH_API_KEY", "").strip() or os.getenv("AZURE_SPEECH_KEY", "").strip() ) if not endpoint: raise RuntimeError("AZURE_SPEECH_ENDPOINT nicht gesetzt") if not key: raise RuntimeError("AZURE_SPEECH_KEY nicht gesetzt") return endpoint.rstrip("/"), key def _build_azure_definition() -> dict: diarization_enabled = ( os.getenv("AZURE_SPEECH_DIARIZATION_ENABLED", "true").lower() == "true" ) max_speakers = int(os.getenv("AZURE_SPEECH_MAX_SPEAKERS", "8")) definition = { "enhancedMode": { "enabled": True, "task": "transcribe", }, "profanityFilterMode": os.getenv("AZURE_SPEECH_PROFANITY_MODE", "Masked"), } prompt = os.getenv("AZURE_SPEECH_LLM_PROMPT", "").strip() if prompt: definition["enhancedMode"]["prompt"] = [prompt] if diarization_enabled: definition["diarization"] = { "enabled": True, "maxSpeakers": max(2, min(max_speakers, 35)), } return definition def _encode_multipart_form(fields: dict[str, str], file_field: str, file_path: Path) -> tuple[bytes, str]: boundary = f"----CodexBoundary{uuid.uuid4().hex}" body = bytearray() for name, value in fields.items(): body.extend(f"--{boundary}\r\n".encode("utf-8")) body.extend( f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("utf-8") ) body.extend(value.encode("utf-8")) body.extend(b"\r\n") mime_type = "audio/wav" if file_path.suffix.lower() == ".wav" else "application/octet-stream" body.extend(f"--{boundary}\r\n".encode("utf-8")) body.extend( ( f'Content-Disposition: form-data; name="{file_field}"; ' f'filename="{file_path.name}"\r\n' ).encode("utf-8") ) body.extend(f"Content-Type: {mime_type}\r\n\r\n".encode("utf-8")) body.extend(file_path.read_bytes()) body.extend(b"\r\n") body.extend(f"--{boundary}--\r\n".encode("utf-8")) return bytes(body), boundary def _transcribe_with_azure(audio_path: Path) -> dict: endpoint, api_key = _azure_speech_config() api_version = os.getenv("AZURE_SPEECH_API_VERSION", "2025-10-15") url = ( f"{endpoint}/speechtotext/transcriptions:transcribe" f"?api-version={api_version}" ) definition = json.dumps(_build_azure_definition(), ensure_ascii=False) body, boundary = _encode_multipart_form({"definition": definition}, "audio", audio_path) request = urllib_request.Request( url, data=body, method="POST", headers={ "Content-Type": f"multipart/form-data; boundary={boundary}", "Ocp-Apim-Subscription-Key": api_key, "Accept": "application/json", }, ) try: with urllib_request.urlopen(request, timeout=180) as response: return json.loads(response.read().decode("utf-8")) except urllib_error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"Azure Speech HTTP {exc.code}: {detail}") from exc except urllib_error.URLError as exc: raise RuntimeError(f"Azure Speech nicht erreichbar: {exc.reason}") from exc def _format_azure_transcript(result: dict) -> str: phrases = result.get("phrases") or [] lines = [] for phrase in phrases: text = (phrase.get("text") or "").strip() if not text: continue speaker = phrase.get("speaker") label = f"SPRECHER_{speaker}" if speaker is not None else "SPRECHER" lines.append(f"{label}: {text}") if lines: return "\n".join(lines) combined = result.get("combinedPhrases") or [] merged_text = "\n".join( (item.get("text") or "").strip() for item in combined if (item.get("text") or "").strip() ).strip() if merged_text: return f"SPRECHER: {merged_text}" raise RuntimeError("Azure Speech lieferte kein Transkript zurück") def transcribe_audio(file_path: Path) -> str: fmt = detect_audio_format(file_path.read_bytes()) needs_conversion = not is_azure_direct_upload_supported(file_path, fmt) audio_path = convert_to_wav(file_path) if needs_conversion else file_path try: print("Azure Speech LLM Transkription...") result = _transcribe_with_azure(audio_path) return _format_azure_transcript(result) finally: if needs_conversion: audio_path.unlink(missing_ok=True) 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", } guessed = Path(filename or "").suffix.strip() if guessed: return guessed if guessed.startswith(".") else 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 "" if content_type not in ALLOWED_CONTENT_TYPES and not content_type.startswith( "audio/" ): raise HTTPException( status_code=415, detail=f"Nicht unterstützter Typ: {content_type}" ) 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 helpers ───────────────────────────────────────────────────────────────── def _anthropic_client(): try: from anthropic import AnthropicFoundry except ModuleNotFoundError as exc: raise RuntimeError( "Python-Paket 'anthropic' nicht installiert. Bitte Abhaengigkeiten neu installieren." ) from exc base_url = ( os.getenv("AZURE_ANTHROPIC_BASE_URL", "").strip() or os.getenv("AZURE_EXISTING_AIPROJECT_ENDPOINT", "").strip() ) api_key = ( os.getenv("AZURE_ANTHROPIC_API_KEY", "").strip() or os.getenv("AZURE_API_KEY", "").strip() ) if not base_url: raise RuntimeError("AZURE_ANTHROPIC_BASE_URL nicht gesetzt") if not api_key: raise RuntimeError("AZURE_ANTHROPIC_API_KEY nicht gesetzt") normalized_base_url = re.sub(r"/v1/messages/?$", "", base_url.rstrip("/")) return AnthropicFoundry(api_key=api_key, base_url=normalized_base_url) def _summary_prompt() -> str: if not PROMPT_FILE.exists(): raise RuntimeError(f"{PROMPT_FILE} nicht gefunden") prompt = PROMPT_FILE.read_text(encoding="utf-8").strip() if not prompt: raise RuntimeError(f"{PROMPT_FILE} ist leer") return prompt def _anthropic_text(message) -> str: parts = [] for block in getattr(message, "content", []) or []: text = getattr(block, "text", None) if text: parts.append(text) return "\n".join(parts).strip() def _set_send_job(job_id: str, **updates): with SEND_JOBS_LOCK: job = SEND_JOBS.setdefault(job_id, {}) job.update(updates) def normalize_protocol_text(text: str) -> str: normalized_lines = [] for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): line = raw_line.strip() if not line: normalized_lines.append("") continue line = re.sub(r"^\s{0,3}#{1,6}\s*", "", line) line = line.replace("**", "").replace("__", "") line = re.sub(r"^\s*[-*]\s+", "• ", line) line = re.sub(r"^\s*\d+\.\s+", "", line) line = re.sub(r"\s+", " ", line).strip() normalized_lines.append(line) text = "\n".join(normalized_lines) text = re.sub(r"\n{3,}", "\n\n", text).strip() current_date = datetime.now().strftime("%d.%m.%Y") text = re.sub(r"\[\s*Datum[^\]]*\]", current_date, text, flags=re.IGNORECASE) text = re.sub( r"^Meeting vom\s*\|\s*", f"Meeting vom {current_date} | ", text, count=1, flags=re.MULTILINE, ) text = re.sub( r"^(Meeting vom)\s*(?:\[\s*Datum[^\]]*\]|fehlt|nicht vorhanden|unbekannt)?\s*\|", rf"\1 {current_date} |", text, count=1, flags=re.IGNORECASE | re.MULTILINE, ) return text def summarize_with_ai(transcript: str, title: str) -> str: """Erstellt das Protokoll via Claude Sonnet 4.6 aus Azure Foundry.""" client = _anthropic_client() prompt = _summary_prompt() model = os.getenv("AZURE_ANTHROPIC_MODEL", "claude-sonnet-4-6") message = client.messages.create( model=model, system=prompt, messages=[ { "role": "user", "content": f"Meeting-Titel: {title}\n\nGesprächsprotokoll:\n{transcript}", } ], max_tokens=int(os.getenv("AZURE_ANTHROPIC_MAX_TOKENS", "4096")), temperature=float(os.getenv("AZURE_ANTHROPIC_TEMPERATURE", "0.2")), stream=False, ) content = _anthropic_text(message) if not content: raise RuntimeError("Claude hat keine Zusammenfassung zurückgegeben") return normalize_protocol_text(content) def build_email_html(title: str, summary: str) -> str: escaped_title = html.escape(title) return f"""\
""" # ── PDF generation ───────────────────────────────────────────────────────────── def _safe_filename(title: str) -> str: """Erstellt einen sicheren Dateinamen aus dem Meeting-Titel.""" safe = re.sub(r"[^\w\-äöüÄÖÜß ]", "", title).strip() safe = re.sub(r"\s+", "_", safe) return safe or "Transkript" def build_pdf(title: str, summary: str, transcript: str) -> bytes: """Erzeugt ein Rotary-Protokoll-PDF nach dem Stil des Beispiel-Dokuments.""" from fpdf import FPDF FONT_DIR = Path("/usr/share/fonts/truetype/noto") F_REG = str(FONT_DIR / "NotoSans-Regular.ttf") F_BOLD = str(FONT_DIR / "NotoSans-Bold.ttf") F_ITAL = str(FONT_DIR / "NotoSans-Italic.ttf") C_BLUE = (18, 63, 132) C_BLUE_LIGHT = (58, 92, 150) C_ORANGE = (241, 157, 38) C_TEXT = (0, 0, 0) C_MUTED = (105, 112, 125) C_RULE = (172, 180, 192) C_BG = (255, 255, 255) protocol_text = normalize_protocol_text(summary) def classify_line(line: str) -> str: if line == "Protokoll": return "protocol_title" if line.startswith("Meeting vom "): return "meeting_title" if line.startswith("(") and line.endswith(")"): return "meeting_subtitle" if line.startswith("• "): return "bullet" if len(line) <= 80 and re.match(r"^[A-ZÄÖÜ][A-Za-zÄÖÜäöüß0-9 /()\-,:]+$", line): return "heading" return "paragraph" structured_lines = [ (classify_line(line), line) for line in protocol_text.split("\n") if line.strip() ] header_lines = [] body_lines = [] protocol_started = False for kind, line in structured_lines: if not protocol_started and kind != "protocol_title": header_lines.append((kind, line)) continue protocol_started = True body_lines.append((kind, line)) header_text = "\n".join(line for _, line in header_lines).strip() logo_path = str(ROTARY_LOGO_FILE) if ROTARY_LOGO_FILE.exists() else None class PDF(FPDF): def __init__(self): super().__init__() self.alias_nb_pages() self.add_font("Noto", "", F_REG, uni=True) self.add_font("Noto", "B", F_BOLD, uni=True) self.add_font("Noto", "I", F_ITAL, uni=True) self.set_title(title) def header(self): top_y = 16 if self.page_no() == 1 else 14 logo_w = 52 if self.page_no() == 1 else 44 logo_gap = 10 block_w = self.w - self.l_margin - self.r_margin - logo_w - logo_gap self.set_xy(self.l_margin, top_y) self.set_font("Noto", "", 10.5 if self.page_no() == 1 else 8.7) self.set_text_color(*C_BLUE) line_h = 4.45 if self.page_no() == 1 else 3.9 self.multi_cell(block_w, line_h, header_text) text_bottom = self.get_y() if logo_path: logo_x = self.w - self.r_margin - logo_w logo_y = top_y self.image(logo_path, x=logo_x, y=logo_y, w=logo_w) else: self.set_xy(self.w - self.r_margin - 56, top_y) self.set_font("Noto", "B", 16 if self.page_no() == 1 else 13) self.set_text_color(*C_BLUE) self.cell(44, 6, "Rotary", align="R") self.set_text_color(*C_ORANGE) self.cell(12, 6, "O", align="R") self.set_xy(self.w - self.r_margin - 56, top_y + 8) self.set_font("Noto", "", 10 if self.page_no() == 1 else 8.5) self.set_text_color(*C_BLUE_LIGHT) self.cell(56, 5, "Club Ravensburg", align="R") logo_bottom = top_y + (logo_w * 94 / 310 if logo_path else 14) self.set_y(max(text_bottom, logo_bottom) + (8 if self.page_no() == 1 else 6)) def footer(self): self.set_y(-20) self.set_draw_color(*C_RULE) self.set_line_width(0.3) self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y()) self.ln(3) self.set_font("Noto", "", 8) self.set_text_color(*C_TEXT) self.cell(28, 5, datetime.now().strftime("%d.%m.%Y"), align="L") right_w = 28 center_w = self.w - self.l_margin - self.r_margin - 28 - right_w self.cell(center_w, 5, "Rotary Club Ravensburg", align="C") self.cell(right_w, 5, f"{self.page_no()} von {{nb}}", align="C", new_x="LMARGIN", new_y="NEXT") self.set_x(28 + self.l_margin) self.cell(center_w, 4.8, "https://ravensburg.rotary.de | Rotary Distrikt 1930", align="C") pdf = PDF() pdf.set_margins(22, 18, 22) pdf.set_auto_page_break(auto=True, margin=20) pdf.add_page() pdf.set_fill_color(*C_BG) pdf.set_text_color(*C_TEXT) for kind, line in body_lines: if kind == "meeting_title": pdf.set_font("Noto", "", 11) pdf.set_text_color(*C_BLUE) pdf.multi_cell(0, 4.3, line) pdf.ln(1) elif kind == "meeting_subtitle": pdf.set_font("Noto", "", 10.5) pdf.set_text_color(*C_BLUE) pdf.multi_cell(0, 4.3, line) pdf.ln(3) elif kind == "protocol_title": pdf.set_font("Noto", "B", 22) pdf.set_text_color(*C_BLUE) pdf.multi_cell(0, 9, line) pdf.ln(7) elif kind == "heading": pdf.ln(1.8) pdf.set_font("Noto", "B", 12.5) pdf.set_text_color(*C_TEXT) pdf.multi_cell(0, 5.3, line) pdf.ln(3.6) elif kind == "bullet": pdf.set_font("Noto", "", 10.4) pdf.set_text_color(*C_TEXT) pdf.set_x(pdf.l_margin + 2) pdf.multi_cell(0, 5.15, line) pdf.ln(0.8) else: pdf.set_font("Noto", "", 10.4) pdf.set_text_color(*C_TEXT) pdf.multi_cell(0, 5.05, line) pdf.ln(1.8) return bytes(pdf.output()) # ── Email / file output ──────────────────────────────────────────────────────── def deliver( recipients: list[str], title: str, summary: str, transcript: str, ) -> bool: """Erstellt PDF und sendet es per E-Mail oder speichert es als Datei.""" clean_summary = normalize_protocol_text(summary) pdf_bytes = build_pdf(title, clean_summary, transcript) safe_title = _safe_filename(title) date_str = datetime.now().strftime("%Y-%m-%d") pdf_name = f"{date_str}_{safe_title}.pdf" 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 kann ein Anzeigename sein — wir stellen sicher dass eine # gültige Absenderadresse verwendet wird: "Name