T07 第二块。本批**只做 disabled 路径**(T07 卡片要求"先 disabled 专项路径"), 真正的 Jev/本地适配器属 T08,不预造。 新增契约:JudgmentAnswer(可辨识联合,score 必须有限数值)、NativeMetric、 Judgment、JudgmentConfig。契约层硬约束:disabled 不得产出 completed; 关闭专项必须记录原因且不得冻结任何配置修订。 ErrorCode 补齐 E-06/E-13/E-14/E-15/E-08/E-10,并**区分两类编码**: - 请求级(有 HTTP 语义,进 S1_ERROR_HTTP):含新增 JUDGMENT_CONFIG_INVALID 422、 EGRESS_NOT_APPROVED 403 - 运行级(RUN_LEVEL_CODES,只出现在运行体 degradations/judgment 里): JUDGMENT_UNAVAILABLE / CAPABILITY_UNSUPPORTED / OUTPUT_INVALID / RUN_BUDGET_EXCEEDED 不再把假的 HTTP 200 塞进错误映射;两者互斥且并集等于枚举,有测试锁定。 新增 services/judgment_provider.py: - resolve_config():关闭时记原因、不冻结配置;开关 false 却传 ID 属 E-07; 无可用配置时明确报 E-13 而非悄悄关闭;未验证/提供方不符均拒绝 - judge():disabled → skipped+disabled;缺证**不发起调用**直接 completed+unknown; 超时/异常 → failed+E-06;题型不符 → skipped+unsupported(E-14); 题型对但内容非法 → failed+E-15。失败一律 answer 为空,**不补默认分值** - allowed_tools():工具 = Skill 声明 ∩ 注册表白名单,**取交不取并**;材料正文里 写什么都无法扩展权限(AC-10,有反例测试) 测试:新增 test_judgment_provider.py 22 项。全量 390 项 OK(368 → 390); verify.py 10/10 PASS。 Co-Authored-By: Claude Code <noreply@anthropic.com>
332 lines
14 KiB
Python
332 lines
14 KiB
Python
"""S1 契约层测试:材料集合导入与事实确认。
|
|
|
|
覆盖 T00/04 §9 的 AC-23 / AC-24 可机械断言部分,以及契约自身的边界。
|
|
每条规则都有反例——只测通过路径不能证明约束存在。
|
|
"""
|
|
|
|
import unittest
|
|
from datetime import datetime, timezone
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from contracts.prescreen import (
|
|
RUN_LEVEL_CODES,
|
|
MAX_BATCH_BYTES,
|
|
MAX_BATCH_FILES,
|
|
MAX_ITEM_BYTES,
|
|
ConflictCandidate,
|
|
DataDomain,
|
|
ErrorCode,
|
|
ErrorResponse,
|
|
FactConflict,
|
|
ImportBatch,
|
|
ImportItem,
|
|
Ref,
|
|
S1_ERROR_HTTP,
|
|
S1_ERROR_RETRYABLE,
|
|
SourceLocator,
|
|
)
|
|
|
|
NOW = datetime(2026, 9, 29, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def locator(**over):
|
|
base = dict(kind="pdf", document_id="doc-1", document_version=1, page=3)
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
def item(**over):
|
|
base = dict(
|
|
id="item-1", relative_path="materials/a.pdf", sha256="a" * 64,
|
|
size_bytes=1024, mime_type="application/pdf", status="queued",
|
|
received_at=NOW,
|
|
)
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
def batch(**over):
|
|
base = dict(
|
|
id="batch-1", project_id="DEMO-A", data_domain="demo", revision=1,
|
|
status="queued", created_at=NOW, created_by="demo-manager",
|
|
parser_version="assistant_materials@1", items=[item()], total_bytes=1024,
|
|
)
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
def conflict(**over):
|
|
ids = ["ev-1", "ev-2"]
|
|
base = dict(
|
|
id="fc-1", project_id="DEMO-A", data_domain="demo", revision=1,
|
|
field="facility.amount", candidate_evidence_ids=list(ids),
|
|
candidates=[
|
|
ConflictCandidate(evidence_id="ev-1", value="1000", source="a.csv", locator="第2行"),
|
|
ConflictCandidate(evidence_id="ev-2", value="1200", source="b.csv", locator="第5行"),
|
|
],
|
|
status="unresolved", created_at=NOW,
|
|
)
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
class SharedConstraintTests(unittest.TestCase):
|
|
def test_unknown_field_is_rejected(self):
|
|
# E-07:所有输入禁止未知字段
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(), unexpected="x")
|
|
|
|
def test_naive_datetime_is_rejected(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(received_at=datetime(2026, 9, 29, 12, 0)))
|
|
|
|
def test_ref_requires_both_fields(self):
|
|
self.assertEqual(Ref(id="r-1", version="v1").version, "v1")
|
|
with self.assertRaises(ValidationError):
|
|
Ref(id="r-1")
|
|
|
|
def test_data_domain_enum_values(self):
|
|
self.assertEqual({d.value for d in DataDomain}, {"demo", "production"})
|
|
with self.assertRaises(ValidationError):
|
|
ImportBatch(**batch(data_domain="staging"))
|
|
|
|
|
|
class SourceLocatorTests(unittest.TestCase):
|
|
def test_each_kind_accepts_its_own_fields(self):
|
|
self.assertEqual(SourceLocator(**locator()).page, 3)
|
|
self.assertEqual(
|
|
SourceLocator(**locator(kind="table", page=None, sheet="Sheet1",
|
|
cell_range="C4")).sheet, "Sheet1")
|
|
self.assertEqual(
|
|
SourceLocator(**locator(kind="manual", page=None, note="正文第2段")).note,
|
|
"正文第2段")
|
|
|
|
def test_missing_required_field_per_kind(self):
|
|
for over, missing in [
|
|
(dict(page=None), "page"),
|
|
(dict(kind="table", page=None), "sheet"),
|
|
(dict(kind="table", page=None, sheet="S"), "cell_range"),
|
|
(dict(kind="manual", page=None), "note"),
|
|
]:
|
|
with self.subTest(missing=missing):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
SourceLocator(**locator(**over))
|
|
self.assertIn(missing, str(ctx.exception))
|
|
|
|
def test_cross_kind_fields_are_rejected(self):
|
|
# 三种寻址方式互斥,不允许混填
|
|
with self.assertRaises(ValidationError):
|
|
SourceLocator(**locator(kind="table", sheet="S", cell_range="C4")) # page 也在
|
|
with self.assertRaises(ValidationError):
|
|
SourceLocator(**locator(note="顺带写个说明"))
|
|
|
|
def test_page_must_be_positive(self):
|
|
with self.assertRaises(ValidationError):
|
|
SourceLocator(**locator(page=0))
|
|
|
|
|
|
class ImportItemTests(unittest.TestCase):
|
|
def test_valid_item(self):
|
|
self.assertEqual(ImportItem(**item()).status, "queued")
|
|
|
|
def test_absolute_paths_rejected(self):
|
|
for bad in ["/etc/passwd", "C:/Windows/x.pdf", "C:\\Windows\\x.pdf", "//share/x.pdf"]:
|
|
with self.subTest(path=bad):
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(relative_path=bad))
|
|
|
|
def test_parent_reference_rejected(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(relative_path="../../secret.pdf"))
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(relative_path="a/../../b.pdf"))
|
|
|
|
def test_backslash_relative_path_is_kept_verbatim(self):
|
|
# 校验用规范化副本,但保留原值:落库内容须与客户端提交一致
|
|
parsed = ImportItem(**item(relative_path="materials\\sub\\a.pdf"))
|
|
self.assertEqual(parsed.relative_path, "materials\\sub\\a.pdf")
|
|
|
|
def test_sha256_shape(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(sha256="A" * 64)) # 大写不合法
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(sha256="a" * 63))
|
|
|
|
def test_single_file_over_limit_rejected(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(size_bytes=MAX_ITEM_BYTES + 1))
|
|
|
|
def test_failed_requires_error_code_and_parsed_forbids_it(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(status="failed"))
|
|
self.assertEqual(
|
|
ImportItem(**item(status="failed", error_code=ErrorCode.MATERIAL_PARSE_FAILED)).status,
|
|
"failed")
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(status="parsed", document_id="doc-1",
|
|
error_code=ErrorCode.MATERIAL_PARSE_FAILED))
|
|
|
|
def test_parsed_requires_document_id(self):
|
|
# AC-23:解析成功必须能定位到既有 document
|
|
with self.assertRaises(ValidationError):
|
|
ImportItem(**item(status="parsed"))
|
|
self.assertEqual(
|
|
ImportItem(**item(status="parsed", document_id="doc-9")).document_id, "doc-9")
|
|
|
|
def test_observed_at_may_be_null_and_is_not_faked(self):
|
|
# BR-02:缺发生时间保留 null,不用导入时间冒充
|
|
parsed = ImportItem(**item())
|
|
self.assertIsNone(parsed.observed_at)
|
|
self.assertIsNotNone(parsed.received_at)
|
|
|
|
|
|
class ImportBatchTests(unittest.TestCase):
|
|
def test_valid_batch(self):
|
|
self.assertEqual(ImportBatch(**batch()).total_bytes, 1024)
|
|
|
|
def test_total_bytes_must_match_sum(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportBatch(**batch(total_bytes=999))
|
|
|
|
def test_duplicate_item_ids_rejected(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportBatch(**batch(
|
|
items=[item(), item()], total_bytes=2048))
|
|
|
|
def test_empty_items_rejected(self):
|
|
# E-16:空集合不合法
|
|
with self.assertRaises(ValidationError):
|
|
ImportBatch(**batch(items=[], total_bytes=0))
|
|
|
|
def test_over_file_count_rejected(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportBatch(**batch(items=[], total_bytes=0)) # min_length=1 已拦截
|
|
items = [item(id=f"i-{n}", size_bytes=1) for n in range(MAX_BATCH_FILES + 1)]
|
|
with self.assertRaises(ValidationError):
|
|
ImportBatch(**batch(items=items, total_bytes=len(items)))
|
|
|
|
def test_batch_total_limit_enforced(self):
|
|
# D3 决策:单批 ≤ 20 MiB。每个文件合法但合计超限必须拒绝。
|
|
each = MAX_BATCH_BYTES // 3 + 1
|
|
items = [item(id=f"i-{n}", size_bytes=each) for n in range(3)]
|
|
self.assertLessEqual(each, MAX_ITEM_BYTES)
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
ImportBatch(**batch(items=items, total_bytes=each * 3))
|
|
self.assertIn("拆分", str(ctx.exception))
|
|
|
|
def test_status_enum_bounded(self):
|
|
with self.assertRaises(ValidationError):
|
|
ImportBatch(**batch(status="done"))
|
|
|
|
|
|
class FactConflictTests(unittest.TestCase):
|
|
def test_valid_unresolved(self):
|
|
parsed = FactConflict(**conflict())
|
|
self.assertEqual(parsed.status, "unresolved")
|
|
self.assertEqual(parsed.selected_evidence_ids, [])
|
|
|
|
def test_fewer_than_two_candidates_rejected(self):
|
|
with self.assertRaises(ValidationError):
|
|
FactConflict(**conflict(candidate_evidence_ids=["ev-1"]))
|
|
|
|
def test_identical_values_are_not_a_conflict(self):
|
|
# AC-24 反例:两个来源给出同一个值不应建冲突对象
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
FactConflict(**conflict(candidates=[
|
|
ConflictCandidate(evidence_id="ev-1", value="1000", source="a", locator="L1"),
|
|
ConflictCandidate(evidence_id="ev-2", value="1000", source="b", locator="L2"),
|
|
]))
|
|
self.assertIn("不构成", str(ctx.exception))
|
|
|
|
def test_null_value_counts_as_distinct(self):
|
|
# 未知用 null,且与已有数值构成分歧
|
|
parsed = FactConflict(**conflict(candidates=[
|
|
ConflictCandidate(evidence_id="ev-1", value="1000", source="a", locator="L1"),
|
|
ConflictCandidate(evidence_id="ev-2", value=None, source="b", locator="L2"),
|
|
]))
|
|
self.assertIsNone(parsed.candidates[1].value)
|
|
|
|
def test_candidates_must_align_with_id_list(self):
|
|
with self.assertRaises(ValidationError):
|
|
FactConflict(**conflict(candidates=[
|
|
ConflictCandidate(evidence_id="ev-2", value="1000", source="a", locator="L1"),
|
|
ConflictCandidate(evidence_id="ev-1", value="1200", source="b", locator="L2"),
|
|
]))
|
|
with self.assertRaises(ValidationError):
|
|
FactConflict(**conflict(candidate_evidence_ids=["ev-1", "ev-1"], candidates=[
|
|
ConflictCandidate(evidence_id="ev-1", value="1000", source="a", locator="L1"),
|
|
ConflictCandidate(evidence_id="ev-1", value="1200", source="b", locator="L2"),
|
|
]))
|
|
|
|
def test_resolved_requires_note_selection_and_actor(self):
|
|
good = conflict(status="resolved", resolution_note="以合同为准",
|
|
selected_evidence_ids=["ev-2"], resolved_by="demo-manager",
|
|
resolved_at=NOW)
|
|
self.assertEqual(FactConflict(**good).selected_evidence_ids, ["ev-2"])
|
|
for drop in ("resolution_note", "resolved_by", "resolved_at", "selected_evidence_ids"):
|
|
with self.subTest(drop=drop):
|
|
broken = dict(good)
|
|
broken.pop(drop)
|
|
with self.assertRaises(ValidationError):
|
|
FactConflict(**broken)
|
|
|
|
def test_selection_must_come_from_candidates(self):
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
FactConflict(**conflict(status="resolved", resolution_note="n",
|
|
selected_evidence_ids=["ev-999"],
|
|
resolved_by="demo-manager", resolved_at=NOW))
|
|
self.assertIn("不属于", str(ctx.exception))
|
|
|
|
def test_unresolved_must_not_carry_resolution(self):
|
|
with self.assertRaises(ValidationError):
|
|
FactConflict(**conflict(resolution_note="提前写了结论"))
|
|
|
|
def test_supersedes_chain_is_representable(self):
|
|
# 再次冲突新建对象并指向旧对象,不覆写历史
|
|
parsed = FactConflict(**conflict(id="fc-2", supersedes_id="fc-1"))
|
|
self.assertEqual(parsed.supersedes_id, "fc-1")
|
|
|
|
|
|
class ErrorContractTests(unittest.TestCase):
|
|
def test_request_level_and_run_level_codes_partition_the_enum(self):
|
|
# 请求级编码有 HTTP 语义;运行级编码只出现在运行体里,不得混入错误信封
|
|
self.assertEqual(set(S1_ERROR_HTTP) | set(RUN_LEVEL_CODES), set(ErrorCode))
|
|
self.assertEqual(set(S1_ERROR_HTTP) & set(RUN_LEVEL_CODES), set())
|
|
|
|
def test_status_semantics_match_existing_translate(self):
|
|
# 既有 translate() 的语义:KeyError→404, Conflict→409, PermissionError→403, ValueError→422
|
|
self.assertEqual(S1_ERROR_HTTP[ErrorCode.NOT_FOUND], 404)
|
|
self.assertEqual(S1_ERROR_HTTP[ErrorCode.ACCESS_DENIED], 403)
|
|
self.assertEqual(S1_ERROR_HTTP[ErrorCode.REVISION_CONFLICT], 409)
|
|
self.assertEqual(S1_ERROR_HTTP[ErrorCode.IDEMPOTENCY_CONFLICT], 409)
|
|
self.assertEqual(S1_ERROR_HTTP[ErrorCode.IMPORT_BATCH_INVALID], 422)
|
|
|
|
def test_e17_confirm_refusal_is_422(self):
|
|
# E-17 有两个面:确认动作被拒 = 422(客户端须先裁定);
|
|
# 运行级结果 = HTTP 200 且冲突保存在 degradations(那个面不走错误信封)。
|
|
self.assertEqual(S1_ERROR_HTTP[ErrorCode.FACT_CONFLICT_UNRESOLVED], 422)
|
|
|
|
def test_only_provider_and_budget_errors_are_retryable(self):
|
|
# 03 §5:403/422 不自动重试;仅提供方不可用与预算类可按预算重试一次
|
|
retryable = {code for code, flag in S1_ERROR_RETRYABLE.items() if flag}
|
|
self.assertEqual(retryable, {ErrorCode.JUDGMENT_UNAVAILABLE, ErrorCode.RUN_BUDGET_EXCEEDED})
|
|
|
|
def test_envelope_shape(self):
|
|
body = ErrorResponse.model_validate({"error": {
|
|
"code": "IMPORT_BATCH_INVALID", "message": "材料集合不符合导入要求",
|
|
"retryable": False, "request_id": "req-1"}})
|
|
self.assertEqual(body.error.code, ErrorCode.IMPORT_BATCH_INVALID)
|
|
|
|
def test_no_credentials_in_contract_module(self):
|
|
# 契约层不得出现凭据字段(E-10 / BR-16 的静态底线)
|
|
import contracts.prescreen as module
|
|
with open(module.__file__, encoding="utf-8") as handle:
|
|
source = handle.read()
|
|
for banned in ("api_key", "password", "token", "secret"):
|
|
self.assertNotIn(banned, source.lower())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|