"""合并多份评测 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, }