前面的系列已经覆盖了模型调用、消息组织、结构化输出、错误处理、提示词测试和人工审批。本篇在路线完成后做一个小型综合项目:读取一份原始会议记录,请模型提取摘要、决定事项和行动项,程序严格校验结果后保存为 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 负责结构与副作用,人工负责最终事实确认。以后增加日历同步或任务系统时,也应沿用这条链路:先得到受约束的意图,再验证权限和参数,最后才执行不可逆操作。