"""评测配置与结果的数据模型。 只使用标准库,避免评测器反向污染 zcbot 的生产依赖。 """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Optional DEFAULT_DIMENSION_WEIGHTS = { "task_quality": 40.0, "reliability": 20.0, "security": 15.0, "engineering": 15.0, "performance": 10.0, } class EvalConfigError(ValueError): """任务集或运行配置不合法。""" @dataclass(frozen=True) class AssertionSpec: type: str weight: float = 1.0 value: Any = None path: str = "" min_bytes: int = 0 max_value: Optional[float] = None description: str = "" @classmethod def from_dict(cls, raw: dict[str, Any]) -> "AssertionSpec": if not isinstance(raw, dict): raise EvalConfigError("assertion 必须是 JSON object") kind = str(raw.get("type", "")).strip() if not kind: raise EvalConfigError("assertion.type 不能为空") weight = float(raw.get("weight", 1.0)) if weight <= 0: raise EvalConfigError(f"assertion {kind!r} 的 weight 必须 > 0") return cls( type=kind, weight=weight, value=raw.get("value"), path=str(raw.get("path", "")).strip(), min_bytes=int(raw.get("min_bytes", 0)), max_value=( float(raw["max_value"]) if raw.get("max_value") is not None else None ), description=str(raw.get("description", "")).strip(), ) @dataclass(frozen=True) class EvalCase: id: str name: str dimension: str prompt: str assertions: tuple[AssertionSpec, ...] weight: float = 1.0 repetitions: Optional[int] = None timeout_s: Optional[float] = None skill: str = "" model_profile: str = "" tags: tuple[str, ...] = () @classmethod def from_dict(cls, raw: dict[str, Any]) -> "EvalCase": if not isinstance(raw, dict): raise EvalConfigError("case 必须是 JSON object") case_id = str(raw.get("id", "")).strip() prompt = str(raw.get("prompt", "")).strip() dimension = str(raw.get("dimension", "")).strip() if not case_id or not prompt or not dimension: raise EvalConfigError("case.id、case.prompt、case.dimension 均不能为空") assertions = tuple( AssertionSpec.from_dict(item) for item in raw.get("assertions", []) ) if not assertions: raise EvalConfigError(f"case {case_id!r} 至少需要一个 assertion") weight = float(raw.get("weight", 1.0)) if weight <= 0: raise EvalConfigError(f"case {case_id!r} 的 weight 必须 > 0") repetitions = raw.get("repetitions") timeout_s = raw.get("timeout_s") return cls( id=case_id, name=str(raw.get("name", case_id)).strip() or case_id, dimension=dimension, prompt=prompt, assertions=assertions, weight=weight, repetitions=int(repetitions) if repetitions is not None else None, timeout_s=float(timeout_s) if timeout_s is not None else None, skill=str(raw.get("skill", "")).strip(), model_profile=str(raw.get("model_profile", "")).strip(), tags=tuple(str(v) for v in raw.get("tags", [])), ) @dataclass(frozen=True) class EvalSuite: name: str version: str cases: tuple[EvalCase, ...] dimension_weights: dict[str, float] = field( default_factory=lambda: dict(DEFAULT_DIMENSION_WEIGHTS) ) default_repetitions: int = 3 pass_threshold: float = 0.8 @classmethod def from_dict(cls, raw: dict[str, Any]) -> "EvalSuite": if not isinstance(raw, dict): raise EvalConfigError("suite 文件顶层必须是 JSON object") cases = tuple(EvalCase.from_dict(item) for item in raw.get("cases", [])) if not cases: raise EvalConfigError("suite 至少需要一个 case") ids = [case.id for case in cases] if len(ids) != len(set(ids)): raise EvalConfigError("case.id 不得重复") weights = { str(k): float(v) for k, v in ( raw.get("dimension_weights") or DEFAULT_DIMENSION_WEIGHTS ).items() } if any(v <= 0 for v in weights.values()): raise EvalConfigError("dimension_weights 必须全部 > 0") unknown = sorted({case.dimension for case in cases} - set(weights)) if unknown: raise EvalConfigError(f"case 使用了未声明维度: {', '.join(unknown)}") repetitions = int(raw.get("default_repetitions", 3)) threshold = float(raw.get("pass_threshold", 0.8)) if repetitions < 1: raise EvalConfigError("default_repetitions 必须 >= 1") if not 0 <= threshold <= 1: raise EvalConfigError("pass_threshold 必须在 0..1") return cls( name=str(raw.get("name", "zcbot-eval")).strip() or "zcbot-eval", version=str(raw.get("version", "1")).strip() or "1", cases=cases, dimension_weights=weights, default_repetitions=repetitions, pass_threshold=threshold, ) @dataclass class RunObservation: response: str = "" duration_s: float = 0.0 cost_cny: float = 0.0 run_status: str = "idle" run_error: str = "" task_id: str = "" working_dir: str = "" model_profile: str = "" artifact_loader: Optional[Callable[[str], bytes]] = field( default=None, repr=False ) def artifact(self, relative_path: str) -> bytes: if self.artifact_loader is None: raise FileNotFoundError(relative_path) return self.artifact_loader(relative_path) @dataclass(frozen=True) class AssertionResult: type: str passed: bool weight: float description: str detail: str = "" @dataclass class RepetitionResult: index: int observation: RunObservation assertions: list[AssertionResult] score: float @dataclass class CaseResult: case: EvalCase repetitions: list[RepetitionResult] score: float pass_at_1: bool pass_all: bool def safe_case_slug(value: str) -> str: cleaned = "".join(c.lower() if c.isalnum() else "-" for c in value) cleaned = "-".join(part for part in cleaned.split("-") if part) return (cleaned or "case")[:48] def resolve_under(base: Path, relative: str) -> Path: """将相对路径限制在 base 内,供本地 fixture/报告使用。""" target = (base / relative).resolve() root = base.resolve() try: target.relative_to(root) except ValueError as exc: raise EvalConfigError(f"路径越界: {relative!r}") from exc return target