aeval-framework 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/.gitignore +3 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/PKG-INFO +1 -1
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/pyproject.toml +1 -1
- aeval_framework-0.2.0/src/agent_eval/api/app.py +191 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/runs.py +236 -57
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/tasks.py +12 -3
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/standalone.py +13 -32
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/cli.py +344 -36
- aeval_framework-0.2.0/src/agent_eval/core/__init__.py +99 -0
- aeval_framework-0.2.0/src/agent_eval/core/contract.py +535 -0
- aeval_framework-0.2.0/src/agent_eval/core/metrics.py +671 -0
- aeval_framework-0.2.0/src/agent_eval/core/pricing.py +152 -0
- aeval_framework-0.2.0/src/agent_eval/core/redaction.py +92 -0
- aeval_framework-0.2.0/src/agent_eval/core/runner.py +1927 -0
- aeval_framework-0.2.0/src/agent_eval/core/types.py +1264 -0
- aeval_framework-0.2.0/src/agent_eval/examples/mock_runner.py +366 -0
- aeval_framework-0.2.0/src/agent_eval/graders/_evidence.py +239 -0
- aeval_framework-0.2.0/src/agent_eval/graders/_verdicts.py +37 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/artifact_check.py +59 -27
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/code_based.py +46 -12
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/human.py +25 -5
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/metric.py +65 -12
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/model_based.py +68 -11
- aeval_framework-0.2.0/src/agent_eval/graders/state_check.py +263 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/step_level.py +46 -31
- aeval_framework-0.2.0/src/agent_eval/graders/tool_calls.py +161 -0
- aeval_framework-0.2.0/src/agent_eval/graders/transcript.py +166 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/base.py +66 -9
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/pytest_plugin.py +142 -35
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/report.py +28 -10
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/storage/memory.py +62 -6
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/storage/sqlite.py +135 -4
- aeval_framework-0.2.0/src/agent_eval/trace/__init__.py +56 -0
- aeval_framework-0.2.0/src/agent_eval/trace/mapping.py +243 -0
- aeval_framework-0.2.0/src/agent_eval/trace/normalize.py +572 -0
- aeval_framework-0.2.0/src/agent_eval/trace/observations.py +135 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/trace/phoenix.py +57 -3
- aeval_framework-0.2.0/tests/conftest.py +225 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_api.py +121 -4
- aeval_framework-0.2.0/tests/test_builtin_graders.py +457 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_cli.py +85 -4
- aeval_framework-0.2.0/tests/test_collection_phases.py +644 -0
- aeval_framework-0.2.0/tests/test_e2e.py +437 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_dataset_api.py +18 -12
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_metric_pipeline.py +73 -9
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_metrics_module.py +7 -1
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_pytest_plugin.py +66 -2
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_task_history.py +75 -0
- aeval_framework-0.2.0/tests/test_evidence_capture_and_storage.py +343 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_graders.py +27 -23
- aeval_framework-0.2.0/tests/test_metrics.py +699 -0
- aeval_framework-0.2.0/tests/test_regrade_and_archive.py +543 -0
- aeval_framework-0.2.0/tests/test_run_storage_fields.py +261 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_runner.py +276 -28
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_standalone_api.py +42 -3
- aeval_framework-0.2.0/tests/test_suite.py +438 -0
- aeval_framework-0.2.0/tests/test_termination_and_budgets.py +302 -0
- aeval_framework-0.2.0/tests/test_trace_normalization.py +427 -0
- aeval_framework-0.2.0/tests/test_trial_evidence.py +351 -0
- aeval_framework-0.2.0/tests/test_vocabulary_isolation.py +65 -0
- aeval_framework-0.1.0/src/agent_eval/api/app.py +0 -82
- aeval_framework-0.1.0/src/agent_eval/core/__init__.py +0 -48
- aeval_framework-0.1.0/src/agent_eval/core/contract.py +0 -296
- aeval_framework-0.1.0/src/agent_eval/core/metrics.py +0 -184
- aeval_framework-0.1.0/src/agent_eval/core/runner.py +0 -868
- aeval_framework-0.1.0/src/agent_eval/core/types.py +0 -227
- aeval_framework-0.1.0/src/agent_eval/examples/mock_runner.py +0 -195
- aeval_framework-0.1.0/src/agent_eval/graders/state_check.py +0 -106
- aeval_framework-0.1.0/src/agent_eval/graders/tool_calls.py +0 -102
- aeval_framework-0.1.0/src/agent_eval/graders/transcript.py +0 -86
- aeval_framework-0.1.0/src/agent_eval/trace/__init__.py +0 -16
- aeval_framework-0.1.0/tests/conftest.py +0 -9
- aeval_framework-0.1.0/tests/test_builtin_graders.py +0 -298
- aeval_framework-0.1.0/tests/test_e2e.py +0 -243
- aeval_framework-0.1.0/tests/test_metrics.py +0 -130
- aeval_framework-0.1.0/tests/test_suite.py +0 -209
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/.coverage +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/README.md +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/events.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/datasets.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/graders.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/metrics.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/suites.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/core/suite.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/models.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/quality.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/llm_generator.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/manual.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/regression.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/trace_mining.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/storage.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/version.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/examples/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/examples/basic_usage.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/answer_relevancy.py +1 -1
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/batch_evaluation.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/context_precision.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/context_recall.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/faithfulness.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/llm_judge.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/prompt_metric.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/synthetic_data.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/storage/__init__.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_batch_evaluation.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_dataset_sources.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_dataset_storage.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_metrics_api.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_prompt_metric.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_report.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_import_isolation.py +0 -0
- {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_sse.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aeval-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Aeval — open-source agent evaluation framework driven by OTel traces (suites, graders, metrics, storage, API, CLI)
|
|
5
5
|
Project-URL: Homepage, https://github.com/QiYuyyds/Aeval
|
|
6
6
|
Project-URL: Repository, https://github.com/QiYuyyds/Aeval
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Distribution name: aeval-framework (PyPI). Python module: agent_eval, CLI: eval-suite.
|
|
3
3
|
# (`agent-eval` on PyPI belongs to an unrelated project — UK AISI agenteval.)
|
|
4
4
|
name = "aeval-framework"
|
|
5
|
-
version = "0.
|
|
5
|
+
version = "0.2.0"
|
|
6
6
|
description = "Aeval — open-source agent evaluation framework driven by OTel traces (suites, graders, metrics, storage, API, CLI)"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI application factory for Aeval.
|
|
3
|
+
|
|
4
|
+
Creates a FastAPI app with all eval routes mounted.
|
|
5
|
+
Can be used standalone or mounted in an existing app.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# Standalone
|
|
9
|
+
app = create_app(runner=my_runner)
|
|
10
|
+
|
|
11
|
+
# Mounted in existing FastAPI app
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
app = FastAPI()
|
|
14
|
+
eval_app = create_app(runner=my_runner)
|
|
15
|
+
app.mount("/api/eval", eval_app)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
|
|
24
|
+
from agent_eval.api.routes import datasets, graders, metrics, runs, suites, tasks
|
|
25
|
+
from agent_eval.core.runner import EvalRunner
|
|
26
|
+
from agent_eval.core.types import (
|
|
27
|
+
DEFAULT_BOOTSTRAP_ROUNDS,
|
|
28
|
+
DEFAULT_CONFIDENCE_LEVEL,
|
|
29
|
+
DEFAULT_INVALID_RATIO_LIMIT,
|
|
30
|
+
MIN_VALID_TRIALS_FOR_SATURATION,
|
|
31
|
+
STATISTICS_VERSION,
|
|
32
|
+
)
|
|
33
|
+
from agent_eval.graders import get_grader_catalog
|
|
34
|
+
from agent_eval.trace.mapping import (
|
|
35
|
+
ATTRIBUTE_MAPPING_VERSION,
|
|
36
|
+
OTEL_GENAI_SPEC_VERSION,
|
|
37
|
+
VOCABULARY_OTEL_GENAI,
|
|
38
|
+
VOCABULARY_SPEC_VERSIONS,
|
|
39
|
+
known_vocabularies,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Global runner reference (set by create_app)
|
|
43
|
+
_runner: EvalRunner | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_runner() -> EvalRunner | None:
|
|
47
|
+
"""Get the global EvalRunner instance"""
|
|
48
|
+
return _runner
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_runner(runner: EvalRunner | None) -> None:
|
|
52
|
+
"""Set the global EvalRunner instance.
|
|
53
|
+
|
|
54
|
+
Used by the host app to inject the real runner during async startup
|
|
55
|
+
(e.g. main.py lifespan → eval_integration.config.create_aeval_runner).
|
|
56
|
+
"""
|
|
57
|
+
global _runner
|
|
58
|
+
_runner = runner
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
DISTRIBUTION_NAME = "aeval-framework" # PyPI 发行包名 (Python 模块为 agent_eval)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def package_version() -> str:
|
|
65
|
+
"""包版本: 优先取已安装元数据, 源码直跑时回退模块常量。"""
|
|
66
|
+
from importlib.metadata import PackageNotFoundError
|
|
67
|
+
from importlib.metadata import version as _metadata_version
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return _metadata_version(DISTRIBUTION_NAME)
|
|
71
|
+
except PackageNotFoundError:
|
|
72
|
+
from agent_eval import __version__
|
|
73
|
+
|
|
74
|
+
return __version__
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def statistics_defaults() -> dict[str, Any]:
|
|
78
|
+
"""统计口径版本与 D7 数值默认值 (经元信息接口公布, 供调用方判断可比性)。"""
|
|
79
|
+
return {
|
|
80
|
+
"version": STATISTICS_VERSION,
|
|
81
|
+
"confidence_level": DEFAULT_CONFIDENCE_LEVEL,
|
|
82
|
+
"bootstrap_rounds": DEFAULT_BOOTSTRAP_ROUNDS,
|
|
83
|
+
"min_valid_trials_for_saturation": MIN_VALID_TRIALS_FOR_SATURATION,
|
|
84
|
+
"gate_invalid_ratio_limit": DEFAULT_INVALID_RATIO_LIMIT,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def meta_payload(
|
|
89
|
+
api_prefix: str = "",
|
|
90
|
+
endpoints: list[str] | None = None,
|
|
91
|
+
version: str | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""元信息响应体: 版本 + 统计口径 + 能力清单。
|
|
94
|
+
|
|
95
|
+
同一大版本内响应结构向后兼容, 但统计口径的数值语义可能变化 —— 口径版本
|
|
96
|
+
必须显式公布, 调用方才能判断两个 run 是否可直接比较。
|
|
97
|
+
"""
|
|
98
|
+
return {
|
|
99
|
+
"name": "Aeval",
|
|
100
|
+
"package": DISTRIBUTION_NAME,
|
|
101
|
+
"version": version or package_version(),
|
|
102
|
+
"api_prefix": api_prefix,
|
|
103
|
+
"endpoints": endpoints or ["/suites", "/tasks", "/runs", "/compare", "/graders",
|
|
104
|
+
"/datasets", "/metrics", "/health"],
|
|
105
|
+
"statistics": statistics_defaults(),
|
|
106
|
+
"evidence": {
|
|
107
|
+
"spec_version": OTEL_GENAI_SPEC_VERSION,
|
|
108
|
+
"mapping_version": ATTRIBUTE_MAPPING_VERSION,
|
|
109
|
+
# default_mapping(vocabulary=...) 的可填值在这里枚举: 调用方不需要读
|
|
110
|
+
# 源码猜词汇, 也不需要为了发现一个字符串去 import 内部模块。
|
|
111
|
+
"vocabularies": {
|
|
112
|
+
"default": VOCABULARY_OTEL_GENAI,
|
|
113
|
+
"known": list(known_vocabularies()),
|
|
114
|
+
"spec_versions": dict(VOCABULARY_SPEC_VERSIONS),
|
|
115
|
+
},
|
|
116
|
+
"tool_arguments_captured_by_default": False,
|
|
117
|
+
"model_content_captured_by_default": False,
|
|
118
|
+
"capture_is_one_declaration": True,
|
|
119
|
+
},
|
|
120
|
+
"capabilities": {
|
|
121
|
+
"graders": [g["name"] for g in get_grader_catalog()],
|
|
122
|
+
"storage": ["memory", "sqlite"],
|
|
123
|
+
"trace_providers": ["phoenix (optional, lazily imported)"],
|
|
124
|
+
"sse": True,
|
|
125
|
+
"datasets": True,
|
|
126
|
+
"metrics": True,
|
|
127
|
+
"validity_verdicts": True,
|
|
128
|
+
"confidence_intervals": True,
|
|
129
|
+
"normalized_trace_observations": True,
|
|
130
|
+
"termination_reasons": True,
|
|
131
|
+
"cost_axis": True,
|
|
132
|
+
"evidence_provenance_levels": True,
|
|
133
|
+
"deferred_grading": True,
|
|
134
|
+
# 重评分本期只有库层入口 (EvalRunner.regrade_run)。它牵涉同一大版本的
|
|
135
|
+
# 响应结构兼容承诺, 暴露面另立变更 —— 列出来免得调用方以为 /runs 上能调。
|
|
136
|
+
"regrade_over_http": False,
|
|
137
|
+
"regrade_over_cli": False,
|
|
138
|
+
},
|
|
139
|
+
"not_exposed": {
|
|
140
|
+
"regrade": (
|
|
141
|
+
"证据归档与重评分已落地 (EvalRunner.regrade_run / verdict_drift), "
|
|
142
|
+
"但 HTTP 与 CLI 入口本期未提供"
|
|
143
|
+
)
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def create_app(runner: EvalRunner | None = None) -> FastAPI:
|
|
149
|
+
"""
|
|
150
|
+
Create a FastAPI app with Aeval routes.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
runner: EvalRunner instance. If None, routes will return 503.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
FastAPI application
|
|
157
|
+
"""
|
|
158
|
+
app = FastAPI(
|
|
159
|
+
title="Aeval API",
|
|
160
|
+
version="0.1.0",
|
|
161
|
+
description="Agent Evaluation Framework API",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Store runner in app state and global reference
|
|
165
|
+
global _runner
|
|
166
|
+
_runner = runner
|
|
167
|
+
app.state.runner = runner
|
|
168
|
+
|
|
169
|
+
# Include routers
|
|
170
|
+
app.include_router(suites.router, prefix="/suites", tags=["suites"])
|
|
171
|
+
app.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
|
172
|
+
app.include_router(runs.router, prefix="/runs", tags=["runs"])
|
|
173
|
+
# compare 挂在 /compare (spec §REST API), 不带 /runs 前缀
|
|
174
|
+
app.include_router(runs.compare_router, tags=["compare"])
|
|
175
|
+
app.include_router(graders.router, prefix="/graders", tags=["graders"])
|
|
176
|
+
# 数据集管理 (change ③: 数据集构建闭环 — CRUD/导入/挖掘/生成/质量/to-suite)
|
|
177
|
+
app.include_router(datasets.router, prefix="/datasets", tags=["datasets"])
|
|
178
|
+
# 批量评测 (change ④: 对已有输出直接批量打分 — POST /metrics/batch)
|
|
179
|
+
app.include_router(metrics.router, prefix="/metrics", tags=["metrics"])
|
|
180
|
+
|
|
181
|
+
@app.get("/health")
|
|
182
|
+
async def health():
|
|
183
|
+
# 动态读取全局 runner — host app 会在 startup 阶段注入真实 runner
|
|
184
|
+
return {"status": "ok", "runner_configured": _get_runner() is not None}
|
|
185
|
+
|
|
186
|
+
@app.get("/meta")
|
|
187
|
+
async def meta():
|
|
188
|
+
"""寄宿形态的元信息接口 (挂载前缀由宿主决定, 例如 /api/eval/meta)。"""
|
|
189
|
+
return meta_payload()
|
|
190
|
+
|
|
191
|
+
return app
|
|
@@ -29,6 +29,8 @@ from pydantic import BaseModel, Field
|
|
|
29
29
|
from sse_starlette.sse import EventSourceResponse
|
|
30
30
|
|
|
31
31
|
from agent_eval.api.events import run_event_bus
|
|
32
|
+
from agent_eval.core.metrics import classify_trial, trial_invalid_reason
|
|
33
|
+
from agent_eval.core.types import STATISTICS_VERSION, TrialVerdict
|
|
32
34
|
|
|
33
35
|
router = APIRouter()
|
|
34
36
|
|
|
@@ -60,6 +62,9 @@ async def list_runs(suite_name: str | None = None, limit: int = 50):
|
|
|
60
62
|
"started_at": r.started_at,
|
|
61
63
|
"completed_at": r.completed_at,
|
|
62
64
|
"duration_ms": r.duration_ms,
|
|
65
|
+
"statistics_version": r.statistics_version,
|
|
66
|
+
"evidence": r.evidence.model_dump() if r.evidence else None,
|
|
67
|
+
"regrade": _regrade_entry(r),
|
|
63
68
|
"task_count": len(r.trials),
|
|
64
69
|
"summary": r.summary.model_dump() if r.summary else None,
|
|
65
70
|
}
|
|
@@ -98,7 +103,12 @@ async def create_run(request: CreateRunRequest):
|
|
|
98
103
|
from agent_eval.core.types import RunResult
|
|
99
104
|
|
|
100
105
|
await runner.storage.save_run(
|
|
101
|
-
RunResult(
|
|
106
|
+
RunResult(
|
|
107
|
+
run_id=run_id,
|
|
108
|
+
suite_name=suite.name,
|
|
109
|
+
status="pending",
|
|
110
|
+
statistics_version=STATISTICS_VERSION,
|
|
111
|
+
)
|
|
102
112
|
)
|
|
103
113
|
|
|
104
114
|
async def _run():
|
|
@@ -164,6 +174,10 @@ async def get_run(run_id: str):
|
|
|
164
174
|
"completed_at": run.completed_at,
|
|
165
175
|
"duration_ms": run.duration_ms,
|
|
166
176
|
"error": run.error,
|
|
177
|
+
"statistics_version": run.statistics_version,
|
|
178
|
+
"evidence": run.evidence.model_dump() if run.evidence else None,
|
|
179
|
+
# 本期只开库层重评分入口: 这里如实标能不能重评与原因, 不提供 HTTP 触发
|
|
180
|
+
"regrade": _regrade_entry(run),
|
|
167
181
|
"trials": {
|
|
168
182
|
task_id: [
|
|
169
183
|
{
|
|
@@ -173,12 +187,36 @@ async def get_run(run_id: str):
|
|
|
173
187
|
"score": t.avg_score(),
|
|
174
188
|
"duration_ms": t.duration_ms,
|
|
175
189
|
"error": t.error,
|
|
190
|
+
"verdict": t.verdict.value,
|
|
191
|
+
"invalid_reason": t.invalid_reason.value if t.invalid_reason else None,
|
|
192
|
+
"termination_reason": (
|
|
193
|
+
t.termination_reason.value if t.termination_reason else None
|
|
194
|
+
),
|
|
195
|
+
"metrics": t.metrics,
|
|
196
|
+
"evidence_gaps": [gap.model_dump() for gap in t.evidence_gaps],
|
|
197
|
+
"unrecognized_attributes": t.unrecognized_attributes,
|
|
198
|
+
# 结论的分量: 它依据的最弱一级证据 + 证据是否已归档
|
|
199
|
+
"weakest_evidence": (
|
|
200
|
+
t.weakest_evidence.value if t.weakest_evidence else None
|
|
201
|
+
),
|
|
202
|
+
"evidence_archived": t.evidence_archived,
|
|
176
203
|
"grader_results": [
|
|
177
204
|
{
|
|
178
205
|
"grader_name": gr.grader_name,
|
|
179
206
|
"score": gr.score,
|
|
180
207
|
"passed": gr.passed,
|
|
181
208
|
"explanation": gr.explanation,
|
|
209
|
+
"verdict": gr.verdict.value,
|
|
210
|
+
"invalid_reason": (
|
|
211
|
+
gr.invalid_reason.value if gr.invalid_reason else None
|
|
212
|
+
),
|
|
213
|
+
"evidence_levels": [
|
|
214
|
+
level.value for level in gr.evidence_levels
|
|
215
|
+
],
|
|
216
|
+
"judgment_moment": (
|
|
217
|
+
gr.judgment_moment.value if gr.judgment_moment else None
|
|
218
|
+
),
|
|
219
|
+
"subject_only": gr.subject_only,
|
|
182
220
|
}
|
|
183
221
|
for gr in t.grader_results
|
|
184
222
|
],
|
|
@@ -191,6 +229,14 @@ async def get_run(run_id: str):
|
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
|
|
232
|
+
def _regrade_entry(run) -> dict[str, Any]:
|
|
233
|
+
"""能不能重评分 + 为什么不能 (与库层 ``regrade_run`` 共用同一份判断)。"""
|
|
234
|
+
from agent_eval.core.runner import regrade_state
|
|
235
|
+
|
|
236
|
+
possible, reason = regrade_state(run)
|
|
237
|
+
return {"available": possible, "reason": reason, "exposed_over_http": False}
|
|
238
|
+
|
|
239
|
+
|
|
194
240
|
@router.delete("/{run_id}")
|
|
195
241
|
async def delete_run(run_id: str):
|
|
196
242
|
"""删除运行"""
|
|
@@ -204,6 +250,10 @@ async def delete_run(run_id: str):
|
|
|
204
250
|
if not deleted:
|
|
205
251
|
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
|
|
206
252
|
|
|
253
|
+
# 派生物一并清除: 内容寻址的评分缓存可能缓存了该 run 采集到的证据内容
|
|
254
|
+
runner.evict_run_cache(run_id)
|
|
255
|
+
_background_tasks.pop(run_id, None)
|
|
256
|
+
|
|
207
257
|
return {"deleted": True}
|
|
208
258
|
|
|
209
259
|
|
|
@@ -410,11 +460,13 @@ async def submit_human_score(run_id: str, request: HumanScoreRequest):
|
|
|
410
460
|
"threshold", threshold
|
|
411
461
|
)
|
|
412
462
|
|
|
413
|
-
# 更新已存的 GraderResult
|
|
463
|
+
# 更新已存的 GraderResult (评分回传 → 离开 pending 通道)
|
|
414
464
|
grader_result.score = request.score
|
|
415
465
|
grader_result.passed = request.score >= threshold
|
|
416
466
|
grader_result.explanation = request.explanation or "人工评分"
|
|
417
467
|
grader_result.confidence = 1.0
|
|
468
|
+
grader_result.verdict = TrialVerdict.VALID
|
|
469
|
+
grader_result.invalid_reason = None
|
|
418
470
|
grader_result.details = {
|
|
419
471
|
**grader_result.details,
|
|
420
472
|
"status": "scored",
|
|
@@ -425,6 +477,10 @@ async def submit_human_score(run_id: str, request: HumanScoreRequest):
|
|
|
425
477
|
# 重算 trial 成功状态与 run 汇总 (含该 task 汇总)
|
|
426
478
|
if task is not None:
|
|
427
479
|
trial.success = runner._compute_trial_success(task, trial.grader_results)
|
|
480
|
+
trial.verdict = classify_trial(trial)
|
|
481
|
+
trial.invalid_reason = trial_invalid_reason(trial)
|
|
482
|
+
if run.statistics_version is None:
|
|
483
|
+
run.statistics_version = STATISTICS_VERSION
|
|
428
484
|
run.summary = runner._compute_summary(run, suite) if suite is not None else run.summary
|
|
429
485
|
await runner.storage.save_run(run)
|
|
430
486
|
|
|
@@ -436,6 +492,7 @@ async def submit_human_score(run_id: str, request: HumanScoreRequest):
|
|
|
436
492
|
"score": grader_result.score,
|
|
437
493
|
"passed": grader_result.passed,
|
|
438
494
|
"trial_success": trial.success,
|
|
495
|
+
"trial_verdict": trial.verdict.value,
|
|
439
496
|
"summary": run.summary.model_dump() if run.summary else None,
|
|
440
497
|
}
|
|
441
498
|
|
|
@@ -489,85 +546,207 @@ async def compare_runs(request: CompareRequest):
|
|
|
489
546
|
|
|
490
547
|
|
|
491
548
|
def _build_comparison(run_a, run_b) -> dict[str, Any]:
|
|
492
|
-
"""构建两次运行的对比
|
|
549
|
+
"""构建两次运行的对比 (口径版本不同则标注不可比; 区间重叠则不判方向)。
|
|
550
|
+
|
|
551
|
+
既有字段 (`a`/`b`/`delta`/`regressions`/`improvements`/`tasks`) 的名称与类型
|
|
552
|
+
保持不变; 分母为 0 时 `a`/`b`/`delta` 为 null 而非 0.0。
|
|
553
|
+
"""
|
|
493
554
|
summary_a = run_a.summary
|
|
494
555
|
summary_b = run_b.summary
|
|
495
556
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
557
|
+
version_a = getattr(run_a, "statistics_version", None)
|
|
558
|
+
version_b = getattr(run_b, "statistics_version", None)
|
|
559
|
+
evidence_a = getattr(run_a, "evidence", None)
|
|
560
|
+
evidence_b = getattr(run_b, "evidence", None)
|
|
561
|
+
same_caliber = version_a is not None and version_a == version_b
|
|
562
|
+
same_boundary, boundary_reason = _compare_evidence_boundaries(evidence_a, evidence_b)
|
|
563
|
+
comparable = same_caliber and same_boundary
|
|
564
|
+
|
|
565
|
+
all_k_values: set[int] = set()
|
|
566
|
+
for summary in (summary_a, summary_b):
|
|
567
|
+
for k in (summary.pass_at_k or {}):
|
|
568
|
+
all_k_values.add(int(k))
|
|
569
|
+
|
|
570
|
+
def _entry(
|
|
571
|
+
a_val: float | None,
|
|
572
|
+
b_val: float | None,
|
|
573
|
+
a_ci: tuple[float | None, float | None] | None,
|
|
574
|
+
b_ci: tuple[float | None, float | None] | None,
|
|
575
|
+
extrapolated: bool,
|
|
576
|
+
) -> dict[str, Any]:
|
|
577
|
+
overlapping = _intervals_overlap(a_ci, b_ci)
|
|
578
|
+
return {
|
|
508
579
|
"a": a_val,
|
|
509
580
|
"b": b_val,
|
|
510
|
-
"delta":
|
|
581
|
+
"delta": _delta(a_val, b_val),
|
|
582
|
+
"a_ci": _ci_list(a_ci),
|
|
583
|
+
"b_ci": _ci_list(b_ci),
|
|
584
|
+
"intervals_overlap": overlapping,
|
|
585
|
+
"significant": (
|
|
586
|
+
None if overlapping is None else (comparable and not overlapping)
|
|
587
|
+
),
|
|
588
|
+
"extrapolated": extrapolated,
|
|
589
|
+
"comparable": comparable,
|
|
511
590
|
}
|
|
512
591
|
|
|
592
|
+
def _k_map(summary, field: str) -> dict:
|
|
593
|
+
raw = getattr(summary, field, None) or {}
|
|
594
|
+
return {int(k): v for k, v in raw.items()}
|
|
595
|
+
|
|
596
|
+
pass_at_k_comparison = {}
|
|
513
597
|
pass_power_k_comparison = {}
|
|
514
598
|
for k in sorted(all_k_values):
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
599
|
+
est_a = _k_map(summary_a, "estimates").get(k)
|
|
600
|
+
est_b = _k_map(summary_b, "estimates").get(k)
|
|
601
|
+
pass_at_k_comparison[f"pass_at_{k}"] = _entry(
|
|
602
|
+
est_a.value if est_a else None,
|
|
603
|
+
est_b.value if est_b else None,
|
|
604
|
+
_est_ci(est_a),
|
|
605
|
+
_est_ci(est_b),
|
|
606
|
+
bool(est_a and est_a.extrapolated) or bool(est_b and est_b.extrapolated),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
pow_a = _k_map(summary_a, "power_estimates").get(k)
|
|
610
|
+
pow_b = _k_map(summary_b, "power_estimates").get(k)
|
|
611
|
+
pass_power_k_comparison[f"pass_power_{k}"] = _entry(
|
|
612
|
+
pow_a.value if pow_a else None,
|
|
613
|
+
pow_b.value if pow_b else None,
|
|
614
|
+
_est_ci(pow_a),
|
|
615
|
+
_est_ci(pow_b),
|
|
616
|
+
bool(pow_a and pow_a.extrapolated) or bool(pow_b and pow_b.extrapolated),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
dist_a = getattr(summary_a, "score_distribution", None)
|
|
620
|
+
dist_b = getattr(summary_b, "score_distribution", None)
|
|
621
|
+
avg_entry = _entry(
|
|
622
|
+
summary_a.avg_score,
|
|
623
|
+
summary_b.avg_score,
|
|
624
|
+
_dist_ci(dist_a),
|
|
625
|
+
_dist_ci(dist_b),
|
|
626
|
+
False,
|
|
627
|
+
)
|
|
522
628
|
|
|
523
|
-
# 逐 task
|
|
629
|
+
# 逐 task 对比: 只有区间不重叠才判为退化/提升 (D4)
|
|
524
630
|
task_a_map = {ts.task_id: ts for ts in summary_a.task_summaries}
|
|
525
631
|
task_b_map = {ts.task_id: ts for ts in summary_b.task_summaries}
|
|
526
632
|
|
|
527
633
|
all_task_ids = set(task_a_map.keys()) | set(task_b_map.keys())
|
|
528
|
-
regressions = []
|
|
529
|
-
improvements = []
|
|
530
|
-
task_comparisons = {}
|
|
634
|
+
regressions: list[dict[str, Any]] = []
|
|
635
|
+
improvements: list[dict[str, Any]] = []
|
|
636
|
+
task_comparisons: dict[str, Any] = {}
|
|
531
637
|
|
|
532
638
|
for task_id in sorted(all_task_ids):
|
|
533
639
|
ts_a = task_a_map.get(task_id)
|
|
534
640
|
ts_b = task_b_map.get(task_id)
|
|
641
|
+
if not (ts_a and ts_b):
|
|
642
|
+
continue
|
|
643
|
+
|
|
644
|
+
a_score = ts_a.avg_score
|
|
645
|
+
b_score = ts_b.avg_score
|
|
646
|
+
delta = _delta(a_score, b_score)
|
|
647
|
+
overlapping = _intervals_overlap(
|
|
648
|
+
_dist_ci(ts_a.score_distribution), _dist_ci(ts_b.score_distribution)
|
|
649
|
+
)
|
|
650
|
+
significant = (
|
|
651
|
+
comparable and overlapping is False and delta is not None and abs(delta) > 0.1
|
|
652
|
+
)
|
|
653
|
+
task_comparisons[task_id] = {
|
|
654
|
+
"a": a_score,
|
|
655
|
+
"b": b_score,
|
|
656
|
+
"delta": delta,
|
|
657
|
+
"a_ci": _ci_list(_dist_ci(ts_a.score_distribution)),
|
|
658
|
+
"b_ci": _ci_list(_dist_ci(ts_b.score_distribution)),
|
|
659
|
+
"intervals_overlap": overlapping,
|
|
660
|
+
"significant": (None if overlapping is None else (
|
|
661
|
+
comparable and overlapping is False
|
|
662
|
+
)),
|
|
663
|
+
"comparable": comparable,
|
|
664
|
+
}
|
|
535
665
|
|
|
536
|
-
if
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
"b": b_score,
|
|
544
|
-
"delta": delta,
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if delta < -0.1:
|
|
548
|
-
regressions.append({
|
|
549
|
-
"task_id": task_id,
|
|
550
|
-
"a": a_score,
|
|
551
|
-
"b": b_score,
|
|
552
|
-
"delta": delta,
|
|
553
|
-
})
|
|
554
|
-
elif delta > 0.1:
|
|
555
|
-
improvements.append({
|
|
556
|
-
"task_id": task_id,
|
|
557
|
-
"a": a_score,
|
|
558
|
-
"b": b_score,
|
|
559
|
-
"delta": delta,
|
|
560
|
-
})
|
|
666
|
+
if not significant:
|
|
667
|
+
continue
|
|
668
|
+
row = {"task_id": task_id, "a": a_score, "b": b_score, "delta": delta}
|
|
669
|
+
if delta < 0:
|
|
670
|
+
regressions.append(row)
|
|
671
|
+
else:
|
|
672
|
+
improvements.append(row)
|
|
561
673
|
|
|
562
674
|
return {
|
|
563
675
|
"pass_at_k": pass_at_k_comparison,
|
|
564
676
|
"pass_power_k": pass_power_k_comparison,
|
|
565
|
-
"avg_score":
|
|
566
|
-
"a": summary_a.avg_score,
|
|
567
|
-
"b": summary_b.avg_score,
|
|
568
|
-
"delta": round(summary_b.avg_score - summary_a.avg_score, 4),
|
|
569
|
-
},
|
|
677
|
+
"avg_score": avg_entry,
|
|
570
678
|
"regressions": regressions,
|
|
571
679
|
"improvements": improvements,
|
|
572
680
|
"tasks": task_comparisons,
|
|
681
|
+
"statistics_version": {"a": version_a, "b": version_b},
|
|
682
|
+
"evidence_boundary": {
|
|
683
|
+
"a": evidence_a.model_dump() if evidence_a else None,
|
|
684
|
+
"b": evidence_b.model_dump() if evidence_b else None,
|
|
685
|
+
},
|
|
686
|
+
"comparable": comparable,
|
|
687
|
+
"not_comparable_reason": _not_comparable_reason(
|
|
688
|
+
comparable, same_caliber, version_a, version_b, boundary_reason
|
|
689
|
+
),
|
|
573
690
|
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _compare_evidence_boundaries(
|
|
694
|
+
evidence_a: Any, evidence_b: Any
|
|
695
|
+
) -> tuple[bool, str | None]:
|
|
696
|
+
"""两个 run 的证据边界是否一致 (缺记录即无法判定可比)。"""
|
|
697
|
+
if evidence_a is None and evidence_b is None:
|
|
698
|
+
return False, "两个 run 均未记录证据采集边界 (历史 run), 无法判定可比性"
|
|
699
|
+
if evidence_a is None or evidence_b is None:
|
|
700
|
+
missing = "a" if evidence_a is None else "b"
|
|
701
|
+
return False, f"run_{missing} 未记录证据采集边界 (历史 run), 无法判定可比性"
|
|
702
|
+
return evidence_a.compare_with(evidence_b)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _not_comparable_reason(
|
|
706
|
+
comparable: bool,
|
|
707
|
+
same_caliber: bool,
|
|
708
|
+
version_a: str | None,
|
|
709
|
+
version_b: str | None,
|
|
710
|
+
boundary_reason: str | None,
|
|
711
|
+
) -> str | None:
|
|
712
|
+
if comparable:
|
|
713
|
+
return None
|
|
714
|
+
if not same_caliber:
|
|
715
|
+
return (
|
|
716
|
+
"两个 run 的统计口径版本不同"
|
|
717
|
+
if version_a is not None and version_b is not None
|
|
718
|
+
else "至少一个 run 未记录统计口径版本 (历史 run), 无法判定可比性"
|
|
719
|
+
)
|
|
720
|
+
return boundary_reason or "两个 run 的证据采集边界不同"
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _delta(a: float | None, b: float | None) -> float | None:
|
|
724
|
+
if a is None or b is None:
|
|
725
|
+
return None
|
|
726
|
+
return round(b - a, 4)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _est_ci(est) -> tuple[float | None, float | None] | None:
|
|
730
|
+
if est is None or est.p_lower_bound is None or est.p_upper_bound is None:
|
|
731
|
+
return None
|
|
732
|
+
return (est.p_lower_bound, est.p_upper_bound)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _dist_ci(dist) -> tuple[float | None, float | None] | None:
|
|
736
|
+
if dist is None or dist.ci_low is None or dist.ci_high is None:
|
|
737
|
+
return None
|
|
738
|
+
return (dist.ci_low, dist.ci_high)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _ci_list(ci: tuple[float | None, float | None] | None) -> list[float] | None:
|
|
742
|
+
return [ci[0], ci[1]] if ci else None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _intervals_overlap(
|
|
746
|
+
a: tuple[float | None, float | None] | None,
|
|
747
|
+
b: tuple[float | None, float | None] | None,
|
|
748
|
+
) -> bool | None:
|
|
749
|
+
"""两侧区间是否重叠 (D4)。任一侧无区间 → None (无法判定)。"""
|
|
750
|
+
if a is None or b is None:
|
|
751
|
+
return None
|
|
752
|
+
return not (a[1] < b[0] or b[1] < a[0])
|
|
@@ -10,6 +10,9 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from fastapi import APIRouter, HTTPException
|
|
12
12
|
|
|
13
|
+
from agent_eval.core.metrics import split_trials_by_verdict
|
|
14
|
+
from agent_eval.core.types import TrialVerdict
|
|
15
|
+
|
|
13
16
|
router = APIRouter()
|
|
14
17
|
|
|
15
18
|
|
|
@@ -91,18 +94,24 @@ async def get_task_history(task_id: str):
|
|
|
91
94
|
trials = run.trials.get(task_id)
|
|
92
95
|
if not trials:
|
|
93
96
|
continue
|
|
97
|
+
buckets = split_trials_by_verdict(trials)
|
|
98
|
+
valid = [t for _, t in buckets[TrialVerdict.VALID]]
|
|
94
99
|
grader_scores: dict[str, list[float]] = {}
|
|
95
|
-
for trial in
|
|
100
|
+
for trial in valid:
|
|
96
101
|
for gr in trial.grader_results:
|
|
97
102
|
grader_scores.setdefault(gr.grader_name, []).append(gr.score)
|
|
98
103
|
history.append({
|
|
99
104
|
"run_id": run.run_id,
|
|
100
105
|
"suite_name": run.suite_name,
|
|
101
106
|
"started_at": run.started_at,
|
|
102
|
-
"trials_passed": sum(1 for t in
|
|
107
|
+
"trials_passed": sum(1 for t in valid if t.success),
|
|
103
108
|
"trials_total": len(trials),
|
|
109
|
+
"valid_trials": len(valid),
|
|
110
|
+
"invalid_trials": len(buckets[TrialVerdict.INVALID]),
|
|
111
|
+
"pending_trials": len(buckets[TrialVerdict.PENDING]),
|
|
104
112
|
"avg_score": (
|
|
105
|
-
round(sum(t.avg_score() for t in
|
|
113
|
+
round(sum(t.avg_score() for t in valid) / len(valid), 4)
|
|
114
|
+
if valid else None
|
|
106
115
|
),
|
|
107
116
|
"graders": {
|
|
108
117
|
name: round(sum(scores) / len(scores), 4)
|