AI 工具

Gemini Interactions API:结构化提取会议待办

用 Gemini 的 Interactions API(官方结构化输出文档现在使用的接口)把会议纪要提取成待办事项列表。 官方文档有一句很关键的话:maxLength、minLength 等计数约束不会被约束解码执行,而是被自动去掉。所以 schema 只能保证 JSON 格式正确,内容是否满足约束还要在客户端用 Pydantic 再校验一次。 此外,状态不

BINBHO · 图文教程

用 Gemini 的 Interactions API(官方结构化输出文档现在使用的接口)把会议纪要提取成待办事项列表。 官方文档有一句很关键的话:maxLength、minLength 等计数约束不会被约束解码执行,而是被自动去掉。所以 schema 只能保证 JSON 格式正确,内容是否满足约束还要在客户端用 Pydantic 再校验一次。 此外,状态不是 completed(例如 incomplete)时,不能使用这次的输出。

先看清成品

  • 本地替身上:正常提取出 2 条待办;任务超过 60 字被客户端拦下;JSON 被截断时报“输出不符合约束”;状态为 incomplete 时拒绝使用。pytest 5 项通过。
  • 用无效 Key 调用真实接口:✗ 调用失败(HTTP 400): BadRequestError ... [{'error': {'code': 400, 'message': 'API key not valid...,注意返回体是一个数组。

合规提醒:Gemini API 和 Google AI Studio 只在官方支持的地区提供,官方列表中没有中国大陆和香港。不在支持地区就不要使用,不要用代理或虚假信息绕过地区限制,也不要共享账号或 API Key。按照官方条款,使用免费额度(Unpaid Services)时,提交的内容和生成的结果可能被 Google 用来改进产品,还可能有人工审阅,所以不要提交个人敏感信息或公司机密。限额按项目计算,不是按 Key 计算,多建 Key 也不会增加额度。API Key 放在环境变量 GEMINI_API_KEY 中。本文不列价格和具体限额。

运行说明:box 上没有 Gemini API Key,所以没有拿到真实的模型输出。真实运行过的部分是:用无效 Key 调用真实接口,记录真实的错误格式。成功路径用的是我自己写的本地替身(fake_gemini.py,不是 Google 官方程序),客户端是真实的 google-genai 2.32.0;替身的请求和响应格式来自本地抓取的 SDK 实际请求,以及 SDK 自带测试中的数据结构。

做完之后你会得到什么?

成果 说明
action_items.py 提取待办、客户端校验、状态检查
fake_gemini.py 本地替身(62B、62C 共用同一份)
notes.txt 示例纪要(我自己写的)
test_action_items.py 5 项测试

需要准备哪些环境?

项目 box 上的版本
Python 3.13
google-genai 2.32.0(pip install google-genai pydantic pytest)
模型 默认 gemini-3.8-flash、gemini-embedding-2(来自官方模型页,可以用环境变量覆盖)

关键事实

事实 来源
client.interactions.create(model=, input=, response_format={"type":"text","mime_type":"application/json","schema": ...}),结果从 output_text 读取 结构化输出文档的 Python 示例
计数约束会被自动去掉,需要在客户端校验 结构化输出文档的 Limitations 部分
SDK 实际请求的是 POST /v1beta/interactions 本地抓包
status 的取值:in_progress、requires_action、completed、failed、cancelled、incomplete、budget_exceeded、queued SDK 类型定义

文件结构

62-google-ai-A-src/
├── action_items.py
├── fake_gemini.py
├── notes.txt
└── test_action_items.py

第 1 步:提取脚本

"""会议纪要 → 待办事项(Gemini Interactions API 结构化输出)。
- 按官方文档:response_format = {"type": "text", "mime_type": "application/json", "schema": Model.model_json_schema()}
- 文档明确说明:maxLength、minLength 等计数约束**不会**被约束解码执行(会被自动去掉),
  所以必须在客户端用 Pydantic 再校验一次
- status 不是 completed(比如 incomplete、failed、budget_exceeded)时,不使用这次输出"""
import os, sys
from typing import Optional
from pydantic import BaseModel, Field, ValidationError

MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.8-flash")

class Item(BaseModel):
    owner: str = Field(description="负责人姓名,原文没有写就填 待定")
    task: str = Field(max_length=60, description="一句话描述的任务,不超过 60 个字")
    due: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$", description="截止日期 YYYY-MM-DD,没有就填 null")

class Items(BaseModel):
    items: list[Item]

PROMPT = "从下面的会议纪要中提取待办事项,只依据原文:\n\n{notes}"

class ExtractError(RuntimeError):
    pass

def extract(client, notes, model=MODEL):
    it = client.interactions.create(
        model=model, input=PROMPT.format(notes=notes), store=False,          # 一次性任务,不需要在服务端保存
        response_format={"type": "text", "mime_type": "application/json", "schema": Items.model_json_schema()})
    if it.status != "completed":
        raise ExtractError(f"状态为 {it.status},不使用这次输出")
    try:
        return Items.model_validate_json(it.output_text)
    except ValidationError as e:
        raise ExtractError(f"输出不符合约束:{e.errors()[0]['loc']} {e.errors()[0]['msg']}") from e

if __name__ == "__main__":
    from google import genai
    client = genai.Client()                                                # 读取环境变量 GEMINI_API_KEY
    notes = open(sys.argv[1], encoding="utf-8").read()
    try:
        for i in extract(client, notes).items:
            print(f"- [{i.owner}] {i.task}(截止:{i.due or '未定'})")
    except ExtractError as e:
        print("✗", e); sys.exit(1)
    except Exception as e:
        # Interactions 的错误类不是 google.genai.errors.APIError 的子类(SDK 2.32 实测),
        # 所以统一按属性读取 HTTP 状态码
        code = getattr(e, "status_code", None) or getattr(e, "code", None)
        print(f"✗ 调用失败(HTTP {code}):", type(e).__name__, str(e)[:300]); sys.exit(2)

第 2 步:本地替身

"""本地替身(我自己写的,不是 Google 官方程序),用于离线测试 google-genai SDK。
格式来源:SDK 2.32.0 实际发出的请求(本地抓包),以及 SDK 自带测试中的 Interaction 结构;
错误 Key 的返回照搬真实接口:/v1beta/interactions 返回的是 JSON **数组** [{"error": ...}],
generateContent / embed 返回的是对象 {"error": ...}。"""
import hashlib, json, math, re, threading, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

STATE = {"requests": [], "deleted": [], "store": {}, "next_id": 0, "scenario": "ok", "rate_429": 0}
KEY_ERR = {"error": {"code": 400, "message": "API key not valid. Please pass a valid API key.", "status": "INVALID_ARGUMENT",
                     "details": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "API_KEY_INVALID",
                                  "domain": "googleapis.com", "metadata": {"service": "generativelanguage.googleapis.com"}}]}}

def embed_text(text, dim):
    """确定性的“词袋哈希”向量,只用于测试:共享的词越多越相似。"""
    v = [0.0] * dim
    for w in re.findall(r"[a-z0-9]+|[\u4e00-\u9fff]", text.lower()):
        if w in {"task", "search", "result", "query", "title", "none", "text"}:
            continue
        v[int(hashlib.md5(w.encode()).hexdigest(), 16) % dim] += 1.0
    n = math.sqrt(sum(x * x for x in v)) or 1.0
    return [x / n for x in v]

def reply_for(body):
    """根据输入生成模型输出;scenario 控制几种异常情况。"""
    sc = STATE["scenario"]
    inp = body.get("input")
    last = inp if isinstance(inp, str) else next(
        (c["text"] for s in reversed(inp) if s.get("type") == "user_input" for c in s["content"] if c.get("type") == "text"), "")
    if body.get("response_format"):
        items = [{"owner": "李雷", "task": "整理需求文档并发给设计组", "due": "2026-10-28"},
                 {"owner": "韩梅梅", "task": "确认测试环境", "due": None}]
        if sc == "overlong":
            items[0]["task"] = "整理" * 60
        if sc == "bad_json":
            return "{\"items\": [ {\"owner\": \"李雷\""
        return json.dumps({"items": items}, ensure_ascii=False)
    if isinstance(inp, list):                       # 无状态:根据完整历史回答
        users = [c["text"] for s in inp if s.get("type") == "user_input" for c in s["content"]]
        return f"(看到了 {len(users)} 轮用户输入)你刚才说:{users[0]}" if len(users) > 1 else f"收到:{last}"
    prev = body.get("previous_interaction_id")
    if prev:
        first = STATE["store"].get(prev, {}).get("first_user")
        return f"你之前说:{first}" if first else "(找不到上一轮,可能已删除或未存储)"
    return f"收到:{last}"

class H(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass
    def _send(self, code, obj):
        raw = json.dumps(obj, ensure_ascii=False).encode()
        self.send_response(code); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(raw)
    def _auth(self, array):
        if self.headers.get("x-goog-api-key") == "good":
            return True
        self._send(400, [KEY_ERR] if array else KEY_ERR); return False
    def do_DELETE(self):
        if not self._auth(True):
            return
        iid = urllib.parse.urlsplit(self.path).path.rsplit("/", 1)[-1]
        if STATE["store"].pop(iid, None) is None:
            return self._send(404, [{"error": {"code": 404, "message": "not found", "status": "NOT_FOUND"}}])
        STATE["deleted"].append(iid); self._send(200, {})
    def do_POST(self):
        path = urllib.parse.urlsplit(self.path).path
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        STATE["requests"].append((path, body))
        if path == "/v1beta/interactions":
            if not self._auth(True):
                return
            if STATE["rate_429"]:
                STATE["rate_429"] -= 1
                return self._send(429, [{"error": {"code": 429, "message": "Resource exhausted (fake)", "status": "RESOURCE_EXHAUSTED"}}])
            STATE["next_id"] += 1
            iid = f"v1_fake{STATE['next_id']}"
            status = "incomplete" if STATE["scenario"] == "incomplete" else "completed"
            steps = [{"type": "thought", "signature": "sig", "summary": [{"type": "text", "text": "思考摘要"}]},
                     {"type": "model_output", "content": [{"type": "text", "text": reply_for(body)}]}]
            if body.get("store", True):
                inp = body["input"]
                prev = STATE["store"].get(body.get("previous_interaction_id"), {})
                STATE["store"][iid] = {"first_user": prev.get("first_user") or (inp if isinstance(inp, str) else None)}
            return self._send(200, {"id": iid, "status": status, "steps": steps, "model": body.get("model")})
        if path.endswith(":batchEmbedContents"):
            if not self._auth(False):
                return
            out = []
            for r in body["requests"]:
                text = " ".join(p.get("text", "") for p in r["content"]["parts"])   # 多个 part 聚合成一个向量
                out.append({"values": embed_text(text, r.get("outputDimensionality", 3072))})
            return self._send(200, {"embeddings": out})
        self._send(404, {"error": {"code": 404, "message": f"no route {path}", "status": "NOT_FOUND"}})

def start():
    s = ThreadingHTTPServer(("127.0.0.1", 0), H)
    threading.Thread(target=s.serve_forever, daemon=True).start()
    return s, f"http://127.0.0.1:{s.server_address[1]}"

def reset(**kw):
    STATE.update({**dict(requests=[], deleted=[], store={}, next_id=0, scenario="ok", rate_429=0), **kw})

第 3 步:测试

import pytest
from google import genai
from google.genai import types
import action_items as A, fake_gemini as F

@pytest.fixture
def client():
    F.reset(); s, u = F.start()
    yield genai.Client(api_key="good", http_options=types.HttpOptions(base_url=u)); s.shutdown()

NOTES = open("notes.txt", encoding="utf-8").read()

def test_ok_and_request_shape(client):
    r = A.extract(client, NOTES)
    assert [i.owner for i in r.items] == ["李雷", "韩梅梅"] and r.items[1].due is None
    path, body = F.STATE["requests"][-1]
    assert path == "/v1beta/interactions" and body["store"] is False
    assert body["response_format"]["schema"]["properties"]["items"]["type"] == "array"

def test_overlong_rejected_client_side(client):
    F.STATE["scenario"] = "overlong"
    with pytest.raises(A.ExtractError, match="task"):
        A.extract(client, NOTES)

def test_truncated_json(client):
    F.STATE["scenario"] = "bad_json"
    with pytest.raises(A.ExtractError, match="不符合约束"):
        A.extract(client, NOTES)

def test_incomplete_status(client):
    F.STATE["scenario"] = "incomplete"
    with pytest.raises(A.ExtractError, match="incomplete"):
        A.extract(client, NOTES)

def test_bad_key_error_from_array_body(client):
    bad = genai.Client(api_key="bad", http_options=types.HttpOptions(base_url=client._api_client._http_options.base_url))
    with pytest.raises(Exception) as e:
        A.extract(bad, NOTES)
    assert "API key not valid" in str(e.value)

第 4 步:运行

$ GEMINI_API_KEY=INVALID python action_items.py notes.txt
✗ 调用失败(HTTP 400): BadRequestError Error code: 400 - [{'error': {'code': 400, 'message': 'API key not valid. Please pass a valid API key.', 'status': 'INVALID_ARGUMENT', ...
$ python -m pytest -q
5 passed

换成有效的 Key(并且在支持的地区)后,会输出 - [李雷] 整理需求文档并发给设计组(截止:2026-10-28) 这样的列表(这一步我没有运行)。

调试示例

1. 临时创建的 Client 被提前关闭了。 我在一行里写了 genai.Client(api_key=...).interactions.create(...),得到的错误不是 Key 无效,而是 RuntimeError('Cannot send a request, as the client has been closed.')。原因是临时的 Client 在请求发出前就被回收了。把 Client 保存到变量里之后,才拿到真实的 400 错误。

2. errors.APIError 捕获不到这个错误。 用同一个 Client 实测:generate_content 的错误能被 google.genai.errors.APIError 捕获(code 400,status INVALID_ARGUMENT),但 interactions.create 抛出的是 google.genai._gaos.lib.compat_errors.BadRequestError,不是 APIError 的子类。错误体的格式也不一样,前者是对象,后者是数组。因为不想导入以下划线开头的私有模块,代码里改为通过 status_code 或 code 属性读取 HTTP 状态码。

常见问题

现象 原因 处理
400 API_KEY_INVALID Key 写错 在 AI Studio 中重新生成,放到环境变量里
地区不支持 所在地区不在官方列表中 不要使用,也不要绕过
字段长度超出限制 计数约束不会被执行 用 Pydantic 校验
client has been closed 临时 Client 被回收 把 Client 保存在变量中

模板规则

  • 用 schema 约束 JSON 格式,用 Pydantic 校验内容。
  • 只使用 completed 状态的输出。
  • 一次性任务设置 store=False。
  • 错误统一按 HTTP 状态码处理。

项目资源

用途 地址
Gemini API 文档 https://ai.google.dev/gemini-api/docs
模型列表 https://ai.google.dev/gemini-api/docs/models
支持的地区 https://ai.google.dev/gemini-api/docs/available-regions
Gemini API 条款 https://ai.google.dev/gemini-api/terms
google-genai Python SDK https://github.com/googleapis/python-genai
结构化输出 https://ai.google.dev/gemini-api/docs/structured-output
Interactions API https://ai.google.dev/gemini-api/docs/interactions

格式靠 schema 约束,内容对不对要靠客户端校验。