217 lines
6.8 KiB
Python
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
|