分享一套锋哥原创的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>
更多推荐




所有评论(0)