fix(transcription): Behebt Start- und API-Fehler in der App [2f488f42]

This commit is contained in:
2026-01-26 14:15:23 +00:00
parent eb3f77f092
commit e57aa374ea
10 changed files with 427 additions and 162 deletions

View File

@@ -121,6 +121,28 @@ def create_insight(meeting_id: int, payload: InsightRequest, db: Session = Depen
print(f"ERROR: Unexpected error in create_insight: {e}")
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):
old_name: str
new_name: str

View File

@@ -111,3 +111,38 @@ Please provide the output in Markdown format.
# You can add more prompts here for other analysis types.
# 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

View File

@@ -2,13 +2,9 @@ import sys
import os
from sqlalchemy.orm import Session
from .. import database
from .. import prompt_library
from ..prompt_library import get_prompt
import logging
from sqlalchemy.orm import Session
from .. import database
from .. import prompt_library
from ..lib.gemini_client import call_gemini_flash
from .llm_service import call_gemini_api
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -57,18 +53,7 @@ def _format_transcript(chunks: list[database.TranscriptChunk]) -> str:
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:
"""
@@ -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
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)
# 4. Call the AI model

View 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

View 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

View File

@@ -84,6 +84,10 @@ export default function App() {
const [insightResult, setInsightResult] = useState('')
const [insightTitle, setInsightTitle] = useState('')
// Translation State
const [translationResult, setTranslationResult] = useState<AnalysisResult | null>(null)
const [activeTab, setActiveTab] = useState<'transcript' | 'translation'>('transcript');
const fetchMeetings = async () => {
try {
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 ---
@@ -378,17 +419,17 @@ export default function App() {
</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="flex items-center gap-3 mb-3">
<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" />}
</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('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={() => 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>
@@ -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">
{flatMessages.length > 0 ? (
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{flatMessages.map((msg, uniqueIdx) => {
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';
{/* TAB Navigation */}
<div className="flex border-b border-slate-200 dark:border-slate-800">
<button
onClick={() => setActiveTab('transcript')}
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 (
<div key={uniqueIdx} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex gap-4 group relative">
{/* Time */}
<div className="w-16 pt-1 flex flex-col items-end gap-1 flex-shrink-0">
<span className="text-xs text-slate-400 font-mono">{msg.display_time || "00:00"}</span>
</div>
{/* Conditional Content */}
{activeTab === 'transcript' && (
flatMessages.length > 0 ? (
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{flatMessages.map((msg, uniqueIdx) => {
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">
{/* Speaker */}
<div className="mb-1 flex items-center gap-2">
{isEditingSpeaker ? (
<input
autoFocus
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>
return (
<div key={uniqueIdx} className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex gap-4 group relative">
{/* Time */}
<div className="w-16 pt-1 flex flex-col items-end gap-1 flex-shrink-0">
<span className="text-xs text-slate-400 font-mono">{msg.display_time || "00:00"}</span>
</div>
{/* Text */}
<div>
{isEditingText ? (
<textarea
autoFocus
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}
onChange={e => setEditValue(e.target.value)}
onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'text', 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); }}
>
{msg.text}
</div>
)}
</div>
</div>
<div className="flex-1 min-w-0">
{/* Speaker */}
<div className="mb-1 flex items-center gap-2">
{isEditingSpeaker ? (
<input
autoFocus
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>
{/* Hover Actions (Trim / 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">
<button
onClick={() => handleTrim(msg, 'start')}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
title="Trim Start: Delete everything BEFORE this line"
>
<Scissors className="h-3.5 w-3.5 rotate-180" />
</button>
<button
onClick={() => handleTrim(msg, 'end')}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
title="Trim End: Delete everything AFTER this line"
>
<Scissors className="h-3.5 w-3.5" />
</button>
<div className="w-px bg-slate-200 dark:bg-slate-700 mx-1"></div>
<button
onClick={() => {
// 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>
{/* Text */}
<div>
{isEditingText ? (
<textarea
autoFocus
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}
onChange={e => setEditValue(e.target.value)}
onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'text', 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); }}
>
{msg.text}
</div>
)}
</div>
</div>
{/* Hover Actions (Trim / 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">
<button
onClick={() => handleTrim(msg, 'start')}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
title="Trim Start: Delete everything BEFORE this line"
>
<Scissors className="h-3.5 w-3.5 rotate-180" />
</button>
<button
onClick={() => handleTrim(msg, 'end')}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
title="Trim End: Delete everything AFTER this line"
>
<Scissors className="h-3.5 w-3.5" />
</button>
<div className="w-px bg-slate-200 dark:bg-slate-700 mx-1"></div>
<button
onClick={() => {
// 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 className="text-center py-12">
{detailMeeting.chunks && detailMeeting.chunks.length > 0 && detailMeeting.chunks[0].raw_text ? (
<div className="p-8">
<p className="text-yellow-600 mb-2 font-medium">Legacy Format</p>
<p className="text-slate-500 text-sm mb-4">Re-upload file to enable editing.</p>
<div className="text-left font-mono text-xs overflow-auto max-h-96">{detailMeeting.chunks[0].raw_text}</div>
</div>
) : (<p className="text-slate-500">Processing...</p>)}
</div>
) : (
<div className="text-center py-12">
{detailMeeting.chunks && detailMeeting.chunks.length > 0 && detailMeeting.chunks[0].raw_text ? (
<div className="p-8">
<p className="text-yellow-600 mb-2 font-medium">Legacy Format</p>
<p className="text-slate-500 text-sm mb-4">Re-upload file to enable editing.</p>
<div className="text-left font-mono text-xs overflow-auto max-h-96">{detailMeeting.chunks[0].raw_text}</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>
</>