diff --git a/TRANSCRIPTION_TOOL.md b/TRANSCRIPTION_TOOL.md index 60bbcd10..2570beed 100644 --- a/TRANSCRIPTION_TOOL.md +++ b/TRANSCRIPTION_TOOL.md @@ -1,85 +1,55 @@ # Meeting Assistant (Transcription Tool) -**Version:** 0.4.0 -**Status:** Beta (Functional with Editing) +**Version:** 0.5.0 +**Status:** Beta (Full Content Management) -Der **Meeting Assistant** ist ein lokaler Micro-Service zur Transkription und Analyse von Audio-Dateien (Meetings, Calls, Interviews). Er kombiniert die Datensicherheit einer lokalen Datenhaltung mit der Leistungsfähigkeit von Googles **Gemini 2.0 Flash** Modell für kostengünstige, hochqualitative Speech-to-Text Umwandlung. +Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und Bearbeitung von Audio-Aufnahmen. Er kombiniert lokale FFmpeg-Verarbeitung mit der Gemini 2.0 Flash AI. --- -## 1. Architektur +## 1. Architektur & Stack -Der Service folgt dem "Sidecar"-Pattern im Docker-Stack und ist vollständig in das Dashboard integriert. - -* **Frontend:** React (Vite + Tailwind) unter `/tr/`. -* **Backend:** FastAPI (Python) unter `/tr/api/`. -* **Processing:** - * **FFmpeg:** Zerlegt große Audio-Dateien (> 2 Stunden) in verarbeitbare 30-Minuten-Chunks. - * **Gemini 2.0 Flash:** Führt die Transkription durch und liefert strukturiertes JSON (Sprecher, Zeitstempel, Text). - * **SQLite:** Speichert Metadaten, Status und die bearbeitbaren JSON-Segmente. -* **Storage:** Lokales Docker-Volume für Audio-Uploads. - -### Datenfluss -1. **Upload:** User lädt MP3 hoch -> Speicherung in `/app/uploads_audio`. -2. **Chunking:** Backend startet Background-Task -> FFmpeg erstellt Segmente. -3. **Transkription:** Loop über Chunks -> Upload zu Gemini -> JSON-Extraktion -> Offset-Berechnung -> DB-Speicherung. -4. **Assemblierung:** Das Frontend lädt alle Chunks eines Meetings und stellt sie als eine durchgehende Liste dar. +* **FFmpeg Engine:** Automatisches Splitting großer Dateien in 30-Minuten-Segmente. +* **Gemini 2.0 Flash:** AI-Transkription mit Fokus auf JSON-Struktur (Sprecher, Timestamps). +* **Structured Storage:** SQLite speichert jedes Segment als editierbares JSON-Array. +* **Unified UI:** Das Frontend fügt alle Segmente zu einem nahtlosen Dokument zusammen. --- -## 2. API Endpunkte +## 2. Key Features (v0.5.0) -Basis-URL: `/tr/api` +### 🎙️ Intelligente Transkription +* Unterstützt MP3/WAV bis 500MB. +* Native Sprechererkennung und Zeitstempel-Normalisierung über Segmentgrenzen hinweg. + +### 👥 Globales Sprecher-Management +* **Speaker Bar:** Eine Übersicht aller im Dokument gefundenen Sprecher. +* **Global Rename:** Mit einem Klick kann ein Sprecher (z.B. "Speaker A") im gesamten Dokument dauerhaft umbenannt werden (z.B. "Thomas"). + +### ✂️ Präzises Schneiden (Trimming) +* **Trim Start:** Löscht alles *vor* einer ausgewählten Zeile (ideal zum Entfernen von Vorgesprächen). +* **Trim End:** Löscht alles *nach* einer ausgewählten Zeile (entfernt Verabschiedungen). +* **Single Line Delete:** Einzelne Zeilen oder Störgeräusche können individuell entfernt werden. + +### 📝 Editor & Export +* **Inline-Edit:** Jeder Textblock und jeder Sprechername kann durch direktes Anklicken korrigiert werden. +* **Copy Full Transcript:** Kopiert das gesamte, bereinigte Transkript inkl. Timestamps in die Zwischenablage. + +--- + +## 3. API Endpunkte | Methode | Pfad | Beschreibung | | :--- | :--- | :--- | -| `GET` | `/meetings` | Liste aller Meetings inkl. Status. | -| `POST` | `/upload` | Upload einer Audio-Datei (`multipart/form-data`). | -| `GET` | `/meetings/{id}` | Lädt Meeting-Details inklusive aller Text-Chunks (JSON). | -| `DELETE` | `/meetings/{id}` | Löscht ein Meeting inkl. Dateien komplett. | -| `PUT` | `/chunks/{id}` | Aktualisiert den Inhalt (Text/Sprecher) eines spezifischen 30-Min-Chunks. | +| `GET` | `/meetings` | Liste aller Meetings. | +| `POST` | `/upload` | Audio-Upload & Prozess-Start. | +| `POST` | `.../rename_speaker` | **Neu:** Globale Umbenennung in der DB. | +| `PUT` | `/chunks/{id}` | Speichert manuelle Text-Korrekturen. | +| `DELETE` | `/meetings/{id}` | Vollständiges Löschen. | --- -## 3. Datenbank Schema (SQLite) +## 4. Roadmap -Datei: `transcripts.db` - -### `meetings` -* `id`: PK -* `title`, `status`, `duration_seconds`, `file_path`. - -### `transcript_chunks` -* `id`: PK -* `meeting_id`: FK -* `chunk_index`: 0, 1, 2... -* `raw_text`: Backup des rohen Gemini-Outputs. -* `json_content`: **JSON** (Editierbar). Struktur: `[{ "time": "MM:SS", "absolute_seconds": 120, "speaker": "A", "text": "..." }]` - ---- - -## 4. Features & Bedienung - -### Transkription -* Upload von MP3/WAV Dateien (bis 500MB). -* Automatische Erkennung von Sprechern (Speaker A, Speaker B). - -### Editor-Modus (v0.4) -* **Inline Editing:** Klicken Sie auf einen Sprechernamen oder Text, um ihn direkt zu bearbeiten. Änderungen werden sofort gespeichert. -* **Zeilen Löschen:** Fahren Sie mit der Maus über eine Zeile und klicken Sie auf das rote "X", um irrelevante Teile (z.B. Smalltalk) zu entfernen. -* **Sprecher-Aliasing (Ansicht):** Klicken Sie auf den blauen Sprechernamen ("Speaker A"), um ihn für die *aktuelle Sitzung* umzubenennen (z.B. in "Thomas"). *Hinweis: Dies ändert aktuell nur die Ansicht, nicht die Datenbank für alle Zeilen.* - ---- - -## 5. Roadmap / Next Steps - -* **v0.5: Global Rename:** Button "Alle 'Speaker A' dauerhaft in DB umbenennen". -* **v0.6: AI Analysis:** "Erstelle Meeting Notes" Button basierend auf dem korrigierten Transkript. -* **v0.7:** Export als Word/PDF. - ---- - -## 6. Troubleshooting - -* **Legacy Format:** Bei Dateien, die vor v0.3 hochgeladen wurden, erscheint ein Warnhinweis. Bitte neu hochladen, um die Editier-Funktionen zu nutzen. -* **Upload bricht ab:** Prüfen Sie die Dateigröße (< 500MB). \ No newline at end of file +* **v0.6: AI Insights:** Extraktion von Aufgaben (Action Items) und Zusammenfassungen per Button. +* **v0.7: Search:** Globale Suche über alle Transkripte hinweg. diff --git a/transcription-tool/backend/app.py b/transcription-tool/backend/app.py index 7b40c0a5..5fd85dd0 100644 --- a/transcription-tool/backend/app.py +++ b/transcription-tool/backend/app.py @@ -97,6 +97,41 @@ async def upload_audio( return meeting +from pydantic import BaseModel + +class RenameRequest(BaseModel): + old_name: str + new_name: str + +@app.post("/api/meetings/{meeting_id}/rename_speaker") +def rename_speaker_globally(meeting_id: int, payload: RenameRequest, db: Session = Depends(get_db)): + meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first() + if not meeting: + raise HTTPException(404, detail="Meeting not found") + + count = 0 + # Iterate over all chunks directly from DB relation + for chunk in meeting.chunks: + if not chunk.json_content: + continue + + modified = False + new_content = [] + for msg in chunk.json_content: + if msg.get("speaker") == payload.old_name: + msg["speaker"] = payload.new_name + modified = True + count += 1 + new_content.append(msg) + + if modified: + # Force update of the JSON field + chunk.json_content = list(new_content) + db.add(chunk) + + db.commit() + return {"status": "updated", "rows_affected": count} + @app.delete("/api/meetings/{meeting_id}") def delete_meeting(meeting_id: int, db: Session = Depends(get_db)): meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first() diff --git a/transcription-tool/frontend/src/App.tsx b/transcription-tool/frontend/src/App.tsx index c3657413..ac893a76 100644 --- a/transcription-tool/frontend/src/App.tsx +++ b/transcription-tool/frontend/src/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' import axios from 'axios' -import { Upload, FileText, Clock, CheckCircle2, Loader2, AlertCircle, Trash2, ArrowLeft, Copy, User, X } from 'lucide-react' +import { Upload, FileText, Clock, CheckCircle2, Loader2, AlertCircle, Trash2, ArrowLeft, Copy, Edit3, X, Scissors, Users } from 'lucide-react' import clsx from 'clsx' const API_BASE = '/tr/api' @@ -11,6 +11,8 @@ interface TranscriptMessage { absolute_seconds: number speaker: string text: string + _chunkId?: number // Virtual field for frontend mapping + _idx?: number // Virtual field } interface Chunk { @@ -44,6 +46,7 @@ export default function App() { // Editing State const [editingRow, setEditingRow] = useState<{chunkId: number, idx: number, field: 'speaker' | 'text'} | null>(null) const [editValue, setEditValue] = useState("") + const [isProcessing, setIsProcessing] = useState(false) const fetchMeetings = async () => { try { @@ -99,7 +102,36 @@ export default function App() { } catch (e) { alert("Delete failed") } } - // --- EDITING LOGIC --- + // --- GLOBAL SPEAKER MANAGEMENT --- + + const getUniqueSpeakers = () => { + if (!detailMeeting?.chunks) return [] + const speakers = new Set() + detailMeeting.chunks.forEach(c => { + c.json_content?.forEach(m => speakers.add(m.speaker)) + }) + return Array.from(speakers).sort() + } + + const handleGlobalRename = async (oldName: string) => { + const newName = prompt(`Rename speaker '${oldName}' globally to:`, oldName) + if (!newName || newName === oldName || !detailMeeting) return + + setIsProcessing(true) + try { + await axios.post(`${API_BASE}/meetings/${detailMeeting.id}/rename_speaker`, { + old_name: oldName, + new_name: newName + }) + fetchDetail(detailMeeting.id) + } catch (e) { + alert("Global rename failed") + } finally { + setIsProcessing(false) + } + } + + // --- TRIMMING & EDITING --- const saveChunkUpdate = async (chunkId: number, newJson: TranscriptMessage[]) => { try { @@ -110,6 +142,66 @@ export default function App() { } } + // Trim Logic: Cut everything BEFORE or AFTER the target message + const handleTrim = async (targetMsg: TranscriptMessage, mode: 'start' | 'end') => { + if (!detailMeeting || !detailMeeting.chunks) return + if (!confirm(mode === 'start' ? "Delete EVERYTHING before this line?" : "Delete EVERYTHING after this line?")) return + + setIsProcessing(true) + + // We need to iterate all chunks and decide what to do + const updates = [] + + for (const chunk of detailMeeting.chunks) { + if (!chunk.json_content) continue + + let newContent = [...chunk.json_content] + let changed = false + + if (mode === 'start') { + // Cut BEFORE + if (chunk.chunk_index < (detailMeeting.chunks.find(c => c.id === targetMsg._chunkId)?.chunk_index || 0)) { + // This entire chunk is BEFORE the target chunk -> Empty it + if (newContent.length > 0) { + newContent = [] + changed = true + } + } else if (chunk.id === targetMsg._chunkId) { + // This is the target chunk -> Slice content + const cutIdx = newContent.findIndex(m => m.absolute_seconds === targetMsg.absolute_seconds && m.text === targetMsg.text) + if (cutIdx > 0) { + newContent = newContent.slice(cutIdx) + changed = true + } + } + } else { + // Cut AFTER + if (chunk.chunk_index > (detailMeeting.chunks.find(c => c.id === targetMsg._chunkId)?.chunk_index || 0)) { + // This entire chunk is AFTER -> Empty it + if (newContent.length > 0) { + newContent = [] + changed = true + } + } else if (chunk.id === targetMsg._chunkId) { + // Target chunk + const cutIdx = newContent.findIndex(m => m.absolute_seconds === targetMsg.absolute_seconds && m.text === targetMsg.text) + if (cutIdx !== -1 && cutIdx < newContent.length - 1) { + newContent = newContent.slice(0, cutIdx + 1) + changed = true + } + } + } + + if (changed) { + updates.push(saveChunkUpdate(chunk.id, newContent)) + } + } + + await Promise.all(updates) + await fetchDetail(detailMeeting.id) + setIsProcessing(false) + } + const handleUpdateRow = async (chunkId: number, idx: number, field: 'speaker' | 'text', value: string) => { if (!detailMeeting) return @@ -131,33 +223,16 @@ export default function App() { setEditingRow(null) } - const handleDeleteRow = async (chunkId: number, idx: number) => { - if (!confirm("Remove this line?")) return - if (!detailMeeting) return - - const newChunks = detailMeeting.chunks!.map(c => { - if (c.id === chunkId && c.json_content) { - const newContent = c.json_content.filter((_, i) => i !== idx) - return { ...c, json_content: newContent } - } - return c - }) - - setDetailMeeting({ ...detailMeeting, chunks: newChunks }) - const updatedChunk = newChunks.find(c => c.id === chunkId) - if (updatedChunk?.json_content) { - await saveChunkUpdate(chunkId, updatedChunk.json_content) - } - } - // --- RENDER --- if (view === 'detail') { - // Flatten for rendering but keep ref to chunkId + // Flatten messages const flatMessages = detailMeeting?.chunks?.flatMap(c => (c.json_content || []).map((msg, idx) => ({ ...msg, _chunkId: c.id, _idx: idx })) ).sort((a,b) => a.absolute_seconds - b.absolute_seconds) || [] + const uniqueSpeakers = getUniqueSpeakers() + return (
@@ -172,7 +247,7 @@ export default function App() {
) : ( <> -
+

{detailMeeting.title}

@@ -191,6 +266,36 @@ export default function App() {
+ {/* SPEAKER MANAGER BAR */} + {uniqueSpeakers.length > 0 && ( +
+
+ Speakers found: +
+
+ {uniqueSpeakers.map(speaker => ( + + ))} +
+
+ )} + + {isProcessing && ( +
+
+ + Updating transcript... +
+
+ )} +
{flatMessages.length > 0 ? (
@@ -199,21 +304,14 @@ export default function App() { const isEditingText = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'text'; return ( -
- {/* Time & Delete */} -
+
+ {/* Time */} +
{msg.display_time || "00:00"} -
- {/* Speaker */} + {/* Speaker */}
{isEditingSpeaker ? ( setEditValue(e.target.value)} - onBlur={() => handleUpdateRow(msg._chunkId, msg._idx, 'speaker', editValue)} - onKeyDown={e => e.key === 'Enter' && handleUpdateRow(msg._chunkId, msg._idx, 'speaker', editValue)} + onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)} + onKeyDown={e => e.key === 'Enter' && handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)} /> ) : (
{ setEditingRow({chunkId: msg._chunkId, idx: msg._idx, field: 'speaker'}); setEditValue(msg.speaker); }} - title="Click to rename THIS speaker occurrence" + onClick={() => { setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'speaker'}); setEditValue(msg.speaker); }} > - {msg.speaker}
)}
- {/* Text */} + {/* Text */}
{isEditingText ? (