Files
mini-openclaw-v2/backend/services/judgment_provider.py
T
qiaoqichaoandClaude Code 501a4d9c9b feat(v2): T08 专项离线质检——统一适配 fixture、逐任务准入与预算守卫
覆盖 AC-16/17/18/19/20/38/39/40/41(BR-17/18/19/20/37/38/39、E-10/13/14/15/25)。

新增:
- backend/contracts/judgment.py:JudgeProfile/TaskValidation 契约(逐任务逐用途
  验收登记 + 冻结 checkpoint)。放在独立契约模块,因为规格要求的 tokenizer/预算
  字段会撞上 prescreen.py 既有的"契约层不得出现凭据字样"静态守卫;不放宽该守卫。
- backend/services/judgment_adapters.py:统一适配入口。准入(未登记任务/用途拒绝、
  runtime 须独立评测)、冻结权重(unresolved 与不一致均拒绝)、上下文预算
  (正文与选项分开计,超限返回 E-25 且零发送)、中文否定/条件例外/来源矛盾/未登记
  映射一律落 unknown(inconclusive),不硬选正面结果。
- backend/fixtures/judgment/:中文提炼依据题集、本地模型原始输出、Jev 厂商响应
  fixture(虚构内容,离线契约用)。
- backend/test_judgment_adapters.py:30 项测试。

修改(最小面):
- contracts/prescreen.py:新增 E-25 运行级错误码 JUDGMENT_CONTEXT_EXCEEDED;
  Judgment 增 actual_checkpoint_ref、JudgmentConfig 增 checkpoint_ref。
- services/judgment_provider.py:映射适配层的策略拒绝异常;归一化保留实际权重。

未做(受授权限制):没有 Jev 凭据与 Laya 权重,因此只交付离线 fixture 路径,
live 传输层仅留注入点,未接入硬件与端口探测。fixture 通过不证明中文效果合格。

验证:backend 全量 unittest 440 项 OK(原 410 项无回归)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 09:09:05 +08:00

217 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T07-b 专项判断的统一入口:disabled 路径与工具策略守卫。
依据:docs/v2/11-judgment-providers.md §3–§6、03-detailed-design.md §9.3;
BR-09/10/17/18/20、E-06/08/10/13/14/15、AC-09/10/21。
本批**只做 disabled 路径**(T07 卡片要求"先 disabled 专项路径"):真正的 Jev /
本地适配器属 T08,这里不预造。
三条不可让渡的规则:
1. **关闭就明确关闭**:disabled 时已计划题目记 `skipped + disabled`,运行记录关闭
原因,绝不静默产生判断,也绝不因为"关了"就伪造高分(BR-09 / AC-21)。
2. **失败不补分**:服务失效/超时/畸形输出记 `failed + error_code` 与降级记录,
`answer` 必须为空;不生成默认分值(AC-09 / E-06 / E-15)。
3. **工具白名单来自冻结的 Skill**:材料正文里写什么都**不能**扩展工具集合
(BR-10 / AC-10)。工具 = Skill 声明 ∩ 注册表白名单,二者取交,不取并。
"""
from __future__ import annotations
from contracts.prescreen import (
ErrorCode,
Judgment,
JudgmentAnswer,
JudgmentConfig,
NativeMetric,
)
from services import assistant_store as store
from services.component_registry import TOOLS
# T08:适配层的策略拒绝用异常表达,在这里统一映射成 Judgment 记录。
# judgment_adapters 只依赖 contracts,不反向依赖本模块,因此没有循环导入。
from services.judgment_adapters import (
CheckpointMismatch,
ContextBudgetExceeded,
EgressNotApproved,
TaskNotValidated,
)
DISABLED_REASON_DEFAULT = "本次运行未启用专项判断;主审仍按规则、工具与经验形成意见"
# 仅这些错误码表示"提供方不可用",可按预算重试一次(03 §5)。
RETRYABLE_PROVIDER_ERRORS = (ErrorCode.JUDGMENT_UNAVAILABLE,)
def resolve_config(enabled: bool, profile_id: str | None = None,
profile_revision: int | None = None, *, known_profiles=(),
provider: str = "local") -> JudgmentConfig:
"""解析并冻结本次运行的专项配置(BR-20:切换只影响新运行)。
`known_profiles` 是管理员在后端配置里登记的可用配置;P0 为空,因此在
enabled=True 且未给 ID 时会明确报错(E-13),而不是悄悄关闭专项。
"""
if not enabled:
if profile_id:
# 开关 false 同时传 ID 属 E-07,避免静默忽略用户选择(11 §3)
raise ValueError("专项已关闭却指定了配置 ID;请明确启用或清空选择")
return JudgmentConfig(enabled=False, provider="disabled",
disabled_reason=DISABLED_REASON_DEFAULT)
if not profile_id:
default = next((item for item in known_profiles if item.get("is_default")), None)
if default is None:
raise ValueError("当前没有可用的专项模型配置,请选择可用配置或关闭专项核查")
profile_id, profile_revision = default["id"], default.get("revision", 1)
profile = next((item for item in known_profiles if item.get("id") == profile_id), None)
if profile is None:
raise ValueError("当前专项模型配置不可用,请选择可用配置或关闭专项核查")
if profile.get("readiness") != "ready":
raise ValueError("该专项模型配置尚未通过验证,暂不可用")
if provider not in ("jev", "local"):
raise ValueError("专项提供方只能是 jev 或 local")
if profile.get("provider") != provider:
raise ValueError("所选配置的提供方与请求不一致")
return JudgmentConfig(
enabled=True, provider=provider, profile_id=profile_id,
profile_revision=profile_revision or profile.get("revision", 1),
model_version=profile.get("model_version") or "unresolved",
adapter_version=profile.get("adapter_version") or "not_applicable",
prompt_version=profile.get("prompt_version") or "not_applicable",
mapping_version=profile.get("mapping_version") or "not_applicable",
# T08:权重修订随配置一起冻结;缺失记 unresolved 而不是留空猜测(AC-39)
checkpoint_ref=profile.get("checkpoint_ref") or "unresolved")
def allowed_tools(skill_body: dict, registry_tools=None) -> set[str]:
"""工具集合 = Skill 声明 ∩ 注册表白名单(取交,不取并)。
材料正文、运行补充说明、专项模型输出都**不参与**这个计算,因此无法借它们
扩展权限(BR-10 / AC-10)。
"""
declared = set(skill_body.get("allowed_tools") or ())
return declared & set(registry_tools or TOOLS)
def assert_tools_within_policy(requested, skill_body, registry_tools=None) -> None:
"""任何越界工具请求都必须被拒,而不是"尽量满足"。"""
permitted = allowed_tools(skill_body, registry_tools)
outside = sorted(set(requested or ()) - permitted)
if outside:
raise ValueError(f"工具超出当前 Skill 授权范围,已拒绝:{outside}")
def _skipped(run_id: str, question: dict, config: JudgmentConfig, reason: str) -> dict:
return {"id": store.uid("judgment"), "run_id": run_id,
"question_id": question["id"], "question_version": question["version"],
"type": question["type"], "status": "skipped", "answer": None,
"evidence_ids": list(question.get("evidence_ids", [])),
"provider": "disabled", "profile_id": None, "profile_revision": None,
"model_version": None, "native_metrics": [], "error_code": None,
"skip_reason": reason}
def judge(config: JudgmentConfig, run_id: str, question: dict,
provider_call=None) -> dict:
"""执行一道题的专项判断。
`provider_call` 是 T08 才接的适配器;本批只覆盖 disabled 与失败/畸形路径。
"""
if config.provider == "disabled":
return _skipped(run_id, question, config, "disabled")
if not question.get("evidence_ids"):
# 无证据不请求模型:按缺证处理并明确返回 unknown(11 §4)
return {"id": store.uid("judgment"), "run_id": run_id,
"question_id": question["id"], "question_version": question["version"],
"type": question["type"], "status": "completed",
"answer": {"kind": "unknown", "value": None, "reason_code": "missing_evidence"},
"evidence_ids": [], "provider": config.provider,
"profile_id": config.profile_id, "profile_revision": config.profile_revision,
"model_version": config.model_version, "native_metrics": [],
"error_code": None, "skip_reason": None}
if provider_call is None:
return _failed(run_id, question, config, ErrorCode.JUDGMENT_UNAVAILABLE)
try:
raw = provider_call(config, question)
except TimeoutError:
return _failed(run_id, question, config, ErrorCode.JUDGMENT_UNAVAILABLE)
except ContextBudgetExceeded:
# E-25:超预算不外发、不静默截断;evidence_ids 仍保留原引用范围(AC-40)
return _failed(run_id, question, config, ErrorCode.JUDGMENT_CONTEXT_EXCEEDED)
except TaskNotValidated:
# 未获准的任务不给"未完成"以外的结论(AC-38)
return {**_failed(run_id, question, config, ErrorCode.JUDGMENT_UNAVAILABLE),
"status": "skipped", "error_code": None, "skip_reason": "unsupported"}
except EgressNotApproved:
# 未批准出口:不发送,记录策略拒绝(AC-16/19)
return {**_failed(run_id, question, config, ErrorCode.JUDGMENT_UNAVAILABLE),
"status": "skipped", "error_code": None, "skip_reason": "not_authorized"}
except CheckpointMismatch:
# 实际权重与冻结修订不符:拒绝并记失败,不得静默换模型(AC-39)
return _failed(run_id, question, config, ErrorCode.JUDGMENT_OUTPUT_INVALID)
except Exception:
return _failed(run_id, question, config, ErrorCode.JUDGMENT_UNAVAILABLE)
return _normalise(run_id, question, config, raw)
def _failed(run_id: str, question: dict, config: JudgmentConfig, code: ErrorCode) -> dict:
"""失败必须留痕且**不补分**:answer 为空,不生成任何默认取值。"""
return {"id": store.uid("judgment"), "run_id": run_id,
"question_id": question["id"], "question_version": question["version"],
"type": question["type"], "status": "failed", "answer": None,
"evidence_ids": list(question.get("evidence_ids", [])),
"provider": config.provider, "profile_id": config.profile_id,
"profile_revision": config.profile_revision,
"model_version": config.model_version, "native_metrics": [],
"error_code": code, "skip_reason": None}
def _normalise(run_id: str, question: dict, config: JudgmentConfig, raw: dict) -> dict:
"""校验并归一化提供方输出;畸形即 E-15,不猜一个分值补齐。"""
if not isinstance(raw, dict):
return _failed(run_id, question, config, ErrorCode.JUDGMENT_OUTPUT_INVALID)
answer_kind = raw.get("kind")
if answer_kind == "unknown":
if raw.get("reason_code") not in ("missing_evidence", "inconclusive"):
return _failed(run_id, question, config, ErrorCode.JUDGMENT_OUTPUT_INVALID)
answer = JudgmentAnswer(kind="unknown", reason_code=raw["reason_code"])
else:
if answer_kind != question["type"]:
# 题型不符(含"把连续输出硬当 binary")属能力不支持,不是失败
return {**_failed(run_id, question, config, ErrorCode.JUDGMENT_CAPABILITY_UNSUPPORTED),
"status": "skipped", "error_code": None, "skip_reason": "unsupported"}
try:
answer = JudgmentAnswer(kind=answer_kind, value=raw.get("value"))
except Exception:
return _failed(run_id, question, config, ErrorCode.JUDGMENT_OUTPUT_INVALID)
metrics = []
for item in raw.get("native_metrics") or ():
try:
metrics.append(NativeMetric(**item).model_dump(mode="json"))
except Exception:
return _failed(run_id, question, config, ErrorCode.JUDGMENT_OUTPUT_INVALID)
body = {"id": store.uid("judgment"), "run_id": run_id,
"question_id": question["id"], "question_version": question["version"],
"type": question["type"], "status": "completed",
"answer": answer.model_dump(mode="json"),
"evidence_ids": list(question.get("evidence_ids", [])),
"provider": config.provider, "profile_id": config.profile_id,
"profile_revision": config.profile_revision,
"model_version": config.model_version, "native_metrics": metrics,
"error_code": None, "skip_reason": None,
"actual_checkpoint_ref": raw.get("actual_checkpoint_ref")}
return Judgment(**body).model_dump(mode="json")
def plan_questions(config: JudgmentConfig, questions) -> dict:
"""关闭专项时把已计划题目统一记 skipped,并给出运行级关闭原因。"""
if config.provider != "disabled":
return {"judgments": [], "disabled_reason": None}
return {"judgments": [_skipped("", question, config, "disabled") for question in questions or ()],
"disabled_reason": config.disabled_reason}