fix(transcription): Behebt Start- und API-Fehler in der App [2f488f42]
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"task_id": "2f388f42-8544-805f-8822-d1765c7d354f", "token": "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"}
|
{"task_id": "2f488f42-8544-819a-8407-f29748b3e0b8", "token": "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"}
|
||||||
@@ -1,25 +1,14 @@
|
|||||||
# Zusammenfassung: Task [2f388f42] - Promptdatabase integrieren
|
**Task Summary: Add a Share Button `[2f488f42]`**
|
||||||
|
|
||||||
Der Task wurde erfolgreich abgeschlossen. Das "Meeting Assistant (Transcription Tool)" verfügt nun über eine "AI Insights"-Funktion, mit der Benutzer auf Knopfdruck Meeting-Protokolle, Aufgabenlisten und rollenbasierte Zusammenfassungen aus Transkripten generieren können.
|
**Status:** ✅ Done
|
||||||
|
|
||||||
## Implementierte Features
|
**Project:** Meeting Assistant (Transcription Tool)
|
||||||
|
|
||||||
1. **Prompt-Datenbank (`prompt_library.py`):** Eine zentrale Bibliothek für KI-Anweisungen wurde erstellt, um verschiedene Analyse-Typen (Protokoll, Action Items, Sales Summary) zu steuern.
|
**Changes Implemented:**
|
||||||
2. **Insights Service (`insights_service.py`):** Eine neue Backend-Logik wurde entwickelt, die das Transkript aus der Datenbank abruft, formatiert und zusammen mit dem passenden Prompt an die Gemini 2.0 Flash API sendet.
|
- A new "Share" button has been successfully added to the toolbar in the transcript detail view.
|
||||||
3. **Neuer API-Endpunkt:** Der Endpunkt `POST /api/meetings/{id}/insights` wurde implementiert, um die Analyse-Generierung anzustoßen.
|
- The button is visually aligned with the existing "Copy" and "Download" actions, utilizing the `Share2` icon for consistency.
|
||||||
4. **Frontend-Integration:** Die Benutzeroberfläche wurde um ein "AI Insights"-Panel erweitert. Benutzer können über Buttons die verschiedenen Analysen anfordern. Die Ergebnisse werden in einem Modal-Fenster angezeigt und können in die Zwischenablage kopiert werden.
|
- The feature is UI-only as per the requirements; clicking the button currently triggers a placeholder alert. No backend or sharing logic has been implemented yet.
|
||||||
5. **Caching:** Die generierten Ergebnisse werden in der Datenbank gespeichert. Um eine Neugenerierung zu Testzwecken zu ermöglichen, wird ein bereits vorhandenes Ergebnis gelöscht und neu erstellt, wenn die Analyse erneut angefordert wird.
|
|
||||||
|
|
||||||
## Debugging-Prozess & Gelöste Probleme
|
**Next Steps:**
|
||||||
|
- The functionality for sharing can be implemented in a future task.
|
||||||
Die Implementierung erforderte einen intensiven Debugging-Prozess, um mehrere aufeinanderfolgende Fehler zu beheben:
|
- The change is ready to be committed and pushed.
|
||||||
|
|
||||||
1. **`ModuleNotFoundError`:** Ein anfänglicher Importfehler wurde behoben, indem eine eigenständige `gemini_client.py` für den Microservice erstellt wurde.
|
|
||||||
2. **Fehlender API-Schlüssel:** Der Dienst wurde so angepasst, dass er den API-Schlüssel aus einer Umgebungsvariable liest, die über `docker-compose.yml` bereitgestellt wird.
|
|
||||||
3. **Falsche API-Initialisierung:** Ein Programmierfehler bei der Verwendung der Gemini-Bibliothek wurde korrigiert.
|
|
||||||
4. **Falscher Modellname:** Der API-Aufruf wurde auf das korrekte, im Projekt etablierte `gemini-2.0-flash`-Modell umgestellt.
|
|
||||||
5. **Fehlerhafte Transkript-Formatierung:** Das Kernproblem, das zu leeren KI-Antworten führte, wurde durch die korrekte Interpretation der `absolute_seconds` aus der Datenbank und eine robustere Formatierungslogik gelöst.
|
|
||||||
|
|
||||||
## Ergebnis
|
|
||||||
|
|
||||||
Das Feature ist nun voll funktionsfähig und liefert korrekte, inhaltsbasierte Analysen. Der Code wurde bereinigt, dokumentiert und die Änderungen wurden committed.
|
|
||||||
@@ -19,7 +19,8 @@ Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und B
|
|||||||
|
|
||||||
## 2. Key Features (v0.6.0)
|
## 2. Key Features (v0.6.0)
|
||||||
|
|
||||||
### 🚀 **NEU:** AI Insights auf Knopfdruck
|
### 🚀 **NEU:** AI Insights & Translation
|
||||||
|
* **Übersetzung (DE/EN):** Übersetzt das gesamte Transkript mit einem Klick ins Englische.
|
||||||
* **Meeting-Protokoll:** Erstellt automatisch ein formelles Protokoll (Meeting Minutes) mit Agenda, Entscheidungen und nächsten Schritten.
|
* **Meeting-Protokoll:** Erstellt automatisch ein formelles Protokoll (Meeting Minutes) mit Agenda, Entscheidungen und nächsten Schritten.
|
||||||
* **Action Items:** Extrahiert eine Aufgabenliste mit Verantwortlichen und Fälligkeiten direkt aus dem Gespräch.
|
* **Action Items:** Extrahiert eine Aufgabenliste mit Verantwortlichen und Fälligkeiten direkt aus dem Gespräch.
|
||||||
* **Rollenbasierte Zusammenfassungen:** Generiert spezifische Zusammenfassungen, z.B. eine "Sales Summary", die sich auf Kundenbedürfnisse, Kaufsignale und nächste Schritte für das Vertriebsteam konzentriert.
|
* **Rollenbasierte Zusammenfassungen:** Generiert spezifische Zusammenfassungen, z.B. eine "Sales Summary", die sich auf Kundenbedürfnisse, Kaufsignale und nächste Schritte für das Vertriebsteam konzentriert.
|
||||||
@@ -51,6 +52,7 @@ Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und B
|
|||||||
| `GET` | `/meetings` | Liste aller Meetings. |
|
| `GET` | `/meetings` | Liste aller Meetings. |
|
||||||
| `POST` | `/upload` | Audio-Upload & Prozess-Start. |
|
| `POST` | `/upload` | Audio-Upload & Prozess-Start. |
|
||||||
| `POST` | `/meetings/{id}/insights` | **Neu:** Generiert eine Analyse (z.B. Protokoll, Action Items). |
|
| `POST` | `/meetings/{id}/insights` | **Neu:** Generiert eine Analyse (z.B. Protokoll, Action Items). |
|
||||||
|
| `POST` | `/meetings/{id}/translate` | **Neu:** Übersetzt das Transkript in eine Zielsprache (aktuell: 'English'). |
|
||||||
| `POST` | `/meetings/{id}/rename_speaker` | Globale Umbenennung in der DB. |
|
| `POST` | `/meetings/{id}/rename_speaker` | Globale Umbenennung in der DB. |
|
||||||
| `PUT` | `/chunks/{id}` | Speichert manuelle Text-Korrekturen. |
|
| `PUT` | `/chunks/{id}` | Speichert manuelle Text-Korrekturen. |
|
||||||
| `DELETE` | `/meetings/{id}` | Vollständiges Löschen. |
|
| `DELETE` | `/meetings/{id}` | Vollständiges Löschen. |
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# --- Konfiguration ---
|
# --- Konfiguration ---
|
||||||
# Der Name des finalen Containers, in dem die Gemini CLI läuft.
|
IMAGE_NAME="gemini-dev-env"
|
||||||
CONTAINER_NAME="gemini-session"
|
CONTAINER_NAME="gemini-session"
|
||||||
|
|
||||||
# --- Aufräumen ---
|
# --- Aufräumen ---
|
||||||
# Sicherstellen, dass der Config-Ordner existiert, damit Docker ihn als Ordner und nicht als Datei mountet
|
# Sicherstellen, dass der Config-Ordner existiert
|
||||||
mkdir -p .gemini-config
|
mkdir -p .gemini-config
|
||||||
|
|
||||||
|
# Prüfen, ob das Docker-Image existiert und es bei Bedarf bauen
|
||||||
|
if ! docker image inspect "$IMAGE_NAME" &> /dev/null;
|
||||||
|
then
|
||||||
|
echo "Docker-Image '$IMAGE_NAME' nicht gefunden. Baue es jetzt aus 'gemini.Dockerfile'..."
|
||||||
|
docker build -t "$IMAGE_NAME" -f gemini.Dockerfile .
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "FEHLER: Docker-Image konnte nicht gebaut werden."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Räume alte Docker-Container auf, falls vorhanden..."
|
echo "Räume alte Docker-Container auf, falls vorhanden..."
|
||||||
# Entfernt den Haupt-Container und den temporären Container vom letzten Lauf
|
|
||||||
docker rm -f "$CONTAINER_NAME" > /dev/null 2>&1
|
docker rm -f "$CONTAINER_NAME" > /dev/null 2>&1
|
||||||
docker rm -f "${CONTAINER_NAME}-temp" > /dev/null 2>&1
|
docker rm -f "${CONTAINER_NAME}-temp" > /dev/null 2>&1
|
||||||
|
|
||||||
@@ -20,15 +30,15 @@ echo "Starte interaktive Entwicklungs-Session-Konfiguration..."
|
|||||||
TEMP_FILE=$(mktemp)
|
TEMP_FILE=$(mktemp)
|
||||||
|
|
||||||
# Führe dev_session.py in einem temporären, interaktiven Container aus.
|
# Führe dev_session.py in einem temporären, interaktiven Container aus.
|
||||||
# Die Ausgabe wird gleichzeitig im Terminal angezeigt (damit der User die Fragen sieht)
|
# Die Ausgabe (stdout & stderr) wird gleichzeitig im Terminal angezeigt und in die temporäre Datei geschrieben.
|
||||||
# und in die temporäre Datei geschrieben.
|
|
||||||
docker run -it --rm \
|
docker run -it --rm \
|
||||||
--env-file .env \
|
|
||||||
-v "$(pwd):/app" \
|
-v "$(pwd):/app" \
|
||||||
|
-v "$(pwd)/.gemini-config:/root/.config" \
|
||||||
|
-w /app \
|
||||||
--name "${CONTAINER_NAME}-temp" \
|
--name "${CONTAINER_NAME}-temp" \
|
||||||
gemini-dev-env python3 dev_session.py | tee "$TEMP_FILE"
|
"$IMAGE_NAME" python3 dev_session.py 2>&1 | tee "$TEMP_FILE"
|
||||||
|
|
||||||
# Überprüfe, ob der vorherige Befehl erfolgreich war (exit code 0)
|
# Überprüfe, ob der Docker-Befehl erfolgreich war (exit code 0)
|
||||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||||
echo "------------------------------------------------------------------"
|
echo "------------------------------------------------------------------"
|
||||||
echo "❌ Fehler: Der Konfigurationsprozess wurde abgebrochen oder ist fehlgeschlagen."
|
echo "❌ Fehler: Der Konfigurationsprozess wurde abgebrochen oder ist fehlgeschlagen."
|
||||||
@@ -39,8 +49,7 @@ if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Extrahiere den reinen CLI-Kontext aus der temporären Datei
|
# Extrahiere den reinen CLI-Kontext aus der temporären Datei
|
||||||
# sed sucht nach den Markern und gibt nur den Text dazwischen aus
|
CLI_CONTEXT=$(sed -n '/---GEMINI_CLI_CONTEXT_START---/,/---GEMINI_CLI_CONTEXT_END---/{//!p}' "$TEMP_FILE")
|
||||||
CLI_CONTEXT=$(sed -n '/---GEMINI_CLI_CONTEXT_START---/,/---GEMINI_CLI_CONTEXT_END---/p' "$TEMP_FILE" | sed '1d;$d')
|
|
||||||
|
|
||||||
# Lösche die temporäre Datei
|
# Lösche die temporäre Datei
|
||||||
rm "$TEMP_FILE"
|
rm "$TEMP_FILE"
|
||||||
@@ -66,8 +75,8 @@ $CLI_CONTEXT"
|
|||||||
# Starte den finalen Container mit der Gemini CLI
|
# Starte den finalen Container mit der Gemini CLI
|
||||||
# --prompt-interactive übergibt den initialen Kontext
|
# --prompt-interactive übergibt den initialen Kontext
|
||||||
docker run -it --rm \
|
docker run -it --rm \
|
||||||
--env-file .env \
|
|
||||||
-v "$(pwd):/app" \
|
-v "$(pwd):/app" \
|
||||||
-v "$(pwd)/.gemini-config:/root/.config" \
|
-v "$(pwd)/.gemini-config:/root/.config" \
|
||||||
|
-w /app \
|
||||||
--name "$CONTAINER_NAME" \
|
--name "$CONTAINER_NAME" \
|
||||||
gemini-dev-env --prompt-interactive "$FULL_CONTEXT"
|
"$IMAGE_NAME" gemini --prompt-interactive "$FULL_CONTEXT"
|
||||||
|
|||||||
@@ -121,6 +121,28 @@ def create_insight(meeting_id: int, payload: InsightRequest, db: Session = Depen
|
|||||||
print(f"ERROR: Unexpected error in create_insight: {e}")
|
print(f"ERROR: Unexpected error in create_insight: {e}")
|
||||||
raise HTTPException(status_code=500, detail="An internal error occurred while generating the insight.")
|
raise HTTPException(status_code=500, detail="An internal error occurred while generating the insight.")
|
||||||
|
|
||||||
|
class TranslationRequest(BaseModel):
|
||||||
|
target_language: str
|
||||||
|
|
||||||
|
@app.post("/api/meetings/{meeting_id}/translate")
|
||||||
|
def translate_meeting_transcript(meeting_id: int, payload: TranslationRequest, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Triggers the translation of a meeting's transcript.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# For now, we only support English
|
||||||
|
if payload.target_language.lower() != 'english':
|
||||||
|
raise HTTPException(status_code=400, detail="Currently, only translation to English is supported.")
|
||||||
|
|
||||||
|
from .services.translation_service import translate_transcript
|
||||||
|
translation = translate_transcript(db, meeting_id, payload.target_language)
|
||||||
|
return translation
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Unexpected error in translate_meeting_transcript: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="An internal error occurred during translation.")
|
||||||
|
|
||||||
class RenameRequest(BaseModel):
|
class RenameRequest(BaseModel):
|
||||||
old_name: str
|
old_name: str
|
||||||
new_name: str
|
new_name: str
|
||||||
|
|||||||
@@ -111,3 +111,38 @@ Please provide the output in Markdown format.
|
|||||||
|
|
||||||
# You can add more prompts here for other analysis types.
|
# You can add more prompts here for other analysis types.
|
||||||
# For example, a prompt for a technical summary, a marketing summary, etc.
|
# For example, a prompt for a technical summary, a marketing summary, etc.
|
||||||
|
|
||||||
|
TRANSLATE_TRANSCRIPT_PROMPT = """
|
||||||
|
You are a highly accurate and fluent translator.
|
||||||
|
Your task is to translate the given meeting transcript into {target_language}.
|
||||||
|
Maintain the original format (who said what) as closely as possible.
|
||||||
|
|
||||||
|
**Transcript:**
|
||||||
|
---
|
||||||
|
{transcript}
|
||||||
|
---
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
Provide only the translated text. Do not add any commentary or additional formatting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_PROMPTS = {
|
||||||
|
"meeting_minutes": MEETING_MINUTES_PROMPT,
|
||||||
|
"action_items": ACTION_ITEMS_PROMPT,
|
||||||
|
"sales_summary": SALES_SUMMARY_PROMPT,
|
||||||
|
"translate_transcript": TRANSLATE_TRANSCRIPT_PROMPT,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_prompt(prompt_type: str, context: dict = None) -> str:
|
||||||
|
"""
|
||||||
|
Retrieves a prompt by its type and formats it with the given context.
|
||||||
|
"""
|
||||||
|
prompt_template = _PROMPTS.get(prompt_type)
|
||||||
|
if not prompt_template:
|
||||||
|
raise ValueError(f"Unknown prompt type: {prompt_type}")
|
||||||
|
|
||||||
|
if context:
|
||||||
|
return prompt_template.format(**context)
|
||||||
|
|
||||||
|
return prompt_template
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,9 @@ import sys
|
|||||||
import os
|
import os
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .. import database
|
from .. import database
|
||||||
from .. import prompt_library
|
from ..prompt_library import get_prompt
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from sqlalchemy.orm import Session
|
from .llm_service import call_gemini_api
|
||||||
from .. import database
|
|
||||||
from .. import prompt_library
|
|
||||||
from ..lib.gemini_client import call_gemini_flash
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -57,18 +53,7 @@ def _format_transcript(chunks: list[database.TranscriptChunk]) -> str:
|
|||||||
|
|
||||||
return "\n".join(full_transcript)
|
return "\n".join(full_transcript)
|
||||||
|
|
||||||
def get_prompt_by_type(insight_type: str) -> str:
|
|
||||||
"""
|
|
||||||
Returns the corresponding prompt from the prompt_library based on the type.
|
|
||||||
"""
|
|
||||||
if insight_type == "meeting_minutes":
|
|
||||||
return prompt_library.MEETING_MINUTES_PROMPT
|
|
||||||
elif insight_type == "action_items":
|
|
||||||
return prompt_library.ACTION_ITEMS_PROMPT
|
|
||||||
elif insight_type == "sales_summary":
|
|
||||||
return prompt_library.SALES_SUMMARY_PROMPT
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown insight type: {insight_type}")
|
|
||||||
|
|
||||||
def generate_insight(db: Session, meeting_id: int, insight_type: str) -> database.AnalysisResult:
|
def generate_insight(db: Session, meeting_id: int, insight_type: str) -> database.AnalysisResult:
|
||||||
"""
|
"""
|
||||||
@@ -102,7 +87,7 @@ def generate_insight(db: Session, meeting_id: int, insight_type: str) -> databas
|
|||||||
# This can happen if all chunks are empty or malformed
|
# This can happen if all chunks are empty or malformed
|
||||||
raise ValueError(f"Formatted transcript for meeting {meeting_id} is empty or could not be processed.")
|
raise ValueError(f"Formatted transcript for meeting {meeting_id} is empty or could not be processed.")
|
||||||
|
|
||||||
prompt_template = get_prompt_by_type(insight_type)
|
prompt_template = get_prompt(insight_type)
|
||||||
final_prompt = prompt_template.format(transcript_text=transcript_text)
|
final_prompt = prompt_template.format(transcript_text=transcript_text)
|
||||||
|
|
||||||
# 4. Call the AI model
|
# 4. Call the AI model
|
||||||
|
|||||||
91
transcription-tool/backend/services/llm_service.py
Normal file
91
transcription-tool/backend/services/llm_service.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
def call_gemini_api(prompt: str, retries: int = 3, timeout: int = 600) -> str:
|
||||||
|
"""
|
||||||
|
Calls the Gemini Pro API with a given prompt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: The text prompt to send to the API.
|
||||||
|
retries: The number of times to retry on failure.
|
||||||
|
timeout: The request timeout in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The text response from the API or an empty string if the response is malformed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If the API call fails after all retries.
|
||||||
|
"""
|
||||||
|
api_key = os.getenv("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
logging.error("GEMINI_API_KEY environment variable not set.")
|
||||||
|
raise ValueError("API key not found.")
|
||||||
|
|
||||||
|
url = f"https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key={api_key}"
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
payload = {
|
||||||
|
"contents": [{
|
||||||
|
"parts": [{"text": prompt}]
|
||||||
|
}],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 0.7,
|
||||||
|
"topK": 40,
|
||||||
|
"topP": 0.95,
|
||||||
|
"maxOutputTokens": 8192,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if 'candidates' in result and result['candidates']:
|
||||||
|
candidate = result['candidates'][0]
|
||||||
|
if 'content' in candidate and 'parts' in candidate['content']:
|
||||||
|
# Check for safety ratings
|
||||||
|
if 'safetyRatings' in candidate:
|
||||||
|
blocked = any(r.get('blocked') for r in candidate['safetyRatings'])
|
||||||
|
if blocked:
|
||||||
|
logging.error(f"API call blocked due to safety ratings: {candidate['safetyRatings']}")
|
||||||
|
# Provide a more specific error or return a specific string
|
||||||
|
return "[Blocked by Safety Filter]"
|
||||||
|
return candidate['content']['parts'][0]['text']
|
||||||
|
|
||||||
|
# Handle cases where the response is valid but doesn't contain expected content
|
||||||
|
if 'promptFeedback' in result and result['promptFeedback'].get('blockReason'):
|
||||||
|
reason = result['promptFeedback']['blockReason']
|
||||||
|
logging.error(f"Prompt was blocked by the API. Reason: {reason}")
|
||||||
|
return f"[Prompt Blocked: {reason}]"
|
||||||
|
|
||||||
|
logging.warning(f"Unexpected API response structure on attempt {attempt+1}: {result}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response.status_code in [500, 502, 503, 504] and attempt < retries - 1:
|
||||||
|
wait_time = (2 ** attempt) * 2 # Exponential backoff
|
||||||
|
logging.warning(f"Server Error {e.response.status_code}. Retrying in {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
logging.error(f"HTTP Error calling Gemini API: {e.response.status_code} {e.response.text}")
|
||||||
|
raise
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if attempt < retries - 1:
|
||||||
|
wait_time = (2 ** attempt) * 2
|
||||||
|
logging.warning(f"Connection Error: {e}. Retrying in {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
logging.error(f"Final Connection Error calling Gemini API: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"An unexpected error occurred: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return "" # Should not be reached if retries are exhausted
|
||||||
64
transcription-tool/backend/services/translation_service.py
Normal file
64
transcription-tool/backend/services/translation_service.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..database import Meeting, AnalysisResult
|
||||||
|
from .llm_service import call_gemini_api
|
||||||
|
from ..prompt_library import get_prompt
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
def _format_transcript_for_translation(chunks: List[Any]) -> str:
|
||||||
|
"""Formats the transcript into a single string for the translation prompt."""
|
||||||
|
full_transcript = []
|
||||||
|
# Ensure chunks are treated correctly, whether they are dicts or objects
|
||||||
|
for chunk in chunks:
|
||||||
|
json_content = getattr(chunk, 'json_content', None)
|
||||||
|
if not json_content:
|
||||||
|
continue
|
||||||
|
for line in json_content:
|
||||||
|
speaker = line.get("speaker", "Unknown")
|
||||||
|
text = line.get("text", "")
|
||||||
|
full_transcript.append(f"{speaker}: {text}")
|
||||||
|
return "\n".join(full_transcript)
|
||||||
|
|
||||||
|
def translate_transcript(db: Session, meeting_id: int, target_language: str) -> AnalysisResult:
|
||||||
|
"""
|
||||||
|
Translates the transcript of a meeting and stores it.
|
||||||
|
"""
|
||||||
|
meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first()
|
||||||
|
if not meeting:
|
||||||
|
raise ValueError("Meeting not found")
|
||||||
|
|
||||||
|
prompt_key = f"translation_{target_language.lower()}"
|
||||||
|
|
||||||
|
# Check if translation already exists
|
||||||
|
existing_translation = db.query(AnalysisResult).filter(
|
||||||
|
AnalysisResult.meeting_id == meeting_id,
|
||||||
|
AnalysisResult.prompt_key == prompt_key
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_translation:
|
||||||
|
return existing_translation
|
||||||
|
|
||||||
|
# Prepare transcript
|
||||||
|
transcript_text = _format_transcript_for_translation(meeting.chunks)
|
||||||
|
if not transcript_text:
|
||||||
|
raise ValueError("Transcript is empty, cannot translate.")
|
||||||
|
|
||||||
|
# Get prompt from library
|
||||||
|
prompt = get_prompt('translate_transcript', {
|
||||||
|
'transcript': transcript_text,
|
||||||
|
'target_language': target_language
|
||||||
|
})
|
||||||
|
|
||||||
|
# Call Gemini API using the new service function
|
||||||
|
translated_text = call_gemini_api(prompt)
|
||||||
|
|
||||||
|
# Store result
|
||||||
|
translation_result = AnalysisResult(
|
||||||
|
meeting_id=meeting_id,
|
||||||
|
prompt_key=prompt_key,
|
||||||
|
result_text=translated_text
|
||||||
|
)
|
||||||
|
db.add(translation_result)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(translation_result)
|
||||||
|
|
||||||
|
return translation_result
|
||||||
@@ -84,6 +84,10 @@ export default function App() {
|
|||||||
const [insightResult, setInsightResult] = useState('')
|
const [insightResult, setInsightResult] = useState('')
|
||||||
const [insightTitle, setInsightTitle] = useState('')
|
const [insightTitle, setInsightTitle] = useState('')
|
||||||
|
|
||||||
|
// Translation State
|
||||||
|
const [translationResult, setTranslationResult] = useState<AnalysisResult | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<'transcript' | 'translation'>('transcript');
|
||||||
|
|
||||||
const fetchMeetings = async () => {
|
const fetchMeetings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`${API_BASE}/meetings`)
|
const res = await axios.get(`${API_BASE}/meetings`)
|
||||||
@@ -170,6 +174,43 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTranslate = async () => {
|
||||||
|
if (!detailMeeting) return;
|
||||||
|
setInsightLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a consistent key for the translation type
|
||||||
|
const translationType = 'translation_english';
|
||||||
|
|
||||||
|
// Check if result already exists in the main meeting object
|
||||||
|
const existing = detailMeeting.analysis_results?.find(r => r.prompt_key === translationType);
|
||||||
|
if (existing) {
|
||||||
|
setTranslationResult(existing);
|
||||||
|
// Here you might want to switch to a "Translation" tab or show a modal
|
||||||
|
alert("Translation already exists and has been loaded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not, generate it
|
||||||
|
const res = await axios.post(`${API_BASE}/meetings/${detailMeeting.id}/translate`, {
|
||||||
|
target_language: 'English'
|
||||||
|
});
|
||||||
|
|
||||||
|
// The API returns an AnalysisResult object, let's store it
|
||||||
|
setTranslationResult(res.data);
|
||||||
|
|
||||||
|
// Also refresh the main meeting detail to include this new result for persistence
|
||||||
|
fetchDetail(detailMeeting.id);
|
||||||
|
setActiveTab('translation');
|
||||||
|
alert("Translation successful!");
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.error("Failed to generate translation", e);
|
||||||
|
alert(`Error: Could not translate.`);
|
||||||
|
} finally {
|
||||||
|
setInsightLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- GLOBAL SPEAKER MANAGEMENT ---
|
// --- GLOBAL SPEAKER MANAGEMENT ---
|
||||||
|
|
||||||
@@ -378,17 +419,17 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI INSIGHTS PANEL */}
|
|
||||||
<div className="mb-6 bg-purple-50 dark:bg-purple-900/20 p-4 rounded-xl border border-purple-100 dark:border-purple-800">
|
<div className="mb-6 bg-purple-50 dark:bg-purple-900/20 p-4 rounded-xl border border-purple-100 dark:border-purple-800">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<Wand2 className="h-5 w-5 text-purple-600 dark:text-purple-300" />
|
<Wand2 className="h-5 w-5 text-purple-600 dark:text-purple-300" />
|
||||||
<h3 className="font-bold text-purple-800 dark:text-purple-200">AI Insights</h3>
|
<h3 className="font-bold text-purple-800 dark:text-purple-200">AI Tools</h3>
|
||||||
{insightLoading && <Loader2 className="h-4 w-4 animate-spin text-purple-500" />}
|
{insightLoading && <Loader2 className="h-4 w-4 animate-spin text-purple-500" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
<button disabled={insightLoading} onClick={() => handleGenerateInsight('meeting_minutes', 'Meeting Protocol')} className="text-sm font-medium px-4 py-2 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 disabled:opacity-50 transition-colors">Generate Protocol</button>
|
<button disabled={insightLoading} onClick={() => handleGenerateInsight('meeting_minutes', 'Meeting Protocol')} className="text-sm font-medium px-4 py-2 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 disabled:opacity-50 transition-colors">Generate Protocol</button>
|
||||||
<button disabled={insightLoading} onClick={() => handleGenerateInsight('action_items', 'Action Items')} className="text-sm font-medium px-4 py-2 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 disabled:opacity-50 transition-colors">Extract Action Items</button>
|
<button disabled={insightLoading} onClick={() => handleGenerateInsight('action_items', 'Action Items')} className="text-sm font-medium px-4 py-2 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 disabled:opacity-50 transition-colors">Extract Action Items</button>
|
||||||
<button disabled={insightLoading} onClick={() => handleGenerateInsight('sales_summary', 'Sales Summary')} className="text-sm font-medium px-4 py-2 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 disabled:opacity-50 transition-colors">Sales Summary</button>
|
<button disabled={insightLoading} onClick={() => handleGenerateInsight('sales_summary', 'Sales Summary')} className="text-sm font-medium px-4 py-2 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 disabled:opacity-50 transition-colors">Sales Summary</button>
|
||||||
|
<button disabled={insightLoading} onClick={() => handleTranslate()} className="text-sm font-medium px-4 py-2 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-lg shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 disabled:opacity-50 transition-colors">Translate (EN)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -402,116 +443,143 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
||||||
{flatMessages.length > 0 ? (
|
{/* TAB Navigation */}
|
||||||
<div className="divide-y divide-slate-100 dark:divide-slate-800">
|
<div className="flex border-b border-slate-200 dark:border-slate-800">
|
||||||
{flatMessages.map((msg, uniqueIdx) => {
|
<button
|
||||||
const isEditingSpeaker = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'speaker';
|
onClick={() => setActiveTab('transcript')}
|
||||||
const isEditingText = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'text';
|
className={clsx("px-4 py-3 font-medium text-sm", activeTab === 'transcript' ? "text-blue-600 border-b-2 border-blue-600" : "text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800/50")}
|
||||||
|
>
|
||||||
|
Original Transcript
|
||||||
|
</button>
|
||||||
|
{translationResult && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('translation')}
|
||||||
|
className={clsx("px-4 py-3 font-medium text-sm", activeTab === 'translation' ? "text-blue-600 border-b-2 border-blue-600" : "text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800/50")}
|
||||||
|
>
|
||||||
|
English Translation
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Conditional Content */}
|
||||||
<div key={uniqueIdx} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex gap-4 group relative">
|
{activeTab === 'transcript' && (
|
||||||
{/* Time */}
|
flatMessages.length > 0 ? (
|
||||||
<div className="w-16 pt-1 flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||||
<span className="text-xs text-slate-400 font-mono">{msg.display_time || "00:00"}</span>
|
{flatMessages.map((msg, uniqueIdx) => {
|
||||||
</div>
|
const isEditingSpeaker = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'speaker';
|
||||||
|
const isEditingText = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'text';
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
return (
|
||||||
{/* Speaker */}
|
<div key={uniqueIdx} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex gap-4 group relative">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
{/* Time */}
|
||||||
{isEditingSpeaker ? (
|
<div className="w-16 pt-1 flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
<input
|
<span className="text-xs text-slate-400 font-mono">{msg.display_time || "00:00"}</span>
|
||||||
autoFocus
|
</div>
|
||||||
className="text-sm font-bold text-blue-600 dark:text-blue-400 bg-slate-100 dark:bg-slate-800 border rounded px-2 py-0.5 outline-none w-48"
|
|
||||||
value={editValue}
|
|
||||||
onChange={e => setEditValue(e.target.value)}
|
|
||||||
onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="font-bold text-sm text-blue-600 dark:text-blue-400 cursor-pointer hover:underline flex items-center gap-1 w-fit"
|
|
||||||
onClick={() => { setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'speaker'}); setEditValue(msg.speaker); }}
|
|
||||||
>
|
|
||||||
{msg.speaker}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text */}
|
<div className="flex-1 min-w-0">
|
||||||
<div>
|
{/* Speaker */}
|
||||||
{isEditingText ? (
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<textarea
|
{isEditingSpeaker ? (
|
||||||
autoFocus
|
<input
|
||||||
className="w-full text-slate-800 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 border rounded px-2 py-1 outline-none min-h-[60px]"
|
autoFocus
|
||||||
value={editValue}
|
className="text-sm font-bold text-blue-600 dark:text-blue-400 bg-slate-100 dark:bg-slate-800 border rounded px-2 py-0.5 outline-none w-48"
|
||||||
onChange={e => setEditValue(e.target.value)}
|
value={editValue}
|
||||||
onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'text', editValue)}
|
onChange={e => setEditValue(e.target.value)}
|
||||||
/>
|
onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)}
|
||||||
) : (
|
onKeyDown={e => e.key === 'Enter' && handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)}
|
||||||
<div
|
/>
|
||||||
className="text-slate-800 dark:text-slate-200 leading-relaxed whitespace-pre-wrap cursor-text hover:bg-slate-100/50 dark:hover:bg-slate-800/30 rounded p-1 -ml-1 transition-colors"
|
) : (
|
||||||
onClick={() => { setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'text'}); setEditValue(msg.text); }}
|
<div
|
||||||
>
|
className="font-bold text-sm text-blue-600 dark:text-blue-400 cursor-pointer hover:underline flex items-center gap-1 w-fit"
|
||||||
{msg.text}
|
onClick={() => { setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'speaker'}); setEditValue(msg.speaker); }}
|
||||||
</div>
|
>
|
||||||
)}
|
{msg.speaker}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hover Actions (Trim / Delete) */}
|
{/* Text */}
|
||||||
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 flex gap-1 bg-white dark:bg-slate-900 border dark:border-slate-700 shadow-sm rounded-lg p-1 transition-all">
|
<div>
|
||||||
<button
|
{isEditingText ? (
|
||||||
onClick={() => handleTrim(msg, 'start')}
|
<textarea
|
||||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
autoFocus
|
||||||
title="Trim Start: Delete everything BEFORE this line"
|
className="w-full text-slate-800 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 border rounded px-2 py-1 outline-none min-h-[60px]"
|
||||||
>
|
value={editValue}
|
||||||
<Scissors className="h-3.5 w-3.5 rotate-180" />
|
onChange={e => setEditValue(e.target.value)}
|
||||||
</button>
|
onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'text', editValue)}
|
||||||
<button
|
/>
|
||||||
onClick={() => handleTrim(msg, 'end')}
|
) : (
|
||||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
<div
|
||||||
title="Trim End: Delete everything AFTER this line"
|
className="text-slate-800 dark:text-slate-200 leading-relaxed whitespace-pre-wrap cursor-text hover:bg-slate-100/50 dark:hover:bg-slate-800/30 rounded p-1 -ml-1 transition-colors"
|
||||||
>
|
onClick={() => { setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'text'}); setEditValue(msg.text); }}
|
||||||
<Scissors className="h-3.5 w-3.5" />
|
>
|
||||||
</button>
|
{msg.text}
|
||||||
<div className="w-px bg-slate-200 dark:bg-slate-700 mx-1"></div>
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={() => {
|
</div>
|
||||||
// Single delete logic is redundant if we have trims, but nice to keep
|
</div>
|
||||||
// Reuse update logic but filter out index
|
|
||||||
// Simplified: Just use handleTrim but that's overkill
|
{/* Hover Actions (Trim / Delete) */}
|
||||||
// Let's implement quick single delete
|
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 flex gap-1 bg-white dark:bg-slate-900 border dark:border-slate-700 shadow-sm rounded-lg p-1 transition-all">
|
||||||
const cId = msg._chunkId!;
|
<button
|
||||||
const idx = msg._idx!;
|
onClick={() => handleTrim(msg, 'start')}
|
||||||
const newChunks = detailMeeting?.chunks?.map(c => {
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
||||||
if (c.id === cId && c.json_content) {
|
title="Trim Start: Delete everything BEFORE this line"
|
||||||
return { ...c, json_content: c.json_content.filter((_, i) => i !== idx) }
|
>
|
||||||
}
|
<Scissors className="h-3.5 w-3.5 rotate-180" />
|
||||||
return c
|
</button>
|
||||||
}) || [];
|
<button
|
||||||
setDetailMeeting(prev => prev ? ({ ...prev, chunks: newChunks }) : null);
|
onClick={() => handleTrim(msg, 'end')}
|
||||||
const updatedC = newChunks.find(c => c.id === cId);
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
||||||
if(updatedC?.json_content) saveChunkUpdate(cId, updatedC.json_content);
|
title="Trim End: Delete everything AFTER this line"
|
||||||
}}
|
>
|
||||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
<Scissors className="h-3.5 w-3.5" />
|
||||||
title="Delete this line"
|
</button>
|
||||||
>
|
<div className="w-px bg-slate-200 dark:bg-slate-700 mx-1"></div>
|
||||||
<X className="h-3.5 w-3.5" />
|
<button
|
||||||
</button>
|
onClick={() => {
|
||||||
</div>
|
// Single delete logic is redundant if we have trims, but nice to keep
|
||||||
|
// Reuse update logic but filter out index
|
||||||
|
// Simplified: Just use handleTrim but that's overkill
|
||||||
|
// Let's implement quick single delete
|
||||||
|
const cId = msg._chunkId!;
|
||||||
|
const idx = msg._idx!;
|
||||||
|
const newChunks = detailMeeting?.chunks?.map(c => {
|
||||||
|
if (c.id === cId && c.json_content) {
|
||||||
|
return { ...c, json_content: c.json_content.filter((_, i) => i !== idx) }
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}) || [];
|
||||||
|
setDetailMeeting(prev => prev ? ({ ...prev, chunks: newChunks }) : null);
|
||||||
|
const updatedC = newChunks.find(c => c.id === cId);
|
||||||
|
if(updatedC?.json_content) saveChunkUpdate(cId, updatedC.json_content);
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
||||||
|
title="Delete this line"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)})}
|
||||||
</div>
|
</div>
|
||||||
)})}
|
) : (
|
||||||
</div>
|
<div className="text-center py-12">
|
||||||
) : (
|
{detailMeeting.chunks && detailMeeting.chunks.length > 0 && detailMeeting.chunks[0].raw_text ? (
|
||||||
<div className="text-center py-12">
|
<div className="p-8">
|
||||||
{detailMeeting.chunks && detailMeeting.chunks.length > 0 && detailMeeting.chunks[0].raw_text ? (
|
<p className="text-yellow-600 mb-2 font-medium">Legacy Format</p>
|
||||||
<div className="p-8">
|
<p className="text-slate-500 text-sm mb-4">Re-upload file to enable editing.</p>
|
||||||
<p className="text-yellow-600 mb-2 font-medium">Legacy Format</p>
|
<div className="text-left font-mono text-xs overflow-auto max-h-96">{detailMeeting.chunks[0].raw_text}</div>
|
||||||
<p className="text-slate-500 text-sm mb-4">Re-upload file to enable editing.</p>
|
</div>
|
||||||
<div className="text-left font-mono text-xs overflow-auto max-h-96">{detailMeeting.chunks[0].raw_text}</div>
|
) : (<p className="text-slate-500">Processing...</p>)}
|
||||||
</div>
|
</div>
|
||||||
) : (<p className="text-slate-500">Processing...</p>)}
|
)
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'translation' && (
|
||||||
|
<div className="p-6">
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-sm leading-relaxed">{translationResult?.result_text || "Loading translation..."}</pre>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user