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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user