分享一套锋哥原创的AI大模型-基于LangChain的智能会议纪要助手系统(Python+FastAPI+Vue3)

📅 2026/7/11 9:22:26 👁️ 阅读次数 📝 编程学习
分享一套锋哥原创的AI大模型-基于LangChain的智能会议纪要助手系统(Python+FastAPI+Vue3)

大家好,我是Java1234_小锋老师,分享一套锋哥原创的AI大模型-基于LangChain的智能会议纪要助手系统(Python+FastAPI+Vue3)。

项目介绍

随着远程办公与数字化协作的普及,会议成为组织决策与信息同步的重要载体,但传统人工整理会议纪要存在效率低、遗漏多、格式不统一等问题。针对上述痛点,本文设计并实现了一套基于LangChain的智能会议纪要助手系统。系统采用前后端分离架构:后端以Python与FastAPI构建RESTful服务,前端以Vue3与Element Plus实现管理端交互;数据层使用MySQL存储会议、转写、发言人与纪要信息。在智能处理链路中,系统调用阿里云百炼Fun-ASR语音识别大模型完成录音文件异步转写与说话人分离,再通过LangChain编排ChatPromptTemplate与ChatOpenAI,对接通义千问Qwen大模型,分步生成全文摘要、关键词、待办事项与分发言人总结,并自动拼装为结构化Markdown纪要。管理员可完成登录鉴权、会议上传、一键处理、发言人重命名、纪要导出(Markdown/PDF)以及首页数据统计等操作。测试结果表明,系统能够稳定完成“上传—转写—纪要生成—展示导出”闭环,显著降低会议纪要整理成本,具有较好的工程实用价值与推广意义。

源码下载

链接: https://pan.baidu.com/s/1acZnRquBXz0zM9cp8cuSKQ?pwd=1234
提取码: 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 SDK,base_url 指向专属域名 self.llm = ChatOpenAI( model=settings.CHAT_MODEL, api_key=settings.OPENAI_API_KEY, base_url=settings.DASHSCOPE_BASE_URL, temperature=0.3, timeout=120, ) 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, transcript=transcript_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, transcript=transcript_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, transcript=transcript_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, transcript=transcript_text, speaker_list=speaker_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,成功后 status=done。""" 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_id=meeting_id, summary=summary, keywords=keywords, todos=todos, speaker_summary=speaker_summary, markdown=markdown, ) ) 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-loading="loading"> <div class="page-card header-bar"> <div> <el-button link type="primary" @click="$router.back()"> <el-icon><ArrowLeft /></el-icon>返回 </el-button> <span class="title">{{ detail.meeting?.title || '会议详情' }}</span> <el-tag v-if="detail.meeting" :type="statusTagType(detail.meeting.status)" style="margin-left: 10px"> {{ statusLabel(detail.meeting.status) }} </el-tag> </div> <div class="ops"> <el-button type="warning" :disabled="busy" @click="onProcess" > 一键处理 </el-button> <el-button type="primary" :disabled="!detail.minutes" @click="exportMarkdown"> 导出 Markdown </el-button> <el-button type="success" :disabled="!detail.minutes" @click="exportPdf"> 导出 PDF </el-button> </div> </div> <el-alert v-if="detail.meeting?.status === 'failed' && detail.meeting?.error_msg" :title="detail.meeting.error_msg" type="error" show-icon style="margin-top: 12px" /> <el-row :gutter="16" style="margin-top: 16px"> <el-col :span="16"> <div class="page-card" id="minutes-export-area"> <div class="page-title">会议摘要</div> <p class="summary">{{ detail.minutes?.summary || '纪要尚未生成' }}</p> <div class="page-title" style="margin-top: 20px">关键词</div> <!-- 使用普通 span 而非 el-tag,避免 html2pdf/html2canvas 导出时关键词文字丢失 --> <div v-if="keywords.length" class="tags"> <span v-for="k in keywords" :key="k" class="keyword-tag">{{ k }}</span> </div> <div v-else class="empty">暂无</div> <div class="page-title" style="margin-top: 20px">待办事项</div> <el-table v-if="todos.length" :data="todos" style="width: 100%" table-layout="auto" size="small"> <el-table-column prop="content" label="事项" min-width="220" /> <el-table-column prop="owner" label="负责人" width="120" /> <el-table-column prop="deadline" label="截止日期" width="140" /> </el-table> <div v-else class="empty">暂无待办</div> <div class="page-title" style="margin-top: 20px">发言人总结</div> <el-collapse v-if="speakerSummaryList.length"> <el-collapse-item v-for="item in speakerSummaryList" :key="item.id" :title="item.name" > <p>{{ item.summary }}</p> </el-collapse-item> </el-collapse> <div v-else class="empty">暂无</div> <div class="page-title" style="margin-top: 20px">转写全文</div> <div class="transcript-list"> <div v-for="t in detail.transcripts" :key="t.id" class="t-item"> <div class="t-meta"> <el-tag size="small" type="info">{{ t.speaker_name || `发言人${t.speaker_id + 1}` }}</el-tag> <span class="time">{{ msToClock(t.begin_time) }}</span> </div> <div class="t-text">{{ t.text }}</div> </div> <div v-if="!detail.transcripts?.length" class="empty">暂无转写内容</div> </div> </div> </el-col> <el-col :span="8"> <div class="page-card"> <div class="page-title">基本信息</div> <el-descriptions :column="1" border size="small"> <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 class="page-title" style="margin-top: 20px">发言人重命名</div> <div v-for="s in detail.speakers" :key="s.id" class="speaker-row"> <el-input v-model="s.speaker_name" size="small" style="flex: 1" /> <el-button size="small" type="primary" @click="onRename(s)">保存</el-button> </div> <div v-if="!detail.speakers?.length" class="empty">暂无发言人</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;charset=utf-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>