zizi 091b66a9bb 重构: 收敛 Agent/Skill 运行时与创作质量闭环
将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。

同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
2026-08-22 02:12:32 +08:00

176 lines
7.5 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.

#!/usr/bin/env python3
"""Skill 行为评测引擎:由 harness 从外部驱动,Skill 不能自己宣布通过。
引擎把 SKILL.md 文本和场景任务交给 adapter(Agent/模型执行层),接收结构化
观察,再按场景登记的期望裁决。真实模型 adapter 需要显式授权;未授权时以
稳定码 EVAL_ADAPTER_UNAVAILABLE 失败关闭。fake adapter 只用于评测引擎自身
管道验证,不构成 Skill 行为证据。
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
REPORT_SCHEMA = "skill-behavior-eval-report-v1"
SCENARIO_SCHEMA = "skill-behavior-eval-scenarios-v1"
CATEGORIES = (
"positive_trigger",
"negative_trigger",
"input_missing_or_out_of_scope",
"output_contract_and_fail_closed",
"forbidden_action",
"stability_and_confounders",
)
class EvalContractError(ValueError):
"""评测合同错误:场景结构非法或观察缺项。"""
class EvalAdapterUnavailable(RuntimeError):
"""真实模型执行层未授权或不可用;稳定码供 runner 与报告引用。"""
code = "EVAL_ADAPTER_UNAVAILABLE"
def skill_md_fingerprint(skill_dir: Path) -> str:
text = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
def load_scenarios(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise EvalContractError(f"场景文件不可读或不合法: {path} ({exc})") from exc
if not isinstance(data, dict) or data.get("schema_version") != SCENARIO_SCHEMA:
raise EvalContractError(f"场景文件 schema_version 必须是 {SCENARIO_SCHEMA}: {path}")
scenarios = data.get("scenarios")
if not isinstance(scenarios, list) or not scenarios:
raise EvalContractError(f"场景文件必须带非空 scenarios 数组: {path}")
seen = set()
for scenario in scenarios:
sid = scenario.get("id")
if not sid or sid in seen:
raise EvalContractError(f"场景 id 缺失或重复: {sid!r} ({path})")
seen.add(sid)
if scenario.get("category") not in CATEGORIES:
raise EvalContractError(f"场景 {sid} 的 category 非法: {scenario.get('category')!r}")
if not str(scenario.get("task") or "").strip():
raise EvalContractError(f"场景 {sid} 缺 task")
if not isinstance(scenario.get("expectations"), dict) or not scenario["expectations"]:
raise EvalContractError(f"场景 {sid} 缺 expectations")
return data
class FakeAdapter:
"""脚本化观察适配器:只验证评测管道,不产生 Skill 行为证据。"""
def __init__(self, scripted: dict[str, dict]):
self.scripted = dict(scripted)
def run(self, skill_md_text: str, scenario: dict) -> dict:
observation = self.scripted.get(scenario["id"])
if observation is None:
raise EvalContractError(f"fake adapter 没有场景 {scenario['id']} 的脚本观察")
return dict(observation)
class RoleAgentAdapter:
"""真实角色 Agent 执行层占位:未获授权前失败关闭,不允许静默降级为 fake。"""
def run(self, skill_md_text: str, scenario: dict) -> dict:
raise EvalAdapterUnavailable(
"真实模型行为评测未授权:需要显式模型、预算与执行配置授权后才能运行"
)
def _check(expectations: dict, observation: dict) -> list[dict]:
failures: list[dict] = []
invoked = [str(cmd) for cmd in observation.get("invoked_commands", [])]
def missing_required(key: str) -> None:
failures.append({"check": "observation_missing", "evidence": f"观察缺少字段 {key}"})
for needle in expectations.get("must_invoke", []):
if not any(needle in cmd for cmd in invoked):
failures.append({"check": "must_invoke", "evidence": f"未见调用包含 {needle!r};实际 {invoked}"})
for needle in expectations.get("must_not_invoke", []):
hits = [cmd for cmd in invoked if needle in cmd]
if hits:
failures.append({"check": "must_not_invoke", "evidence": f"禁止调用 {needle!r} 出现: {hits}"})
if "expected_exit_codes" in expectations:
if "exit_code" not in observation:
missing_required("exit_code")
elif observation["exit_code"] not in expectations["expected_exit_codes"]:
failures.append({
"check": "expected_exit_codes",
"evidence": f"退出码 {observation['exit_code']} 不在 {expectations['expected_exit_codes']}",
})
output = str(observation.get("output", ""))
for needle in expectations.get("output_must_contain", []):
if needle not in output:
failures.append({"check": "output_must_contain", "evidence": f"输出缺少 {needle!r}"})
for needle in expectations.get("output_must_not_contain", []):
if needle in output:
failures.append({"check": "output_must_not_contain", "evidence": f"输出出现禁止内容 {needle!r}"})
if "input_text_modified" in expectations:
if "input_text_modified" not in observation:
missing_required("input_text_modified")
elif observation["input_text_modified"] != expectations["input_text_modified"]:
failures.append({
"check": "input_text_modified",
"evidence": f"输入正文改动状态 {observation['input_text_modified']} 与期望 "
f"{expectations['input_text_modified']} 不一致",
})
return failures
def run_scenario(skill_md_text: str, scenario: dict, adapter: Any) -> dict:
observation = adapter.run(skill_md_text, scenario)
failures = _check(scenario["expectations"], observation)
return {
"scenario_id": scenario["id"],
"category": scenario["category"],
"verdict": "passed" if not failures else "failed",
"failed_checks": failures,
"observation_summary": {
"invoked_commands": observation.get("invoked_commands", []),
"exit_code": observation.get("exit_code"),
"input_text_modified": observation.get("input_text_modified"),
"output_chars": len(str(observation.get("output", ""))),
},
}
def run_eval(skill_dir: Path, scenario_file: Path, adapter: Any, adapter_name: str) -> dict:
skill_md = (skill_dir / "SKILL.md")
if not skill_md.is_file():
raise EvalContractError(f"SKILL.md 不存在: {skill_md}")
data = load_scenarios(scenario_file)
skill_md_text = skill_md.read_text(encoding="utf-8")
verdicts = [run_scenario(skill_md_text, scenario, adapter) for scenario in data["scenarios"]]
passed = sum(1 for v in verdicts if v["verdict"] == "passed")
return {
"schema_version": REPORT_SCHEMA,
"skill": data["skill"],
"skill_md_sha256": skill_md_fingerprint(skill_dir),
"adapter": adapter_name,
"generated_at": datetime.now(timezone.utc).isoformat(),
"scenario_count": len(verdicts),
"passed": passed,
"failed": len(verdicts) - passed,
"verdicts": verdicts,
"limit": "fake adapter 结果只证明评测管道,不构成 Skill 行为证据",
}
__all__ = [
"REPORT_SCHEMA", "SCENARIO_SCHEMA", "CATEGORIES", "EvalContractError",
"EvalAdapterUnavailable", "FakeAdapter", "RoleAgentAdapter",
"skill_md_fingerprint", "load_scenarios", "run_scenario", "run_eval",
]