iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
生成式 AI

打造基於 MCP 協議與 n8n 工作流的會議處理 Agent系列 第 25

Day 25 實用性升級 — 新增會議記錄與任務清單下載功能

  • 分享至 

  • xImage
  •  

昨天我們強化了中文時間解析能力,並且將轉錄引擎從 OpenAI Whisper 升級到 Faster-Whisper 最佳化了記憶體佔用,讓系統在資源有限的硬體上也能穩定運作。

今天的目標是為 AI 會議助理增加下載會議摘要匯出任務清單這兩項功能,這兩項功能都可以讓使用者輕鬆地將 AI 分析的結果儲存為獨立檔案,無論是進行線下編輯、歸檔,還是匯入其他專案管理工具,都將變得非常方便。

今天的目標與挑戰

  • 在「歷史記錄」頁面中,新增「下載會議摘要(.md)」與「匯出任務清單(.csv)」兩個按鈕
  • 修改後端邏輯,將選定的會議記錄轉換為 Markdown 格式
  • 設計將 Markdown 任務清單轉換並封裝為 CSV 檔案的邏輯
  • 整合 Gradio 的檔案下載功能,提供使用者流暢的下載體驗
  • 確保產生的檔案內容格式正確且命名清晰

Step 1:規劃介面與後端函式

在開始之前,我先規劃一下整體架構。最直觀的設計,就是當使用者在「歷史記錄」頁面點擊一筆會議記錄後,除了顯示詳細內容,同時也動態顯示下載相關檔案的按鈕。

1-1 使用者介面(UI)規劃

我決定在顯示會議詳細內容的「歷史記錄詳情」Markdown 元件下方,新增兩個按鈕與兩個用於下載的 gr.File 元件

  1. 下載會議摘要(.md)按鈕:點擊後觸發後端生成 Markdown 檔案
  2. 匯出任務清單(.csv)按鈕:點擊後觸發後端生成 CSV 檔案
  3. 隱藏的 gr.File 元件:這兩個元件平常是隱藏的,當後端成功生成檔案後,它們會帶著檔案路徑出現,供使用者點擊下載

這樣的設計符合使用者的操作邏輯「先選擇、再操作」。

1-2 後端邏輯規劃

為了支援前端介面,我需要在 app.py 中新增幾個核心函式:

  • generate_summary_markdown(...):負責生成包含完整會議資訊的 Markdown 檔案內容。
  • generate_tasks_csv(...):負責解析原始的 Markdown 任務清dan,並轉換為 CSV 格式。
  • handle_download_summary(...)handle_export_tasks(...):這兩個是事件處理函式,它們會被 Gradio 按鈕觸發,呼叫上述生成函式,並將產生的檔案路徑回傳給前端的 gr.File 元件。

Step 2:建立後端檔案生成邏輯

接下來我開始建立最核心的檔案生成函式,這些函式將負責把記憶體中的會議記錄資料,轉換成使用者需要的檔案格式。

2-1 建立 Markdown 摘要生成函式

先建立一個函式來產生結構化的 Markdown 會議摘要,這個函式不僅包含摘要本文,還會把專案名稱、會議類型等元資訊一併寫入,方便歸檔。

app.py 裡加入以下函式

import csv
import tempfile
from typing import Optional, Dict, Tuple

def generate_summary_markdown(record: Dict) -> str:
    """
    根據會議記錄產生完整的 Markdown 格式字串。

    參數:
        record (dict): 包含所有會議資訊的字典物件。

    回傳:
        str: 格式化後的 Markdown 字串。
    """
    # 根據會議記錄產生完整的 Markdown 格式字串。
    if not record:
        return "# 錯誤\n\n無法產生摘要,會議記錄為空。"

    participants_str = ", ".join(record.get("participants", [])) or "未記錄"

    markdown_content = f"""# 會議記錄:{record.get('project_name', '未指定專案')}

**日期**:{record.get('timestamp', 'N/A')}
**會議類型**:{record.get('meeting_type', 'N/A')}
**參與者**:{participants_str}
**Session ID**:{record.get('session_id', 'N/A')}
"""

    if record.get("notion_url"):
        markdown_content += f"**Notion 連結**:[點此查看]({record['notion_url']})\n"

    markdown_content += f"""
---

## 會議摘要

{record.get('summary', '無摘要')}

---

## 行動任務

{record.get('tasks', '無任務')}
"""
    return markdown_content

程式說明

  • Google 風格 Docstring:我採用了 Google 風格的 docstring,清楚標示函式的用途、參數(Args)與回傳值(Returns)。
  • 結構化內容:函式將專案名稱設為 H1 標題,並將日期、類型、參與者等重要元資訊以粗體標示,最後才附上摘要與任務,結構清晰易讀。
  • 參數與回傳:它接收 record 字典,回傳一個 str 字串。
  • 優雅處理空值:我大量使用 .get(key, '預設值') 的方式來取值,避免因為缺少某個鍵而導致程式崩潰。對於參與者,我還額外判斷了空列表的情況。
  • 條件式欄位:Notion 連結不是每筆記錄都有,所以我用一個 if 判斷來決定是否要顯示該欄位,讓輸出更乾淨。
  • F-string 大顯神威:透過 f-string 的多行字串功能,我可以像寫模板一樣輕鬆地組織出漂亮的 Markdown 結構。

2-2 建立 CSV 任務清單生成函式

CSV 的生成比較複雜一些,因為原始的任務資料是有特定格式的。我需要先解析每個任務的「編號」、「負責人」、「期限」、「內容」拆分到不同的欄位,最後再用 Python 內建的 csv 模組來生成標準的 CSV 格式。

def generate_tasks_csv(record: Dict) -> str:
    """
    解析任務並輸出為 CSV 檔案。

    參數:
        record (dict): 包含所有會議資訊的字典物件。

    回傳:
        str: CSV 檔案的暫存路徑。
    """
    # 解析任務並以獨立列輸出到 CSV。
    import re

    output = tempfile.NamedTemporaryFile(
        mode="w+", delete=False, suffix=".csv", encoding="utf-8-sig"
    )

    try:
        writer = csv.writer(output)
        writer.writerow(["任務編號", "負責人", "期限", "內容摘要"])

        tasks_content = record.get("tasks", "").strip()
        if not tasks_content:
            writer.writerow(["無任務", "", "", ""])
            return output.name

        lines = tasks_content.split("\n")
        current_task = {"number": "", "responsible": "", "deadline": "", "content": ""}
        task_count = 0
        in_task_block = False

        for line in lines:
            line = line.strip()
            if not line or line == "格式化的任務清單":
                continue

            task_match = re.match(r"^任務\s*(\d+)", line)
            if task_match:
                if in_task_block and current_task["number"]:
                    writer.writerow(
                        [
                            current_task["number"],
                            current_task["responsible"],
                            current_task["deadline"],
                            current_task["content"],
                        ]
                    )
                    task_count += 1

                current_task = {
                    "number": line,
                    "responsible": "",
                    "deadline": "",
                    "content": "",
                }
                in_task_block = True
                continue

            if in_task_block:
                if "負責人" in line:
                    match = re.search(r"[•-]\s*負責人[::]\s*(.+)", line)
                    if match:
                        current_task["responsible"] = match.group(1).strip()
                elif "內容" in line:
                    match = re.search(r"[•-]\s*內容[::]\s*(.+)", line)
                    if match:
                        current_task["content"] = match.group(1).strip()
                elif "期限" in line:
                    match = re.search(r"[•-]\s*期限[::]\s*(.+)", line)
                    if match:
                        current_task["deadline"] = match.group(1).strip()

        if in_task_block and current_task["number"]:
            writer.writerow(
                [
                    current_task["number"],
                    current_task["responsible"],
                    current_task["deadline"],
                    current_task["content"],
                ]
            )
            task_count += 1

        if task_count == 0:
            writer.writerow(["無法解析任務", "", "", tasks_content[:100]])

    finally:
        output.close()

    return output.name

程式說明

  • 暫存檔案:使用 tempfile.NamedTemporaryFile 是個好方法,它能建立一個唯一的暫存檔案,並在程式結束後自動刪除(若 delete=True)。這裡我設為 False,因為 Gradio 需要這個檔案路徑來提供下載。
  • 逐行解析:函式的核心是一個 for 迴圈,它一行一行地讀取任務內容。
  • 狀態切換:當一行以 「- 任務 X」 開頭時,就代表一個新任務的開始。我會先儲存前一個任務的資料,然後重設 current_task 字典。
  • 正規表示式:我用 re.search 來分別從行內提取「負責人」、「內容」和「期限」的資訊,這種寫法比 split() 更具彈性,能接受冒號是全形或半形。
  • 邊界處理:最後要記得將迴圈結束後的最後一筆 current_task 寫入。我也加入了防呆機制,如果完全沒解析到任何任務,CSV 中會有一行提示訊息。

Step 3:整合前端與後端

有了後端的檔案生成邏輯,現在就是把它們跟 Gradio 的介面串接起來。我在「歷史記錄」頁面的版面配置中,加入了兩個按鈕元件 gr.Button 和兩個檔案下載元件 gr.File

# ... 在 Gradio 的 with gr.Blocks() 內部 ...
with gr.Tab("歷史記錄"):
    # ... 其他元件 ...
    with gr.Column(scale=3):
        history_output = gr.Markdown(value="### 記錄詳情\n👈 點擊左側會議以載入詳細記錄")
        with gr.Row():
            # 新增下載按鈕
            download_summary_btn = gr.Button(
                "下載 Markdown 會議摘要", variant="secondary", size="sm"
            )
            export_tasks_btn = gr.Button(
                "匯出 CSV 任務清單", variant="secondary", size="sm"
            )

        with gr.Row():
            # 用於接收檔案路徑並提供下載
            summary_download_file = gr.File(
                label="會議摘要檔案", visible=False
            )
            tasks_download_file = gr.File(
                label="任務清單檔案", visible=False
            )
    # ... 其他元件 ...

接著,我需要撰寫兩個「事件處理函式」handle_download_summaryhandle_export_tasks,並將它們綁定到按鈕的 click 事件上。

def handle_download_summary(selected_index: Optional[int]) -> Tuple[gr.File, str]:
    """處理下載 Markdown 摘要的點擊事件。"""
    if selected_index is None:
        return gr.File(visible=False), "請先選擇一筆記錄"
    
    # 從 history_manager 取得完整記錄
    all_records = history_manager.get_all_records()
    
    if selected_index >= len(all_records):
        return gr.File(visible=False), "無效的記錄索引"
    
    record_dict, session_id = normalize_record(all_records[selected_index])
    
    # 呼叫 Markdown 生成函式
    markdown_content = generate_summary_markdown(record_dict)

    # 建立暫存檔來儲存 Markdown
    with tempfile.NamedTemporaryFile(
        mode="w", delete=False, suffix=".md", encoding="utf-8"
    ) as tmpfile:
        tmpfile.write(markdown_content)
        filepath = tmpfile.name
        
    return gr.File(value=filepath, label=f"{session_id}_summary.md", visible=True), f"摘要檔案已準備就緒!"

def handle_export_tasks(selected_index: Optional[int]) -> Tuple[gr.File, str]:
    """處理匯出 CSV 任務的點擊事件。"""
    if selected_index is None:
        return gr.File(visible=False), "請先選擇一筆記錄"
    
    # 從 history_manager 取得完整記錄
    all_records = history_manager.get_all_records()

    if selected_index >= len(all_records):
        return gr.File(visible=False), "無效的記錄索引"
    
    record_dict, session_id = normalize_record(all_records[selected_index])
    
    # 呼叫 CSV 生成函式
    csv_filepath = generate_tasks_csv(record_dict)

    return gr.File(value=csv_filepath, label=f"{session_id}_tasks.csv", visible=True), f"任務清單 CSV 已準備就緒!"

# ... 在 Gradio 的 with gr.Blocks() 內部 ...
# 綁定事件
download_summary_btn.click(
    fn=handle_download_summary,
    inputs=selected_record_index,
    outputs=[summary_download_file, download_status] # download_status 是個 gr.Textbox
)
export_tasks_btn.click(
    fn=handle_export_tasks,
    inputs=selected_record_index,
    outputs=[tasks_download_file, download_status]
)

整合說明

這個串接的關鍵在於 Gradio 的 gr.File 元件。整個流程大概是

  1. 使用者點擊按鈕,觸發 click 事件。
  2. 對應的 handle_ 函式被呼叫,它會接收到當前選取記錄的索引 selected_index
  3. 函式內部呼叫我們先前寫好的 generate_summary_markdowngenerate_tasks_csv,取得檔案內容或路徑。
  4. 函式回傳一個 gr.File 物件,並將 value 設為暫存檔案的路徑。
  5. Gradio 收到這個回傳後,會自動在介面上顯示一個檔案元件,使用者點擊即可下載。我將 visible 預設為 False,只有在成功生成檔案後才顯示出來。

Step 4:驗證與測試

4-1 下載 Markdown 檔案

Download Markdown
Markdown

4-2 下載 CSV 檔案

Download CSV
CSV


今天的成果總結

完成項目

  • 在 Gradio 介面中成功新增了「下載會議摘要」和「匯出任務清單」的功能按鈕。
  • 建立了 generate_summary_markdown 函式,能將會議記錄轉換為結構化的 Markdown 文件。
  • 打造了 generate_tasks_csv 函式,能智慧解析 Markdown 任務並逐項匯出為 CSV 格式。
  • 完成了前端按鈕與後端檔案生成邏輯的整合,實現了一鍵下載功能。
  • 為所有新函式撰寫了符合 Google 風格的 docstring 註解,提升了程式碼的可維護性。

心得

今天我將前幾天累積的 AI 分析能力,轉化為具體的、可攜帶的產出,這讓 AI 助理不再只是一個顯示資訊的工具,而是一個能真正融入工作流程的生產力夥伴,就可以輕鬆地將會議摘要分享到團隊頻道,或將任務清單匯入到其它工具中,實用性大大提升。

在實作 generate_tasks_csv 的過程中我遇到了不少挫折,我再次感受到正規表示式和狀態管理的強大。透過精巧設計的程式邏輯,依然可以從中提取出結構化的資料。

🎯 明天計劃

將 API URL 等硬編碼設定分離至 .env 檔案,並修改程式以讀取設定檔,提升系統靈活性與安全性。


上一篇
Day 24 效能與精度雙升級 — 導入優先級時間解析與 Faster-Whisper
下一篇
Day 26 系統設定檔與環境變數管理 — 提升專案靈活性與安全性
系列文
打造基於 MCP 協議與 n8n 工作流的會議處理 Agent28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言