595 lines
20 KiB
Python
595 lines
20 KiB
Python
import io
|
||
import os
|
||
import re
|
||
import smtplib
|
||
import subprocess
|
||
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 dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
os.environ["TORCH_CODEC_ENABLED"] = "0"
|
||
warnings.filterwarnings("ignore", category=UserWarning, module="pyannote.audio")
|
||
warnings.filterwarnings("ignore", category=UserWarning, module="torchaudio")
|
||
|
||
from fastapi import FastAPI, UploadFile, File, Form, Body, HTTPException
|
||
from fastapi.responses import HTMLResponse
|
||
import whisperx
|
||
import torch
|
||
|
||
MAX_FILE_SIZE = 25 * 1024 * 1024
|
||
ALLOWED_CONTENT_TYPES = {
|
||
"audio/mpeg",
|
||
"audio/mp3",
|
||
"audio/webm",
|
||
"audio/ogg",
|
||
"audio/wav",
|
||
"video/webm",
|
||
}
|
||
|
||
DATA_DIR = Path("data")
|
||
DATA_DIR.mkdir(exist_ok=True)
|
||
OUTPUT_DIR = Path("output")
|
||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||
|
||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||
compute_type = "float32" if device == "cpu" else "float16"
|
||
|
||
_whisper_model = None
|
||
_align_model = None
|
||
_align_metadata = None
|
||
|
||
|
||
# ── Model loading ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def get_whisper_model():
|
||
global _whisper_model
|
||
if _whisper_model is None:
|
||
print(f"Lade WhisperX Modell large-v3 auf {device} ({compute_type})...")
|
||
_whisper_model = whisperx.load_model(
|
||
"large-v3", device=device, compute_type=compute_type
|
||
)
|
||
print("WhisperX Modell geladen.")
|
||
return _whisper_model
|
||
|
||
|
||
def get_align_model():
|
||
global _align_model, _align_metadata
|
||
if _align_model is None:
|
||
print("Lade Alignment Modell (de)...")
|
||
_align_model, _align_metadata = whisperx.load_align_model(
|
||
language_code="de", device=device
|
||
)
|
||
print("Alignment Modell geladen.")
|
||
return _align_model, _align_metadata
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
print(f"Server startet – lade Modelle auf {device}...")
|
||
get_whisper_model()
|
||
get_align_model()
|
||
print("Alle Modelle geladen – Server bereit!")
|
||
yield
|
||
print("Server fährt herunter.")
|
||
|
||
|
||
app = FastAPI(lifespan=lifespan)
|
||
|
||
|
||
# ── Audio helpers ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def convert_to_mp3(input_path: Path) -> Path:
|
||
output_path = input_path.with_suffix(".converted.mp3")
|
||
r = subprocess.run(
|
||
[
|
||
"ffmpeg",
|
||
"-y",
|
||
"-i",
|
||
str(input_path),
|
||
"-acodec",
|
||
"libmp3lame",
|
||
"-q:a",
|
||
"2",
|
||
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"
|
||
|
||
|
||
# ── Transcription ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def transcribe_audio(file_path: Path) -> str:
|
||
whisper_model = get_whisper_model()
|
||
align_model, align_metadata = get_align_model()
|
||
|
||
fmt = detect_audio_format(file_path.read_bytes())
|
||
needs_conversion = (
|
||
fmt in ("webm", "ogg", "flac") or file_path.suffix.lower() != ".mp3"
|
||
)
|
||
audio_path = convert_to_mp3(file_path) if needs_conversion else file_path
|
||
audio_str = str(audio_path)
|
||
|
||
print("Transkription...")
|
||
result = whisper_model.transcribe(audio_str, language="de")
|
||
print("Alignment...")
|
||
result = whisperx.align(
|
||
result["segments"], align_model, align_metadata, audio_str, device=device
|
||
)
|
||
|
||
if os.getenv("ENABLE_SPEAKER_DIARIZATION", "true").lower() == "true":
|
||
try:
|
||
print("Diarization...")
|
||
from whisperx.diarize import DiarizationPipeline
|
||
|
||
pipeline = DiarizationPipeline(token=os.getenv("HF_TOKEN"), device=device)
|
||
diarize_segments = pipeline(audio_str)
|
||
result = whisperx.assign_word_speakers(diarize_segments, result)
|
||
print("Diarization fertig.")
|
||
except Exception as e:
|
||
print(f"Diarization fehlgeschlagen: {e}")
|
||
|
||
if needs_conversion:
|
||
audio_path.unlink(missing_ok=True)
|
||
|
||
return "\n".join(
|
||
f"{seg.get('speaker', 'SPRECHER')}: {seg['text'].strip()}"
|
||
for seg in result["segments"]
|
||
if seg["text"].strip()
|
||
)
|
||
|
||
|
||
# ── AI helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _openai_client():
|
||
from openai import OpenAI
|
||
|
||
key = os.getenv("OPENAI_API_KEY")
|
||
if not key:
|
||
raise RuntimeError("OPENAI_API_KEY nicht gesetzt")
|
||
return OpenAI(api_key=key)
|
||
|
||
|
||
def process_with_ai(text: str) -> str:
|
||
"""Grammatik- und Interpunktionskorrektur, Sprecher-Labels bleiben."""
|
||
client = _openai_client()
|
||
r = client.chat.completions.create(
|
||
model="gpt-4o",
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
"Du bist ein Assistent zur Nachbearbeitung von Sprachtranskripten. "
|
||
"Verbessere Grammatik, Rechtschreibung und Interpunktion. "
|
||
"Behalte die Sprecher-Labels (z.B. 'Max Mustermann:') exakt bei. "
|
||
"Verändere die inhaltliche Bedeutung NICHT. "
|
||
"Format: Sprecher: Text"
|
||
),
|
||
},
|
||
{"role": "user", "content": text},
|
||
],
|
||
)
|
||
return r.choices[0].message.content or ""
|
||
|
||
|
||
def summarize_with_ai(transcript: str, title: str) -> str:
|
||
"""Erstellt eine strukturierte Meeting-Zusammenfassung ohne Markdown."""
|
||
client = _openai_client()
|
||
r = client.chat.completions.create(
|
||
model="gpt-4o",
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
"Du bist ein Assistent für Meeting-Protokolle. "
|
||
"Erstelle eine strukturierte Zusammenfassung des Meeting-Transkripts. "
|
||
"Gliedere immer in genau diese drei Abschnitte, auch wenn das Transkript kurz oder unvollständig ist – "
|
||
"fasse dann zusammen was vorhanden ist:\n\n"
|
||
"Kernthemen:\n[Was wurde besprochen]\n\n"
|
||
"Entscheidungen:\n[Getroffene Entscheidungen, falls keine: 'Keine Entscheidungen getroffen.']\n\n"
|
||
"Nächste Schritte:\n[Offene Punkte und Aufgaben, falls keine: 'Keine Folgeaufgaben definiert.']\n\n"
|
||
"Wichtige Regeln:\n"
|
||
"- Kein Markdown, keine Sterne, keine Rauten, keine Aufzählungszeichen mit Bindestrich\n"
|
||
"- Nur Fließtext und Zeilenumbrüche\n"
|
||
"- Professionelles Deutsch\n"
|
||
"- Immer alle drei Abschnitte ausgeben, egal wie kurz das Transkript ist"
|
||
),
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": f"Meeting: {title}\n\nTranskript:\n{transcript}",
|
||
},
|
||
],
|
||
)
|
||
return r.choices[0].message.content or ""
|
||
|
||
|
||
# ── 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:
|
||
"""Professionelles PDF: heller Hintergrund, Noto Sans, saubere Typografie."""
|
||
from fpdf import FPDF
|
||
|
||
# Font paths
|
||
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")
|
||
|
||
# Colour palette (fully print-friendly, dark on white)
|
||
C_HEADER_BG = (248, 248, 250) # very light grey header band
|
||
C_HEADER_FG = (20, 20, 30) # near-black title
|
||
C_HEADER_SUB = (120, 120, 135) # label / date in header
|
||
C_HEADER_LINE = (200, 200, 210) # bottom border of header band
|
||
C_LABEL = (130, 130, 145) # section labels
|
||
C_RULE = (220, 220, 228) # horizontal rules
|
||
C_BODY = (30, 30, 40) # main body text
|
||
C_SUMMARY = (55, 55, 70) # summary text
|
||
C_FOOTER = (160, 160, 175)
|
||
|
||
# Speaker accent colours (left border + name) — muted, print-safe
|
||
SPEAKER_PALETTE = [
|
||
(51, 102, 187), # blue
|
||
(180, 60, 60), # red
|
||
(40, 140, 80), # green
|
||
(140, 70, 170), # purple
|
||
(190, 120, 20), # amber
|
||
(20, 150, 160), # teal
|
||
(190, 80, 120), # rose
|
||
(80, 100, 180), # indigo
|
||
]
|
||
|
||
date_str = datetime.now().strftime("%d. %B %Y")
|
||
|
||
class PDF(FPDF):
|
||
def __init__(self):
|
||
super().__init__()
|
||
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)
|
||
|
||
def header(self):
|
||
if self.page_no() == 1:
|
||
return # cover drawn manually
|
||
# Running header on pages 2+
|
||
self.set_font("Noto", "", 8)
|
||
self.set_text_color(*C_LABEL)
|
||
self.cell(0, 6, title, align="L")
|
||
self.cell(0, 6, date_str, align="R", new_x="LMARGIN", new_y="NEXT")
|
||
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(5)
|
||
|
||
def footer(self):
|
||
self.set_y(-14)
|
||
self.set_font("Noto", "", 8)
|
||
self.set_text_color(*C_FOOTER)
|
||
self.cell(0, 8, f"{self.page_no()}", align="C")
|
||
|
||
pdf = PDF()
|
||
pdf.set_margins(22, 28, 22)
|
||
pdf.set_auto_page_break(auto=True, margin=20)
|
||
|
||
# ── PAGE 1: Cover block ───────────────────────────────────────────────────
|
||
pdf.add_page()
|
||
|
||
# Light header band
|
||
pdf.set_fill_color(*C_HEADER_BG)
|
||
pdf.rect(0, 0, pdf.w, 52, style="F")
|
||
# Bottom border line
|
||
pdf.set_draw_color(*C_HEADER_LINE)
|
||
pdf.set_line_width(0.4)
|
||
pdf.line(0, 52, pdf.w, 52)
|
||
|
||
# "MEETING-PROTOKOLL" label
|
||
pdf.set_xy(22, 13)
|
||
pdf.set_font("Noto", "", 8)
|
||
pdf.set_text_color(*C_HEADER_SUB)
|
||
pdf.cell(0, 5, "MEETING-PROTOKOLL")
|
||
|
||
# Title
|
||
pdf.set_xy(22, 21)
|
||
pdf.set_font("Noto", "B", 20)
|
||
pdf.set_text_color(*C_HEADER_FG)
|
||
pdf.cell(0, 10, title)
|
||
|
||
# Date
|
||
pdf.set_xy(22, 38)
|
||
pdf.set_font("Noto", "", 9)
|
||
pdf.set_text_color(*C_HEADER_SUB)
|
||
pdf.cell(0, 5, date_str)
|
||
|
||
pdf.set_y(62) # below header band
|
||
|
||
# ── SUMMARY SECTION ───────────────────────────────────────────────────────
|
||
if summary:
|
||
# Section label
|
||
pdf.set_font("Noto", "B", 8)
|
||
pdf.set_text_color(*C_LABEL)
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.cell(0, 5, "ZUSAMMENFASSUNG", new_x="LMARGIN", new_y="NEXT")
|
||
# Rule
|
||
pdf.set_draw_color(*C_RULE)
|
||
pdf.set_line_width(0.3)
|
||
pdf.line(pdf.l_margin, pdf.get_y() + 1, pdf.w - pdf.r_margin, pdf.get_y() + 1)
|
||
pdf.ln(5)
|
||
|
||
# Summary text — render each paragraph
|
||
body_w = pdf.w - pdf.l_margin - pdf.r_margin
|
||
pdf.set_font("Noto", "", 10)
|
||
pdf.set_text_color(*C_SUMMARY)
|
||
for para in summary.strip().split("\n"):
|
||
para = para.strip()
|
||
if not para:
|
||
pdf.ln(3)
|
||
continue
|
||
pdf.set_x(pdf.l_margin)
|
||
# Section headers inside summary (e.g. "Kernthemen:")
|
||
if para.endswith(":") and len(para) < 50:
|
||
pdf.ln(2)
|
||
pdf.set_font("Noto", "B", 10)
|
||
pdf.set_text_color(*C_BODY)
|
||
pdf.multi_cell(body_w, 6, para)
|
||
pdf.set_font("Noto", "", 10)
|
||
pdf.set_text_color(*C_SUMMARY)
|
||
else:
|
||
pdf.multi_cell(body_w, 6, para)
|
||
pdf.ln(10)
|
||
|
||
# ── TRANSCRIPT SECTION ────────────────────────────────────────────────────
|
||
pdf.set_font("Noto", "B", 8)
|
||
pdf.set_text_color(*C_LABEL)
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.cell(0, 5, "TRANSKRIPT", new_x="LMARGIN", new_y="NEXT")
|
||
pdf.set_draw_color(*C_RULE)
|
||
pdf.set_line_width(0.3)
|
||
pdf.line(pdf.l_margin, pdf.get_y() + 1, pdf.w - pdf.r_margin, pdf.get_y() + 1)
|
||
pdf.ln(6)
|
||
|
||
for line in transcript.split("\n"):
|
||
if not line.strip():
|
||
pdf.ln(2)
|
||
continue
|
||
|
||
colon = line.find(":")
|
||
if colon == -1:
|
||
pdf.set_font("Noto", "", 10)
|
||
pdf.set_text_color(*C_BODY)
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.multi_cell(pdf.w - pdf.l_margin - pdf.r_margin, 6, line.strip())
|
||
pdf.ln(1)
|
||
continue
|
||
|
||
speaker = line[:colon].strip()
|
||
text = line[colon + 1 :].strip()
|
||
if not text:
|
||
continue
|
||
|
||
# Speaker name bold, then text inline — no colour, no background
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.set_font("Noto", "B", 10)
|
||
pdf.set_text_color(*C_BODY)
|
||
name_label = speaker + ": "
|
||
name_w = pdf.get_string_width(name_label)
|
||
|
||
y = pdf.get_y()
|
||
pdf.set_xy(pdf.l_margin, y)
|
||
pdf.cell(name_w, 5, name_label)
|
||
|
||
text_x = pdf.l_margin + name_w
|
||
text_w = pdf.w - pdf.r_margin - text_x
|
||
pdf.set_font("Noto", "", 10)
|
||
pdf.set_xy(text_x, y)
|
||
pdf.multi_cell(text_w, 5, text)
|
||
|
||
pdf.ln(0.5)
|
||
|
||
return bytes(pdf.output())
|
||
|
||
|
||
# ── Email / file output ────────────────────────────────────────────────────────
|
||
|
||
|
||
def deliver(
|
||
to_email: str,
|
||
title: str,
|
||
summary: str,
|
||
transcript: str,
|
||
) -> bool:
|
||
"""Erstellt PDF und sendet es per E-Mail oder speichert es als Datei."""
|
||
pdf_bytes = build_pdf(title, 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"--- Zusammenfassung ---\n\n{summary}\n\n"
|
||
f"Das vollständige Transkript ist als PDF angehängt."
|
||
)
|
||
|
||
msg = MIMEMultipart()
|
||
msg["Subject"] = f"Meeting-Protokoll: {title}"
|
||
msg["From"] = smtp_from
|
||
msg["To"] = to_email
|
||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||
|
||
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(file: UploadFile = File(...)):
|
||
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:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"Datei zu groß ({len(content) // 1024 // 1024} MB). Max 25 MB",
|
||
)
|
||
if len(content) == 0:
|
||
raise HTTPException(status_code=400, detail="Leere Datei")
|
||
|
||
ext_map = {
|
||
"audio/mpeg": ".mp3",
|
||
"audio/mp3": ".mp3",
|
||
"audio/webm": ".webm",
|
||
"video/webm": ".webm",
|
||
"audio/ogg": ".ogg",
|
||
"audio/wav": ".wav",
|
||
"audio/flac": ".flac",
|
||
}
|
||
ext = ext_map.get(content_type, ".audio")
|
||
file_id = str(uuid.uuid4())
|
||
file_path = DATA_DIR / f"{file_id}{ext}"
|
||
file_path.write_bytes(content)
|
||
return {"file_id": file_id, "filename": file.filename, "size": len(content)}
|
||
|
||
|
||
@app.post("/transcribe")
|
||
async def transcribe(body: dict = Body(...)):
|
||
file_id = body.get("file_id")
|
||
if not file_id:
|
||
raise HTTPException(status_code=400, detail="file_id required")
|
||
|
||
matches = [m for m in DATA_DIR.glob(f"{file_id}.*") if ".converted." not in m.name]
|
||
if not matches:
|
||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||
|
||
try:
|
||
text = transcribe_audio(matches[0])
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500, detail=f"Transkription fehlgeschlagen: {e}"
|
||
)
|
||
|
||
return {"transcription": text}
|
||
|
||
|
||
@app.post("/process")
|
||
async def process_text(text: str = Form(...)):
|
||
if not text.strip():
|
||
raise HTTPException(status_code=400, detail="Kein Text")
|
||
try:
|
||
return {"processed": process_with_ai(text)}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@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(
|
||
email: str = Form(...),
|
||
title: str = Form(...),
|
||
summary: str = Form(...),
|
||
transcript: str = Form(...),
|
||
):
|
||
"""Generiert PDF und sendet/speichert es."""
|
||
if not email or "@" not in email:
|
||
raise HTTPException(status_code=400, detail="Ungültige E-Mail")
|
||
try:
|
||
deliver(email, title or "Meeting", summary, transcript)
|
||
return {"success": True}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
|
||
uvicorn.run(app, host="0.0.0.0", port=8000)
|