114 lines
4.2 KiB
Python
114 lines
4.2 KiB
Python
"""确定性评分器。"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import re
|
|
import zipfile
|
|
|
|
from .models import AssertionResult, AssertionSpec, RunObservation
|
|
|
|
|
|
SUPPORTED_ASSERTIONS = {
|
|
"run_succeeded",
|
|
"response_nonempty",
|
|
"response_contains",
|
|
"response_not_contains",
|
|
"response_regex",
|
|
"artifact_exists",
|
|
"artifact_min_bytes",
|
|
"artifact_zip_valid",
|
|
"artifact_text_contains",
|
|
"max_cost_cny",
|
|
"max_duration_s",
|
|
}
|
|
|
|
|
|
def validate_assertion(spec: AssertionSpec) -> None:
|
|
if spec.type not in SUPPORTED_ASSERTIONS:
|
|
raise ValueError(f"不支持的 assertion.type: {spec.type!r}")
|
|
if spec.type.startswith("artifact_") and not spec.path:
|
|
raise ValueError(f"{spec.type} 需要 path")
|
|
if spec.type in {
|
|
"response_contains",
|
|
"response_not_contains",
|
|
"response_regex",
|
|
"artifact_text_contains",
|
|
} and not isinstance(spec.value, str):
|
|
raise ValueError(f"{spec.type} 需要字符串 value")
|
|
if spec.type in {"max_cost_cny", "max_duration_s"} and spec.max_value is None:
|
|
raise ValueError(f"{spec.type} 需要 max_value")
|
|
|
|
|
|
def _description(spec: AssertionSpec) -> str:
|
|
if spec.description:
|
|
return spec.description
|
|
if spec.path:
|
|
return f"{spec.type}: {spec.path}"
|
|
if spec.value is not None:
|
|
return f"{spec.type}: {spec.value}"
|
|
if spec.max_value is not None:
|
|
return f"{spec.type}: <= {spec.max_value}"
|
|
return spec.type
|
|
|
|
|
|
def evaluate_assertion(
|
|
spec: AssertionSpec, observation: RunObservation
|
|
) -> AssertionResult:
|
|
validate_assertion(spec)
|
|
passed = False
|
|
detail = ""
|
|
try:
|
|
if spec.type == "run_succeeded":
|
|
passed = observation.run_status == "idle" and not observation.run_error
|
|
detail = observation.run_error or observation.run_status
|
|
elif spec.type == "response_nonempty":
|
|
passed = bool(observation.response.strip())
|
|
detail = f"{len(observation.response)} chars"
|
|
elif spec.type == "response_contains":
|
|
passed = spec.value in observation.response
|
|
elif spec.type == "response_not_contains":
|
|
passed = spec.value not in observation.response
|
|
elif spec.type == "response_regex":
|
|
passed = re.search(spec.value, observation.response, re.MULTILINE) is not None
|
|
elif spec.type == "artifact_exists":
|
|
observation.artifact(spec.path)
|
|
passed = True
|
|
elif spec.type == "artifact_min_bytes":
|
|
data = observation.artifact(spec.path)
|
|
passed = len(data) >= spec.min_bytes
|
|
detail = f"{len(data)} bytes; required >= {spec.min_bytes}"
|
|
elif spec.type == "artifact_zip_valid":
|
|
data = observation.artifact(spec.path)
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
bad = archive.testzip()
|
|
passed = bad is None and bool(archive.namelist())
|
|
detail = f"first bad member: {bad}" if bad else f"{len(archive.namelist())} members"
|
|
elif spec.type == "artifact_text_contains":
|
|
text = observation.artifact(spec.path).decode("utf-8")
|
|
passed = spec.value in text
|
|
elif spec.type == "max_cost_cny":
|
|
passed = observation.cost_cny <= float(spec.max_value)
|
|
detail = f"{observation.cost_cny:.6f} CNY"
|
|
elif spec.type == "max_duration_s":
|
|
passed = observation.duration_s <= float(spec.max_value)
|
|
detail = f"{observation.duration_s:.3f}s"
|
|
except (FileNotFoundError, UnicodeDecodeError, zipfile.BadZipFile, OSError) as exc:
|
|
detail = f"{type(exc).__name__}: {exc}"
|
|
passed = False
|
|
return AssertionResult(
|
|
type=spec.type,
|
|
passed=passed,
|
|
weight=spec.weight,
|
|
description=_description(spec),
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
def score_observation(
|
|
specs: tuple[AssertionSpec, ...], observation: RunObservation
|
|
) -> tuple[float, list[AssertionResult]]:
|
|
results = [evaluate_assertion(spec, observation) for spec in specs]
|
|
denominator = sum(result.weight for result in results)
|
|
numerator = sum(result.weight for result in results if result.passed)
|
|
return (numerator / denominator if denominator else 0.0), results
|