大家好,我是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>

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐