Files
MeetingTranskrip/main.py
2026-04-07 15:08:56 +02:00

772 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
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)