|
|
@@ -0,0 +1,560 @@
|
|
|
+{
|
|
|
+ "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
|
|
|
+}
|