{ "cells": [ { "cell_type": "markdown", "id": "4384048b", "metadata": {}, "source": [ "# MeetingActionAgent:双智能体会议纪要助手\n", "\n", "> 输入会议文字记录,由 MinutesAgent 提取纪要,再由 ReviewAgent 对照原文审核。\n", "\n", "作者:[@Henry2513](https://github.com/Henry2513)\n", "\n", "日期:2026-08-04\n", "\n", "**适合读者**\n", "- 第一次学习 Agent 或 HelloAgents 的开发者\n", "- 希望理解“生成 Agent + 审核 Agent”协作方式的学习者\n", "\n", "**前置条件**\n", "- Python 3.11+\n", "- 已安装 `requirements.txt`\n", "- 真实运行时需要一个 OpenAI-compatible LLM API\n", "\n", "**学习目标**\n", "- 使用两个 `SimpleAgent` 顺序协作\n", "- 用 Pydantic 校验模型返回的 JSON\n", "- 限制重试次数并生成 JSON、Markdown 两种结果\n" ] }, { "cell_type": "markdown", "id": "e05efd5f", "metadata": {}, "source": [ "## 学习路线\n", "\n", "1. 加载环境与项目路径\n", "2. 定义会议纪要数据结构\n", "3. 解析并渲染结构化结果\n", "4. 创建 MinutesAgent 和 ReviewAgent\n", "5. 编排提取、审核和一次修正\n", "6. 运行真实双 Agent 流程和自检\n" ] }, { "cell_type": "code", "execution_count": null, "id": "5e843101", "metadata": {}, "outputs": [], "source": [ "from __future__ import annotations\n", "\n", "import json\n", "from pathlib import Path\n", "from typing import Literal\n", "\n", "from dotenv import load_dotenv\n", "from hello_agents import HelloAgentsLLM, SimpleAgent\n", "from pydantic import BaseModel, Field, ValidationError\n", "\n", "PROJECT_ROOT = Path.cwd()\n", "load_dotenv(PROJECT_ROOT / \".env\")\n", "print(f\"项目目录: {PROJECT_ROOT}\")\n" ] }, { "cell_type": "markdown", "id": "0877f32d", "metadata": {}, "source": [ "## 1. 双 Agent 架构\n", "\n", "```text\n", "会议文字记录\n", " → MinutesAgent:只提取原文支持的信息\n", " → ReviewAgent:检查遗漏、编造和冲突\n", " → 必要时修正并复核一次\n", " → JSON + Markdown\n", "```\n", "\n", "第一版不使用工具调用。普通 Python 代码负责读取、校验和保存文件,Agent 只负责语言理解与审核。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "dcfe633b", "metadata": {}, "outputs": [], "source": [ "# 表示从会议原文中提取的一条行动项。\n", "class ActionItem(BaseModel):\n", " task: str = Field(min_length=1)\n", " owner: str | None = None\n", " due_date_raw: str | None = None\n", " priority: Literal[\"高\", \"中\", \"低\", \"未说明\"] = \"未说明\"\n", " evidence: str = Field(min_length=1)\n", "\n", "\n", "# 表示最终输出的完整会议纪要及其审核状态。\n", "class MeetingResult(BaseModel):\n", " title: str = Field(min_length=1)\n", " meeting_date: str | None = None\n", " participants: list[str] = Field(default_factory=list)\n", " summary: str = Field(min_length=1)\n", " decisions: list[str] = Field(default_factory=list)\n", " action_items: list[ActionItem] = Field(default_factory=list)\n", " open_questions: list[str] = Field(default_factory=list)\n", " review_status: Literal[\"pending\", \"passed\", \"needs_manual_review\"] = \"pending\"\n", " review_issues: list[str] = Field(default_factory=list)\n", "\n", "\n", "# 表示 ReviewAgent 对纪要草稿的审核结论和问题。\n", "class ReviewResult(BaseModel):\n", " passed: bool\n", " issues: list[str] = Field(default_factory=list)\n", " missing_items: list[str] = Field(default_factory=list)\n", " unsupported_items: list[str] = Field(default_factory=list)\n", " revision_advice: list[str] = Field(default_factory=list)\n", "\n" ] }, { "cell_type": "markdown", "id": "3184f77e", "metadata": {}, "source": [ "## 2. 解析 Agent 返回的 JSON\n", "\n", "模型有时会把 JSON 包在 Markdown 代码围栏中,或者在前后添加一句解释。下面的函数先提取最外层 JSON 对象,再交给 Pydantic 校验。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "fb68843b", "metadata": {}, "outputs": [], "source": [ "# 从模型响应中截取并解析 JSON 对象。\n", "def extract_json_object(text: str) -> dict:\n", " start = text.find(\"{\")\n", " end = text.rfind(\"}\")\n", " if start == -1 or end == -1 or end < start:\n", " raise ValueError(\"模型响应中没有完整的 JSON 对象\")\n", " return json.loads(text[start : end + 1])\n", "\n", "\n", "# 将模型响应解析并验证为指定的 Pydantic 模型。\n", "def parse_model_response(text: str, model_type: type[BaseModel]) -> BaseModel:\n", " return model_type.model_validate(extract_json_object(text))\n" ] }, { "cell_type": "markdown", "id": "ad5a8562", "metadata": {}, "source": [ "## 3. 把结构化结果转换为 Markdown\n", "\n", "Markdown 由普通 Python 生成,避免让模型重复改写已经审核过的内容。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "84801fbf", "metadata": {}, "outputs": [], "source": [ "# 将可空文本整理成适合 Markdown 表格的内容。\n", "def markdown_cell(value: str | None) -> str:\n", " if not value:\n", " return \"未提供\"\n", " return value.replace(\"|\", \"\\\\|\").replace(\"\\n\", \" \")\n", "\n", "\n", "# 将会议纪要转换成 Markdown 文本。\n", "def to_markdown(result: MeetingResult) -> str:\n", " meeting_date = markdown_cell(result.meeting_date)\n", " participants = \"、\".join(result.participants) if result.participants else \"未提供\"\n", " lines = [\n", " f\"# {result.title}\",\n", " \"\",\n", " f\"**会议日期:** {meeting_date}\",\n", " \"\",\n", " f\"**参会者:** {participants}\",\n", " \"\",\n", " \"## 会议摘要\",\n", " \"\",\n", " result.summary,\n", " \"\",\n", " \"## 已确认决策\",\n", " \"\",\n", " ]\n", " lines.extend([f\"- {item}\" for item in result.decisions] or [\"- 无\"])\n", " lines.extend([\n", " \"\",\n", " \"## 行动项\",\n", " \"\",\n", " \"| 任务 | 负责人 | 截止日期(原文) | 优先级 | 原文证据 |\",\n", " \"|---|---|---|---|---|\",\n", " ])\n", " if result.action_items:\n", " for item in result.action_items:\n", " lines.append(\n", " \"| \"\n", " + \" | \".join([\n", " markdown_cell(item.task),\n", " markdown_cell(item.owner),\n", " markdown_cell(item.due_date_raw),\n", " markdown_cell(item.priority),\n", " markdown_cell(item.evidence),\n", " ])\n", " + \" |\"\n", " )\n", " else:\n", " lines.append(\"| 无 | 未提供 | 未提供 | 未说明 | 未提供 |\")\n", "\n", " lines.extend([\"\", \"## 待确认问题\", \"\"])\n", " lines.extend([f\"- {item}\" for item in result.open_questions] or [\"- 无\"])\n", " lines.extend([\"\", \"## 审核状态\", \"\", f\"`{result.review_status}`\"])\n", " if result.review_issues:\n", " lines.extend([\"\", \"### 审核问题\", \"\"])\n", " lines.extend([f\"- {item}\" for item in result.review_issues])\n", " return \"\\n\".join(lines) + \"\\n\"\n", "\n", "\n", "# 将会议结果保存为 JSON 和 Markdown 文件。\n", "def save_result(result: MeetingResult, stem: str = \"meeting_result\") -> tuple[Path, Path]:\n", " output_dir = PROJECT_ROOT / \"outputs\"\n", " json_path = output_dir / f\"{stem}.json\"\n", " markdown_path = output_dir / f\"{stem}.md\"\n", " json_path.write_text(result.model_dump_json(indent=2), encoding=\"utf-8\")\n", " markdown_path.write_text(to_markdown(result), encoding=\"utf-8\")\n", " return json_path, markdown_path\n" ] }, { "cell_type": "markdown", "id": "757240ce", "metadata": {}, "source": [ "## 4. Agent 职责与提示词\n", "\n", "- MinutesAgent 只能提取原文支持的信息,未知字段必须保持为空。\n", "- ReviewAgent 必须同时查看原文和草稿,重点检查遗漏、编造、日期冲突,以及“建议”是否被误写为“决定”。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "db220c24", "metadata": {}, "outputs": [], "source": [ "MINUTES_SYSTEM_PROMPT = \"\"\"你是严谨的中文会议纪要提取专家。\n", "只使用会议原文明确支持的信息,不得补充常识或猜测。\n", "严格区分讨论、建议和已确认决策。\n", "每个行动项必须保留一段原文证据;未知负责人、会议日期或截止日期必须为 null。\n", "只返回符合用户给定 Schema 的 JSON,不要返回 Markdown 或额外解释。\"\"\"\n", "\n", "REVIEW_SYSTEM_PROMPT = \"\"\"你是独立的会议纪要审核员。\n", "必须逐项对照会议原文和纪要草稿,检查遗漏、编造、模糊行动项和日期冲突。\n", "不能因为文字通顺就判定通过,也不能使用外部信息。\n", "只返回符合用户给定 Schema 的 JSON,不要返回 Markdown 或额外解释。\"\"\"\n" ] }, { "cell_type": "markdown", "id": "a3276b50", "metadata": {}, "source": [ "## 5. 有限调用与格式修复\n", "\n", "整个流程共享四次模型调用预算。JSON 首次解析失败时允许请求一次格式修复,但修复同样计入预算。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "d3d42947", "metadata": {}, "outputs": [], "source": [ "class CallBudget:\n", " # 初始化模型调用次数上限。\n", " def __init__(self, maximum: int = 4) -> None:\n", " self.maximum = maximum\n", " self.used = 0\n", "\n", " # 计算剩余的模型调用次数。\n", " @property\n", " def remaining(self) -> int:\n", " return self.maximum - self.used\n", "\n", " # 在次数限制内执行一次 Agent 调用。\n", " def run(self, agent, prompt: str) -> str:\n", " if self.remaining <= 0:\n", " raise RuntimeError(\"已达到四次模型调用上限\")\n", " self.used += 1\n", " return agent.run(prompt)\n", "\n", "\n", "# 调用 Agent 并将响应解析为指定的数据模型。\n", "def run_structured(\n", " agent,\n", " prompt: str,\n", " model_type: type[BaseModel],\n", " budget: CallBudget,\n", ") -> BaseModel:\n", " schema = json.dumps(model_type.model_json_schema(), ensure_ascii=False)\n", " full_prompt = f\"{prompt}\\n\\n必须遵循以下 JSON Schema:\\n{schema}\"\n", " raw_response = budget.run(agent, full_prompt)\n", " try:\n", " return parse_model_response(raw_response, model_type)\n", " except ValueError as error:\n", " if budget.remaining <= 0:\n", " raise RuntimeError(f\"JSON 校验失败且没有剩余调用次数:{error}\") from error\n", " repair_prompt = (\n", " \"上一次响应无法通过 JSON 校验。不要改变内容含义,只修复格式。\\n\"\n", " f\"校验错误:{error}\\n\"\n", " f\"原响应:\\n{raw_response}\\n\"\n", " f\"目标 Schema:\\n{schema}\\n\"\n", " \"只返回修复后的 JSON。\"\n", " )\n", " repaired_response = budget.run(agent, repair_prompt)\n", " return parse_model_response(repaired_response, model_type)\n", "\n", "\n", "# 创建 MinutesAgent 和 ReviewAgent。\n", "def build_agents():\n", " llm = HelloAgentsLLM()\n", " minutes_agent = SimpleAgent(name=\"MinutesAgent\", llm=llm, system_prompt=MINUTES_SYSTEM_PROMPT)\n", " review_agent = SimpleAgent(name=\"ReviewAgent\", llm=llm, system_prompt=REVIEW_SYSTEM_PROMPT)\n", " return minutes_agent, review_agent\n" ] }, { "cell_type": "markdown", "id": "e05c43ac", "metadata": {}, "source": [ "## 6. 完整分析流程\n", "\n", "首次审核通过时只调用两次模型;未通过且仍有两次预算时,MinutesAgent 修正一次,再由 ReviewAgent 最终复核。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "225d6a8f", "metadata": {}, "outputs": [], "source": [ "# 检查并清理输入的会议文本。\n", "def validate_transcript(transcript: str) -> str:\n", " cleaned = transcript.strip()\n", " if len(cleaned) < 20:\n", " raise ValueError(\"会议记录过短,请至少提供 20 个字符\")\n", " return cleaned\n", "\n", "\n", "# 组合审核会议纪要所需的提示词。\n", "def make_review_prompt(transcript: str, draft: MeetingResult) -> str:\n", " return (\n", " \"请审核以下会议纪要草稿。\\n\\n\"\n", " f\"【会议原文】\\n{transcript}\\n\\n\"\n", " f\"【纪要草稿】\\n{draft.model_dump_json(indent=2)}\"\n", " )\n", "\n", "\n", "# 执行纪要提取、审核和必要时修正的完整流程。\n", "def analyze_meeting(transcript: str) -> tuple[MeetingResult, ReviewResult, int]:\n", " transcript = validate_transcript(transcript)\n", " minutes_agent, review_agent = build_agents()\n", " budget = CallBudget(maximum=4)\n", "\n", " draft_prompt = f\"请从以下会议原文提取结构化纪要:\\n\\n{transcript}\"\n", " draft = run_structured(minutes_agent, draft_prompt, MeetingResult, budget)\n", " review = run_structured(review_agent, make_review_prompt(transcript, draft), ReviewResult, budget)\n", "\n", " if review.passed:\n", " final_result = draft.model_copy(update={\"review_status\": \"passed\", \"review_issues\": []})\n", " return final_result, review, budget.used\n", "\n", " issues = review.issues + review.missing_items + review.unsupported_items\n", " if budget.remaining < 2:\n", " final_result = draft.model_copy(\n", " update={\"review_status\": \"needs_manual_review\", \"review_issues\": issues}\n", " )\n", " return final_result, review, budget.used\n", "\n", " revision_prompt = (\n", " \"请根据审核意见修正纪要。仍然只能使用会议原文支持的信息。\\n\\n\"\n", " f\"【会议原文】\\n{transcript}\\n\\n\"\n", " f\"【原草稿】\\n{draft.model_dump_json(indent=2)}\\n\\n\"\n", " f\"【审核意见】\\n{review.model_dump_json(indent=2)}\"\n", " )\n", " revised = run_structured(minutes_agent, revision_prompt, MeetingResult, budget)\n", " final_review = run_structured(review_agent, make_review_prompt(transcript, revised), ReviewResult, budget)\n", " final_issues = final_review.issues + final_review.missing_items + final_review.unsupported_items\n", " status = \"passed\" if final_review.passed else \"needs_manual_review\"\n", " final_result = revised.model_copy(update={\"review_status\": status, \"review_issues\": final_issues})\n", " return final_result, final_review, budget.used\n" ] }, { "cell_type": "markdown", "id": "8d2fe2d1", "metadata": {}, "source": [ "## 7. 运行双 Agent 会议示例\n", "\n", "本单元格直接读取模型配置并执行 MinutesAgent、ReviewAgent。运行前必须在 `.env` 中填写 `LLM_MODEL_ID`、`LLM_API_KEY` 和 `LLM_BASE_URL`。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "ce32abf9", "metadata": {}, "outputs": [], "source": [ "sample_transcript = (PROJECT_ROOT / \"data\" / \"sample_meeting.txt\").read_text(encoding=\"utf-8\")\n", "result, _, call_count = analyze_meeting(sample_transcript)\n", "json_path, markdown_path = save_result(result)\n", "print(f\"模型调用次数: {call_count}\")\n", "print(f\"审核状态: {result.review_status}\")\n", "print(f\"已保存: {json_path.name}, {markdown_path.name}\")\n" ] }, { "cell_type": "markdown", "id": "ce2a14c3", "metadata": {}, "source": [ "## 8. 结构与编排自检\n", "\n", "这些检查不调用模型,验证 Pydantic 结构、JSON 提取、缺失字段、空行动项、空输入和 Markdown 渲染。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "bb6f6818", "metadata": {}, "outputs": [], "source": [ "expected_path = PROJECT_ROOT / \"outputs\" / \"example_result.json\"\n", "expected_result = MeetingResult.model_validate_json(expected_path.read_text(encoding=\"utf-8\"))\n", "\n", "fenced = \"```json\\n\" + expected_result.model_dump_json() + \"\\n```\"\n", "assert parse_model_response(fenced, MeetingResult).title == \"新用户注册功能迭代\"\n", "assert expected_result.meeting_date == \"2026-07-27\"\n", "assert expected_result.action_items[-1].owner is None\n", "\n", "empty_actions = MeetingResult(\n", " title=\"信息同步会\",\n", " participants=[],\n", " summary=\"本次会议仅同步信息,没有形成行动项。\",\n", " decisions=[],\n", " action_items=[],\n", " open_questions=[],\n", ")\n", "assert empty_actions.action_items == []\n", "assert \"| 无 |\" in to_markdown(empty_actions)\n", "\n", "try:\n", " validate_transcript(\"太短\")\n", "except ValueError:\n", " pass\n", "else:\n", " raise AssertionError(\"过短会议记录应被拒绝\")\n", "\n", "try:\n", " ActionItem(task=\"测试\", owner=None, due_date_raw=None, priority=\"紧急\", evidence=\"原文\")\n", "except ValidationError:\n", " pass\n", "else:\n", " raise AssertionError(\"非法优先级应被 Pydantic 拒绝\")\n", "\n", "rendered = to_markdown(expected_result)\n", "tracked_markdown = (PROJECT_ROOT / \"outputs\" / \"example_minutes.md\").read_text(encoding=\"utf-8\")\n", "assert rendered == tracked_markdown\n", "print(\"自检通过:6 组\")\n" ] }, { "cell_type": "markdown", "id": "abc42119", "metadata": {}, "source": [ "## 练习:分析边界会议\n", "\n", "打开 `data/edge_case_meeting.txt`,先人工预测结果:\n", "\n", "1. “可以考虑下个月上线”是否属于已确认决策?\n", "2. 测试环境确认任务是否有负责人?\n", "3. 产品文档的日期是否存在冲突?\n", "\n", "启用真实模型后,可以把下面的 `edge_transcript` 传给 `analyze_meeting`,再与预测比较。\n" ] }, { "cell_type": "code", "execution_count": null, "id": "f8882d28", "metadata": {}, "outputs": [], "source": [ "edge_transcript = (PROJECT_ROOT / \"data\" / \"edge_case_meeting.txt\").read_text(encoding=\"utf-8\")\n", "answer_scaffold = {\n", " \"confirmed_launch_decision\": False,\n", " \"test_environment_owner\": None,\n", " \"document_date_conflict\": True,\n", "}\n", "answer_scaffold\n" ] }, { "cell_type": "markdown", "id": "23f22c4d", "metadata": {}, "source": [ "## 常见问题与下一步\n", "\n", "- **把建议写成决定**:Reviewer 必须检查“考虑、建议、可能”等措辞。\n", "- **编造负责人或日期**:未知值保持 `null`,最终 Markdown 显示“未提供”。\n", "- **JSON 不稳定**:允许一次格式修复,但仍受四次调用预算限制。\n", "- **多份会议连续分析**:每次调用 `analyze_meeting` 都会创建新的 Agent,避免历史记录互相污染。\n", "\n", "第二版可增加日期标准化工具,但第一版保持双 Agent、无工具调用。\n" ] } ], "metadata": { "kernelspec": { "display_name": "MeetingActionAgent", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.11" } }, "nbformat": 4, "nbformat_minor": 5 }