zcbot/evaluation/models.py

217 lines
6.8 KiB
Python

"""评测配置与结果的数据模型。
只使用标准库,避免评测器反向污染 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