ecp-runtime 0.3.0__tar.gz → 0.3.2__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.
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/.gitignore +1 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/PKG-INFO +21 -14
- ecp_runtime-0.3.2/README.md +53 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/pyproject.toml +4 -3
- ecp_runtime-0.3.2/src/ecp_runtime/__init__.py +2 -0
- ecp_runtime-0.3.2/src/ecp_runtime/cli.py +384 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/src/ecp_runtime/graders.py +4 -2
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/src/ecp_runtime/manifest.py +1 -1
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/src/ecp_runtime/reporter.py +7 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/src/ecp_runtime/runner.py +7 -3
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/tests/test_cli.py +23 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/tests/test_graders.py +20 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/tests/test_manifest.py +10 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/tests/test_runner.py +6 -1
- ecp_runtime-0.3.0/README.md +0 -46
- ecp_runtime-0.3.0/src/ecp_runtime/__init__.py +0 -1
- ecp_runtime-0.3.0/src/ecp_runtime/cli.py +0 -205
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/src/ecp_runtime/trend.py +0 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/tests/__init__.py +0 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/tests/test_example_integrations.py +0 -0
- {ecp_runtime-0.3.0 → ecp_runtime-0.3.2}/tests/test_trend.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ecp-runtime
|
|
3
|
-
Version: 0.3.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Vendor-neutral runtime for portable AI agent evaluations with outputs, tool calls, and audit context.
|
|
5
5
|
Project-URL: Homepage, https://github.com/evaluation-context-protocol/ecp
|
|
6
6
|
Project-URL: Documentation, https://evaluationcontextprotocol.io/
|
|
7
7
|
Project-URL: Repository, https://github.com/evaluation-context-protocol/ecp
|
|
8
8
|
Project-URL: Issues, https://github.com/evaluation-context-protocol/ecp/issues
|
|
9
|
-
Author-email: ECP Maintainers <
|
|
9
|
+
Author-email: ECP Maintainers <aniket.wattamwar17@gmail.com>
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
11
|
Requires-Dist: jinja2>=3.1.0
|
|
12
12
|
Requires-Dist: openai>=1.0.0
|
|
@@ -18,33 +18,39 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
|
|
19
19
|
# ECP Runtime
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
Reference runtime and CLI for the Evaluation Context Protocol (ECP).
|
|
22
|
+
|
|
23
|
+
ECP is a vendor-neutral protocol for testing agent outputs, tool calls, and evaluator-visible audit context across frameworks, models, eval platforms, and CI systems.
|
|
23
24
|
|
|
24
25
|
## Install
|
|
25
26
|
|
|
26
27
|
```bash
|
|
27
|
-
pip install "ecp-runtime==0.2
|
|
28
|
+
pip install "ecp-runtime==0.3.2"
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
|
|
31
|
-
|
|
32
31
|
## Usage
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
```bash
|
|
34
|
+
ecp init
|
|
35
|
+
ecp validate ecp_eval/manifest.yaml
|
|
36
|
+
ecp run --manifest ecp_eval/manifest.yaml --json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Run the flagship example:
|
|
35
40
|
|
|
36
41
|
```bash
|
|
37
|
-
ecp run --manifest
|
|
42
|
+
ecp run --manifest examples/customer_support_demo/manifest.yaml --report report.html
|
|
38
43
|
```
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
Useful commands:
|
|
41
46
|
|
|
42
47
|
```bash
|
|
43
|
-
|
|
48
|
+
ecp validate examples/customer_support_demo/manifest.yaml
|
|
49
|
+
ecp conformance --target "python examples/customer_support_demo/agent.py"
|
|
50
|
+
ecp doctor
|
|
44
51
|
```
|
|
45
52
|
|
|
46
|
-
Manifest `target` values may be either a command for the default stdio transport
|
|
47
|
-
or an ECP Streamable HTTP endpoint:
|
|
53
|
+
Manifest `target` values may be either a command for the default stdio transport or an ECP Streamable HTTP endpoint:
|
|
48
54
|
|
|
49
55
|
```yaml
|
|
50
56
|
target: "http://127.0.0.1:8765/ecp"
|
|
@@ -62,3 +68,4 @@ $env:ECP_LLM_JUDGE_MODEL="gpt-4o-mini"
|
|
|
62
68
|
- Documentation: https://evaluationcontextprotocol.io/
|
|
63
69
|
- Repository: https://github.com/evaluation-context-protocol/ecp
|
|
64
70
|
- Issues: https://github.com/evaluation-context-protocol/ecp/issues
|
|
71
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ECP Runtime
|
|
2
|
+
|
|
3
|
+
Reference runtime and CLI for the Evaluation Context Protocol (ECP).
|
|
4
|
+
|
|
5
|
+
ECP is a vendor-neutral protocol for testing agent outputs, tool calls, and evaluator-visible audit context across frameworks, models, eval platforms, and CI systems.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "ecp-runtime==0.3.2"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ecp init
|
|
17
|
+
ecp validate ecp_eval/manifest.yaml
|
|
18
|
+
ecp run --manifest ecp_eval/manifest.yaml --json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Run the flagship example:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ecp run --manifest examples/customer_support_demo/manifest.yaml --report report.html
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Useful commands:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
ecp validate examples/customer_support_demo/manifest.yaml
|
|
31
|
+
ecp conformance --target "python examples/customer_support_demo/agent.py"
|
|
32
|
+
ecp doctor
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Manifest `target` values may be either a command for the default stdio transport or an ECP Streamable HTTP endpoint:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
target: "http://127.0.0.1:8765/ecp"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If your manifest includes `llm_judge`, set an API key and optional judge model:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
$env:OPENAI_API_KEY="your_key_here"
|
|
45
|
+
$env:ECP_LLM_JUDGE_MODEL="gpt-4o-mini"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Links
|
|
49
|
+
|
|
50
|
+
- Documentation: https://evaluationcontextprotocol.io/
|
|
51
|
+
- Repository: https://github.com/evaluation-context-protocol/ecp
|
|
52
|
+
- Issues: https://github.com/evaluation-context-protocol/ecp/issues
|
|
53
|
+
|
|
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ecp-runtime"
|
|
7
|
-
version = "0.3.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.3.2"
|
|
8
|
+
description = "Vendor-neutral runtime for portable AI agent evaluations with outputs, tool calls, and audit context."
|
|
9
9
|
authors = [
|
|
10
|
-
{ name = "ECP Maintainers", email = "
|
|
10
|
+
{ name = "ECP Maintainers", email = "aniket.wattamwar17@gmail.com" },
|
|
11
11
|
]
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.9"
|
|
@@ -29,3 +29,4 @@ Issues = "https://github.com/evaluation-context-protocol/ecp/issues"
|
|
|
29
29
|
[project.scripts]
|
|
30
30
|
ecp = "ecp_runtime.cli:app"
|
|
31
31
|
|
|
32
|
+
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from .manifest import ECPManifest
|
|
14
|
+
from .reporter import HTMLReporter
|
|
15
|
+
from .runner import ECPRunner
|
|
16
|
+
from .trend import RunTrendAnalyzer
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="ecp",
|
|
20
|
+
help="Evaluation Context Protocol Runtime CLI",
|
|
21
|
+
add_completion=False
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _configure_logging(verbose: bool) -> None:
|
|
28
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=level,
|
|
31
|
+
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
32
|
+
datefmt="%H:%M:%S",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def main():
|
|
38
|
+
"""
|
|
39
|
+
Official Runtime for the Evaluation Context Protocol (ECP).
|
|
40
|
+
"""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def run(
|
|
46
|
+
manifest: Path = typer.Option(
|
|
47
|
+
...,
|
|
48
|
+
"--manifest", "-m",
|
|
49
|
+
exists=True,
|
|
50
|
+
file_okay=True,
|
|
51
|
+
dir_okay=False,
|
|
52
|
+
writable=False,
|
|
53
|
+
readable=True,
|
|
54
|
+
resolve_path=True,
|
|
55
|
+
help="Path to the test manifest YAML file"
|
|
56
|
+
),
|
|
57
|
+
|
|
58
|
+
verbose: bool = False,
|
|
59
|
+
report: Optional[Path] = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--report",
|
|
62
|
+
help="Path to save an HTML report (e.g., output.html)",
|
|
63
|
+
resolve_path=True,
|
|
64
|
+
),
|
|
65
|
+
json_out: Optional[Path] = typer.Option(
|
|
66
|
+
None,
|
|
67
|
+
"--json-out",
|
|
68
|
+
help="Path to save a JSON report (e.g., output.json)",
|
|
69
|
+
resolve_path=True,
|
|
70
|
+
),
|
|
71
|
+
print_json: bool = typer.Option(
|
|
72
|
+
False,
|
|
73
|
+
"--json",
|
|
74
|
+
help="Print the JSON report to stdout",
|
|
75
|
+
),
|
|
76
|
+
fail_on_error: bool = typer.Option(
|
|
77
|
+
True,
|
|
78
|
+
"--fail-on-error/--no-fail-on-error",
|
|
79
|
+
help="Exit non-zero if any checks fail (useful for CI)",
|
|
80
|
+
),
|
|
81
|
+
):
|
|
82
|
+
"""
|
|
83
|
+
Execute an evaluation run based on a manifest file.
|
|
84
|
+
"""
|
|
85
|
+
_configure_logging(verbose)
|
|
86
|
+
|
|
87
|
+
logger.info("ECP Runtime Initializing...")
|
|
88
|
+
logger.info("Loading manifest: %s", manifest)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Load the YAML
|
|
92
|
+
config = ECPManifest.from_yaml(str(manifest))
|
|
93
|
+
|
|
94
|
+
# Run the Tests
|
|
95
|
+
runner = ECPRunner(config)
|
|
96
|
+
result_summary = runner.run_scenarios()
|
|
97
|
+
total = int(result_summary.get("total", 0) or 0)
|
|
98
|
+
passed = int(result_summary.get("passed", 0) or 0)
|
|
99
|
+
failed = max(total - passed, 0)
|
|
100
|
+
|
|
101
|
+
report_payload: Dict[str, Any] = {
|
|
102
|
+
"manifest": str(manifest),
|
|
103
|
+
"passed": passed,
|
|
104
|
+
"total": total,
|
|
105
|
+
"failed": failed,
|
|
106
|
+
"scenarios": result_summary.get("scenarios", []),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if report:
|
|
110
|
+
logger.info("Generating HTML report: %s", report)
|
|
111
|
+
reporter = HTMLReporter()
|
|
112
|
+
# Feed scenarios directly to reporter
|
|
113
|
+
for scenario in result_summary.get("scenarios", []):
|
|
114
|
+
reporter.add_scenario(scenario.get("name"), scenario.get("steps", []))
|
|
115
|
+
reporter.save(str(report))
|
|
116
|
+
logger.info("Report saved to %s", report)
|
|
117
|
+
|
|
118
|
+
if json_out:
|
|
119
|
+
json_out.write_text(json.dumps(report_payload, indent=2), encoding="utf-8")
|
|
120
|
+
logger.info("JSON report saved to %s", json_out)
|
|
121
|
+
|
|
122
|
+
if print_json:
|
|
123
|
+
typer.echo(json.dumps(report_payload, indent=2))
|
|
124
|
+
|
|
125
|
+
if fail_on_error and failed > 0:
|
|
126
|
+
raise typer.Exit(code=2)
|
|
127
|
+
|
|
128
|
+
except typer.Exit:
|
|
129
|
+
raise
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error("CRITICAL ERROR: %s", e)
|
|
132
|
+
if verbose:
|
|
133
|
+
raise e
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def validate(
|
|
139
|
+
manifest: Path = typer.Argument(
|
|
140
|
+
...,
|
|
141
|
+
exists=True,
|
|
142
|
+
file_okay=True,
|
|
143
|
+
dir_okay=False,
|
|
144
|
+
readable=True,
|
|
145
|
+
resolve_path=True,
|
|
146
|
+
help="Path to the ECP manifest YAML file",
|
|
147
|
+
),
|
|
148
|
+
):
|
|
149
|
+
"""
|
|
150
|
+
Validate a manifest without running the agent.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
config = ECPManifest.from_yaml(str(manifest))
|
|
154
|
+
except ValidationError as exc:
|
|
155
|
+
typer.echo(f"Manifest invalid: {manifest}", err=True)
|
|
156
|
+
typer.echo(str(exc), err=True)
|
|
157
|
+
raise typer.Exit(code=1)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
typer.echo(f"Manifest invalid: {manifest}", err=True)
|
|
160
|
+
typer.echo(str(exc), err=True)
|
|
161
|
+
raise typer.Exit(code=1)
|
|
162
|
+
|
|
163
|
+
step_count = sum(len(scenario.steps) for scenario in config.scenarios)
|
|
164
|
+
grader_count = sum(len(step.graders) for scenario in config.scenarios for step in scenario.steps)
|
|
165
|
+
typer.echo(f"Manifest valid: {manifest}")
|
|
166
|
+
typer.echo(f"Name: {config.name}")
|
|
167
|
+
typer.echo(f"Scenarios: {len(config.scenarios)}")
|
|
168
|
+
typer.echo(f"Steps: {step_count}")
|
|
169
|
+
typer.echo(f"Graders: {grader_count}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command()
|
|
173
|
+
def doctor():
|
|
174
|
+
"""
|
|
175
|
+
Check the local ECP runtime environment.
|
|
176
|
+
"""
|
|
177
|
+
typer.echo("ECP doctor")
|
|
178
|
+
typer.echo(f"Python: {sys.version.split()[0]}")
|
|
179
|
+
typer.echo(f"Executable: {sys.executable}")
|
|
180
|
+
typer.echo(f"Working directory: {Path.cwd()}")
|
|
181
|
+
typer.echo(f"OpenAI key: {'set' if os.environ.get('OPENAI_API_KEY') else 'not set'}")
|
|
182
|
+
typer.echo(f"Git: {'available' if shutil.which('git') else 'not found'}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command()
|
|
186
|
+
def init(
|
|
187
|
+
directory: Path = typer.Argument(
|
|
188
|
+
Path("ecp_eval"),
|
|
189
|
+
help="Directory where a starter ECP agent and manifest should be created",
|
|
190
|
+
),
|
|
191
|
+
force: bool = typer.Option(
|
|
192
|
+
False,
|
|
193
|
+
"--force",
|
|
194
|
+
help="Overwrite existing starter files",
|
|
195
|
+
),
|
|
196
|
+
):
|
|
197
|
+
"""
|
|
198
|
+
Create a minimal ECP evaluation project.
|
|
199
|
+
"""
|
|
200
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
agent_path = directory / "agent.py"
|
|
202
|
+
manifest_path = directory / "manifest.yaml"
|
|
203
|
+
|
|
204
|
+
if not force and (agent_path.exists() or manifest_path.exists()):
|
|
205
|
+
typer.echo(f"Refusing to overwrite existing files in {directory}. Use --force to replace starters.", err=True)
|
|
206
|
+
raise typer.Exit(code=1)
|
|
207
|
+
|
|
208
|
+
agent_path.write_text(_starter_agent(), encoding="utf-8")
|
|
209
|
+
manifest_path.write_text(_starter_manifest(agent_path.as_posix()), encoding="utf-8")
|
|
210
|
+
|
|
211
|
+
typer.echo(f"Created {agent_path}")
|
|
212
|
+
typer.echo(f"Created {manifest_path}")
|
|
213
|
+
typer.echo(f"Try: ecp validate {manifest_path}")
|
|
214
|
+
typer.echo(f"Then: ecp run --manifest {manifest_path}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def conformance(
|
|
219
|
+
target: str = typer.Option(
|
|
220
|
+
...,
|
|
221
|
+
"--target",
|
|
222
|
+
"-t",
|
|
223
|
+
help="Agent command or ECP HTTP endpoint to check",
|
|
224
|
+
),
|
|
225
|
+
step_input: str = typer.Option(
|
|
226
|
+
"hello",
|
|
227
|
+
"--input",
|
|
228
|
+
help="Input text for the conformance step call",
|
|
229
|
+
),
|
|
230
|
+
):
|
|
231
|
+
"""
|
|
232
|
+
Run a small protocol conformance smoke test against an ECP agent.
|
|
233
|
+
"""
|
|
234
|
+
runner = ECPRunner(type("Manifest", (), {"target": target, "scenarios": []})())
|
|
235
|
+
agent = runner._create_agent(target, rpc_timeout=float(os.environ.get("ECP_RPC_TIMEOUT", "30")))
|
|
236
|
+
agent.start()
|
|
237
|
+
try:
|
|
238
|
+
init_resp = agent.send_rpc("agent/initialize", {"config": {}})
|
|
239
|
+
_assert_rpc_result(init_resp, "agent/initialize")
|
|
240
|
+
step_resp = agent.send_rpc("agent/step", {"input": step_input})
|
|
241
|
+
_assert_rpc_result(step_resp, "agent/step")
|
|
242
|
+
result = step_resp.get("result", {})
|
|
243
|
+
if not isinstance(result, dict):
|
|
244
|
+
raise ValueError("agent/step result must be an object")
|
|
245
|
+
if "public_output" not in result and "evaluation_context" not in result and "tool_calls" not in result:
|
|
246
|
+
raise ValueError("agent/step result should expose public_output, evaluation_context, or tool_calls")
|
|
247
|
+
reset_resp = agent.send_rpc("agent/reset", {})
|
|
248
|
+
_assert_rpc_result(reset_resp, "agent/reset")
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
typer.echo(f"Conformance failed: {exc}", err=True)
|
|
251
|
+
raise typer.Exit(code=1)
|
|
252
|
+
finally:
|
|
253
|
+
agent.stop()
|
|
254
|
+
|
|
255
|
+
typer.echo("Conformance smoke test passed")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command()
|
|
259
|
+
def trend(
|
|
260
|
+
pattern: str = typer.Argument(
|
|
261
|
+
...,
|
|
262
|
+
help="Glob pattern matching saved JSON report files (e.g. 'results/run-*.json')",
|
|
263
|
+
),
|
|
264
|
+
window: int = typer.Option(
|
|
265
|
+
20,
|
|
266
|
+
"--window",
|
|
267
|
+
"-w",
|
|
268
|
+
help="Maximum number of recent runs to include in the analysis",
|
|
269
|
+
min=1,
|
|
270
|
+
),
|
|
271
|
+
exit_on_regression: bool = typer.Option(
|
|
272
|
+
False,
|
|
273
|
+
"--exit-on-regression",
|
|
274
|
+
help="Exit with code 2 when a degrading trend is detected (for CI gates)",
|
|
275
|
+
),
|
|
276
|
+
verbose: bool = typer.Option(
|
|
277
|
+
False,
|
|
278
|
+
"--verbose",
|
|
279
|
+
"-v",
|
|
280
|
+
help="Enable verbose output",
|
|
281
|
+
),
|
|
282
|
+
):
|
|
283
|
+
"""
|
|
284
|
+
Analyse pass-rate trends across a sequence of saved JSON report files.
|
|
285
|
+
"""
|
|
286
|
+
_configure_logging(verbose)
|
|
287
|
+
|
|
288
|
+
matched: List[Path] = sorted(Path(path) for path in glob.glob(pattern, recursive=True))
|
|
289
|
+
|
|
290
|
+
if not matched:
|
|
291
|
+
typer.echo(f"No files matched the pattern: {pattern}", err=True)
|
|
292
|
+
raise typer.Exit(code=1)
|
|
293
|
+
|
|
294
|
+
typer.echo(f"Found {len(matched)} report(s). Analysing last {window}...")
|
|
295
|
+
|
|
296
|
+
analyzer = RunTrendAnalyzer(matched, window=window)
|
|
297
|
+
report = analyzer.analyze()
|
|
298
|
+
|
|
299
|
+
typer.echo("")
|
|
300
|
+
typer.echo(f"{'Run':<5} {'Manifest':<45} {'Passed':>6} {'Total':>6} {'Rate':>6}")
|
|
301
|
+
typer.echo("-" * 74)
|
|
302
|
+
for index, run in enumerate(report.runs, start=1):
|
|
303
|
+
manifest_label = Path(run.manifest).name if len(run.manifest) > 45 else run.manifest
|
|
304
|
+
rate_pct = f"{run.pass_rate * 100:.1f}%"
|
|
305
|
+
typer.echo(f"{index:<5} {manifest_label:<45} {run.passed:>6} {run.total:>6} {rate_pct:>6}")
|
|
306
|
+
|
|
307
|
+
typer.echo("")
|
|
308
|
+
typer.echo(f"Trend Direction : {report.direction.upper()}")
|
|
309
|
+
typer.echo(f"Slope (per run) : {report.pass_rate_slope:+.6f}")
|
|
310
|
+
typer.echo(f"Window : {len(report.runs)} run(s) analysed (max {report.window})")
|
|
311
|
+
typer.echo(f"Regression flag : {'YES' if report.any_regression else 'No'}")
|
|
312
|
+
typer.echo("")
|
|
313
|
+
|
|
314
|
+
if report.any_regression and exit_on_regression:
|
|
315
|
+
typer.echo("Regression detected. Exiting with code 2 (--exit-on-regression).", err=True)
|
|
316
|
+
raise typer.Exit(code=2)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _assert_rpc_result(response: Dict[str, Any], method: str) -> None:
|
|
320
|
+
if not isinstance(response, dict):
|
|
321
|
+
raise ValueError(f"{method} response must be a JSON-RPC object")
|
|
322
|
+
if response.get("jsonrpc") != "2.0":
|
|
323
|
+
raise ValueError(f"{method} response must include jsonrpc='2.0'")
|
|
324
|
+
if "error" in response:
|
|
325
|
+
raise ValueError(f"{method} returned error: {response['error']}")
|
|
326
|
+
if "result" not in response:
|
|
327
|
+
raise ValueError(f"{method} response must include result")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _starter_agent() -> str:
|
|
331
|
+
return '''from ecp import Result, agent, on_reset, on_step, serve
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@agent(name="StarterAgent")
|
|
335
|
+
class StarterAgent:
|
|
336
|
+
def __init__(self):
|
|
337
|
+
self.seen = []
|
|
338
|
+
|
|
339
|
+
@on_step
|
|
340
|
+
def step(self, user_input: str):
|
|
341
|
+
self.seen.append(user_input)
|
|
342
|
+
return Result(
|
|
343
|
+
public_output=f"Echo: {user_input}",
|
|
344
|
+
evaluation_context="The starter agent echoed the user input.",
|
|
345
|
+
tool_calls=[{"name": "echo", "arguments": {"text": user_input}}],
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
@on_reset
|
|
349
|
+
def reset(self):
|
|
350
|
+
self.seen.clear()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
serve(StarterAgent())
|
|
355
|
+
'''
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _starter_manifest(agent_filename: str) -> str:
|
|
359
|
+
return f'''manifest_version: "v1"
|
|
360
|
+
name: "Starter ECP Evaluation"
|
|
361
|
+
target: 'python {agent_filename}'
|
|
362
|
+
|
|
363
|
+
scenarios:
|
|
364
|
+
- name: "Echo contract"
|
|
365
|
+
steps:
|
|
366
|
+
- input: "hello ecp"
|
|
367
|
+
graders:
|
|
368
|
+
- type: text_match
|
|
369
|
+
field: public_output
|
|
370
|
+
condition: contains
|
|
371
|
+
value: "hello ecp"
|
|
372
|
+
- type: text_match
|
|
373
|
+
field: evaluation_context
|
|
374
|
+
condition: contains
|
|
375
|
+
value: "echoed"
|
|
376
|
+
- type: tool_usage
|
|
377
|
+
tool_name: "echo"
|
|
378
|
+
arguments:
|
|
379
|
+
text: "hello ecp"
|
|
380
|
+
'''
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == "__main__":
|
|
384
|
+
app()
|
|
@@ -112,8 +112,10 @@ def evaluate_step(step_config: StepConfig, result_obj: Any) -> List[Dict[str, An
|
|
|
112
112
|
target_text = ""
|
|
113
113
|
if grader.field == "public_output":
|
|
114
114
|
target_text = result_obj.public_output
|
|
115
|
+
elif grader.field == "evaluation_context":
|
|
116
|
+
target_text = getattr(result_obj, "evaluation_context", None)
|
|
115
117
|
elif grader.field == "private_thought":
|
|
116
|
-
target_text = result_obj
|
|
118
|
+
target_text = getattr(result_obj, "private_thought", None) or getattr(result_obj, "evaluation_context", None)
|
|
117
119
|
|
|
118
120
|
passed = False
|
|
119
121
|
reasoning = ""
|
|
@@ -132,7 +134,7 @@ def evaluate_step(step_config: StepConfig, result_obj: Any) -> List[Dict[str, An
|
|
|
132
134
|
|
|
133
135
|
check_results.append({
|
|
134
136
|
"type": grader.type,
|
|
135
|
-
"field": grader.field,
|
|
137
|
+
"field": "tool_calls" if grader.type == "tool_usage" else grader.field,
|
|
136
138
|
"passed": passed,
|
|
137
139
|
"score": score,
|
|
138
140
|
"reasoning": reasoning
|
|
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, model_validator
|
|
|
7
7
|
# --- The Grader (Assertion) Schema ---
|
|
8
8
|
class GraderConfig(BaseModel):
|
|
9
9
|
type: Literal["text_match", "llm_judge", "tool_usage"]
|
|
10
|
-
field: Literal["public_output", "private_thought"] = "public_output"
|
|
10
|
+
field: Literal["public_output", "evaluation_context", "private_thought"] = "public_output"
|
|
11
11
|
# For text_match
|
|
12
12
|
condition: Optional[Literal["contains", "equals", "does_not_contain", "regex"]] = None
|
|
13
13
|
value: Optional[str] = None
|
|
@@ -30,6 +30,13 @@ HTML_TEMPLATE = """
|
|
|
30
30
|
<div style="border-top: 1px solid #eee; margin-top: 10px; padding-top: 10px;">
|
|
31
31
|
<p><strong>Input:</strong> {{ step.input }}</p>
|
|
32
32
|
<p><strong>Output:</strong> {{ step.output }}</p>
|
|
33
|
+
{% if step.evaluation_context %}
|
|
34
|
+
<p><strong>Evaluation context:</strong> {{ step.evaluation_context }}</p>
|
|
35
|
+
{% endif %}
|
|
36
|
+
{% if step.tool_calls %}
|
|
37
|
+
<h4>Tool Calls:</h4>
|
|
38
|
+
<pre>{{ step.tool_calls | tojson(indent=2) }}</pre>
|
|
39
|
+
{% endif %}
|
|
33
40
|
|
|
34
41
|
<h4>Graders:</h4>
|
|
35
42
|
<ul>
|
|
@@ -26,6 +26,7 @@ logger = logging.getLogger(__name__)
|
|
|
26
26
|
class StepResult:
|
|
27
27
|
status: str
|
|
28
28
|
public_output: Optional[str] = None
|
|
29
|
+
evaluation_context: Optional[str] = None
|
|
29
30
|
private_thought: Optional[str] = None
|
|
30
31
|
logs: Optional[str] = None
|
|
31
32
|
tool_calls: Optional[List[Dict[str, Any]]] = None
|
|
@@ -252,14 +253,15 @@ class ECPRunner:
|
|
|
252
253
|
step_result = StepResult(
|
|
253
254
|
status=result_data.get("status", "done"),
|
|
254
255
|
public_output=result_data.get("public_output"),
|
|
255
|
-
|
|
256
|
+
evaluation_context=result_data.get("evaluation_context") or result_data.get("private_thought"),
|
|
257
|
+
private_thought=result_data.get("private_thought") or result_data.get("evaluation_context"),
|
|
256
258
|
tool_calls=result_data.get("tool_calls") if isinstance(result_data.get("tool_calls"), list) else None
|
|
257
259
|
)
|
|
258
260
|
|
|
259
261
|
logger.info("Step %d: Input='%s'", i + 1, step.input)
|
|
260
262
|
logger.info("Output: %s", step_result.public_output)
|
|
261
|
-
if step_result.
|
|
262
|
-
logger.debug("
|
|
263
|
+
if step_result.evaluation_context:
|
|
264
|
+
logger.debug("Evaluation context: %s", step_result.evaluation_context)
|
|
263
265
|
|
|
264
266
|
checks = evaluate_step(step, step_result)
|
|
265
267
|
|
|
@@ -278,6 +280,8 @@ class ECPRunner:
|
|
|
278
280
|
scenario_steps.append({
|
|
279
281
|
"input": step.input,
|
|
280
282
|
"output": step_result.public_output,
|
|
283
|
+
"evaluation_context": step_result.evaluation_context,
|
|
284
|
+
"tool_calls": step_result.tool_calls or [],
|
|
281
285
|
"checks": checks
|
|
282
286
|
})
|
|
283
287
|
|
|
@@ -72,6 +72,29 @@ class CLISmokeTests(unittest.TestCase):
|
|
|
72
72
|
result = self.runner.invoke(app, ["run", "--manifest", self.manifest_path, "--no-fail-on-error"])
|
|
73
73
|
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
74
74
|
|
|
75
|
+
def test_validate_command(self) -> None:
|
|
76
|
+
result = self.runner.invoke(app, ["validate", self.manifest_path])
|
|
77
|
+
|
|
78
|
+
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
79
|
+
self.assertIn("Manifest valid", result.output)
|
|
80
|
+
|
|
81
|
+
def test_doctor_command(self) -> None:
|
|
82
|
+
result = self.runner.invoke(app, ["doctor"])
|
|
83
|
+
|
|
84
|
+
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
85
|
+
self.assertIn("ECP doctor", result.output)
|
|
86
|
+
|
|
87
|
+
def test_init_command_creates_starter_files(self) -> None:
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
89
|
+
target = Path(tmpdir) / "evals"
|
|
90
|
+
result = self.runner.invoke(app, ["init", str(target)])
|
|
91
|
+
|
|
92
|
+
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
93
|
+
self.assertTrue((target / "agent.py").exists())
|
|
94
|
+
manifest = target / "manifest.yaml"
|
|
95
|
+
self.assertTrue(manifest.exists())
|
|
96
|
+
self.assertIn("evaluation_context", manifest.read_text(encoding="utf-8"))
|
|
97
|
+
|
|
75
98
|
|
|
76
99
|
if __name__ == "__main__":
|
|
77
100
|
unittest.main()
|
|
@@ -81,6 +81,26 @@ class EvaluateStepSmokeTests(unittest.TestCase):
|
|
|
81
81
|
self.assertEqual(len(checks), 1)
|
|
82
82
|
self.assertTrue(checks[0]["passed"])
|
|
83
83
|
|
|
84
|
+
def test_step_can_grade_evaluation_context(self) -> None:
|
|
85
|
+
grader = GraderConfig(
|
|
86
|
+
type="text_match",
|
|
87
|
+
field="evaluation_context",
|
|
88
|
+
condition="contains",
|
|
89
|
+
value="policy checked",
|
|
90
|
+
)
|
|
91
|
+
step = StepConfig(input="refund", graders=[grader])
|
|
92
|
+
result_obj = SimpleNamespace(
|
|
93
|
+
public_output="ok",
|
|
94
|
+
evaluation_context="policy checked before response",
|
|
95
|
+
private_thought=None,
|
|
96
|
+
tool_calls=[],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
from ecp_runtime.graders import evaluate_step
|
|
100
|
+
|
|
101
|
+
checks = evaluate_step(step, result_obj)
|
|
102
|
+
self.assertTrue(checks[0]["passed"])
|
|
103
|
+
|
|
84
104
|
|
|
85
105
|
if __name__ == "__main__":
|
|
86
106
|
unittest.main()
|
|
@@ -33,6 +33,16 @@ class ManifestValidationTests(unittest.TestCase):
|
|
|
33
33
|
with self.assertRaises(ValidationError):
|
|
34
34
|
GraderConfig(type="unknown") # type: ignore[arg-type]
|
|
35
35
|
|
|
36
|
+
def test_evaluation_context_field_allowed(self) -> None:
|
|
37
|
+
grader = GraderConfig(
|
|
38
|
+
type="text_match",
|
|
39
|
+
field="evaluation_context",
|
|
40
|
+
condition="contains",
|
|
41
|
+
value="checked",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.assertEqual(grader.field, "evaluation_context")
|
|
45
|
+
|
|
36
46
|
def test_manifest_root_must_be_mapping(self) -> None:
|
|
37
47
|
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
|
|
38
48
|
tmp.write("- not: a mapping\n")
|
|
@@ -33,7 +33,11 @@ class RunnerTests(unittest.TestCase):
|
|
|
33
33
|
def send_rpc(self, method, params=None):
|
|
34
34
|
if method == "agent/initialize":
|
|
35
35
|
return {"jsonrpc": "2.0", "id": 1, "result": {"name": "x", "capabilities": {}}}
|
|
36
|
-
return {
|
|
36
|
+
return {
|
|
37
|
+
"jsonrpc": "2.0",
|
|
38
|
+
"id": 2,
|
|
39
|
+
"result": {"status": "done", "public_output": "ok", "evaluation_context": "checked"},
|
|
40
|
+
}
|
|
37
41
|
|
|
38
42
|
with mock.patch("ecp_runtime.runner.AgentProcess", FakeAgentProcess):
|
|
39
43
|
output = ECPRunner(self._manifest()).run_scenarios()
|
|
@@ -41,6 +45,7 @@ class RunnerTests(unittest.TestCase):
|
|
|
41
45
|
self.assertEqual(output["total"], 0)
|
|
42
46
|
self.assertEqual(len(output["scenarios"]), 1)
|
|
43
47
|
self.assertEqual(output["scenarios"][0]["name"], "Scenario A")
|
|
48
|
+
self.assertEqual(output["scenarios"][0]["steps"][0]["evaluation_context"], "checked")
|
|
44
49
|
|
|
45
50
|
def test_runner_raises_on_rpc_error_with_context(self) -> None:
|
|
46
51
|
class FakeAgentProcess:
|
ecp_runtime-0.3.0/README.md
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# ECP Runtime
|
|
2
|
-
|
|
3
|
-
This is the reference implementation of the Evaluation Context Protocol (ECP) Runtime.
|
|
4
|
-
It includes the CLI tool `ecp` for running agent evaluations.
|
|
5
|
-
|
|
6
|
-
## Install
|
|
7
|
-
|
|
8
|
-
```bash
|
|
9
|
-
pip install "ecp-runtime==0.2.9"
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
|
|
13
|
-
|
|
14
|
-
## Usage
|
|
15
|
-
|
|
16
|
-
Run an evaluation manifest:
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
ecp run --manifest .\examples\langchain_demo\manifest.yaml
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
You can also run via module entrypoint:
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Manifest `target` values may be either a command for the default stdio transport
|
|
29
|
-
or an ECP Streamable HTTP endpoint:
|
|
30
|
-
|
|
31
|
-
```yaml
|
|
32
|
-
target: "http://127.0.0.1:8765/ecp"
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
If your manifest includes `llm_judge`, set an API key and optional judge model:
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
$env:OPENAI_API_KEY="your_key_here"
|
|
39
|
-
$env:ECP_LLM_JUDGE_MODEL="gpt-4o-mini"
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
## Links
|
|
43
|
-
|
|
44
|
-
- Documentation: https://evaluationcontextprotocol.io/
|
|
45
|
-
- Repository: https://github.com/evaluation-context-protocol/ecp
|
|
46
|
-
- Issues: https://github.com/evaluation-context-protocol/ecp/issues
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.9"
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import glob
|
|
2
|
-
import json
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
|
-
import sys
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any, Dict, List, Optional
|
|
8
|
-
|
|
9
|
-
import typer
|
|
10
|
-
|
|
11
|
-
from .reporter import HTMLReporter
|
|
12
|
-
from .trend import RunTrendAnalyzer
|
|
13
|
-
|
|
14
|
-
# Import local modules (Using relative imports)
|
|
15
|
-
try:
|
|
16
|
-
from .manifest import ECPManifest
|
|
17
|
-
from .runner import ECPRunner
|
|
18
|
-
except ImportError:
|
|
19
|
-
# Fallback for direct execution debugging
|
|
20
|
-
sys.path.append(os.path.dirname(__file__))
|
|
21
|
-
from manifest import ECPManifest
|
|
22
|
-
from runner import ECPRunner
|
|
23
|
-
|
|
24
|
-
app = typer.Typer(
|
|
25
|
-
name="ecp",
|
|
26
|
-
help="Evaluation Context Protocol Runtime CLI",
|
|
27
|
-
add_completion=False
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
logger = logging.getLogger(__name__)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _configure_logging(verbose: bool) -> None:
|
|
34
|
-
level = logging.DEBUG if verbose else logging.INFO
|
|
35
|
-
logging.basicConfig(
|
|
36
|
-
level=level,
|
|
37
|
-
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
38
|
-
datefmt="%H:%M:%S",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@app.callback()
|
|
43
|
-
def main():
|
|
44
|
-
"""
|
|
45
|
-
Official Runtime for the Evaluation Context Protocol (ECP).
|
|
46
|
-
"""
|
|
47
|
-
pass
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@app.command()
|
|
51
|
-
def run(
|
|
52
|
-
manifest: Path = typer.Option(
|
|
53
|
-
...,
|
|
54
|
-
"--manifest", "-m",
|
|
55
|
-
exists=True,
|
|
56
|
-
file_okay=True,
|
|
57
|
-
dir_okay=False,
|
|
58
|
-
writable=False,
|
|
59
|
-
readable=True,
|
|
60
|
-
resolve_path=True,
|
|
61
|
-
help="Path to the test manifest YAML file"
|
|
62
|
-
),
|
|
63
|
-
|
|
64
|
-
verbose: bool = False,
|
|
65
|
-
report: Optional[Path] = typer.Option(
|
|
66
|
-
None,
|
|
67
|
-
"--report",
|
|
68
|
-
help="Path to save an HTML report (e.g., output.html)",
|
|
69
|
-
resolve_path=True,
|
|
70
|
-
),
|
|
71
|
-
json_out: Optional[Path] = typer.Option(
|
|
72
|
-
None,
|
|
73
|
-
"--json-out",
|
|
74
|
-
help="Path to save a JSON report (e.g., output.json)",
|
|
75
|
-
resolve_path=True,
|
|
76
|
-
),
|
|
77
|
-
print_json: bool = typer.Option(
|
|
78
|
-
False,
|
|
79
|
-
"--json",
|
|
80
|
-
help="Print the JSON report to stdout",
|
|
81
|
-
),
|
|
82
|
-
fail_on_error: bool = typer.Option(
|
|
83
|
-
True,
|
|
84
|
-
"--fail-on-error/--no-fail-on-error",
|
|
85
|
-
help="Exit non-zero if any checks fail (useful for CI)",
|
|
86
|
-
),
|
|
87
|
-
):
|
|
88
|
-
"""
|
|
89
|
-
Execute an evaluation run based on a manifest file.
|
|
90
|
-
"""
|
|
91
|
-
_configure_logging(verbose)
|
|
92
|
-
|
|
93
|
-
logger.info("ECP Runtime Initializing...")
|
|
94
|
-
logger.info("Loading manifest: %s", manifest)
|
|
95
|
-
|
|
96
|
-
try:
|
|
97
|
-
# Load the YAML
|
|
98
|
-
config = ECPManifest.from_yaml(str(manifest))
|
|
99
|
-
|
|
100
|
-
# Run the Tests
|
|
101
|
-
runner = ECPRunner(config)
|
|
102
|
-
result_summary = runner.run_scenarios()
|
|
103
|
-
total = int(result_summary.get("total", 0) or 0)
|
|
104
|
-
passed = int(result_summary.get("passed", 0) or 0)
|
|
105
|
-
failed = max(total - passed, 0)
|
|
106
|
-
|
|
107
|
-
report_payload: Dict[str, Any] = {
|
|
108
|
-
"manifest": str(manifest),
|
|
109
|
-
"passed": passed,
|
|
110
|
-
"total": total,
|
|
111
|
-
"failed": failed,
|
|
112
|
-
"scenarios": result_summary.get("scenarios", []),
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if report:
|
|
116
|
-
logger.info("Generating HTML report: %s", report)
|
|
117
|
-
reporter = HTMLReporter()
|
|
118
|
-
# Feed scenarios directly to reporter
|
|
119
|
-
for scenario in result_summary.get("scenarios", []):
|
|
120
|
-
reporter.add_scenario(scenario.get("name"), scenario.get("steps", []))
|
|
121
|
-
reporter.save(str(report))
|
|
122
|
-
logger.info("Report saved to %s", report)
|
|
123
|
-
|
|
124
|
-
if json_out:
|
|
125
|
-
json_out.write_text(json.dumps(report_payload, indent=2), encoding="utf-8")
|
|
126
|
-
logger.info("JSON report saved to %s", json_out)
|
|
127
|
-
|
|
128
|
-
if print_json:
|
|
129
|
-
typer.echo(json.dumps(report_payload, indent=2))
|
|
130
|
-
|
|
131
|
-
if fail_on_error and failed > 0:
|
|
132
|
-
raise typer.Exit(code=2)
|
|
133
|
-
|
|
134
|
-
except typer.Exit:
|
|
135
|
-
raise
|
|
136
|
-
except Exception as e:
|
|
137
|
-
logger.error("CRITICAL ERROR: %s", e)
|
|
138
|
-
if verbose:
|
|
139
|
-
raise e
|
|
140
|
-
sys.exit(1)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
@app.command()
|
|
144
|
-
def trend(
|
|
145
|
-
pattern: str = typer.Argument(
|
|
146
|
-
...,
|
|
147
|
-
help="Glob pattern matching saved JSON report files (e.g. 'results/run-*.json')",
|
|
148
|
-
),
|
|
149
|
-
window: int = typer.Option(
|
|
150
|
-
20,
|
|
151
|
-
"--window",
|
|
152
|
-
"-w",
|
|
153
|
-
help="Maximum number of recent runs to include in the analysis",
|
|
154
|
-
min=1,
|
|
155
|
-
),
|
|
156
|
-
exit_on_regression: bool = typer.Option(
|
|
157
|
-
False,
|
|
158
|
-
"--exit-on-regression",
|
|
159
|
-
help="Exit with code 2 when a degrading trend is detected (for CI gates)",
|
|
160
|
-
),
|
|
161
|
-
verbose: bool = typer.Option(
|
|
162
|
-
False,
|
|
163
|
-
"--verbose",
|
|
164
|
-
"-v",
|
|
165
|
-
help="Enable verbose output",
|
|
166
|
-
),
|
|
167
|
-
):
|
|
168
|
-
"""
|
|
169
|
-
Analyse pass-rate trends across a sequence of saved JSON report files.
|
|
170
|
-
"""
|
|
171
|
-
_configure_logging(verbose)
|
|
172
|
-
|
|
173
|
-
matched: List[Path] = sorted(Path(path) for path in glob.glob(pattern, recursive=True))
|
|
174
|
-
|
|
175
|
-
if not matched:
|
|
176
|
-
typer.echo(f"No files matched the pattern: {pattern}", err=True)
|
|
177
|
-
raise typer.Exit(code=1)
|
|
178
|
-
|
|
179
|
-
typer.echo(f"Found {len(matched)} report(s). Analysing last {window}...")
|
|
180
|
-
|
|
181
|
-
analyzer = RunTrendAnalyzer(matched, window=window)
|
|
182
|
-
report = analyzer.analyze()
|
|
183
|
-
|
|
184
|
-
typer.echo("")
|
|
185
|
-
typer.echo(f"{'Run':<5} {'Manifest':<45} {'Passed':>6} {'Total':>6} {'Rate':>6}")
|
|
186
|
-
typer.echo("-" * 74)
|
|
187
|
-
for index, run in enumerate(report.runs, start=1):
|
|
188
|
-
manifest_label = Path(run.manifest).name if len(run.manifest) > 45 else run.manifest
|
|
189
|
-
rate_pct = f"{run.pass_rate * 100:.1f}%"
|
|
190
|
-
typer.echo(f"{index:<5} {manifest_label:<45} {run.passed:>6} {run.total:>6} {rate_pct:>6}")
|
|
191
|
-
|
|
192
|
-
typer.echo("")
|
|
193
|
-
typer.echo(f"Trend Direction : {report.direction.upper()}")
|
|
194
|
-
typer.echo(f"Slope (per run) : {report.pass_rate_slope:+.6f}")
|
|
195
|
-
typer.echo(f"Window : {len(report.runs)} run(s) analysed (max {report.window})")
|
|
196
|
-
typer.echo(f"Regression flag : {'YES' if report.any_regression else 'No'}")
|
|
197
|
-
typer.echo("")
|
|
198
|
-
|
|
199
|
-
if report.any_regression and exit_on_regression:
|
|
200
|
-
typer.echo("Regression detected. Exiting with code 2 (--exit-on-regression).", err=True)
|
|
201
|
-
raise typer.Exit(code=2)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if __name__ == "__main__":
|
|
205
|
-
app()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|