昨天我們強化了中文時間解析能力,並且將轉錄引擎從 OpenAI Whisper 升級到 Faster-Whisper 最佳化了記憶體佔用,讓系統在資源有限的硬體上也能穩定運作。
今天的目標是為 AI 會議助理增加下載會議摘要與匯出任務清單這兩項功能,這兩項功能都可以讓使用者輕鬆地將 AI 分析的結果儲存為獨立檔案,無論是進行線下編輯、歸檔,還是匯入其他專案管理工具,都將變得非常方便。
在開始之前,我先規劃一下整體架構。最直觀的設計,就是當使用者在「歷史記錄」頁面點擊一筆會議記錄後,除了顯示詳細內容,同時也動態顯示下載相關檔案的按鈕。
我決定在顯示會議詳細內容的「歷史記錄詳情」Markdown 元件下方,新增兩個按鈕與兩個用於下載的 gr.File
元件
gr.File
元件:這兩個元件平常是隱藏的,當後端成功生成檔案後,它們會帶著檔案路徑出現,供使用者點擊下載這樣的設計符合使用者的操作邏輯「先選擇、再操作」。
為了支援前端介面,我需要在 app.py
中新增幾個核心函式:
generate_summary_markdown(...)
:負責生成包含完整會議資訊的 Markdown 檔案內容。generate_tasks_csv(...)
:負責解析原始的 Markdown 任務清dan,並轉換為 CSV 格式。handle_download_summary(...)
、 handle_export_tasks(...)
:這兩個是事件處理函式,它們會被 Gradio 按鈕觸發,呼叫上述生成函式,並將產生的檔案路徑回傳給前端的 gr.File
元件。接下來我開始建立最核心的檔案生成函式,這些函式將負責把記憶體中的會議記錄資料,轉換成使用者需要的檔案格式。
先建立一個函式來產生結構化的 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
record
字典,回傳一個 str
字串。.get(key, '預設值')
的方式來取值,避免因為缺少某個鍵而導致程式崩潰。對於參與者,我還額外判斷了空列表的情況。if
判斷來決定是否要顯示該欄位,讓輸出更乾淨。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 中會有一行提示訊息。有了後端的檔案生成邏輯,現在就是把它們跟 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_summary
和 handle_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
元件。整個流程大概是
click
事件。handle_
函式被呼叫,它會接收到當前選取記錄的索引 selected_index
。generate_summary_markdown
或 generate_tasks_csv
,取得檔案內容或路徑。gr.File
物件,並將 value
設為暫存檔案的路徑。visible
預設為 False
,只有在成功生成檔案後才顯示出來。✅ 完成項目
generate_summary_markdown
函式,能將會議記錄轉換為結構化的 Markdown 文件。generate_tasks_csv
函式,能智慧解析 Markdown 任務並逐項匯出為 CSV 格式。今天我將前幾天累積的 AI 分析能力,轉化為具體的、可攜帶的產出,這讓 AI 助理不再只是一個顯示資訊的工具,而是一個能真正融入工作流程的生產力夥伴,就可以輕鬆地將會議摘要分享到團隊頻道,或將任務清單匯入到其它工具中,實用性大大提升。
在實作 generate_tasks_csv
的過程中我遇到了不少挫折,我再次感受到正規表示式和狀態管理的強大。透過精巧設計的程式邏輯,依然可以從中提取出結構化的資料。
🎯 明天計劃
將 API URL 等硬編碼設定分離至 .env 檔案,並修改程式以讀取設定檔,提升系統靈活性與安全性。