工具参数的 JSON Schema 通过了,为什么运行时仍要拒绝?

两个日期都符合格式,不代表它们组成了一个可执行的时间窗。亲手跑一遍 Pydantic 的参数验证边界。

你能带走什么
  • 区分 JSON 与 Python 验证入口
  • 用模型校验器声明时间先后
  • 实测生成 Schema 与运行时差别
  • 让参数验证停在权限边界之前

1. 先把那次倒着走的导出拦下来

给一个导出工具声明 start、end 和 limit,看起来已经说得很清楚:时间是日期字符串,数量是整数。可当 start 是 10 月 1 日、end 是 9 月 30 日时,两个字段分别都很像正确答案。生成的 JSON Schema 能读懂各自的形状,却没有自动带上“结束时间必须更晚”这条程序规则。

这篇做一个只读的导出时间窗参数预览器。输入全部是合成数据;程序只验证并打印计划,不读取账户、不查询数据库、不生成业务导出文件。真实运行的是 Pydantic 2.13.4 和 jsonschema 4.26.0。没有调用模型、没有接 Agent SDK,也不需要 API key。

你会拿到同一模型的 Schema、20 组独立输入及预期、30 项测试,以及两种验证入口的真实输出。重点是把第三方库的解析、约束和序列化 API 用对;这里不重做批量文本抽取或来源引用核验。

2. 准备一个可以完整复制的小目录

实测环境是 Python 3.12.14。源码使用 Self,需要 Python 3.11 以上;复现本次结果请用相同版本组合。本文环境的依赖原先已安装,本次没有执行安装。requirements.txt 记录直接依赖版本,并不包含库源码,也不是完整的跨平台锁文件。若你的环境缺少这两个库,先在自己的隔离环境准备依赖,再运行;本例不会替你下载或安装。

requirements.txt

pydantic==2.13.4
jsonschema==4.26.0

建立 fixtures 目录,将下面各段代码分别保存到标出的路径。所有运行依赖的自编文件都在正文完整给出;下载包还带有 HTML 和三张独立 SVG。运行目录应为这些 Python 文件所在的位置。

python -c "import pydantic; from importlib.metadata import version; print(pydantic.__version__, version('jsonschema'))"
python demo.py
python -m unittest -v

3. 让模型承担四条明确的规则

这份教学契约要求四个字段全部出现:start 与 end 必须带时区,limit 是 1 到 500 的整数,include_archived 是布尔值。两端转换成 UTC 后,还必须落在 Python datetime 能表示的年份范围内;极端年份与偏移量组合造成的溢出会转换为校验错误。时间窗按半开区间 [start, end) 理解,结束必须晚于开始,跨度最多七个连续的 24 小时。这不是按某个地区日历计算的“七天”;夏令时和地区日历需求要另外定义。

contract.py

"""Offline parameter validation only: no account, network, export or authorization."""
from datetime import timezone, timedelta
from typing import Self
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator

class ExportWindow(BaseModel):
    model_config = ConfigDict(strict=True, extra='forbid', frozen=True)
    start: AwareDatetime
    end: AwareDatetime
    limit: int = Field(ge=1, le=500)
    include_archived: bool

    @model_validator(mode='after')
    def check_window(self) -> Self:
        try:
            start = self.start.astimezone(timezone.utc)
            end = self.end.astimezone(timezone.utc)
        except OverflowError as exc:
            raise ValueError('timestamps must be representable in UTC') from exc
        if end <= start:
            raise ValueError('end must be after start')
        if end - start > timedelta(days=7):
            raise ValueError('window must not exceed 7 days')
        return self

def preview_json(raw: str) -> dict:
    # Keep raw JSON at this boundary; do not json.loads then validate_python.
    window = ExportWindow.model_validate_json(raw)
    return {
        'state': 'parameters_valid_authorization_required',
        'start_utc': window.start.astimezone(timezone.utc).isoformat(),
        'end_utc': window.end.astimezone(timezone.utc).isoformat(),
        'limit': window.limit,
        'include_archived': window.include_archived,
        'export_performed': False,
    }

strict 模式文档提醒不同类型、不同入口可能有不同处理规则。这里不给字符串数字兜底转换,"100"、true 和 100.0 都不能冒充 limit 的整数。extra 配置设为 forbid,让拼错的字段和多出的字段都显式报错。frozen 只防止通常的属性赋值,不能把 Python 对象变成安全边界。

AwareDatetime要求带时区。跨字段规则放在 after 模型校验器里:只有字段验证成功后,才拿到可以比较的 datetime。我们把两端转为 UTC 后计算先后与时长,避免直接比较带不同偏移量的日期文字。具体写法对应模型校验器文档,两条业务规则是本文自行定义的。

4. 保留 JSON 边界,别在中途换成另一种输入

图 1:strict JSON 入口接受日期字符串;Python 入口需要 datetime 对象。
图 1:strict JSON 入口接受日期字符串;Python 入口需要 datetime 对象。

最容易踩到的坑藏在一个看似无害的 json.loads 里。model_validate_json 接收原始 JSON;JSON 没有 datetime 这种类型,因此严格模式仍允许符合要求的日期字符串。model_validate 接收 Python 值,strict 模式对日期字段要求 datetime 对象。把同一份 JSON 先转成 dict 再送进去,会让 start 和 end 各报一个 datetime_type。

还要单独考虑重复 JSON 键。在本次固定版本中,同一个对象出现两次 limit 时,Pydantic 默认保留后一个值;strict 与 extra=forbid 不会替你拒绝重复的已知键。新增回归把这一行为固定下来。如果接入契约要求拒绝重复键,必须在原始 JSON 边界另加检测,并保留原始 JSON 交给这里的验证入口;只检查解析后的 dict 已经看不到被覆盖的键。本文预览器没有实现该检测。

这不是随机行为,也不适合用 strict=False 草率掩盖。先确定工具边界交给你的是什么:原始 JSON 用 JSON 入口;已有 Python 对象用 Python 入口,并让日期字段真正成为 datetime。本文同时保留两个失败和成功样本,防止未来把入口重构时悄悄改变契约。

5. 先独立写输入和预期,再运行程序

inputs.json 只描述输入与入口。expected.json 是依据前述契约单独编写的预期,没有从模型返回值自动生成。python_datetime 用标准库显式构造日期对象,python 则保持原样。offset_order 特意写成当地钟面 10:00 对 UTC 03:00;换算后开始是 UTC 02:00,因此应当通过。

fixtures/inputs.json

[
  {
    "id": "valid",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "limit_string",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": "100",
      "include_archived": false
    }
  },
  {
    "id": "limit_bool",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": true,
      "include_archived": false
    }
  },
  {
    "id": "limit_float",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100.0,
      "include_archived": false
    }
  },
  {
    "id": "limit_zero",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 0,
      "include_archived": false
    }
  },
  {
    "id": "limit_over",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 501,
      "include_archived": false
    }
  },
  {
    "id": "bool_string",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": "false"
    }
  },
  {
    "id": "bool_number",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": 0
    }
  },
  {
    "id": "approved_extra",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": false,
      "approved": true
    }
  },
  {
    "id": "tenant_extra",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": false,
      "tenantID": "tenant-other"
    }
  },
  {
    "id": "naive",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "reverse",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-09-30T00:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "equal",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-01T00:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "seven_days",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-08T00:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "over_seven_days",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-08T00:00:01Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "offset_order",
    "entry": "json",
    "input": {
      "start": "2026-10-01T10:00:00+08:00",
      "end": "2026-10-01T03:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "python_strings",
    "entry": "python",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "python_datetimes",
    "entry": "python_datetime",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100,
      "include_archived": false
    }
  },
  {
    "id": "null_limit",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": null,
      "include_archived": false
    }
  },
  {
    "id": "missing_bool",
    "entry": "json",
    "input": {
      "start": "2026-10-01T00:00:00Z",
      "end": "2026-10-02T00:00:00Z",
      "limit": 100
    }
  }
]

fixtures/expected.json

{
  "valid": {
    "ok": true,
    "errors": []
  },
  "limit_string": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "limit"
        ],
        "type": "int_type"
      }
    ]
  },
  "limit_bool": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "limit"
        ],
        "type": "int_type"
      }
    ]
  },
  "limit_float": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "limit"
        ],
        "type": "int_type"
      }
    ]
  },
  "limit_zero": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "limit"
        ],
        "type": "greater_than_equal"
      }
    ]
  },
  "limit_over": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "limit"
        ],
        "type": "less_than_equal"
      }
    ]
  },
  "bool_string": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "include_archived"
        ],
        "type": "bool_type"
      }
    ]
  },
  "bool_number": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "include_archived"
        ],
        "type": "bool_type"
      }
    ]
  },
  "approved_extra": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "approved"
        ],
        "type": "extra_forbidden"
      }
    ]
  },
  "tenant_extra": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "tenantID"
        ],
        "type": "extra_forbidden"
      }
    ]
  },
  "naive": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "start"
        ],
        "type": "timezone_aware"
      }
    ]
  },
  "reverse": {
    "ok": false,
    "errors": [
      {
        "loc": [],
        "type": "value_error"
      }
    ]
  },
  "equal": {
    "ok": false,
    "errors": [
      {
        "loc": [],
        "type": "value_error"
      }
    ]
  },
  "seven_days": {
    "ok": true,
    "errors": []
  },
  "over_seven_days": {
    "ok": false,
    "errors": [
      {
        "loc": [],
        "type": "value_error"
      }
    ]
  },
  "offset_order": {
    "ok": true,
    "errors": []
  },
  "python_strings": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "start"
        ],
        "type": "datetime_type"
      },
      {
        "loc": [
          "end"
        ],
        "type": "datetime_type"
      }
    ]
  },
  "python_datetimes": {
    "ok": true,
    "errors": []
  },
  "null_limit": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "limit"
        ],
        "type": "int_type"
      }
    ]
  },
  "missing_bool": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "include_archived"
        ],
        "type": "missing"
      }
    ]
  }
}

预期只匹配成功状态、错误位置与类型,不依赖异常文案的标点或错误链接。根级别错误的 loc 是空数组,表示跨字段关系未通过。额外字段的错误位置则直接指向 approved 或 tenantID。

6. 用真库验证,不再手写一套字段检查器

matrix.py

"""Inputs and expectations are separate files. No result-derived oracle."""
import json
from pathlib import Path
from datetime import datetime
from pydantic import ValidationError
from contract import ExportWindow

ROOT = Path(__file__).resolve().parent

def read(name):
    return json.loads((ROOT / 'fixtures' / name).read_text(encoding='utf-8'))

def execute(case):
    payload = dict(case['input'])
    if case['entry'] == 'python_datetime':
        for name in ('start', 'end'):
            payload[name] = datetime.fromisoformat(payload[name])
    try:
        if case['entry'] == 'json':
            model = ExportWindow.model_validate_json(json.dumps(payload))
        else:
            model = ExportWindow.model_validate(payload)
        return {'ok': True, 'errors': [], 'normalized': model.model_dump(mode='json')}
    except ValidationError as exc:
        return {'ok': False, 'errors': [
            {'loc': list(e['loc']), 'type': e['type']} for e in exc.errors()
        ]}

demo.py

import json
import platform
import pydantic
from jsonschema import Draft202012Validator
from contract import ExportWindow, preview_json
from matrix import execute, read

print('Python', platform.python_version(), 'Pydantic', pydantic.__version__)
cases = read('inputs.json')
for case in cases:
    result = execute(case)
    print(case['id'], json.dumps(result, ensure_ascii=False, sort_keys=True))
reverse = next(c['input'] for c in cases if c['id'] == 'reverse')
validator = Draft202012Validator(ExportWindow.model_json_schema())
validator.validate(reverse)
print('reverse: generated JSON Schema structure PASS (format not asserted); runtime REJECT')
print('preview', json.dumps(preview_json(json.dumps(cases[0]['input'])), sort_keys=True))
m = ExportWindow.model_validate_json(json.dumps(cases[0]['input']))
print('json round trip:', ExportWindow.model_validate_json(m.model_dump_json()) == m)
print('python round trip:', ExportWindow.model_validate(m.model_dump()) == m)
print('schema', json.dumps(ExportWindow.model_json_schema(), ensure_ascii=False, sort_keys=True))
图 2:Schema 中两个字段各自合法;运行时按 UTC 比较,结束时间必须晚于开始时间。
图 2:Schema 中两个字段各自合法;运行时按 UTC 比较,结束时间必须晚于开始时间。

demo.py 从同一个 ExportWindow 生成 Schema,再用已安装的 jsonschema Draft202012Validator 验证倒序样本。这里只检查结构和 Schema 中的约束,不断言 format:本次环境没有 date-time 的可选格式检查器,也没有为此安装依赖。jsonschema 的官方验证文档说明,未配置可用格式检查器时,format 不会强制检查。

倒序样本的日期字符串都能被 Pydantic 正常解析;运行时最终拒绝的是两者关系。这一点由返回的根级 value_error 和独立输入入口测试体现。即使下游再增加正确的日期格式检查,格式正确也不会凭空表达 end > start。本文只证明这份生成 Schema 的行为,不声称所有 Schema 或所有验证器都没有表达业务约束的办法。

生成的 Schema 包含 required、additionalProperties: false、整数上下界和日期 format。after 校验器里的七天上限与先后比较没有自动变成其中的关键字。也不要直接把这份 Schema 称作某家模型服务已经支持的工具契约:服务商支持的 Schema 子集和工具包装行为需要另行接线验证,本篇没有做这一步。

7. 读一次真实输出,再检查序列化往返

以下为本次完整演示输出。失败项只打印 loc 和 type,不把传入内容复制进错误日志。实际系统的 ValidationError 可能包含原始输入,记录前需要按数据敏感程度处理。

Python 3.12.14 Pydantic 2.13.4
valid {"errors": [], "normalized": {"end": "2026-10-02T00:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T00:00:00Z"}, "ok": true}
limit_string {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
limit_bool {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
limit_float {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
limit_zero {"errors": [{"loc": ["limit"], "type": "greater_than_equal"}], "ok": false}
limit_over {"errors": [{"loc": ["limit"], "type": "less_than_equal"}], "ok": false}
bool_string {"errors": [{"loc": ["include_archived"], "type": "bool_type"}], "ok": false}
bool_number {"errors": [{"loc": ["include_archived"], "type": "bool_type"}], "ok": false}
approved_extra {"errors": [{"loc": ["approved"], "type": "extra_forbidden"}], "ok": false}
tenant_extra {"errors": [{"loc": ["tenantID"], "type": "extra_forbidden"}], "ok": false}
naive {"errors": [{"loc": ["start"], "type": "timezone_aware"}], "ok": false}
reverse {"errors": [{"loc": [], "type": "value_error"}], "ok": false}
equal {"errors": [{"loc": [], "type": "value_error"}], "ok": false}
seven_days {"errors": [], "normalized": {"end": "2026-10-08T00:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T00:00:00Z"}, "ok": true}
over_seven_days {"errors": [{"loc": [], "type": "value_error"}], "ok": false}
offset_order {"errors": [], "normalized": {"end": "2026-10-01T03:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T10:00:00+08:00"}, "ok": true}
python_strings {"errors": [{"loc": ["start"], "type": "datetime_type"}, {"loc": ["end"], "type": "datetime_type"}], "ok": false}
python_datetimes {"errors": [], "normalized": {"end": "2026-10-02T00:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T00:00:00Z"}, "ok": true}
null_limit {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
missing_bool {"errors": [{"loc": ["include_archived"], "type": "missing"}], "ok": false}
reverse: generated JSON Schema structure PASS (format not asserted); runtime REJECT
preview {"end_utc": "2026-10-02T00:00:00+00:00", "export_performed": false, "include_archived": false, "limit": 100, "start_utc": "2026-10-01T00:00:00+00:00", "state": "parameters_valid_authorization_required"}
json round trip: True
python round trip: True
schema {"additionalProperties": false, "properties": {"end": {"format": "date-time", "title": "End", "type": "string"}, "include_archived": {"title": "Include Archived", "type": "boolean"}, "limit": {"maximum": 500, "minimum": 1, "title": "Limit", "type": "integer"}, "start": {"format": "date-time", "title": "Start", "type": "string"}}, "required": ["start", "end", "limit", "include_archived"], "title": "ExportWindow", "type": "object"}

序列化文档区分 Python 模式和 JSON 模式。model_dump() 在本例保留 datetime,适合回到 Python 入口;model_dump_json() 产生 JSON 文本,回到 JSON 入口。model_dump(mode="json") 虽是 dict,日期已成字符串,不能直接套进 strict Python 入口。往返测试确认的是这份模型的值保持一致,不保证输出字节、日期原始写法或字段顺序完全不变,也没有获得签名或防篡改能力。

8. 把回归测试一起留下

test_contract.py

import json
import unittest
from datetime import datetime
from pydantic import ValidationError
from jsonschema import Draft202012Validator
from contract import ExportWindow, preview_json
from matrix import execute, read

CASES = read('inputs.json')
EXPECTED = read('expected.json')

class ContractTests(unittest.TestCase):
    def good(self):
        return ExportWindow.model_validate_json(json.dumps(CASES[0]['input']))

    def test_matrix_has_independent_complete_expectations(self):
        self.assertEqual(set(EXPECTED), {c['id'] for c in CASES})
        self.assertEqual(len(CASES), len(EXPECTED))

    def test_schema_accepts_reverse_structure(self):
        s = ExportWindow.model_json_schema()
        Draft202012Validator.check_schema(s)
        payload = next(c['input'] for c in CASES if c['id'] == 'reverse')
        Draft202012Validator(s).validate(payload)
        self.assertFalse(execute(next(c for c in CASES if c['id'] == 'reverse'))['ok'])

    def test_schema_limits_and_extras(self):
        s = ExportWindow.model_json_schema()
        self.assertFalse(s['additionalProperties'])
        self.assertEqual(s['properties']['limit']['maximum'], 500)
        self.assertEqual(s['properties']['start']['format'], 'date-time')
        self.assertEqual(set(s['required']), {'start', 'end', 'limit', 'include_archived'})

    def test_json_roundtrip(self):
        m = self.good()
        self.assertEqual(ExportWindow.model_validate_json(m.model_dump_json()), m)

    def test_python_roundtrip(self):
        m = self.good()
        self.assertIsInstance(m.model_dump()['start'], datetime)
        self.assertEqual(ExportWindow.model_validate(m.model_dump()), m)

    def test_json_mode_dict_is_not_python_mode_dict(self):
        with self.assertRaises(ValidationError) as caught:
            ExportWindow.model_validate(self.good().model_dump(mode='json'))
        self.assertEqual([e['type'] for e in caught.exception.errors()], ['datetime_type', 'datetime_type'])

    def test_preview_never_claims_authorization_or_export(self):
        p = preview_json(json.dumps(CASES[0]['input']))
        self.assertEqual(p['state'], 'parameters_valid_authorization_required')
        self.assertIs(p['export_performed'], False)
        self.assertNotIn('tenantID', p)

    def test_utc_representation_overflow_is_validation_error(self):
        pairs = [
            ('0001-01-01T00:00:00+01:00', '0001-01-01T01:00:00+01:00'),
            ('9999-12-31T22:00:00-02:00', '9999-12-31T23:00:00-02:00'),
        ]
        for start, end in pairs:
            with self.subTest(start=start):
                payload = CASES[0]['input'] | {'start': start, 'end': end}
                with self.assertRaises(ValidationError) as caught:
                    preview_json(json.dumps(payload))
                errors = caught.exception.errors()
                self.assertEqual(errors[0]['type'], 'value_error')
                self.assertEqual(errors[0]['loc'], ())
                self.assertIn('representable in UTC', errors[0]['msg'])

    def test_duplicate_known_json_key_uses_last_value(self):
        raw = json.dumps(CASES[0]['input'])
        duplicated = raw[:-1] + ', "limit": 17}'
        self.assertEqual(ExportWindow.model_validate_json(duplicated).limit, 17)
        self.assertEqual(preview_json(duplicated)['limit'], 17)

    def test_frozen_assignment(self):
        with self.assertRaises(ValidationError):
            self.good().limit = 999

for case in CASES:
    def test(self, case=case):
        actual = execute(case)
        self.assertEqual({'ok': actual['ok'], 'errors': actual['errors']}, EXPECTED[case['id']])
    setattr(ContractTests, 'test_case_' + case['id'], test)

if __name__ == '__main__':
    unittest.main(verbosity=2)

本次执行 30 项测试通过:20 个逐项输入用例,加上 Schema、往返、冻结赋值和权限状态等十项检查。测试不依靠网络或 API key,也不会创建导出任务。它们检验的是这份固定契约,不能当作模型抽取准确率、权限覆盖率或真实工具成功率。

test_case_approved_extra (test_contract.ContractTests.test_case_approved_extra) ... ok
test_case_bool_number (test_contract.ContractTests.test_case_bool_number) ... ok
test_case_bool_string (test_contract.ContractTests.test_case_bool_string) ... ok
test_case_equal (test_contract.ContractTests.test_case_equal) ... ok
test_case_limit_bool (test_contract.ContractTests.test_case_limit_bool) ... ok
test_case_limit_float (test_contract.ContractTests.test_case_limit_float) ... ok
test_case_limit_over (test_contract.ContractTests.test_case_limit_over) ... ok
test_case_limit_string (test_contract.ContractTests.test_case_limit_string) ... ok
test_case_limit_zero (test_contract.ContractTests.test_case_limit_zero) ... ok
test_case_missing_bool (test_contract.ContractTests.test_case_missing_bool) ... ok
test_case_naive (test_contract.ContractTests.test_case_naive) ... ok
test_case_null_limit (test_contract.ContractTests.test_case_null_limit) ... ok
test_case_offset_order (test_contract.ContractTests.test_case_offset_order) ... ok
test_case_over_seven_days (test_contract.ContractTests.test_case_over_seven_days) ... ok
test_case_python_datetimes (test_contract.ContractTests.test_case_python_datetimes) ... ok
test_case_python_strings (test_contract.ContractTests.test_case_python_strings) ... ok
test_case_reverse (test_contract.ContractTests.test_case_reverse) ... ok
test_case_seven_days (test_contract.ContractTests.test_case_seven_days) ... ok
test_case_tenant_extra (test_contract.ContractTests.test_case_tenant_extra) ... ok
test_case_valid (test_contract.ContractTests.test_case_valid) ... ok
test_duplicate_known_json_key_uses_last_value (test_contract.ContractTests.test_duplicate_known_json_key_uses_last_value) ... ok
test_frozen_assignment (test_contract.ContractTests.test_frozen_assignment) ... ok
test_json_mode_dict_is_not_python_mode_dict (test_contract.ContractTests.test_json_mode_dict_is_not_python_mode_dict) ... ok
test_json_roundtrip (test_contract.ContractTests.test_json_roundtrip) ... ok
test_matrix_has_independent_complete_expectations (test_contract.ContractTests.test_matrix_has_independent_complete_expectations) ... ok
test_preview_never_claims_authorization_or_export (test_contract.ContractTests.test_preview_never_claims_authorization_or_export) ... ok
test_python_roundtrip (test_contract.ContractTests.test_python_roundtrip) ... ok
test_schema_accepts_reverse_structure (test_contract.ContractTests.test_schema_accepts_reverse_structure) ... ok
test_schema_limits_and_extras (test_contract.ContractTests.test_schema_limits_and_extras) ... ok
test_utc_representation_overflow_is_validation_error (test_contract.ContractTests.test_utc_representation_overflow_is_validation_error) ... ok

----------------------------------------------------------------------
Ran 30 tests in 0.007s

OK

9. 参数合格以后,业务权限仍然要另查

图 3:模型只验收参数。身份、租户范围和业务许可来自受信任的服务端上下文。
图 3:模型只验收参数。身份、租户范围和业务许可来自受信任的服务端上下文。

approved: true 和 tenantID: "tenant-other" 在本模型里都是不允许的额外字段,因此直接报 extra_forbidden。这样做能减少接口含混,不能独自承担授权。即便将来业务确实需要 tenantID 字段,它也只能是请求目标,服务端仍须将其与已验证的调用者身份和允许的数据范围核对;请求者填写的值不能成为授予权限的依据。

preview_json 的成功状态故意停在 parameters_valid_authorization_required,export_performed 恒为 false。这里没有假装实现一个能放行的权限函数。要接真实业务,需在服务端用受信任上下文验证身份、租户范围、档案读取许可、必要审批和审计,再进入真正读取或导出步骤。只读操作也可能泄露数据,参数正确不等于有权查看。

同样,七天窗口内的数据是否存在、时间是否符合用户本意、结果是否完整,模型都不知道。不要对外来数据使用 model_construct 绕过验证,也不要把未经验证的更新塞入对象后直接执行。本篇只用正常验证入口和不可变字段,并未测试所有可能的 Python 绕过方式。

把这个例子接进 Agent 时,先记录工具参数究竟在什么时候、由谁解析,确认失败不会进入业务函数;再拿这里的矩阵重跑。到那一步才能声称真实接线已验证。当前完成的是本地参数契约实验,边界止于预览。

strict 模式为什么还接受日期字符串?

JSON 没有 datetime 原生类型,严格 JSON 入口允许符合要求的日期字符串;Python 入口规则不同。

本文运行了真实 Agent 吗?

没有。真实运行的是 Pydantic 和 jsonschema,本例只打印参数预览,不调用模型、SDK 或业务工具。

参数通过能证明有权导出吗?

不能。身份、租户范围与业务权限需由受信任的服务端上下文独立核对。