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.
Files changed (118) hide show
  1. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/.gitignore +3 -0
  2. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/PKG-INFO +1 -1
  3. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/pyproject.toml +1 -1
  4. aeval_framework-0.2.0/src/agent_eval/api/app.py +191 -0
  5. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/runs.py +236 -57
  6. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/tasks.py +12 -3
  7. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/standalone.py +13 -32
  8. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/cli.py +344 -36
  9. aeval_framework-0.2.0/src/agent_eval/core/__init__.py +99 -0
  10. aeval_framework-0.2.0/src/agent_eval/core/contract.py +535 -0
  11. aeval_framework-0.2.0/src/agent_eval/core/metrics.py +671 -0
  12. aeval_framework-0.2.0/src/agent_eval/core/pricing.py +152 -0
  13. aeval_framework-0.2.0/src/agent_eval/core/redaction.py +92 -0
  14. aeval_framework-0.2.0/src/agent_eval/core/runner.py +1927 -0
  15. aeval_framework-0.2.0/src/agent_eval/core/types.py +1264 -0
  16. aeval_framework-0.2.0/src/agent_eval/examples/mock_runner.py +366 -0
  17. aeval_framework-0.2.0/src/agent_eval/graders/_evidence.py +239 -0
  18. aeval_framework-0.2.0/src/agent_eval/graders/_verdicts.py +37 -0
  19. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/artifact_check.py +59 -27
  20. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/code_based.py +46 -12
  21. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/human.py +25 -5
  22. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/metric.py +65 -12
  23. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/model_based.py +68 -11
  24. aeval_framework-0.2.0/src/agent_eval/graders/state_check.py +263 -0
  25. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/step_level.py +46 -31
  26. aeval_framework-0.2.0/src/agent_eval/graders/tool_calls.py +161 -0
  27. aeval_framework-0.2.0/src/agent_eval/graders/transcript.py +166 -0
  28. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/base.py +66 -9
  29. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/pytest_plugin.py +142 -35
  30. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/report.py +28 -10
  31. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/storage/memory.py +62 -6
  32. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/storage/sqlite.py +135 -4
  33. aeval_framework-0.2.0/src/agent_eval/trace/__init__.py +56 -0
  34. aeval_framework-0.2.0/src/agent_eval/trace/mapping.py +243 -0
  35. aeval_framework-0.2.0/src/agent_eval/trace/normalize.py +572 -0
  36. aeval_framework-0.2.0/src/agent_eval/trace/observations.py +135 -0
  37. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/trace/phoenix.py +57 -3
  38. aeval_framework-0.2.0/tests/conftest.py +225 -0
  39. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_api.py +121 -4
  40. aeval_framework-0.2.0/tests/test_builtin_graders.py +457 -0
  41. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_cli.py +85 -4
  42. aeval_framework-0.2.0/tests/test_collection_phases.py +644 -0
  43. aeval_framework-0.2.0/tests/test_e2e.py +437 -0
  44. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_dataset_api.py +18 -12
  45. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_metric_pipeline.py +73 -9
  46. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_metrics_module.py +7 -1
  47. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_pytest_plugin.py +66 -2
  48. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_task_history.py +75 -0
  49. aeval_framework-0.2.0/tests/test_evidence_capture_and_storage.py +343 -0
  50. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_graders.py +27 -23
  51. aeval_framework-0.2.0/tests/test_metrics.py +699 -0
  52. aeval_framework-0.2.0/tests/test_regrade_and_archive.py +543 -0
  53. aeval_framework-0.2.0/tests/test_run_storage_fields.py +261 -0
  54. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_runner.py +276 -28
  55. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_standalone_api.py +42 -3
  56. aeval_framework-0.2.0/tests/test_suite.py +438 -0
  57. aeval_framework-0.2.0/tests/test_termination_and_budgets.py +302 -0
  58. aeval_framework-0.2.0/tests/test_trace_normalization.py +427 -0
  59. aeval_framework-0.2.0/tests/test_trial_evidence.py +351 -0
  60. aeval_framework-0.2.0/tests/test_vocabulary_isolation.py +65 -0
  61. aeval_framework-0.1.0/src/agent_eval/api/app.py +0 -82
  62. aeval_framework-0.1.0/src/agent_eval/core/__init__.py +0 -48
  63. aeval_framework-0.1.0/src/agent_eval/core/contract.py +0 -296
  64. aeval_framework-0.1.0/src/agent_eval/core/metrics.py +0 -184
  65. aeval_framework-0.1.0/src/agent_eval/core/runner.py +0 -868
  66. aeval_framework-0.1.0/src/agent_eval/core/types.py +0 -227
  67. aeval_framework-0.1.0/src/agent_eval/examples/mock_runner.py +0 -195
  68. aeval_framework-0.1.0/src/agent_eval/graders/state_check.py +0 -106
  69. aeval_framework-0.1.0/src/agent_eval/graders/tool_calls.py +0 -102
  70. aeval_framework-0.1.0/src/agent_eval/graders/transcript.py +0 -86
  71. aeval_framework-0.1.0/src/agent_eval/trace/__init__.py +0 -16
  72. aeval_framework-0.1.0/tests/conftest.py +0 -9
  73. aeval_framework-0.1.0/tests/test_builtin_graders.py +0 -298
  74. aeval_framework-0.1.0/tests/test_e2e.py +0 -243
  75. aeval_framework-0.1.0/tests/test_metrics.py +0 -130
  76. aeval_framework-0.1.0/tests/test_suite.py +0 -209
  77. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/.coverage +0 -0
  78. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/README.md +0 -0
  79. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/__init__.py +0 -0
  80. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/__init__.py +0 -0
  81. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/events.py +0 -0
  82. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/__init__.py +0 -0
  83. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/datasets.py +0 -0
  84. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/graders.py +0 -0
  85. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/metrics.py +0 -0
  86. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/api/routes/suites.py +0 -0
  87. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/core/suite.py +0 -0
  88. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/__init__.py +0 -0
  89. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/models.py +0 -0
  90. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/quality.py +0 -0
  91. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/__init__.py +0 -0
  92. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/llm_generator.py +0 -0
  93. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/manual.py +0 -0
  94. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/regression.py +0 -0
  95. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/sources/trace_mining.py +0 -0
  96. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/storage.py +0 -0
  97. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/dataset/version.py +0 -0
  98. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/examples/__init__.py +0 -0
  99. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/examples/basic_usage.py +0 -0
  100. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/graders/__init__.py +0 -0
  101. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/__init__.py +0 -0
  102. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/answer_relevancy.py +1 -1
  103. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/batch_evaluation.py +0 -0
  104. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/context_precision.py +0 -0
  105. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/context_recall.py +0 -0
  106. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/faithfulness.py +0 -0
  107. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/llm_judge.py +0 -0
  108. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/prompt_metric.py +0 -0
  109. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/metrics/synthetic_data.py +0 -0
  110. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/src/agent_eval/storage/__init__.py +0 -0
  111. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_batch_evaluation.py +0 -0
  112. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_dataset_sources.py +0 -0
  113. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_dataset_storage.py +0 -0
  114. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_metrics_api.py +0 -0
  115. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_prompt_metric.py +0 -0
  116. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_eval_report.py +0 -0
  117. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_import_isolation.py +0 -0
  118. {aeval_framework-0.1.0 → aeval_framework-0.2.0}/tests/test_sse.py +0 -0
@@ -21,3 +21,6 @@ out/
21
21
  .env.*
22
22
  .DS_Store
23
23
  Thumbs.db
24
+
25
+ # Editor-local state (Qoder)
26
+ .qoder/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aeval-framework
3
- Version: 0.1.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.1.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(run_id=run_id, suite_name=suite.name, status="pending")
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
- all_k_values = set()
498
- if summary_a.pass_at_k:
499
- all_k_values.update(summary_a.pass_at_k.keys())
500
- if summary_b.pass_at_k:
501
- all_k_values.update(summary_b.pass_at_k.keys())
502
-
503
- pass_at_k_comparison = {}
504
- for k in sorted(all_k_values):
505
- a_val = summary_a.pass_at_k.get(k, 0.0)
506
- b_val = summary_b.pass_at_k.get(k, 0.0)
507
- pass_at_k_comparison[f"pass_at_{k}"] = {
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": round(b_val - a_val, 4),
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
- a_val = summary_a.pass_power_k.get(k, 0.0)
516
- b_val = summary_b.pass_power_k.get(k, 0.0)
517
- pass_power_k_comparison[f"pass_power_{k}"] = {
518
- "a": a_val,
519
- "b": b_val,
520
- "delta": round(b_val - a_val, 4),
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 ts_a and ts_b:
537
- a_score = ts_a.avg_score
538
- b_score = ts_b.avg_score
539
- delta = round(b_score - a_score, 4)
540
-
541
- task_comparisons[task_id] = {
542
- "a": a_score,
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 trials:
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 trials if t.success),
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 trials) / len(trials), 4)
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)