feat(v2): T07-b 专项 disabled 路径与工具策略守卫

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>
This commit is contained in:
2026-09-29 20:33:36 +08:00
co-authored by Claude Code
parent b5feee6982
commit 27d57c9227
4 changed files with 527 additions and 6 deletions
+140 -1
View File
@@ -103,6 +103,14 @@ class ErrorCode(StrEnum):
MATERIAL_PARSE_FAILED = "MATERIAL_PARSE_FAILED"
IMPORT_BATCH_INVALID = "IMPORT_BATCH_INVALID"
FACT_CONFLICT_UNRESOLVED = "FACT_CONFLICT_UNRESOLVED"
JUDGMENT_CONFIG_INVALID = "JUDGMENT_CONFIG_INVALID"
EGRESS_NOT_APPROVED = "EGRESS_NOT_APPROVED"
# 运行级编码:记录在运行体(degradations / judgment)里,**不构成 HTTP 错误**,
# 因此刻意不进 S1_ERROR_HTTP;见下方 RUN_LEVEL_CODES。
JUDGMENT_UNAVAILABLE = "JUDGMENT_UNAVAILABLE"
JUDGMENT_CAPABILITY_UNSUPPORTED = "JUDGMENT_CAPABILITY_UNSUPPORTED"
JUDGMENT_OUTPUT_INVALID = "JUDGMENT_OUTPUT_INVALID"
RUN_BUDGET_EXCEEDED = "RUN_BUDGET_EXCEEDED"
# HTTP 语义沿用既有 translate()(backend/api/project_assistant.py:36-46),
@@ -121,10 +129,24 @@ S1_ERROR_HTTP: dict[ErrorCode, int] = {
ErrorCode.MATERIAL_PARSE_FAILED: 422,
ErrorCode.IMPORT_BATCH_INVALID: 422,
ErrorCode.FACT_CONFLICT_UNRESOLVED: 422,
ErrorCode.JUDGMENT_CONFIG_INVALID: 422,
ErrorCode.EGRESS_NOT_APPROVED: 403,
}
# 这些编码只出现在运行结果里("HTTP 200 仅表示结果可读取"),永远不会作为
# 错误信封返回,因此与 S1_ERROR_HTTP 互不相交。
RUN_LEVEL_CODES = frozenset({
ErrorCode.JUDGMENT_UNAVAILABLE,
ErrorCode.JUDGMENT_CAPABILITY_UNSUPPORTED,
ErrorCode.JUDGMENT_OUTPUT_INVALID,
ErrorCode.RUN_BUDGET_EXCEEDED,
})
# 403/422 不自动重试;409 需客户端改载荷或刷新。见 03 §5。
S1_ERROR_RETRYABLE: dict[ErrorCode, bool] = {code: False for code in ErrorCode}
S1_ERROR_RETRYABLE: dict[ErrorCode, bool] = {
code: code in (ErrorCode.JUDGMENT_UNAVAILABLE, ErrorCode.RUN_BUDGET_EXCEEDED)
for code in ErrorCode
}
class ErrorBody(Contract):
@@ -616,3 +638,120 @@ class ReviewPlan(Contract):
if self.status == GovernanceStatus.published and self.reviewer_id is None:
raise ValueError("已发布方案必须有审核人")
return self
# ---------------------------------------------------------------- 专项判断(T07-b)
class JudgmentAnswer(Contract):
"""可辨识联合:正常答案的 kind 必须与题型一致(11 §5)。"""
kind: Literal["choice", "score", "binary", "unknown"]
value: str | float | bool | None = None
reason_code: Literal["missing_evidence", "inconclusive"] | None = None
@model_validator(mode="after")
def _check_answer(self) -> JudgmentAnswer:
if self.kind == "unknown":
if self.reason_code is None:
raise ValueError("unknown 必须给出 reason_code")
if self.value is not None:
raise ValueError("unknown 不得带取值")
return self
if self.reason_code is not None:
raise ValueError("非 unknown 的答案不得带 reason_code")
if self.kind == "choice" and not isinstance(self.value, str):
raise ValueError("choice 的取值必须是字符串")
if self.kind == "binary" and not isinstance(self.value, bool):
raise ValueError("binary 的取值必须是布尔值")
if self.kind == "score":
if isinstance(self.value, bool) or not isinstance(self.value, (int, float)):
raise ValueError("score 的取值必须是数值")
if self.value != self.value or self.value in (float("inf"), float("-inf")):
raise ValueError("score 必须是有限数值")
return self
class NativeMetric(Contract):
"""提供方原生指标。保留语义,**不得**互换阈值或补造概率(BR-18)。"""
name: str = Field(min_length=1, max_length=80)
value: float
semantics: str = Field(min_length=1, max_length=DESC_MAX)
source: Literal["provider_native", "self_reported"]
@model_validator(mode="after")
def _check_finite(self) -> NativeMetric:
if self.value != self.value or self.value in (float("inf"), float("-inf")):
raise ValueError("原生指标必须是有限数值;缺失应留空而不是填 0/1")
return self
class Judgment(Contract):
"""单题专项判断结果。`completed` 只表示完成该题处理与校验,不表示支持结论。"""
id: Identifier
run_id: Identifier
question_id: Identifier
question_version: str = Field(min_length=1, max_length=70)
type: Literal["choice", "score", "binary"]
status: Literal["completed", "skipped", "failed"]
answer: JudgmentAnswer | None = None
evidence_ids: list[Identifier] = Field(default_factory=list, max_length=20)
provider: Literal["jev", "local", "disabled"]
profile_id: Identifier | None = None
profile_revision: int | None = Field(default=None, ge=1)
model_version: str | None = Field(default=None, max_length=ID_MAX)
native_metrics: list[NativeMetric] = Field(default_factory=list, max_length=32)
error_code: ErrorCode | None = None
skip_reason: Literal["disabled", "not_configured", "not_authorized", "unsupported"] | None = None
@model_validator(mode="after")
def _check_status_consistency(self) -> Judgment:
if self.status == "completed":
if self.answer is None:
raise ValueError("completed 必须有合法 answer")
if self.error_code or self.skip_reason:
raise ValueError("completed 不得带 error_code 或 skip_reason")
if self.status == "skipped":
if self.skip_reason is None:
raise ValueError("skipped 必须有 skip_reason")
if self.answer is not None:
raise ValueError("skipped 的 answer 必须为空")
if self.status == "failed":
if self.error_code is None:
raise ValueError("failed 必须有 error_code")
if self.answer is not None:
raise ValueError("failed 的 answer 必须为空")
# 关闭专项时不得声称有提供方配置(避免"关了却像在跑")
if self.provider == "disabled" and self.status == "completed":
raise ValueError("专项已关闭时不得产出 completed 判断")
return self
class JudgmentConfig(Contract):
"""运行创建时冻结的专项配置(BR-20:切换只影响新运行)。"""
enabled: bool
provider: Literal["jev", "local", "disabled"]
profile_id: Identifier | None = None
profile_revision: int | None = Field(default=None, ge=1)
model_version: str | None = Field(default=None, max_length=ID_MAX)
adapter_version: str | None = Field(default=None, max_length=ID_MAX)
prompt_version: str | None = Field(default=None, max_length=ID_MAX)
mapping_version: str | None = Field(default=None, max_length=ID_MAX)
disabled_reason: str | None = Field(default=None, max_length=DESC_MAX)
@model_validator(mode="after")
def _check_freeze(self) -> JudgmentConfig:
if self.enabled:
if self.provider == "disabled":
raise ValueError("启用专项时提供方不得为 disabled")
if not self.profile_id or self.profile_revision is None:
raise ValueError("启用专项时必须冻结配置 ID 与修订")
else:
if self.provider != "disabled":
raise ValueError("关闭专项时提供方必须是 disabled")
if self.profile_id or self.profile_revision is not None:
raise ValueError("关闭专项时不得冻结任何配置修订")
if not self.disabled_reason:
raise ValueError("关闭专项必须记录关闭原因,不能静默")
return self