diff --git a/backend/app/api/endpoints/meetings.py b/backend/app/api/endpoints/meetings.py index 1abc4dc..a0525d7 100644 --- a/backend/app/api/endpoints/meetings.py +++ b/backend/app/api/endpoints/meetings.py @@ -262,7 +262,8 @@ def _build_task_status_model(task_record: Optional[Dict[str, Any]]) -> Optional[ return TranscriptionTaskStatus( task_id=task_record.get('task_id'), status=task_record.get('status', 'pending') or 'pending', - progress=int(task_record.get('progress') or 0) + progress=int(task_record.get('progress') or 0), + message=task_record.get('message') ) def _verify_meeting_owner(cursor, meeting_id: int, current_user_id: int): @@ -499,8 +500,8 @@ def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_curren attendees_data = _load_attendees_map(cursor, [meeting_id]).get(meeting_id, []) attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] tags = _load_tags_map(cursor, [meeting]).get(meeting_id, []) - transcription_task = _load_latest_task_record(cursor, meeting_id, 'transcription') - llm_task = _load_latest_task_record(cursor, meeting_id, 'llm') + transcription_task = transcription_service.get_meeting_transcription_status(meeting_id) + llm_task = async_meeting_service.get_meeting_llm_status(meeting_id) overall_status = _build_meeting_overall_status(transcription_task, llm_task) cursor.close() diff --git a/backend/app/services/async_meeting_service.py b/backend/app/services/async_meeting_service.py index 297c239..09fc43e 100644 --- a/backend/app/services/async_meeting_service.py +++ b/backend/app/services/async_meeting_service.py @@ -5,6 +5,7 @@ import uuid import time import os +import re from datetime import datetime from typing import Optional, Dict, Any, List from pathlib import Path @@ -96,7 +97,7 @@ class AsyncMeetingService: # 3. 构建提示词 self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...") - full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id) + full_prompt = self._build_prompt(meeting_id, transcript_text, user_prompt, prompt_id) # 4. 调用LLM API(支持指定模型) self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...") @@ -345,23 +346,128 @@ class AsyncMeetingService: print(f"获取会议转录内容错误: {e}") return "" - def _build_prompt(self, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str: + def _get_meeting_prompt_context(self, meeting_id: int) -> Dict[str, Any]: + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + """ + SELECT m.title, m.meeting_time, u.caption AS creator_name + FROM meetings m + LEFT JOIN sys_users u ON m.user_id = u.user_id + WHERE m.meeting_id = %s + """, + (meeting_id,) + ) + meeting = cursor.fetchone() + + if not meeting: + cursor.close() + return {} + + meeting_time = meeting.get('meeting_time') + meeting_time_text = '' + if isinstance(meeting_time, datetime): + meeting_time_text = meeting_time.strftime('%Y-%m-%d %H:%M:%S') + elif meeting_time: + meeting_time_text = str(meeting_time) + + cursor.execute( + """ + SELECT u.caption + FROM attendees a + JOIN sys_users u ON a.user_id = u.user_id + WHERE a.meeting_id = %s + ORDER BY u.caption ASC + """, + (meeting_id,) + ) + attendee_rows = cursor.fetchall() + attendee_names = [row.get('caption') for row in attendee_rows if row.get('caption')] + attendee_text = '、'.join(attendee_names) + cursor.close() + + return { + 'title': meeting.get('title') or '', + 'meeting_time': meeting_time_text, + 'meeting_time_value': meeting_time, + 'creator_name': meeting.get('creator_name') or '', + 'attendees': attendee_text + } + except Exception as e: + print(f"获取会议提示词上下文错误: {e}") + return {} + + def _format_meeting_time_value(self, meeting_time_value: Any, custom_format: Optional[str] = None) -> str: + default_format = '%Y-%m-%d %H:%M:%S' + + if isinstance(meeting_time_value, datetime): + try: + return meeting_time_value.strftime(custom_format or default_format) + except Exception: + return meeting_time_value.strftime(default_format) + + if meeting_time_value: + meeting_time_text = str(meeting_time_value) + if custom_format: + try: + parsed_time = datetime.fromisoformat(meeting_time_text.replace('Z', '+00:00')) + return parsed_time.strftime(custom_format) + except Exception: + return meeting_time_text + return meeting_time_text + + return '' + + def _apply_prompt_variables(self, template: str, variables: Dict[str, Any]) -> str: + if not template: + return template + + rendered = re.sub( + r"\{\{\s*meeting_time\s*:\s*([^{}]+?)\s*\}\}", + lambda match: self._format_meeting_time_value( + variables.get('meeting_time_value'), + match.group(1).strip() + ), + template + ) + + for key, value in variables.items(): + if key == 'meeting_time_value': + continue + rendered = rendered.replace(f"{{{{{key}}}}}", value or '') + rendered = rendered.replace(f"{{{{ {key} }}}}", value or '') + return rendered + + def _build_prompt(self, meeting_id: int, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str: """ 构建完整的提示词 使用数据库中配置的MEETING_TASK提示词模板 Args: + meeting_id: 会议ID transcript_text: 会议转录文本 user_prompt: 用户额外提示词 prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版 """ # 从数据库获取会议任务的提示词模板(支持指定prompt_id) system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id) + meeting_context = self._get_meeting_prompt_context(meeting_id) + prompt_variables = { + 'meeting_id': str(meeting_id), + 'meeting_title': meeting_context.get('title', ''), + 'meeting_time': meeting_context.get('meeting_time', ''), + 'meeting_creator': meeting_context.get('creator_name', ''), + 'meeting_attendees': meeting_context.get('attendees', ''), + 'meeting_time_value': meeting_context.get('meeting_time_value') + } + system_prompt = self._apply_prompt_variables(system_prompt, prompt_variables) + rendered_user_prompt = self._apply_prompt_variables(user_prompt, prompt_variables) if user_prompt else '' prompt = f"{system_prompt}\n\n" - if user_prompt: - prompt += f"用户额外要求:{user_prompt}\n\n" + if rendered_user_prompt: + prompt += f"用户额外要求:{rendered_user_prompt}\n\n" prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:" diff --git a/docs/MEETING_PROMPT_VARIABLES.md b/docs/MEETING_PROMPT_VARIABLES.md new file mode 100644 index 0000000..30bd2cf --- /dev/null +++ b/docs/MEETING_PROMPT_VARIABLES.md @@ -0,0 +1,112 @@ +# 会议总结模板变量说明 + +## 概述 + +会议总结提示词模板支持使用变量占位符。只有在模板内容中显式写入变量时,系统才会替换对应值;未使用的变量不会自动追加到提示词中。 + +## 支持的变量 + +### 1. 会议 ID + +```text +{{meeting_id}} +``` + +输出当前会议的唯一 ID。 + +### 2. 会议标题 + +```text +{{meeting_title}} +``` + +输出当前会议标题。 + +### 3. 会议时间 + +默认格式输出: + +```text +{{meeting_time}} +``` + +默认格式示例: + +```text +2026-04-07 14:30:00 +``` + +自定义格式输出: + +```text +{{meeting_time:%Y年%m月%d日 %H:%M}} +``` + +自定义格式示例: + +```text +2026年04月07日 14:30 +``` + +如果未设置自定义格式,则使用默认格式。若自定义格式无法解析,系统会回退到默认时间值。 + +### 4. 会议创建人 + +```text +{{meeting_creator}} +``` + +输出会议创建人名称。 + +### 5. 参会人员 + +```text +{{meeting_attendees}} +``` + +输出参会人员名称列表,多个名字使用 `、` 连接。 + +## 可直接复制的变量列表 + +```text +{{meeting_id}} +{{meeting_title}} +{{meeting_time}} +{{meeting_time:%Y年%m月%d日 %H:%M}} +{{meeting_creator}} +{{meeting_attendees}} +``` + +## 模板示例 + +### 示例一:正式会议纪要 + +```text +请根据会议转录内容生成正式会议纪要。 + +会议标题:{{meeting_title}} +会议时间:{{meeting_time:%Y年%m月%d日 %H:%M}} +创建人:{{meeting_creator}} +参会人员:{{meeting_attendees}} + +请输出: +1. 会议背景 +2. 核心讨论 +3. 决议事项 +4. 待办事项 +``` + +### 示例二:简洁总结 + +```text +请用简洁风格总结本次会议。 +会议:{{meeting_title}} +时间:{{meeting_time}} +``` + +## 使用规则 + +- 变量必须使用双大括号包裹 +- 仅当模板中写入变量时,系统才会替换对应内容 +- `meeting_time` 支持自定义时间格式 +- 未填写的字段可能返回空字符串 diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx index ede8d6a..aee2390 100644 --- a/frontend/src/pages/MeetingDetails.jsx +++ b/frontend/src/pages/MeetingDetails.jsx @@ -107,6 +107,8 @@ const MeetingDetails = ({ user }) => { const [editDrawerOpen, setEditDrawerOpen] = useState(false); const [showQRModal, setShowQRModal] = useState(false); const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadStatusMessage, setUploadStatusMessage] = useState(''); const [playbackRate, setPlaybackRate] = useState(1); const [accessPasswordEnabled, setAccessPasswordEnabled] = useState(false); const [accessPasswordDraft, setAccessPasswordDraft] = useState(''); @@ -131,11 +133,16 @@ const MeetingDetails = ({ user }) => { const transcriptRefs = useRef([]); const statusCheckIntervalRef = useRef(null); const summaryPollIntervalRef = useRef(null); + const summaryBootstrapTimeoutRef = useRef(null); const activeSummaryTaskIdRef = useRef(null); const isMeetingOwner = user?.user_id === meeting?.creator_id; const creatorName = meeting?.creator_username || '未知创建人'; const hasUploadedAudio = Boolean(audioUrl); const isTranscriptionRunning = ['pending', 'processing'].includes(transcriptionStatus?.status); + const isSummaryRunning = summaryLoading; + const displayUploadProgress = Math.max(0, Math.min(uploadProgress, 100)); + const displayTranscriptionProgress = Math.max(0, Math.min(transcriptionProgress, 100)); + const displaySummaryProgress = Math.max(0, Math.min(summaryTaskProgress, 100)); const summaryDisabledReason = isUploading ? '音频上传中,暂不允许重新总结' : !hasUploadedAudio @@ -153,6 +160,7 @@ const MeetingDetails = ({ user }) => { return () => { if (statusCheckIntervalRef.current) clearInterval(statusCheckIntervalRef.current); if (summaryPollIntervalRef.current) clearInterval(summaryPollIntervalRef.current); + if (summaryBootstrapTimeoutRef.current) clearTimeout(summaryBootstrapTimeoutRef.current); }; }, [meeting_id]); @@ -192,6 +200,103 @@ const MeetingDetails = ({ user }) => { transcriptRefs.current[currentHighlightIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, [currentHighlightIndex, transcriptVisibleCount]); + const clearSummaryBootstrapPolling = () => { + if (summaryBootstrapTimeoutRef.current) { + clearTimeout(summaryBootstrapTimeoutRef.current); + summaryBootstrapTimeoutRef.current = null; + } + }; + + const applyMeetingDetailsState = (meetingData, options = {}) => { + const { allowSummaryBootstrap = true } = options; + setMeeting(meetingData); + if (meetingData.prompt_id) { + setSelectedPromptId(meetingData.prompt_id); + } + setAccessPasswordEnabled(Boolean(meetingData.access_password)); + setAccessPasswordDraft(meetingData.access_password || ''); + + if (meetingData.transcription_status) { + const ts = meetingData.transcription_status; + setTranscriptionStatus(ts); + setTranscriptionProgress(ts.progress || 0); + if (['pending', 'processing'].includes(ts.status) && ts.task_id) { + startStatusPolling(ts.task_id); + } + } else { + setTranscriptionStatus(null); + setTranscriptionProgress(0); + } + + if (meetingData.llm_status) { + const llmStatus = meetingData.llm_status; + clearSummaryBootstrapPolling(); + setSummaryTaskProgress(llmStatus.progress || 0); + setSummaryTaskMessage( + llmStatus.message + || (llmStatus.status === 'processing' + ? 'AI 正在分析会议内容...' + : llmStatus.status === 'pending' + ? 'AI 总结任务排队中...' + : '') + ); + if (['pending', 'processing'].includes(llmStatus.status) && llmStatus.task_id) { + startSummaryPolling(llmStatus.task_id); + } else { + setSummaryLoading(false); + } + } else if (meetingData.transcription_status?.status === 'completed' && !meetingData.summary) { + if (!activeSummaryTaskIdRef.current) { + setSummaryLoading(true); + setSummaryTaskProgress(0); + setSummaryTaskMessage('转录完成,正在启动 AI 分析...'); + } + if (allowSummaryBootstrap) { + scheduleSummaryBootstrapPolling(); + } + } else { + clearSummaryBootstrapPolling(); + if (!activeSummaryTaskIdRef.current) { + setSummaryLoading(false); + setSummaryTaskProgress(0); + setSummaryTaskMessage(''); + } + } + + const hasAudioFile = Boolean(meetingData.audio_file_path && String(meetingData.audio_file_path).length > 5); + setAudioUrl(hasAudioFile ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null); + }; + + const scheduleSummaryBootstrapPolling = (attempt = 0) => { + if (summaryPollIntervalRef.current || activeSummaryTaskIdRef.current) { + return; + } + clearSummaryBootstrapPolling(); + if (attempt >= 10) { + setSummaryLoading(false); + setSummaryTaskMessage(''); + return; + } + summaryBootstrapTimeoutRef.current = setTimeout(async () => { + summaryBootstrapTimeoutRef.current = null; + try { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))); + const meetingData = response.data; + applyMeetingDetailsState(meetingData, { allowSummaryBootstrap: false }); + if (meetingData.llm_status || meetingData.summary) { + return; + } + } catch { + if (attempt >= 9) { + setSummaryLoading(false); + setSummaryTaskMessage(''); + return; + } + } + scheduleSummaryBootstrapPolling(attempt + 1); + }, attempt === 0 ? 1200 : 2000); + }; + const fetchMeetingDetails = async (options = {}) => { const { showPageLoading = true } = options; try { @@ -199,43 +304,11 @@ const MeetingDetails = ({ user }) => { setLoading(true); } const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.DETAIL(meeting_id))); - setMeeting(response.data); - if (response.data.prompt_id) { - setSelectedPromptId(response.data.prompt_id); - } - setAccessPasswordEnabled(Boolean(response.data.access_password)); - setAccessPasswordDraft(response.data.access_password || ''); - - if (response.data.transcription_status) { - const ts = response.data.transcription_status; - setTranscriptionStatus(ts); - setTranscriptionProgress(ts.progress || 0); - if (['pending', 'processing'].includes(ts.status)) { - startStatusPolling(ts.task_id); - } - } else { - setTranscriptionStatus(null); - setTranscriptionProgress(0); - } - - if (response.data.llm_status) { - setSummaryTaskProgress(response.data.llm_status.progress || 0); - setSummaryTaskMessage(response.data.llm_status.message || ''); - if (['pending', 'processing'].includes(response.data.llm_status.status)) { - startSummaryPolling(response.data.llm_status.task_id); - } else { - setSummaryLoading(false); - } - } else { - setSummaryLoading(false); - setSummaryTaskProgress(0); - setSummaryTaskMessage(''); - } - - const hasAudio = Boolean(response.data.audio_file_path && String(response.data.audio_file_path).length > 5); - setAudioUrl(hasAudio ? buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`) : null); + applyMeetingDetailsState(response.data); + return response.data; } catch { message.error('加载会议详情失败'); + return null; } finally { if (showPageLoading) { setLoading(false); @@ -283,6 +356,7 @@ const MeetingDetails = ({ user }) => { const status = res.data; setTranscriptionStatus(status); setTranscriptionProgress(status.progress || 0); + setMeeting(prev => (prev ? { ...prev, transcription_status: status } : prev)); if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) { clearInterval(interval); statusCheckIntervalRef.current = null; @@ -348,7 +422,8 @@ const MeetingDetails = ({ user }) => { const statusRes = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId))); const status = statusRes.data; setSummaryTaskProgress(status.progress || 0); - setSummaryTaskMessage(status.message || ''); + setSummaryTaskMessage(status.message || 'AI 正在分析会议内容...'); + setMeeting(prev => (prev ? { ...prev, llm_status: status } : prev)); if (status.status === 'completed') { clearInterval(interval); @@ -424,16 +499,33 @@ const MeetingDetails = ({ user }) => { formData.append('model_code', selectedModelCode); } setIsUploading(true); + setUploadProgress(0); + setUploadStatusMessage('正在上传音频文件...'); try { - await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData); + await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formData, { + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + setUploadProgress(Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total))); + } + setUploadStatusMessage('正在上传音频文件...'); + } + }); + setUploadProgress(100); + setUploadStatusMessage('上传完成,正在启动转录任务...'); message.success('音频上传成功'); setTranscript([]); setSpeakerList([]); setEditingSpeakers({}); - fetchMeetingDetails({ showPageLoading: false }); - fetchTranscript(); - } catch { message.error('上传失败'); } - finally { setIsUploading(false); } + await fetchMeetingDetails({ showPageLoading: false }); + await fetchTranscript(); + } catch (error) { + message.error(error?.response?.data?.message || error?.response?.data?.detail || '上传失败'); + throw error; + } finally { + setIsUploading(false); + setUploadProgress(0); + setUploadStatusMessage(''); + } }; const saveAccessPassword = async () => { @@ -727,7 +819,7 @@ const MeetingDetails = ({ user }) => { ]; const audioMoreMenuItems = [ - { key: 'transcribe', icon: , label: '智能转录', disabled: !audioUrl || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription }, + { key: 'transcribe', icon: , label: '智能转录', disabled: !audioUrl || isUploading || transcriptionStatus?.status === 'processing', onClick: handleStartTranscription }, { key: 'upload', icon: , label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker }, ]; @@ -801,14 +893,56 @@ const MeetingDetails = ({ user }) => { + {isUploading && ( + +
+ + +
+ 音频上传中 + {uploadStatusMessage || '正在上传音频文件...'} +
+
+ {displayUploadProgress}% +
+ +
+ )} + {/* ── 转录进度条 ── */} {transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status) && (
正在转录中... - {transcriptionProgress}% + {displayTranscriptionProgress}%
- + +
+ )} + + {isSummaryRunning && ( + +
+ + +
+ AI 正在分析会议内容 + {summaryTaskMessage || '正在生成会议总结,请稍候...'} +
+
+ {displaySummaryProgress}% +
+
)} @@ -816,7 +950,14 @@ const MeetingDetails = ({ user }) => { handleUploadAudio(file)} + customRequest={async ({ file, onSuccess, onError }) => { + try { + await handleUploadAudio(file); + onSuccess?.({}, file); + } catch (error) { + onError?.(error); + } + }} style={{ display: 'none' }} > @@ -1081,18 +1222,6 @@ const MeetingDetails = ({ user }) => { /> )} - {/* 总结生成中进度条 */} - {summaryLoading && ( -
- -
- {summaryTaskMessage || 'AI 正在思考中...'} - {summaryTaskProgress}% -
- -
-
- )} ), },