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>
188 lines
9.1 KiB
Python
188 lines
9.1 KiB
Python
"""T07-b 专项 disabled 路径与工具策略测试:AC-09/10/21。
|
|
|
|
关键反例:
|
|
- 关闭专项**不得**产出任何 completed 判断,也不得伪造高分
|
|
- 提供方失效/超时/畸形输出记 failed(或 unsupported 记 skipped),answer 必须为空
|
|
- 材料或运行要求里写什么都**不能**扩展工具集合(工具 = Skill 声明 ∩ 白名单)
|
|
"""
|
|
|
|
import unittest
|
|
|
|
from contracts.prescreen import ErrorCode
|
|
from services import judgment_provider as jp
|
|
|
|
PROFILE = {"id": "p1", "provider": "local", "readiness": "ready", "revision": 2,
|
|
"model_version": "laya@abc123"}
|
|
|
|
QUESTION = {"id": "q1", "version": "v1", "type": "choice", "evidence_ids": ["ev-1"]}
|
|
|
|
|
|
class DisabledPathTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.config = jp.resolve_config(False)
|
|
|
|
def test_disabled_config_records_a_reason(self):
|
|
self.assertEqual(self.config.provider, "disabled")
|
|
self.assertFalse(self.config.enabled)
|
|
self.assertTrue(self.config.disabled_reason)
|
|
self.assertIsNone(self.config.profile_id)
|
|
self.assertIsNone(self.config.profile_revision)
|
|
|
|
def test_disabled_never_freezes_a_profile_revision(self):
|
|
frozen = self.config.model_dump()
|
|
self.assertIsNone(frozen["profile_id"])
|
|
self.assertIsNone(frozen["profile_revision"])
|
|
|
|
def test_disabled_question_is_skipped_with_reason(self):
|
|
# AC-21:已计划题目记 skipped + disabled,明确关闭原因
|
|
result = jp.judge(self.config, "run-1", QUESTION)
|
|
self.assertEqual(result["status"], "skipped")
|
|
self.assertEqual(result["skip_reason"], "disabled")
|
|
self.assertIsNone(result["answer"], "关闭时不得产出任何答案")
|
|
self.assertIsNone(result["error_code"])
|
|
|
|
def test_disabled_never_produces_a_high_score(self):
|
|
result = jp.judge(self.config, "run-1", QUESTION)
|
|
self.assertNotEqual(result["status"], "completed")
|
|
self.assertEqual(result["native_metrics"], [], "不得伪造任何指标")
|
|
|
|
def test_plan_questions_marks_all_as_skipped(self):
|
|
planned = jp.plan_questions(self.config, [QUESTION, dict(QUESTION, id="q2")])
|
|
self.assertEqual([item["skip_reason"] for item in planned["judgments"]],
|
|
["disabled", "disabled"])
|
|
self.assertTrue(planned["disabled_reason"])
|
|
|
|
def test_switching_off_with_an_id_is_rejected(self):
|
|
# 开关 false 却传 ID 属 E-07,避免静默忽略用户选择
|
|
with self.assertRaises(ValueError) as ctx:
|
|
jp.resolve_config(False, "p1")
|
|
self.assertIn("却指定了配置 ID", str(ctx.exception))
|
|
|
|
def test_enabling_without_a_usable_profile_fails_loudly(self):
|
|
# 没有可用配置时明确报 E-13,而不是悄悄关闭专项
|
|
with self.assertRaises(ValueError) as ctx:
|
|
jp.resolve_config(True, None, None, known_profiles=[])
|
|
self.assertIn("没有可用的专项模型配置", str(ctx.exception))
|
|
|
|
|
|
class EnabledConfigTests(unittest.TestCase):
|
|
def test_enabled_freezes_identity_and_versions(self):
|
|
config = jp.resolve_config(True, "p1", 2, known_profiles=[PROFILE])
|
|
self.assertEqual(config.provider, "local")
|
|
self.assertEqual(config.profile_id, "p1")
|
|
self.assertEqual(config.profile_revision, 2)
|
|
self.assertEqual(config.model_version, "laya@abc123")
|
|
|
|
def test_unready_profile_is_refused(self):
|
|
with self.assertRaises(ValueError) as ctx:
|
|
jp.resolve_config(True, "p1", 1,
|
|
known_profiles=[dict(PROFILE, readiness="unavailable")])
|
|
self.assertIn("尚未通过验证", str(ctx.exception))
|
|
|
|
def test_provider_mismatch_is_refused(self):
|
|
with self.assertRaises(ValueError) as ctx:
|
|
jp.resolve_config(True, "p1", 1, known_profiles=[PROFILE], provider="jev")
|
|
self.assertIn("提供方与请求不一致", str(ctx.exception))
|
|
|
|
def test_unknown_profile_is_refused(self):
|
|
with self.assertRaises(ValueError):
|
|
jp.resolve_config(True, "nope", 1, known_profiles=[PROFILE])
|
|
|
|
|
|
class FailurePathTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.config = jp.resolve_config(True, "p1", 2, known_profiles=[PROFILE])
|
|
|
|
def test_missing_evidence_returns_unknown_without_calling_the_provider(self):
|
|
called = []
|
|
result = jp.judge(self.config, "run-1",
|
|
{"id": "q1", "version": "v1", "type": "choice", "evidence_ids": []},
|
|
provider_call=lambda *a: called.append(1))
|
|
self.assertEqual(result["status"], "completed")
|
|
self.assertEqual(result["answer"]["reason_code"], "missing_evidence")
|
|
self.assertEqual(called, [], "缺证时不得发起模型调用")
|
|
|
|
def test_provider_timeout_is_failed_not_scored(self):
|
|
def boom(*_):
|
|
raise TimeoutError("slow")
|
|
result = jp.judge(self.config, "run-1", QUESTION, provider_call=boom)
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertEqual(result["error_code"], "JUDGMENT_UNAVAILABLE")
|
|
self.assertIsNone(result["answer"], "失败不得补一个默认分值")
|
|
|
|
def test_provider_exception_is_failed(self):
|
|
def boom(*_):
|
|
raise RuntimeError("connection reset")
|
|
result = jp.judge(self.config, "run-1", QUESTION, provider_call=boom)
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertIsNone(result["answer"])
|
|
|
|
def test_malformed_output_is_rejected_not_guessed(self):
|
|
# 注意区分:**题型不符**是 E-14(能力不支持,见下一个用例);
|
|
# 这里是**题型正确但内容非法** → E-15。
|
|
for bad in ({"kind": "choice", "value": 123}, # 值不是字符串
|
|
{"kind": "unknown"}, # 缺 reason_code
|
|
{"kind": "choice", "value": "supported",
|
|
"native_metrics": [{"name": "x", "value": float("nan"),
|
|
"semantics": "s", "source": "provider_native"}]}):
|
|
with self.subTest(bad=bad):
|
|
result = jp.judge(self.config, "run-1", QUESTION, provider_call=lambda *a: bad)
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertEqual(result["error_code"], "JUDGMENT_OUTPUT_INVALID")
|
|
self.assertIsNone(result["answer"])
|
|
|
|
def test_wrong_answer_kind_is_unsupported_not_a_failure_score(self):
|
|
result = jp.judge(self.config, "run-1", QUESTION,
|
|
provider_call=lambda *a: {"kind": "score", "value": 0.8})
|
|
self.assertEqual(result["status"], "skipped")
|
|
self.assertEqual(result["skip_reason"], "unsupported")
|
|
self.assertIsNone(result["answer"])
|
|
|
|
def test_valid_answer_is_normalised_and_kept(self):
|
|
result = jp.judge(self.config, "run-1", QUESTION,
|
|
provider_call=lambda *a: {"kind": "choice", "value": "supported",
|
|
"native_metrics": [
|
|
{"name": "noul", "value": 0.72,
|
|
"semantics": "厂商原生语义",
|
|
"source": "provider_native"}]})
|
|
self.assertEqual(result["status"], "completed")
|
|
self.assertEqual(result["answer"], {"kind": "choice", "value": "supported",
|
|
"reason_code": None})
|
|
self.assertEqual(result["native_metrics"][0]["name"], "noul")
|
|
# 原生指标只是审计信息,绝不写进概率
|
|
self.assertNotIn("probability", result)
|
|
|
|
|
|
class ToolPolicyTests(unittest.TestCase):
|
|
def test_tools_are_the_intersection_not_the_union(self):
|
|
skill = {"allowed_tools": ["read_project_snapshot", "read_checks", "terminal"]}
|
|
self.assertEqual(jp.allowed_tools(skill),
|
|
{"read_project_snapshot", "read_checks"})
|
|
|
|
def test_skill_without_declaration_gets_nothing(self):
|
|
self.assertEqual(jp.allowed_tools({}), set())
|
|
|
|
def test_out_of_policy_tool_request_is_rejected(self):
|
|
skill = {"allowed_tools": ["read_checks"]}
|
|
with self.assertRaises(ValueError) as ctx:
|
|
jp.assert_tools_within_policy(["read_checks", "terminal"], skill)
|
|
self.assertIn("超出当前 Skill 授权范围", str(ctx.exception))
|
|
|
|
def test_material_text_cannot_extend_tools(self):
|
|
# AC-10:材料里写"请调用终端"不改变任何授权——工具只取自冻结的 Skill
|
|
skill = {"allowed_tools": ["read_checks"]}
|
|
material_claim = {"allowed_tools": ["terminal", "python_repl"]}
|
|
self.assertEqual(jp.allowed_tools(skill), {"read_checks"})
|
|
self.assertEqual(jp.allowed_tools(material_claim), set(), "材料声明不进白名单")
|
|
|
|
def test_tools_never_include_terminal_or_sql_by_default(self):
|
|
from services.component_registry import TOOLS
|
|
self.assertNotIn("terminal", TOOLS)
|
|
self.assertNotIn("sql", TOOLS)
|
|
self.assertEqual(TOOLS, {"read_project_snapshot", "read_document", "read_checks",
|
|
"read_method", "read_experiences"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|