覆盖 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>
252 lines
12 KiB
Python
252 lines
12 KiB
Python
"""T08 专项离线质检:统一适配 fixture、逐任务/用途准入、冻结 checkpoint 与上下文预算。
|
||
|
||
依据:docs/v2/11-judgment-providers.md §8、docs/v2/06-validation.md §4/§6;
|
||
BR-17/18/19/20/37/38/39、E-10/13/14/15/25、AC-16/17/18/19/20/38/39/40/41。
|
||
|
||
本批**只交付离线 fixture 路径**。没有 Jev 凭据、没有 Laya 权重与授权,
|
||
因此:
|
||
- live 传输层只留注入点,未注入时明确 `LiveTransportUnavailable`;
|
||
- fixture 通过只证明适配代码结构成立,**不证明**中文专项效果合格
|
||
(06-validation.md §6:"fixture 通过仅证明适配代码结构")。
|
||
|
||
四条不可让渡的规则:
|
||
1. **逐任务逐用途准入**:`task_validations` 里没有 (task, usage) 的 passed 记录,
|
||
就不执行;服务能连通、别的任务通过、装好了权重,都不算准入(AC-38)。
|
||
2. **权重不可隐式换**:运行冻结 `checkpoint_ref`;提供方实际权重不一致即拒绝,
|
||
不按语言或负载偷偷路由到别的模型(AC-39)。
|
||
3. **超预算不外发**:超出已验证上下文预算时返回 E-25 并保留原证据范围,
|
||
绝不截断后半段照样发送(AC-40 / BR-39)。
|
||
4. **歧义不硬选**:映射未知、中文否定、条件例外、来源矛盾都落 unknown/人工复核,
|
||
不挑一个"看起来正面"的选项(AC-18)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from contracts.judgment import JudgmentProfile
|
||
from contracts.prescreen import JudgmentConfig
|
||
|
||
FIXTURE_ROOT = Path(__file__).resolve().parent.parent / "fixtures" / "judgment"
|
||
|
||
# 离线预算估算:中文按 1 字≈1 token、ASCII 按 4 字符≈1 token 的保守上界。
|
||
# 真实预算必须按选定模型的 tokenizer 实测填写(11 §8.1),这里的估算只用于
|
||
# 离线 fixture 与"宁可拒绝"的方向,不作为生产阈值。
|
||
_CJK_FLOOR = 0x2E80
|
||
|
||
|
||
class ContextBudgetExceeded(RuntimeError):
|
||
"""超出已验证上下文预算(E-25)。携带原证据范围,供上层保留引用。"""
|
||
|
||
def __init__(self, message: str, evidence_ids=()):
|
||
super().__init__(message)
|
||
self.evidence_ids = list(evidence_ids)
|
||
|
||
|
||
class TaskNotValidated(ValueError):
|
||
"""该任务/用途没有 passed 的验收登记(AC-38)。"""
|
||
|
||
|
||
class CheckpointMismatch(ValueError):
|
||
"""实际权重修订与运行冻结值不一致(AC-39)。"""
|
||
|
||
|
||
class EgressNotApproved(PermissionError):
|
||
"""未获批准的外部出口:不发送(AC-16/19)。"""
|
||
|
||
|
||
class LiveTransportUnavailable(RuntimeError):
|
||
"""没有可用的 live 传输层(本批未接入硬件与授权)。"""
|
||
|
||
|
||
def estimate_tokens(text: str) -> int:
|
||
"""保守估算 token 数:CJK 每字 1,其它每 4 字符 1,向上取整。"""
|
||
if not text:
|
||
return 0
|
||
cjk = sum(1 for char in text if ord(char) >= _CJK_FLOOR)
|
||
other = len(text) - cjk
|
||
return cjk + (other + 3) // 4
|
||
|
||
|
||
def load_fixture(name: str) -> dict:
|
||
return json.loads((FIXTURE_ROOT / name).read_text(encoding="utf-8"))
|
||
|
||
|
||
# ------------------------------------------------------------------ 准入
|
||
def validation_for(profile: JudgmentProfile, task: str, usage: str):
|
||
"""取出该任务与用途的登记;没有登记返回 None,不做"默认通过"。"""
|
||
for item in profile.task_validations:
|
||
if item.task == task and item.allowed_usage == usage:
|
||
return item
|
||
return None
|
||
|
||
|
||
def assert_task_allowed(profile: JudgmentProfile, task: str, usage: str) -> None:
|
||
"""逐任务逐用途准入(AC-38 / AC-41)。
|
||
|
||
offline 与 runtime 分开登记:只有 offline 通过的配置不能进 runtime;
|
||
runtime 还要求验收是独立评测——同模型自评或其生成标签不算证据。
|
||
"""
|
||
if profile.readiness != "ready":
|
||
raise TaskNotValidated("该专项配置尚未就绪,不能执行任何任务")
|
||
record = validation_for(profile, task, usage)
|
||
if record is None or record.decision != "passed":
|
||
raise TaskNotValidated(
|
||
f"该配置未在用途 {usage} 通过任务 {task} 的验收;"
|
||
"连通、安装或其它任务通过都不能替代该任务准入")
|
||
if usage == "runtime" and not record.independent:
|
||
raise TaskNotValidated("runtime 准入必须是独立评测结果,同模型自评不能作为效果证据")
|
||
|
||
|
||
def assert_checkpoint_frozen(config: JudgmentConfig, profile: JudgmentProfile) -> None:
|
||
"""运行冻结值优先:配置登记被改动后,旧运行仍按原值执行(AC-20/39)。"""
|
||
if profile.checkpoint_ref == "unresolved":
|
||
raise CheckpointMismatch("权重修订不可复现(unresolved),不得用于任何执行")
|
||
if config.checkpoint_ref and config.checkpoint_ref != profile.checkpoint_ref:
|
||
raise CheckpointMismatch(
|
||
"登记配置的权重修订与本次运行冻结值不一致;请新建运行而不是就地替换")
|
||
|
||
|
||
def assert_within_budget(question: dict, profile: JudgmentProfile) -> None:
|
||
"""调用前算预算;超限即拒绝,不截断、不外发(AC-40)。
|
||
|
||
正文(含提示词)与选项分开计:选项预算不能被正文的余量顶掉,反之亦然。
|
||
"""
|
||
body = "".join(str(item) for item in question.get("evidence_text") or ())
|
||
prompt = str(question.get("prompt") or "")
|
||
options = " ".join(str(item) for item in question.get("options") or ())
|
||
evidence_ids = question.get("evidence_ids") or ()
|
||
|
||
body_tokens = estimate_tokens(body) + estimate_tokens(prompt)
|
||
if body_tokens > profile.input_token_limit:
|
||
raise ContextBudgetExceeded(
|
||
f"正文约 {body_tokens} token,超出该模型已验证输入预算 "
|
||
f"{profile.input_token_limit};请缩小问题或转人工复核",
|
||
evidence_ids=evidence_ids)
|
||
|
||
option_tokens = estimate_tokens(options)
|
||
if option_tokens > profile.option_token_limit:
|
||
raise ContextBudgetExceeded(
|
||
f"选项约 {option_tokens} token,超出该模型已验证选项预算 "
|
||
f"{profile.option_token_limit};请缩减候选项或转人工复核",
|
||
evidence_ids=evidence_ids)
|
||
|
||
|
||
# ------------------------------------------------------------------ 映射
|
||
def _unknown(reason: str) -> dict:
|
||
return {"kind": "unknown", "reason_code": reason}
|
||
|
||
|
||
def map_local_answer(raw: dict, question: dict, profile: JudgmentProfile) -> dict:
|
||
"""本地模型原始输出 → 统一契约形状。
|
||
|
||
歧义一律落 unknown/inconclusive:映射未知、中文否定、条件例外、来源矛盾
|
||
都不许"挑一个正面结果"(AC-18)。`confidence` 只作为 self_reported 指标
|
||
保留原语义,**不**换算成任何概率。
|
||
"""
|
||
mapping = (raw.get("mapping") or {})
|
||
if raw.get("conflicts"):
|
||
return _unknown("inconclusive")
|
||
if raw.get("exception_applies"):
|
||
return _unknown("inconclusive")
|
||
label = raw.get("label")
|
||
if not label:
|
||
return _unknown("missing_evidence" if not raw.get("evidence_ids") else "inconclusive")
|
||
option = mapping.get(label)
|
||
if option is None:
|
||
# 例如中文否定表述("不符合")未登记映射:不得当成正面选项使用
|
||
return _unknown("inconclusive")
|
||
metrics = []
|
||
if raw.get("confidence") is not None:
|
||
metrics.append({"name": "self_confidence", "value": float(raw["confidence"]),
|
||
"semantics": "模型自报置信度,未经校准,与审批概率无关",
|
||
"source": "self_reported"})
|
||
return {"kind": question["type"], "value": option, "native_metrics": metrics,
|
||
"actual_checkpoint_ref": raw.get("actual_checkpoint_ref")}
|
||
|
||
|
||
# jev 厂商字段名与本地完全不同:映射表按 mapping_version 固定,主审不读厂商字段
|
||
# (AC-17)。新增厂商版本要新增一行映射并单独登记验收,不改这一层的行为。
|
||
# 本行只登记 choice 题型的映射:其它题型没有登记就落 unknown,而不是借这一行
|
||
# 顺带支持(06-validation.md §6:"阈值和映射按提供方/模型/题型独立登记")。
|
||
_DEV_VENDOR_CHOICES = {
|
||
"APPROVE": "supported",
|
||
"REJECT": "unsupported",
|
||
# COND_APPROVE(附条件)刻意**不**映射为通过:附条件不等于通过(BR-21)
|
||
}
|
||
|
||
|
||
def map_jev_answer(raw: dict, question: dict, profile: JudgmentProfile) -> dict:
|
||
"""Jev 厂商响应 → 统一契约形状(fixture 验证用,不发送任何请求)。"""
|
||
if question["type"] != "choice":
|
||
return _unknown("inconclusive")
|
||
envelope = raw.get("result") or {}
|
||
code = envelope.get("decision_code")
|
||
value = _DEV_VENDOR_CHOICES.get(code)
|
||
if value is None:
|
||
return _unknown("inconclusive")
|
||
metrics = []
|
||
for name, item in (raw.get("metrics") or {}).items():
|
||
# 厂商原生指标保留语义与来源;不跨提供方套阈值,也不补齐缺失概率(AC-18)
|
||
metrics.append({"name": str(name), "value": float(item),
|
||
"semantics": "厂商原生指标,定义与兴邦校准口径不一致",
|
||
"source": "provider_native"})
|
||
return {"kind": question["type"], "value": value, "native_metrics": metrics,
|
||
"actual_checkpoint_ref": raw.get("checkpoint")}
|
||
|
||
|
||
MAPPERS = {"local": map_local_answer, "jev": map_jev_answer}
|
||
|
||
|
||
# ------------------------------------------------------------------ 适配入口
|
||
def build_provider_call(config: JudgmentConfig, profile: JudgmentProfile, task: str,
|
||
usage: str, *, fixture=None, transport=None, call_log=None):
|
||
"""构造 `judge()` 用的 provider_call:先过准入与冻结,再决定是否真的发送。
|
||
|
||
`fixture(question) -> raw` 是离线路径(不联网);`transport(target, payload)`
|
||
是 live 路径。两者不能互相冒充:call_log 会记录 mode,测试据此断言
|
||
"local 运行期间没有任何 jev 请求"(AC-17)。
|
||
"""
|
||
assert_task_allowed(profile, task, usage)
|
||
assert_checkpoint_frozen(config, profile)
|
||
mapper = MAPPERS[profile.provider]
|
||
|
||
def call(config: JudgmentConfig, question: dict) -> dict:
|
||
# 预算在发送之前判定:超限即结束,调用记录里不会出现任何发送
|
||
assert_within_budget(question, profile)
|
||
payload = json.dumps({
|
||
"question_id": question["id"], "version": question["version"],
|
||
"type": question["type"], "prompt": question.get("prompt", ""),
|
||
"options": list(question.get("options") or ()),
|
||
"evidence_ids": list(question.get("evidence_ids") or ()),
|
||
"checkpoint_ref": config.checkpoint_ref,
|
||
"prompt_version": config.prompt_version,
|
||
"mapping_version": config.mapping_version,
|
||
}, ensure_ascii=False)
|
||
|
||
if fixture is not None:
|
||
raw = fixture(question)
|
||
mode = "fixture"
|
||
else:
|
||
if config.provider == "jev" and not profile.egress_approved:
|
||
raise EgressNotApproved("该外部出口未获批准,已拒绝发送")
|
||
if transport is None:
|
||
raise LiveTransportUnavailable(
|
||
"本批未接入 live 传输层(缺硬件与授权),只能走离线 fixture")
|
||
raw = transport(profile.provider, payload)
|
||
mode = "live"
|
||
|
||
if call_log is not None:
|
||
call_log.append({"target": config.provider, "mode": mode,
|
||
"question_id": question["id"], "payload": payload})
|
||
|
||
adapted = mapper(raw, question, profile)
|
||
actual = adapted.get("actual_checkpoint_ref")
|
||
if actual and actual != config.checkpoint_ref:
|
||
raise CheckpointMismatch(
|
||
f"提供方实际权重 {actual} 与运行冻结值 {config.checkpoint_ref} 不一致")
|
||
return adapted
|
||
|
||
return call
|