前面的系列已经覆盖了模型调用、消息组织、结构化输出、错误处理、提示词测试和人工审批。本篇在路线完成后做一个小型综合项目:读取一份原始会议记录,请模型提取摘要、决定事项和行动项,程序严格校验结果后保存为 JSON。项目不自动发送通知,也不把模型的猜测当成事实,重点是练习如何把生成能力放进可复核的应用边界。
先定义输入和输出
输入是一份纯文本会议记录,输出包含 summary、decisions 和 actions 三个字段。每个行动项只有 owner、task、due 和 status:负责人不明确时使用 null,日期不明确时也使用 null,status 只能是 open。模型必须只依据原文整理,不能替参会者补充未说过的截止日期。
数据流很简单:读取文件、调用模型、解析 JSON、校验字段、人工查看、保存结果。模型只负责提出候选纪要,Python 负责长度限制和格式边界,人工负责确认事实。即使模型返回格式正确,内容仍可能遗漏或理解错误,所以“通过校验”不等于“已经发布”。
准备环境和配置
在独立虚拟环境中安装官方 Python SDK 和环境变量加载库:
1 2 3
| python -m venv .venv source .venv/bin/activate python -m pip install openai python-dotenv
|
在项目目录创建 .env,值只作为占位示例,真实密钥不要写进代码:
1 2 3
| OPENAI_API_KEY=替换为你的真实密钥 MODEL_NAME=替换为你可用的模型名称 OPENAI_BASE_URL=
|
同时把 .env 加入 .gitignore。如果使用兼容服务,只有在服务商文档确认兼容同一 SDK 接口时,才配置 OPENAI_BASE_URL。模型名称从环境变量读取,避免把某个具体模型和示例绑定。
编写整理程序
新建 minutes.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
| import json import os import sys from pathlib import Path
from dotenv import load_dotenv from openai import OpenAI
load_dotenv() client = OpenAI( api_key=os.environ.get("OPENAI_API_KEY"), base_url=os.environ.get("OPENAI_BASE_URL") or None, ) MODEL = os.environ["MODEL_NAME"]
INSTRUCTIONS = """ 你是会议纪要整理助手。只返回 JSON,不要 Markdown 围栏或解释文字。 顶层字段必须是 summary(字符串)、decisions(字符串数组)、actions(对象数组)。 每个 action 只能有 owner(字符串或 null)、task(非空字符串)、due(YYYY-MM-DD 或 null)、 status(固定为 open)。只使用会议原文明确的信息;没有明确负责人或日期时必须为 null,不能猜测。 """.strip()
def validate_minutes(raw: str) -> dict: try: data = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError("模型没有返回合法 JSON") from exc
if not isinstance(data, dict) or set(data) != {"summary", "decisions", "actions"}: raise ValueError("顶层字段不完整或包含未知字段") if not isinstance(data["summary"], str) or not data["summary"].strip(): raise ValueError("summary 必须是非空字符串") if not isinstance(data["decisions"], list) or not all( isinstance(item, str) and item.strip() for item in data["decisions"] ): raise ValueError("decisions 必须是字符串数组")
checked_actions = [] for action in data["actions"]: if not isinstance(action, dict) or set(action) != {"owner", "task", "due", "status"}: raise ValueError("行动项字段不完整或包含未知字段") if action["owner"] is not None and ( not isinstance(action["owner"], str) or not action["owner"].strip() ): raise ValueError("owner 必须是非空字符串或 null") if not isinstance(action["task"], str) or not action["task"].strip(): raise ValueError("task 必须是非空字符串") if action["due"] is not None and not isinstance(action["due"], str): raise ValueError("due 必须是日期字符串或 null") if action["status"] != "open": raise ValueError("status 只能是 open") checked_actions.append(action) data["actions"] = checked_actions return data
def summarize(notes: str) -> dict: notes = notes.strip() if not notes: raise ValueError("会议记录不能为空") if len(notes) > 16000: raise ValueError("示例只接受不超过 16000 个字符的会议记录")
response = client.responses.create( model=MODEL, instructions=INSTRUCTIONS, input=notes, ) return validate_minutes(response.output_text)
def main() -> None: if len(sys.argv) != 2: raise SystemExit("用法:python minutes.py meeting.txt") source = Path(sys.argv[1]).read_text(encoding="utf-8") result = summarize(source) print(json.dumps(result, ensure_ascii=False, indent=2)) answer = input("请人工核对内容,确认后保存?[y/N] ").strip().lower() if answer != "y": print("未保存") return Path("meeting-minutes.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" ) print("已保存到 meeting-minutes.json")
if __name__ == "__main__": main()
|
准备 meeting.txt 后运行 python minutes.py meeting.txt。responses.create 负责一次模型请求,instructions 放不随输入变化的规则,input 放会议原文,output_text 取出文本结果。注意,程序先调用 validate_minutes,只有解析和校验成功,才会进入人工确认;用户输入不是 y 时不会产生文件副作用。
这里的日期只检查“字符串或空值”,没有把自然语言日期强行转换成日期。提示词要求模型只输出 YYYY-MM-DD,但如果业务真的依赖日期,还应使用 datetime.date.fromisoformat 再做一次严格校验,并在无法确认时退回人工处理。校验规则要和实际业务风险匹配,不能只检查 JSON 能否解析。
用固定数据做离线测试
模型输出不稳定且会消耗配额,先测试纯函数。创建 test_minutes.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import json import unittest
from minutes import validate_minutes
class MinutesTest(unittest.TestCase): def test_valid_result_is_accepted(self): raw = json.dumps({ "summary": "讨论发布计划。", "decisions": ["本周发布测试版。"], "actions": [{ "owner": "小林", "task": "准备发布清单", "due": "2026-10-01", "status": "open", }], }) result = validate_minutes(raw) self.assertEqual(result["actions"][0]["owner"], "小林")
def test_unknown_action_field_is_rejected(self): raw = json.dumps({ "summary": "摘要", "decisions": [], "actions": [{ "owner": None, "task": "检查", "due": None, "status": "open", "priority": "high", }], }) with self.assertRaises(ValueError): validate_minutes(raw)
if __name__ == "__main__": unittest.main()
|
运行 python -m unittest -v test_minutes.py。这个测试不会访问网络,验证的是字段集合、类型和拒绝未知字段的确定性逻辑。实际项目还应覆盖非法 JSON、空行动项任务、错误状态和超长日期等路径。对 summarize 的集成测试则可以注入一个假的客户端,避免测试依赖真实密钥和具体模型。
常见问题
为什么要人工确认? 会议纪要会影响责任分工和截止时间,格式正确不代表事实正确。确认环节是低成本的风险闸门,尤其要重点检查“谁负责”和“何时完成”。
为什么不直接发邮件或创建任务? 写外部系统属于副作用,应先经过权限检查、重复提交保护和人工批准。当前项目只保存 JSON,把执行动作留到下一步,便于审查和回滚。
会议记录很长怎么办? 不要无限增大单次请求。可以先按议题或时间分块,再分别整理,最后由程序合并并检查重复行动项;合并时保留来源片段,才能追溯结论。
模型漏掉了行动项怎么办? 用固定会议样本建立评估集,比较召回情况,并在提示词中明确行动项的判定规则。不要用一次运行结果证明程序可靠,也不要让人工抽查被误认为自动保证。
小结
这个综合项目把一次模型调用拆成输入限制、生成、解析、校验、人工确认和保存六个边界。模型擅长从自然语言中提取候选信息,Python 负责结构与副作用,人工负责最终事实确认。以后增加日历同步或任务系统时,也应沿用这条链路:先得到受约束的意图,再验证权限和参数,最后才执行不可逆操作。