added self sign up and email verification
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -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
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -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/
|
||||||
140
SPEC.md
Normal file
140
SPEC.md
Normal file
@@ -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
|
||||||
1330
index.html
Normal file
1330
index.html
Normal file
File diff suppressed because it is too large
Load Diff
594
main.py
Normal file
594
main.py
Normal file
@@ -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 <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)
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user