commit f38dde818896dba1a234e9b1c26344da731a5c54 Author: RubenRWU Date: Mon Mar 9 13:31:30 2026 +0100 added self sign up and email verification diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa23a7d --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +OPENAI_API_KEY=your_openai_api_key_here +HF_TOKEN=your_huggingface_token_here +ENABLE_SPEAKER_DIARIZATION=false +SMTP_HOST=smtp.ionos.de +SMTP_PORT=587 +SMTP_USER=ruben.fischer@terrarum.de +SMTP_PASSWORD=ALF_1123RuFi +SMTP_FROM=Onyva LinkedIn Post System \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b114865 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python + +# Virtual Environment +.venv/ +venv/ +env/ + +# Environment / Secrets +.env + +# Projektdaten (Uploads & Transkripte mit persönlichen Daten) +data/ +output/ + +# Große Binärdateien +*.tar.xz +*.tar.gz +*.zip + +# IDE +.idea/ +.vscode/ +*.iml + +# Linter / Tool Caches +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ + +# Build +dist/ +build/ +*.egg-info/ diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..abc9fd1 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,140 @@ +# Transcribe App Specification + +## Project Overview +- **Name**: Transcribe App +- **Type**: Web Application (FastAPI + Vanilla JS) +- **Core Functionality**: Voice recording/transcription with AI processing and email delivery +- **Target Users**: Users who need to transcribe voice notes and receive processed text via email + +## Tech Stack +- **Backend**: FastAPI (Python) +- **Speech-to-Text**: OpenAI Whisper API +- **AI Processing**: OpenAI GPT-4o +- **Email**: SMTP with environment variables +- **Frontend**: Vanilla HTML/CSS/JS + +## UI/UX Specification + +### Layout Structure +- Single page application +- Centered content container (max-width: 600px) +- Sections: Header, Upload/Record area, Email input, Status display + +### Visual Design + +#### Color Palette +- **Background**: #000000 (black) +- **Primary Text**: #FFFFFF (white) +- **Secondary Text**: #888888 (gray) +- **Accent**: #FFFFFF (white) +- **Border**: #333333 (dark gray) +- **Hover/Active**: #1A1A1A (near black) +- **Success**: #00FF00 (green) +- **Error**: #FF0000 (red) + +#### Typography +- **Font Family**: "JetBrains Mono", monospace (Google Fonts) +- **Heading**: 24px, bold +- **Body**: 14px, regular +- **Labels**: 12px, uppercase, letter-spacing: 2px + +#### Spacing +- Container padding: 40px +- Section gap: 30px +- Element gap: 16px + +### Components + +#### Drop Zone +- Dashed border (#333333) +- Height: 200px +- Border radius: 8px +- States: default, hover (border: #FFFFFF), dragover (background: #1A1A1A) +- Icon: microphone SVG +- Text: "MP3 hierher ziehen oder klicken zum Hochladen" + +#### Record Button +- Circular, 80px diameter +- White border, transparent background +- Microphone icon inside +- States: default, recording (pulsing red animation), disabled +- Text below: "Aufnahme starten" / "Aufnahme stoppen" + +#### Email Input +- Full width text input +- Black background, white border +- Placeholder: "E-MAIL-ADRESSE" +- Email validation indicator + +#### Process Indicator +- Steps displayed vertically: + 1. "Hochladen..." / "✓ Hochgeladen" + 2. "Transkribieren..." / "✓ Transkribiert" + 3. "KI-Verarbeitung..." / "✓ Verarbeitet" + 4. "E-Mail senden..." / "✓ Gesendet" +- Each step with icon, timestamp + +#### Send Button +- Full width, white background, black text +- Height: 48px +- States: default, hover (gray #CCCCCC), disabled, loading + +## Functionality Specification + +### Core Features + +#### 1. File Upload (Drag & Drop) +- Accept only .mp3 files +- Max file size: 25MB +- Show progress during upload +- Validate file type before upload + +#### 2. Voice Recording +- Use MediaRecorder API (browser) +- Record in webm/opus format (convert to mp3 on server) +- Show recording duration timer +- Playback recorded audio before submission + +#### 3. Transcription +- Use OpenAI Whisper API +- Support German language +- Return timestamped segments + +#### 4. AI Processing +- Use OpenAI GPT-4o +- Improve transcription: + - Fix grammar and spelling + - Add proper punctuation + - Structure into paragraphs + - Maintain original meaning + +#### 5. Email Delivery +- Send processed text via SMTP +- Include original and processed text +- Email format: plain text + +### API Endpoints +- `POST /upload` - Upload audio file +- `POST /transcribe` - Transcribe audio +- `POST /process` - AI process text +- `POST /send-email` - Send email + +### Environment Variables +- OPENAI_API_KEY +- SMTP_HOST +- SMTP_PORT +- SMTP_USER +- SMTP_PASSWORD +- SMTP_FROM + +## Acceptance Criteria + +1. ✓ User can drag & drop MP3 file onto drop zone +2. ✓ User can click to browse and select MP3 file +3. ✓ User can start and stop voice recording +4. ✓ User can play back recording before submitting +5. ✓ User can enter email address +6. ✓ App shows step-by-step progress +7. ✓ Final email is sent with processed text +8. ✓ Clean black/white design throughout +9. ✓ Responsive on mobile devices \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..ac2e93f --- /dev/null +++ b/index.html @@ -0,0 +1,1330 @@ + + + + + +Transcribe + + + + + + + + +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+
Datei hierher ziehen
oder klicken zum Auswählen
+
MP3 · WAV · WebM · OGG · FLAC
+ +
+
+ +
oder
+ +
+ + +
Aufnahme starten
+ +
+ + + +
+
+
+ Hochladen +
+
+
+ Transkribieren +
+
+
+ KI-Verarbeitung +
+
+ +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
Sprecher
+
+
+
+ + +
+
+
+ +
+
Transkript
+
+
+
+
+ +
+ + + +
+
+ + + + + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..87b01d9 --- /dev/null +++ b/main.py @@ -0,0 +1,594 @@ +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 " 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..621cbb4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.135.1 +openai==2.24.0 +python-multipart==0.0.22 +uvicorn==0.41.0 +whisperx==3.8.1 +torch==2.8.0 +pyannote.audio>=3.3 +python-dotenv==1.2.2 +fpdf2