)
大家好我是Java1234_小锋老师分享一套锋哥原创的AI大模型-基于LangChain的智能会议纪要助手系统(PythonFastAPIVue3)。项目介绍随着远程办公与数字化协作的普及会议成为组织决策与信息同步的重要载体但传统人工整理会议纪要存在效率低、遗漏多、格式不统一等问题。针对上述痛点本文设计并实现了一套基于LangChain的智能会议纪要助手系统。系统采用前后端分离架构后端以Python与FastAPI构建RESTful服务前端以Vue3与Element Plus实现管理端交互数据层使用MySQL存储会议、转写、发言人与纪要信息。在智能处理链路中系统调用阿里云百炼Fun-ASR语音识别大模型完成录音文件异步转写与说话人分离再通过LangChain编排ChatPromptTemplate与ChatOpenAI对接通义千问Qwen大模型分步生成全文摘要、关键词、待办事项与分发言人总结并自动拼装为结构化Markdown纪要。管理员可完成登录鉴权、会议上传、一键处理、发言人重命名、纪要导出Markdown/PDF以及首页数据统计等操作。测试结果表明系统能够稳定完成“上传—转写—纪要生成—展示导出”闭环显著降低会议纪要整理成本具有较好的工程实用价值与推广意义。源码下载链接: https://pan.baidu.com/s/1acZnRquBXz0zM9cp8cuSKQ?pwd1234提取码: 1234系统展示核心代码基于 LangChain 的会议纪要生成服务。 import json import re from typing import Any from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from app.config import get_settings from app.database import SessionLocal from app.models.meeting import Meeting from app.models.transcript import Transcript from app.models.speaker import Speaker from app.models.minutes import Minutes settings get_settings() class LlmService: 会议纪要 LLM 生成服务DashScope OpenAI 兼容接口 LangChain。 def __init__(self): 初始化聊天模型。 # DashScope 兼容 OpenAI SDKbase_url 指向专属域名 self.llm ChatOpenAI( modelsettings.CHAT_MODEL, api_keysettings.OPENAI_API_KEY, base_urlsettings.DASHSCOPE_BASE_URL, temperature0.3, timeout120, ) def _build_transcript_text( self, transcripts: list[Transcript], speakers: dict[int, str] ) - str: 把转写列表拼成可读文本。 lines [] for t in transcripts: name speakers.get(t.speaker_id, f发言人{t.speaker_id 1}) lines.append(f[{name}] {t.text or }) return \n.join(lines) def _extract_json(self, text: str) - Any: 从模型输出中尽量提取 JSON。 text (text or ).strip() # 去掉可能的 markdown 代码块 fence re.search(r(?:json)?\s*([\s\S]*?), text) if fence: text fence.group(1).strip() try: return json.loads(text) except json.JSONDecodeError: # 尝试截取第一个 { 或 [ for opener, closer in (({, }), ([, ])): start text.find(opener) end text.rfind(closer) if start 0 and end start: try: return json.loads(text[start : end 1]) except json.JSONDecodeError: pass raise async def _ainvoke(self, prompt: ChatPromptTemplate, **kwargs) - str: 调用大模型并返回文本内容。 chain prompt | self.llm result await chain.ainvoke(kwargs) content result.content if isinstance(content, list): # 兼容多段 content parts [] for p in content: if isinstance(p, dict) and text in p: parts.append(p[text]) else: parts.append(str(p)) return .join(parts) return str(content) async def generate_summary(self, transcript_text: str) - str: 生成全文摘要。 prompt ChatPromptTemplate.from_messages( [ ( system, 你是专业的会议纪要助手。请基于会议转写内容生成简洁、准确的全文摘要 突出讨论主题、关键结论与共识使用中文300字以内。只输出摘要正文。, ), (human, 会议转写内容\n{transcript}), ] ) return (await self._ainvoke(prompt, transcripttranscript_text)).strip() async def generate_keywords(self, transcript_text: str) - str: 提取关键词返回逗号分隔字符串。 prompt ChatPromptTemplate.from_messages( [ ( system, 你是会议要点提炼助手。请从会议转写中提取 5~10 个关键词。 只输出关键词用英文逗号分隔不要其他说明。, ), (human, 会议转写内容\n{transcript}), ] ) raw (await self._ainvoke(prompt, transcripttranscript_text)).strip() # 规范化 parts [p.strip() for p in re.split(r[,、\n], raw) if p.strip()] return ,.join(parts[:12]) async def generate_todos(self, transcript_text: str) - list[dict]: 提取待办事项。 prompt ChatPromptTemplate.from_messages( [ ( system, 你是会议待办提炼助手。请从会议转写中提取待办事项。 严格输出 JSON 数组格式: [{content:事项,owner:负责人或空,deadline:截止日期或空}]。 若没有待办输出 []。不要输出其他文字。, ), (human, 会议转写内容\n{transcript}), ] ) raw await self._ainvoke(prompt, transcripttranscript_text) data self._extract_json(raw) if not isinstance(data, list): return [] todos [] for item in data: if isinstance(item, dict) and item.get(content): todos.append( { content: str(item.get(content, )), owner: str(item.get(owner) or ), deadline: str(item.get(deadline) or ), } ) elif isinstance(item, str): todos.append({content: item, owner: , deadline: }) return todos async def generate_speaker_summary( self, transcript_text: str, speakers: dict[int, str] ) - dict[str, str]: 按发言人总结发言内容返回 {speaker_id字符串: 总结}。 speaker_list 、.join( [f{sid}:{name} for sid, name in sorted(speakers.items())] ) prompt ChatPromptTemplate.from_messages( [ ( system, 你是会议发言总结助手。请按发言人分别总结其发言要点。 严格输出 JSON 对象键为发言人ID字符串值为总结文本。 例如 {\0\:\...\,\1\:\...\}。不要输出其他文字。, ), ( human, 发言人列表(ID:名称){speaker_list}\n\n会议转写内容\n{transcript}, ), ] ) raw await self._ainvoke( prompt, transcripttranscript_text, speaker_listspeaker_list ) data self._extract_json(raw) if not isinstance(data, dict): return {str(k): 暂无总结 for k in speakers} result {} for sid in speakers: key str(sid) result[key] str(data.get(key) or data.get(speakers[sid]) or 暂无总结) return result def build_markdown( self, title: str, summary: str, keywords: str, todos: list[dict], speaker_summary: dict[str, str], speakers: dict[int, str], transcripts: list[Transcript], ) - str: 拼装完整 Markdown 纪要。 lines [f# {title}, , ## 会议摘要, summary or 无, , ## 关键词] lines.append((keywords or 无).replace(,, , )) lines.extend([, ## 待办事项]) if todos: for t in todos: owner t.get(owner) or 未指定 deadline t.get(deadline) or 未指定 lines.append( f- [ ] {t.get(content)}负责人{owner}截止日期{deadline} ) else: lines.append(- 无) lines.extend([, ## 发言人总结]) for sid, name in sorted(speakers.items()): lines.append(f### {name}) lines.append(speaker_summary.get(str(sid), 暂无总结)) lines.append() lines.extend([## 转写全文, ]) for t in transcripts: name speakers.get(t.speaker_id, f发言人{t.speaker_id 1}) ms t.begin_time or 0 m, s divmod(ms // 1000, 60) lines.append(f**[{m:02d}:{s:02d}] {name}**{t.text or }) lines.append() return \n.join(lines) async def process_meeting(self, meeting_id: int) - None: 后台生成纪要并写入 t_minutes成功后 statusdone。 db SessionLocal() try: meeting db.query(Meeting).filter(Meeting.id meeting_id).first() if not meeting: return meeting.status summarizing meeting.error_msg None db.commit() transcripts ( db.query(Transcript) .filter(Transcript.meeting_id meeting_id) .order_by(Transcript.sentence_id.asc()) .all() ) speaker_rows ( db.query(Speaker).filter(Speaker.meeting_id meeting_id).all() ) speakers { s.speaker_id: (s.speaker_name or f发言人{s.speaker_id 1}) for s in speaker_rows } if not transcripts: raise RuntimeError(无转写内容无法生成纪要) transcript_text self._build_transcript_text(transcripts, speakers) summary await self.generate_summary(transcript_text) keywords await self.generate_keywords(transcript_text) todos await self.generate_todos(transcript_text) speaker_summary await self.generate_speaker_summary( transcript_text, speakers ) markdown self.build_markdown( meeting.title, summary, keywords, todos, speaker_summary, speakers, transcripts, ) existing ( db.query(Minutes).filter(Minutes.meeting_id meeting_id).first() ) if existing: existing.summary summary existing.keywords keywords existing.todos todos existing.speaker_summary speaker_summary existing.markdown markdown else: db.add( Minutes( meeting_idmeeting_id, summarysummary, keywordskeywords, todostodos, speaker_summaryspeaker_summary, markdownmarkdown, ) ) meeting.status done meeting.error_msg None db.commit() except Exception as e: db.rollback() meeting db.query(Meeting).filter(Meeting.id meeting_id).first() if meeting: meeting.status failed meeting.error_msg str(e)[:1000] db.commit() finally: db.close() llm_service LlmService()template !-- 会议纪要详情摘要/关键词/待办/发言总结/转写全文 导出 -- div v-loadingloading div classpage-card header-bar div el-button link typeprimary click$router.back() el-iconArrowLeft //el-icon返回 /el-button span classtitle{{ detail.meeting?.title || 会议详情 }}/span el-tag v-ifdetail.meeting :typestatusTagType(detail.meeting.status) stylemargin-left: 10px {{ statusLabel(detail.meeting.status) }} /el-tag /div div classops el-button typewarning :disabledbusy clickonProcess 一键处理 /el-button el-button typeprimary :disabled!detail.minutes clickexportMarkdown 导出 Markdown /el-button el-button typesuccess :disabled!detail.minutes clickexportPdf 导出 PDF /el-button /div /div el-alert v-ifdetail.meeting?.status failed detail.meeting?.error_msg :titledetail.meeting.error_msg typeerror show-icon stylemargin-top: 12px / el-row :gutter16 stylemargin-top: 16px el-col :span16 div classpage-card idminutes-export-area div classpage-title会议摘要/div p classsummary{{ detail.minutes?.summary || 纪要尚未生成 }}/p div classpage-title stylemargin-top: 20px关键词/div !-- 使用普通 span 而非 el-tag避免 html2pdf/html2canvas 导出时关键词文字丢失 -- div v-ifkeywords.length classtags span v-fork in keywords :keyk classkeyword-tag{{ k }}/span /div div v-else classempty暂无/div div classpage-title stylemargin-top: 20px待办事项/div el-table v-iftodos.length :datatodos stylewidth: 100% table-layoutauto sizesmall el-table-column propcontent label事项 min-width220 / el-table-column propowner label负责人 width120 / el-table-column propdeadline label截止日期 width140 / /el-table div v-else classempty暂无待办/div div classpage-title stylemargin-top: 20px发言人总结/div el-collapse v-ifspeakerSummaryList.length el-collapse-item v-foritem in speakerSummaryList :keyitem.id :titleitem.name p{{ item.summary }}/p /el-collapse-item /el-collapse div v-else classempty暂无/div div classpage-title stylemargin-top: 20px转写全文/div div classtranscript-list div v-fort in detail.transcripts :keyt.id classt-item div classt-meta el-tag sizesmall typeinfo{{ t.speaker_name || 发言人${t.speaker_id 1} }}/el-tag span classtime{{ msToClock(t.begin_time) }}/span /div div classt-text{{ t.text }}/div /div div v-if!detail.transcripts?.length classempty暂无转写内容/div /div /div /el-col el-col :span8 div classpage-card div classpage-title基本信息/div el-descriptions :column1 border sizesmall el-descriptions-item label文件名{{ detail.meeting?.file_name || - }}/el-descriptions-item el-descriptions-item label大小{{ formatFileSize(detail.meeting?.file_size) }}/el-descriptions-item el-descriptions-item label时长{{ formatDuration(detail.meeting?.duration) }}/el-descriptions-item el-descriptions-item label创建时间{{ formatDateTime(detail.meeting?.create_time) }}/el-descriptions-item el-descriptions-item label更新时间{{ formatDateTime(detail.meeting?.update_time) }}/el-descriptions-item /el-descriptions div classpage-title stylemargin-top: 20px发言人重命名/div div v-fors in detail.speakers :keys.id classspeaker-row el-input v-models.speaker_name sizesmall styleflex: 1 / el-button sizesmall typeprimary clickonRename(s)保存/el-button /div div v-if!detail.speakers?.length classempty暂无发言人/div /div /el-col /el-row /div /template script setup /** * 会议纪要详情与导出页面 */ import { computed, onMounted, onBeforeUnmount, reactive, ref } from vue import { useRoute } from vue-router import { ElMessage } from element-plus import html2pdf from html2pdf.js import { getMeetingApi, processMeetingApi, renameSpeakerApi, getMarkdownApi, } from /api/meeting import { formatDateTime, formatDuration, formatFileSize, statusLabel, statusTagType, } from /utils/format const route useRoute() const loading ref(false) const detail reactive({ meeting: null, speakers: [], transcripts: [], minutes: null, }) let pollTimer null const busy computed(() [transcribing, summarizing].includes(detail.meeting?.status) ) const keywords computed(() { const raw detail.minutes?.keywords || return raw .split(/[,]/) .map((s) s.trim()) .filter(Boolean) }) const todos computed(() { const t detail.minutes?.todos return Array.isArray(t) ? t : [] }) const speakerSummaryList computed(() { const map detail.minutes?.speaker_summary || {} return (detail.speakers || []).map((s) ({ id: s.speaker_id, name: s.speaker_name || 发言人${s.speaker_id 1}, summary: map[String(s.speaker_id)] || 暂无总结, })) }) function msToClock(ms) { const sec Math.floor((Number(ms) || 0) / 1000) const m Math.floor(sec / 60) const s sec % 60 return ${String(m).padStart(2, 0)}:${String(s).padStart(2, 0)} } /** 加载详情 */ async function loadDetail() { loading.value true try { const res await getMeetingApi(route.params.id) Object.assign(detail, res.data || {}) } finally { loading.value false } } async function onProcess() { await processMeetingApi(route.params.id) ElMessage.success(已提交处理任务) loadDetail() } async function onRename(s) { if (!s.speaker_name?.trim()) { ElMessage.warning(名称不能为空) return } await renameSpeakerApi(route.params.id, s.speaker_id, s.speaker_name.trim()) ElMessage.success(已保存) loadDetail() } /** 导出 Markdown */ async function exportMarkdown() { const res await getMarkdownApi(route.params.id) const md res.data?.markdown || const title res.data?.title || 会议纪要 const blob new Blob([md], { type: text/markdown;charsetutf-8 }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download ${title}.md a.click() URL.revokeObjectURL(url) } /** 导出 PDF前端 html2pdf中文正常 */ async function exportPdf() { const el document.getElementById(minutes-export-area) if (!el) return const title detail.meeting?.title || 会议纪要 await html2pdf() .set({ margin: 10, filename: ${title}.pdf, image: { type: jpeg, quality: 0.98 }, html2canvas: { scale: 2, useCORS: true }, jsPDF: { unit: mm, format: a4, orientation: portrait }, }) .from(el) .save() } onMounted(() { loadDetail() pollTimer setInterval(() { if (busy.value) loadDetail() }, 5000) }) onBeforeUnmount(() { if (pollTimer) clearInterval(pollTimer) }) /script style scoped .header-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; } .title { font-size: 18px; font-weight: 600; margin-left: 8px; } .ops { display: flex; gap: 8px; flex-wrap: wrap; } .summary { line-height: 1.8; color: #303133; white-space: pre-wrap; } .empty { color: #c0c4cc; } .transcript-list { max-height: 480px; overflow: auto; } .t-item { padding: 10px 0; border-bottom: 1px solid #f0f2f5; } .t-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } .time { color: #909399; font-size: 12px; } .t-text { line-height: 1.7; color: #606266; } .speaker-row { display: flex; gap: 8px; margin-bottom: 10px; } /* 关键词标签纯 CSS保证 PDF 导出可见 */ .tags { display: flex; flex-wrap: wrap; gap: 8px; } .keyword-tag { display: inline-block; padding: 4px 12px; font-size: 13px; line-height: 1.5; color: #409eff; background: #ecf5ff; border: 1px solid #d9ecff; border-radius: 4px; white-space: nowrap; } /style