772 lines
26 KiB
Python
772 lines
26 KiB
Python
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"""\
|
||
<!doctype html>
|
||
<html lang="de">
|
||
<body style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,Helvetica,sans-serif;">
|
||
<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #d9e1ee;border-radius:16px;overflow:hidden;">
|
||
<div style="padding:22px 26px;background:#1f5aa6;">
|
||
<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#dbe7f8;margin-bottom:8px;">Rotary Club Ravensburg</div>
|
||
<div style="font-size:24px;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:#24364b;">Im Anhang finden Sie das Protokoll als PDF.</p>
|
||
<p style="margin:0;font-size:15px;line-height:1.7;color:#24364b;">Freundliche Grüße<br>Rotary Club Ravensburg</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# ── 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 <email>" oder nur die E-Mail
|
||
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]):
|
||
# Fallback: Datei speichern
|
||
out = OUTPUT_DIR / pdf_name
|
||
out.write_bytes(pdf_bytes)
|
||
print(f"SMTP nicht konfiguriert – PDF gespeichert: {out}")
|
||
return True
|
||
|
||
body = (
|
||
f"Meeting: {title}\n"
|
||
f"Datum: {datetime.now().strftime('%d.%m.%Y')}\n\n"
|
||
f"Im Anhang finden Sie das Protokoll als PDF."
|
||
)
|
||
html_body = build_email_html(title, clean_summary)
|
||
|
||
msg = MIMEMultipart("mixed")
|
||
msg["Subject"] = f"Meeting-Protokoll: {title}"
|
||
msg["From"] = smtp_from
|
||
msg["To"] = smtp_from if len(recipients) > 1 else recipients[0]
|
||
if len(recipients) > 1:
|
||
msg["Bcc"] = ", ".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)
|
||
|
||
with smtplib.SMTP(str(smtp_host), smtp_port) as server:
|
||
server.starttls()
|
||
server.login(str(smtp_user), str(smtp_password))
|
||
server.send_message(msg)
|
||
|
||
return True
|
||
|
||
|
||
# ── API Endpoints ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def get_index():
|
||
return Path(__file__).parent.joinpath("index.html").read_text(encoding="utf-8")
|
||
|
||
|
||
@app.post("/upload")
|
||
async def upload_audio():
|
||
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(file: UploadFile = File(...)):
|
||
temp_path, size = await _persist_upload_temporarily(file)
|
||
|
||
try:
|
||
text = transcribe_audio(temp_path)
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500, detail=f"Transkription fehlgeschlagen: {e}"
|
||
)
|
||
finally:
|
||
temp_path.unlink(missing_ok=True)
|
||
|
||
return {"transcription": text, "filename": file.filename, "size": size}
|
||
|
||
|
||
def _run_send_job(job_id: str, recipients: list[str], title: str, transcript: str):
|
||
try:
|
||
_set_send_job(job_id, status="running")
|
||
summary = summarize_with_ai(transcript, title or "Meeting")
|
||
deliver(recipients, title or "Meeting", summary, transcript)
|
||
_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(transcript: str = Form(...), title: str = Form(...)):
|
||
"""Erstellt eine KI-Zusammenfassung des finalen Transkripts."""
|
||
if not transcript.strip():
|
||
raise HTTPException(status_code=400, detail="Kein Transkript")
|
||
try:
|
||
return {"summary": summarize_with_ai(transcript, title or "Meeting")}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@app.post("/send")
|
||
async def send(
|
||
background_tasks: BackgroundTasks,
|
||
emails: str = Form(...),
|
||
title: str = Form(...),
|
||
transcript: str = Form(...),
|
||
):
|
||
"""Queuet die Protokollerstellung und den Versand im Hintergrund."""
|
||
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 "Meeting", transcript)
|
||
return {"success": True, "job_id": job_id, "status": "queued"}
|
||
|
||
|
||
@app.get("/send-status/{job_id}")
|
||
async def send_status(job_id: str):
|
||
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
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
|
||
port = int(os.getenv("PORT", "8276"))
|
||
uvicorn.run(app, host="0.0.0.0", port=port)
|