zcbot/evaluation/combine.py

132 lines
4.8 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.

"""合并多份评测 JSON生成统一的五维评分视图。"""
from __future__ import annotations
import datetime as dt
import json
from pathlib import Path
from typing import Any
from .models import DEFAULT_DIMENSION_WEIGHTS, EvalConfigError
from .report import FRAMEWORK_INFO, SECURITY_CAP
def _load_report(path: Path) -> dict[str, Any]:
try:
report = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise EvalConfigError(f"报告不是有效 JSON: {path}: {exc}") from exc
if not isinstance(report, dict):
raise EvalConfigError(f"报告顶层必须是 JSON object: {path}")
if not isinstance(report.get("dimensions"), dict):
raise EvalConfigError(f"报告缺少 dimensions: {path}")
if not isinstance(report.get("suite"), dict):
raise EvalConfigError(f"报告缺少 suite: {path}")
return report
def combine_reports(paths: list[Path]) -> dict[str, Any]:
"""按用例数加权合并同维度得分,并保留每份报告的来源信息。"""
if not paths:
raise EvalConfigError("至少需要一份 --report")
loaded = [(path.resolve(), _load_report(path)) for path in paths]
dimensions: dict[str, dict[str, Any]] = {}
for name, weight in DEFAULT_DIMENSION_WEIGHTS.items():
values: list[tuple[float, int]] = []
total_cases = 0
for _, report in loaded:
item = report["dimensions"].get(name)
if not isinstance(item, dict) or item.get("score") is None:
continue
case_count = int(item.get("case_count", 0))
values.append((float(item["score"]), max(case_count, 1)))
total_cases += max(case_count, 0)
denominator = sum(case_weight for _, case_weight in values)
score = (
sum(value * case_weight for value, case_weight in values)
/ denominator
if denominator
else None
)
dimensions[name] = {
"weight": weight,
"score": round(score, 2) if score is not None else None,
"case_count": total_cases,
}
present = [item for item in dimensions.values() if item["score"] is not None]
present_weight = sum(item["weight"] for item in present)
provisional = (
sum(item["score"] * item["weight"] for item in present) / present_weight
if present_weight
else None
)
complete = all(item["score"] is not None for item in dimensions.values())
total = provisional if complete else None
security_failures: list[dict[str, Any]] = []
cases: list[dict[str, Any]] = []
sources: list[dict[str, Any]] = []
for path, report in loaded:
suite = report["suite"]
source_label = str(suite.get("name") or path.stem)
sources.append(
{
"path": str(path),
"suite": source_label,
"version": str(suite.get("version", "")),
"generated_at": report.get("generated_at"),
}
)
for case in report.get("cases", []):
if isinstance(case, dict):
cases.append({**case, "source_report": source_label})
cap = report.get("security_cap")
if isinstance(cap, dict):
for failure in cap.get("failures", []):
if isinstance(failure, dict):
security_failures.append(
{**failure, "source_report": source_label}
)
cap_applied = bool(
security_failures and total is not None and total > SECURITY_CAP
)
if cap_applied:
total = SECURITY_CAP
first_environment = loaded[0][1].get("environment")
environment = (
dict(first_environment) if isinstance(first_environment, dict) else {}
)
return {
"schema_version": 1,
"suite": {
"name": "zcbot-unified",
"version": "+".join(
str(report["suite"].get("version", ""))
for _, report in loaded
),
},
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"environment": environment,
"framework": FRAMEWORK_INFO,
"sources": sources,
"merge_method": "同维度按报告中的 case_count 加权;五维按固定权重汇总",
"dimensions": dimensions,
"pre_cap_score": (
round(provisional, 2) if provisional is not None else None
),
"total_score": round(total, 2) if total is not None else None,
"provisional_score": (
round(provisional, 2) if provisional is not None else None
),
"is_complete": complete,
"security_cap": {
"threshold": SECURITY_CAP,
"applied": cap_applied,
"failures": security_failures,
},
"cases": cases,
}