agentops-accelerator 0.3.0__py3-none-any.whl
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.
- agentops/__init__.py +10 -0
- agentops/__main__.py +6 -0
- agentops/agent/__init__.py +12 -0
- agentops/agent/_legacy_ids.py +92 -0
- agentops/agent/analyzer.py +207 -0
- agentops/agent/checks/__init__.py +1 -0
- agentops/agent/checks/catalog.py +880 -0
- agentops/agent/checks/errors.py +279 -0
- agentops/agent/checks/foundry_config.py +75 -0
- agentops/agent/checks/latency.py +84 -0
- agentops/agent/checks/opex.py +157 -0
- agentops/agent/checks/opex_workspace.py +874 -0
- agentops/agent/checks/posture.py +36 -0
- agentops/agent/checks/posture_rules/__init__.py +53 -0
- agentops/agent/checks/posture_rules/content_filter.py +59 -0
- agentops/agent/checks/posture_rules/diagnostics.py +74 -0
- agentops/agent/checks/posture_rules/local_auth.py +55 -0
- agentops/agent/checks/posture_rules/managed_identity.py +59 -0
- agentops/agent/checks/posture_rules/network.py +68 -0
- agentops/agent/checks/regression.py +78 -0
- agentops/agent/checks/release_readiness.py +182 -0
- agentops/agent/checks/safety.py +247 -0
- agentops/agent/checks/spec_conformance.py +375 -0
- agentops/agent/cockpit.py +5159 -0
- agentops/agent/config.py +240 -0
- agentops/agent/findings.py +113 -0
- agentops/agent/history.py +142 -0
- agentops/agent/knowledge/__init__.py +182 -0
- agentops/agent/knowledge/waf-checklist.csv +39 -0
- agentops/agent/llm_assist/__init__.py +16 -0
- agentops/agent/llm_assist/_base.py +124 -0
- agentops/agent/llm_assist/_bundle_rule.py +154 -0
- agentops/agent/llm_assist/_client.py +347 -0
- agentops/agent/llm_assist/_dataset_rules.py +191 -0
- agentops/agent/llm_assist/_engine.py +106 -0
- agentops/agent/llm_assist/_prompt_rules.py +291 -0
- agentops/agent/llm_assist/_spec_rules.py +235 -0
- agentops/agent/production_telemetry.py +430 -0
- agentops/agent/report.py +207 -0
- agentops/agent/server/__init__.py +1 -0
- agentops/agent/server/app.py +84 -0
- agentops/agent/server/auth.py +94 -0
- agentops/agent/server/chat.py +44 -0
- agentops/agent/server/protocol.py +72 -0
- agentops/agent/sources/__init__.py +1 -0
- agentops/agent/sources/azure_monitor.py +523 -0
- agentops/agent/sources/azure_resources.py +602 -0
- agentops/agent/sources/foundry_control.py +174 -0
- agentops/agent/sources/results_history.py +494 -0
- agentops/agent/sources/spec_detectors/__init__.py +42 -0
- agentops/agent/sources/spec_detectors/_base.py +58 -0
- agentops/agent/sources/spec_detectors/agents_md.py +75 -0
- agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
- agentops/agent/time_range.py +117 -0
- agentops/cli/__init__.py +1 -0
- agentops/cli/app.py +4823 -0
- agentops/core/__init__.py +1 -0
- agentops/core/agentops_config.py +592 -0
- agentops/core/config_loader.py +22 -0
- agentops/core/evaluators.py +480 -0
- agentops/core/release_evidence.py +56 -0
- agentops/core/results.py +117 -0
- agentops/mcp/__init__.py +10 -0
- agentops/mcp/server.py +232 -0
- agentops/pipeline/__init__.py +8 -0
- agentops/pipeline/cloud_results.py +189 -0
- agentops/pipeline/cloud_runner.py +901 -0
- agentops/pipeline/comparison.py +108 -0
- agentops/pipeline/diagnostics.py +51 -0
- agentops/pipeline/invocations.py +535 -0
- agentops/pipeline/official_eval.py +414 -0
- agentops/pipeline/orchestrator.py +775 -0
- agentops/pipeline/prompt_deploy.py +377 -0
- agentops/pipeline/publisher.py +121 -0
- agentops/pipeline/reporter.py +202 -0
- agentops/pipeline/runtime.py +409 -0
- agentops/pipeline/thresholds.py +84 -0
- agentops/services/__init__.py +1 -0
- agentops/services/cicd.py +720 -0
- agentops/services/eval_analysis.py +848 -0
- agentops/services/evidence_pack.py +757 -0
- agentops/services/initializer.py +86 -0
- agentops/services/preflight.py +470 -0
- agentops/services/setup_wizard.py +709 -0
- agentops/services/skills.py +643 -0
- agentops/services/trace_promotion.py +300 -0
- agentops/services/workflow_analysis.py +1129 -0
- agentops/templates/.gitignore +15 -0
- agentops/templates/__init__.py +1 -0
- agentops/templates/agent-server/Dockerfile +23 -0
- agentops/templates/agent-server/README.md +61 -0
- agentops/templates/agent-server/main.bicep +94 -0
- agentops/templates/agent.yaml +87 -0
- agentops/templates/agentops.yaml +58 -0
- agentops/templates/foundry.svg +71 -0
- agentops/templates/icon.png +0 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
- agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
- agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
- agentops/templates/project.gitignore +36 -0
- agentops/templates/sample-traces.jsonl +3 -0
- agentops/templates/skills/agentops-agent/SKILL.md +137 -0
- agentops/templates/skills/agentops-config/SKILL.md +113 -0
- agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
- agentops/templates/skills/agentops-eval/SKILL.md +189 -0
- agentops/templates/skills/agentops-report/SKILL.md +71 -0
- agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
- agentops/templates/smoke.jsonl +3 -0
- agentops/templates/waf-checklist.README.md +84 -0
- agentops/templates/waf-checklist.csv +22 -0
- agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
- agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
- agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
- agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
- agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
- agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
- agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
- agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/workflows/agentops-pr.yml +148 -0
- agentops/templates/workflows/agentops-watchdog.yml +122 -0
- agentops/utils/__init__.py +1 -0
- agentops/utils/azd_env.py +435 -0
- agentops/utils/azure_endpoints.py +62 -0
- agentops/utils/colors.py +47 -0
- agentops/utils/dotenv_loader.py +105 -0
- agentops/utils/foundry_discovery.py +229 -0
- agentops/utils/logging.py +59 -0
- agentops/utils/telemetry.py +554 -0
- agentops/utils/yaml.py +36 -0
- agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
- agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
- agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
- agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
- agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
- agentops_accelerator-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"""End-to-end evaluation orchestrator for AgentOps 1.0.
|
|
2
|
+
|
|
3
|
+
This is the single entry point exercised by ``agentops eval``. It loads the
|
|
4
|
+
flat config, classifies the target, infers evaluators from the dataset shape,
|
|
5
|
+
invokes the target row-by-row, runs each evaluator, applies thresholds, and
|
|
6
|
+
writes ``results.json`` and ``report.md``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import statistics
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
21
|
+
|
|
22
|
+
from agentops.core.agentops_config import AgentOpsConfig, Threshold, classify_agent
|
|
23
|
+
from agentops.core.evaluators import (
|
|
24
|
+
detect_dataset_shape,
|
|
25
|
+
merge_thresholds,
|
|
26
|
+
select_evaluators,
|
|
27
|
+
)
|
|
28
|
+
from agentops.core.results import (
|
|
29
|
+
RowMetric,
|
|
30
|
+
RowResult,
|
|
31
|
+
RunResult,
|
|
32
|
+
RunSummary,
|
|
33
|
+
TargetInfo,
|
|
34
|
+
)
|
|
35
|
+
from agentops.pipeline import comparison as comparison_module
|
|
36
|
+
from agentops.pipeline import invocations, publisher, reporter, runtime, thresholds
|
|
37
|
+
from agentops.utils import telemetry
|
|
38
|
+
from agentops.utils.colors import style
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("agentops.pipeline")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Public entry point
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RunOptions:
|
|
50
|
+
config_path: Path
|
|
51
|
+
output_dir: Path
|
|
52
|
+
baseline_path: Optional[Path] = None
|
|
53
|
+
timeout_seconds: float = 120.0
|
|
54
|
+
dataset_override: Optional[Path] = None
|
|
55
|
+
agent_override: Optional[str] = None
|
|
56
|
+
# Optional callback invoked with progress messages during a run. The
|
|
57
|
+
# CLI wires this to ``typer.echo`` so users see per-row progress
|
|
58
|
+
# ("invoking", "scored", ...) instead of long unexplained pauses.
|
|
59
|
+
# Library callers can leave it as ``None`` to keep runs silent.
|
|
60
|
+
progress: Optional[Callable[[str], None]] = field(default=None, repr=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_evaluation(
|
|
64
|
+
config: AgentOpsConfig,
|
|
65
|
+
*,
|
|
66
|
+
options: RunOptions,
|
|
67
|
+
) -> RunResult:
|
|
68
|
+
"""Run a full evaluation and persist artifacts. Returns the RunResult."""
|
|
69
|
+
telemetry.init_tracing()
|
|
70
|
+
try:
|
|
71
|
+
return _run_evaluation(config, options=options)
|
|
72
|
+
finally:
|
|
73
|
+
telemetry.shutdown()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_evaluation(
|
|
77
|
+
config: AgentOpsConfig,
|
|
78
|
+
*,
|
|
79
|
+
options: RunOptions,
|
|
80
|
+
) -> RunResult:
|
|
81
|
+
"""Run a full evaluation after optional telemetry has been initialized."""
|
|
82
|
+
if options.baseline_path is not None and not options.baseline_path.exists():
|
|
83
|
+
raise FileNotFoundError(
|
|
84
|
+
f"baseline file not found: {options.baseline_path}. "
|
|
85
|
+
"Run `agentops eval run` once without `--baseline` first, then copy "
|
|
86
|
+
"`.agentops/results/latest/results.json` to the baseline path."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if config.execution == "cloud":
|
|
90
|
+
return _run_evaluation_cloud(config, options=options)
|
|
91
|
+
return _run_evaluation_local(config, options=options)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_evaluation_local(
|
|
95
|
+
config: AgentOpsConfig,
|
|
96
|
+
*,
|
|
97
|
+
options: RunOptions,
|
|
98
|
+
) -> RunResult:
|
|
99
|
+
"""Local execution: AgentOps invokes the agent + evaluators row-by-row."""
|
|
100
|
+
|
|
101
|
+
started_at = datetime.now(timezone.utc)
|
|
102
|
+
started_perf = time.perf_counter()
|
|
103
|
+
|
|
104
|
+
target = classify_agent(
|
|
105
|
+
options.agent_override or config.agent,
|
|
106
|
+
config.protocol,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
dataset_path = options.dataset_override or _resolve_dataset_path(config, options)
|
|
110
|
+
shape = detect_dataset_shape(dataset_path)
|
|
111
|
+
|
|
112
|
+
overrides = (
|
|
113
|
+
[override.name for override in config.evaluators] if config.evaluators else None
|
|
114
|
+
)
|
|
115
|
+
presets = select_evaluators(
|
|
116
|
+
target,
|
|
117
|
+
shape,
|
|
118
|
+
overrides=overrides,
|
|
119
|
+
threshold_metrics=config.thresholds.keys(),
|
|
120
|
+
)
|
|
121
|
+
user_thresholds = [
|
|
122
|
+
Threshold.from_expression(metric, expr)
|
|
123
|
+
for metric, expr in config.thresholds.items()
|
|
124
|
+
]
|
|
125
|
+
threshold_rules = merge_thresholds(presets, user_thresholds)
|
|
126
|
+
|
|
127
|
+
evaluator_runtimes = runtime.load_evaluators(presets)
|
|
128
|
+
|
|
129
|
+
progress = options.progress or (lambda _msg: None)
|
|
130
|
+
|
|
131
|
+
dataset_rows = list(_iter_dataset(dataset_path))
|
|
132
|
+
total = len(dataset_rows)
|
|
133
|
+
from agentops import __version__ as _agentops_version
|
|
134
|
+
py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
135
|
+
progress(
|
|
136
|
+
f"{style('agentops', 'bold', 'cyan')} {style(_agentops_version, 'cyan')} "
|
|
137
|
+
f"{style('|', 'dim')} python {py} "
|
|
138
|
+
f"{style('|', 'dim')} config: {style(options.config_path.name, 'cyan')}"
|
|
139
|
+
)
|
|
140
|
+
progress(
|
|
141
|
+
f"Loaded {style(str(total), 'bold')} row(s) from "
|
|
142
|
+
f"{style(dataset_path.name, 'cyan')}; running "
|
|
143
|
+
f"{style(str(len(presets)), 'bold')} evaluator(s) against "
|
|
144
|
+
f"{_friendly_target_kind(target.kind)}: {style(target.raw, 'bold')}."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
with telemetry.eval_run_span(
|
|
148
|
+
bundle_name=options.config_path.stem,
|
|
149
|
+
dataset_name=dataset_path.name,
|
|
150
|
+
backend_type=target.kind,
|
|
151
|
+
target=target.raw,
|
|
152
|
+
model=target.deployment,
|
|
153
|
+
agent_id=target.raw if target.kind.startswith("foundry") else None,
|
|
154
|
+
) as run_span:
|
|
155
|
+
rows: List[RowResult] = []
|
|
156
|
+
rules_by_metric = {rule.metric: rule for rule in threshold_rules}
|
|
157
|
+
for index, row in enumerate(dataset_rows):
|
|
158
|
+
rows.append(
|
|
159
|
+
_evaluate_row(
|
|
160
|
+
row=row,
|
|
161
|
+
index=index,
|
|
162
|
+
total=total,
|
|
163
|
+
target=target,
|
|
164
|
+
config=config,
|
|
165
|
+
evaluators=evaluator_runtimes,
|
|
166
|
+
timeout=options.timeout_seconds,
|
|
167
|
+
progress=progress,
|
|
168
|
+
rules_by_metric=rules_by_metric,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
aggregate = _aggregate_metrics(rows)
|
|
173
|
+
threshold_results = thresholds.evaluate(threshold_rules, aggregate)
|
|
174
|
+
summary = _summarize(rows, threshold_results)
|
|
175
|
+
telemetry.set_eval_run_result(
|
|
176
|
+
run_span,
|
|
177
|
+
passed=summary.overall_passed,
|
|
178
|
+
items_total=summary.items_total,
|
|
179
|
+
items_passed=summary.items_passed_all,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
finished_at = datetime.now(timezone.utc)
|
|
183
|
+
duration = time.perf_counter() - started_perf
|
|
184
|
+
|
|
185
|
+
result = RunResult(
|
|
186
|
+
started_at=started_at.isoformat(),
|
|
187
|
+
finished_at=finished_at.isoformat(),
|
|
188
|
+
duration_seconds=duration,
|
|
189
|
+
target=TargetInfo(
|
|
190
|
+
kind=target.kind,
|
|
191
|
+
raw=target.raw,
|
|
192
|
+
protocol=target.protocol,
|
|
193
|
+
name=target.name,
|
|
194
|
+
version=target.version,
|
|
195
|
+
url=target.url,
|
|
196
|
+
deployment=target.deployment,
|
|
197
|
+
),
|
|
198
|
+
dataset_path=str(dataset_path),
|
|
199
|
+
evaluators=[preset.name for preset in presets],
|
|
200
|
+
rows=rows,
|
|
201
|
+
aggregate_metrics=aggregate,
|
|
202
|
+
thresholds=threshold_results,
|
|
203
|
+
summary=summary,
|
|
204
|
+
config={
|
|
205
|
+
"version": config.version,
|
|
206
|
+
"agent": config.agent,
|
|
207
|
+
"thresholds": dict(config.thresholds),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if options.baseline_path is not None:
|
|
212
|
+
baseline = comparison_module.load_baseline(options.baseline_path)
|
|
213
|
+
result.comparison = comparison_module.build_comparison(
|
|
214
|
+
current=result,
|
|
215
|
+
baseline=baseline,
|
|
216
|
+
baseline_path=options.baseline_path,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
_persist(result, options.output_dir)
|
|
220
|
+
|
|
221
|
+
# Local execution only ever publishes to Classic Foundry. Cloud
|
|
222
|
+
# execution goes through _run_evaluation_cloud and never reaches here.
|
|
223
|
+
if config.publish_target() == "foundry":
|
|
224
|
+
_publish_to_foundry_safely(result, config, options.output_dir, progress=progress)
|
|
225
|
+
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _run_evaluation_cloud(
|
|
230
|
+
config: AgentOpsConfig,
|
|
231
|
+
*,
|
|
232
|
+
options: RunOptions,
|
|
233
|
+
) -> RunResult:
|
|
234
|
+
"""Cloud execution: Foundry invokes the agent + evaluators server-side.
|
|
235
|
+
|
|
236
|
+
The agent is invoked exactly once - on Foundry's side. AgentOps does
|
|
237
|
+
not run the row-by-row local loop. After the cloud run completes we
|
|
238
|
+
download the per-row ``output_items`` and reshape them into the same
|
|
239
|
+
:class:`RunResult` schema that local execution produces, so
|
|
240
|
+
``report.md`` and ``--baseline`` work identically.
|
|
241
|
+
"""
|
|
242
|
+
started_at = datetime.now(timezone.utc)
|
|
243
|
+
started_perf = time.perf_counter()
|
|
244
|
+
|
|
245
|
+
target = classify_agent(
|
|
246
|
+
options.agent_override or config.agent,
|
|
247
|
+
config.protocol,
|
|
248
|
+
)
|
|
249
|
+
if target.kind != "foundry_prompt":
|
|
250
|
+
raise ValueError(
|
|
251
|
+
"execution: cloud only supports Foundry prompt agents "
|
|
252
|
+
f"('name:version'); got target.kind={target.kind!r}."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
dataset_path = options.dataset_override or _resolve_dataset_path(config, options)
|
|
256
|
+
shape = detect_dataset_shape(dataset_path)
|
|
257
|
+
overrides = (
|
|
258
|
+
[override.name for override in config.evaluators] if config.evaluators else None
|
|
259
|
+
)
|
|
260
|
+
all_presets = select_evaluators(
|
|
261
|
+
target,
|
|
262
|
+
shape,
|
|
263
|
+
overrides=overrides,
|
|
264
|
+
threshold_metrics=config.thresholds.keys(),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Cloud execution runs server-side, so client-side runtime evaluators
|
|
268
|
+
# (e.g. avg_latency_seconds) cannot be measured. Excluding them is the
|
|
269
|
+
# right choice - otherwise their default thresholds would mark the run
|
|
270
|
+
# FAILED for a metric we never had a chance to observe.
|
|
271
|
+
presets = [p for p in all_presets if "runtime" not in p.categories]
|
|
272
|
+
skipped_runtime = [p.name for p in all_presets if "runtime" in p.categories]
|
|
273
|
+
|
|
274
|
+
user_thresholds = [
|
|
275
|
+
Threshold.from_expression(metric, expr)
|
|
276
|
+
for metric, expr in config.thresholds.items()
|
|
277
|
+
# Drop user-specified thresholds for runtime metrics too - they
|
|
278
|
+
# would otherwise fail with actual="missing".
|
|
279
|
+
if metric not in {p.score_key for p in all_presets if "runtime" in p.categories}
|
|
280
|
+
]
|
|
281
|
+
threshold_rules = merge_thresholds(presets, user_thresholds)
|
|
282
|
+
|
|
283
|
+
# Build a "shell" result that carries just enough metadata for the
|
|
284
|
+
# cloud publisher to map evaluator class names onto Azure AI evaluator
|
|
285
|
+
# testing criteria.
|
|
286
|
+
shell_target = TargetInfo(
|
|
287
|
+
kind=target.kind,
|
|
288
|
+
raw=target.raw,
|
|
289
|
+
protocol=target.protocol,
|
|
290
|
+
name=target.name,
|
|
291
|
+
version=target.version,
|
|
292
|
+
url=target.url,
|
|
293
|
+
deployment=target.deployment,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
progress = options.progress or (lambda _msg: None)
|
|
297
|
+
from agentops import __version__ as _agentops_version
|
|
298
|
+
py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
299
|
+
progress(
|
|
300
|
+
f"{style('agentops', 'bold', 'cyan')} {style(_agentops_version, 'cyan')} "
|
|
301
|
+
f"{style('|', 'dim')} python {py} "
|
|
302
|
+
f"{style('|', 'dim')} config: {style(options.config_path.name, 'cyan')}"
|
|
303
|
+
)
|
|
304
|
+
progress(
|
|
305
|
+
f"execution: {style('cloud', 'bold')} - Foundry will run the agent "
|
|
306
|
+
f"and {style(str(len(presets)), 'bold')} evaluator(s) server-side. "
|
|
307
|
+
f"Agent: {style(target.raw, 'bold')}."
|
|
308
|
+
)
|
|
309
|
+
if skipped_runtime:
|
|
310
|
+
progress(
|
|
311
|
+
f" (skipped client-side runtime evaluators: "
|
|
312
|
+
f"{', '.join(skipped_runtime)} - not measurable in cloud mode)"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
shell_result = RunResult(
|
|
316
|
+
started_at=started_at.isoformat(),
|
|
317
|
+
finished_at=started_at.isoformat(),
|
|
318
|
+
duration_seconds=0.0,
|
|
319
|
+
target=shell_target,
|
|
320
|
+
dataset_path=str(dataset_path),
|
|
321
|
+
evaluators=[preset.name for preset in presets],
|
|
322
|
+
rows=[],
|
|
323
|
+
aggregate_metrics={},
|
|
324
|
+
thresholds=[],
|
|
325
|
+
summary=_summarize([], []),
|
|
326
|
+
config={
|
|
327
|
+
"version": config.version,
|
|
328
|
+
"agent": config.agent,
|
|
329
|
+
"thresholds": dict(config.thresholds),
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
endpoint = config.project_endpoint or os.getenv("AZURE_AI_FOUNDRY_PROJECT_ENDPOINT")
|
|
334
|
+
if not endpoint:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
"execution: cloud requires either 'project_endpoint' in "
|
|
337
|
+
"agentops.yaml or the AZURE_AI_FOUNDRY_PROJECT_ENDPOINT env var."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
from agentops.pipeline import cloud_runner
|
|
341
|
+
from agentops.pipeline import cloud_results
|
|
342
|
+
|
|
343
|
+
with telemetry.eval_run_span(
|
|
344
|
+
bundle_name=options.config_path.stem,
|
|
345
|
+
dataset_name=dataset_path.name,
|
|
346
|
+
backend_type="foundry_cloud",
|
|
347
|
+
target=target.raw,
|
|
348
|
+
model=target.deployment,
|
|
349
|
+
agent_id=target.raw,
|
|
350
|
+
) as run_span:
|
|
351
|
+
published = cloud_runner.run_on_foundry_cloud(
|
|
352
|
+
shell_result,
|
|
353
|
+
dataset_path=dataset_path,
|
|
354
|
+
project_endpoint=endpoint,
|
|
355
|
+
dataset_sync=config.dataset_sync,
|
|
356
|
+
progress=progress,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if run_span is not None:
|
|
360
|
+
run_span.set_attribute("agentops.eval.execution", "cloud")
|
|
361
|
+
run_span.set_attribute("agentops.eval.cloud.eval_id", published.eval_id)
|
|
362
|
+
run_span.set_attribute("agentops.eval.cloud.run_id", published.run_id)
|
|
363
|
+
run_span.set_attribute("agentops.eval.cloud.status", published.status)
|
|
364
|
+
if published.report_url:
|
|
365
|
+
run_span.set_attribute(
|
|
366
|
+
"agentops.eval.cloud.report_url",
|
|
367
|
+
published.report_url,
|
|
368
|
+
)
|
|
369
|
+
if published.dataset:
|
|
370
|
+
run_span.set_attribute(
|
|
371
|
+
"agentops.eval.cloud.dataset.mode",
|
|
372
|
+
str(published.dataset.get("mode") or ""),
|
|
373
|
+
)
|
|
374
|
+
dataset_id = published.dataset.get("id")
|
|
375
|
+
if dataset_id:
|
|
376
|
+
run_span.set_attribute(
|
|
377
|
+
"agentops.eval.cloud.dataset.id",
|
|
378
|
+
str(dataset_id),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
rows = cloud_results.rows_from_cloud_output_items(published.output_items)
|
|
382
|
+
aggregate = _aggregate_metrics(rows)
|
|
383
|
+
threshold_results = thresholds.evaluate(threshold_rules, aggregate)
|
|
384
|
+
summary = _summarize(rows, threshold_results)
|
|
385
|
+
telemetry.set_eval_run_result(
|
|
386
|
+
run_span,
|
|
387
|
+
passed=summary.overall_passed,
|
|
388
|
+
items_total=summary.items_total,
|
|
389
|
+
items_passed=summary.items_passed_all,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
finished_at = datetime.now(timezone.utc)
|
|
393
|
+
duration = time.perf_counter() - started_perf
|
|
394
|
+
|
|
395
|
+
result = RunResult(
|
|
396
|
+
started_at=started_at.isoformat(),
|
|
397
|
+
finished_at=finished_at.isoformat(),
|
|
398
|
+
duration_seconds=duration,
|
|
399
|
+
target=shell_target,
|
|
400
|
+
dataset_path=str(dataset_path),
|
|
401
|
+
evaluators=[preset.name for preset in presets],
|
|
402
|
+
rows=rows,
|
|
403
|
+
aggregate_metrics=aggregate,
|
|
404
|
+
thresholds=threshold_results,
|
|
405
|
+
summary=summary,
|
|
406
|
+
config={
|
|
407
|
+
"version": config.version,
|
|
408
|
+
"agent": config.agent,
|
|
409
|
+
"thresholds": dict(config.thresholds),
|
|
410
|
+
"execution": "cloud",
|
|
411
|
+
"cloud_evaluation": {
|
|
412
|
+
"mode": "cloud",
|
|
413
|
+
"evaluation_name": published.evaluation_name,
|
|
414
|
+
"eval_id": published.eval_id,
|
|
415
|
+
"run_id": published.run_id,
|
|
416
|
+
"status": published.status,
|
|
417
|
+
"report_url": published.report_url,
|
|
418
|
+
"dataset": published.dataset,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if options.baseline_path is not None:
|
|
424
|
+
baseline = comparison_module.load_baseline(options.baseline_path)
|
|
425
|
+
result.comparison = comparison_module.build_comparison(
|
|
426
|
+
current=result,
|
|
427
|
+
baseline=baseline,
|
|
428
|
+
baseline_path=options.baseline_path,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
_persist(result, options.output_dir)
|
|
432
|
+
|
|
433
|
+
# Write cloud_evaluation.json next to the other artifacts for parity
|
|
434
|
+
# with the (now-removed) post-run cloud publish path.
|
|
435
|
+
cloud_meta_path = options.output_dir / "cloud_evaluation.json"
|
|
436
|
+
cloud_meta_path.write_text(
|
|
437
|
+
json.dumps(result.config["cloud_evaluation"], indent=2),
|
|
438
|
+
encoding="utf-8",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
progress(
|
|
442
|
+
f"Submitted to {style('New Foundry Evaluations', 'bold')}: "
|
|
443
|
+
f"{style(published.report_url or '(no portal URL)', 'cyan')}"
|
|
444
|
+
)
|
|
445
|
+
progress(
|
|
446
|
+
f" eval_id={published.eval_id} run_id={published.run_id} "
|
|
447
|
+
f"status={style(published.status, 'green' if published.status == 'completed' else 'yellow')} "
|
|
448
|
+
f"rows={len(rows)}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if not rows:
|
|
452
|
+
progress(
|
|
453
|
+
f"{style('WARNING', 'yellow')}: no per-row results were "
|
|
454
|
+
f"downloaded from Foundry; report.md will be minimal. The "
|
|
455
|
+
f"canonical view is the Foundry portal."
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return result
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _publish_to_foundry_safely(
|
|
462
|
+
result: RunResult,
|
|
463
|
+
config: AgentOpsConfig,
|
|
464
|
+
output_dir: Path,
|
|
465
|
+
*,
|
|
466
|
+
progress: Optional[Callable[[str], None]] = None,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Best-effort Classic Foundry publish. Failures are logged, never fatal."""
|
|
469
|
+
if config.publish_target() != "foundry":
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
notify = progress or (lambda _msg: None)
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
published = publisher.publish_to_foundry(
|
|
476
|
+
result,
|
|
477
|
+
project_endpoint=config.project_endpoint,
|
|
478
|
+
)
|
|
479
|
+
except Exception as exc: # noqa: BLE001
|
|
480
|
+
logger.debug("foundry publish failed: %s", exc)
|
|
481
|
+
notify(
|
|
482
|
+
f"{style('publish foundry FAILED', 'red')}: {exc}. "
|
|
483
|
+
f"Local results.json is the source of truth."
|
|
484
|
+
)
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
cloud_meta_path = output_dir / "cloud_evaluation.json"
|
|
488
|
+
cloud_meta_path.write_text(
|
|
489
|
+
json.dumps(
|
|
490
|
+
{
|
|
491
|
+
"mode": "classic",
|
|
492
|
+
"evaluation_name": published.evaluation_name,
|
|
493
|
+
"report_url": published.studio_url,
|
|
494
|
+
},
|
|
495
|
+
indent=2,
|
|
496
|
+
),
|
|
497
|
+
encoding="utf-8",
|
|
498
|
+
)
|
|
499
|
+
notify(
|
|
500
|
+
f"Published to {style('Classic Foundry Evaluations', 'bold')}: "
|
|
501
|
+
f"{style(published.studio_url, 'cyan')}"
|
|
502
|
+
)
|
|
503
|
+
notify(
|
|
504
|
+
f"Tip: to run server-side in the {style('New Foundry', 'bold')} "
|
|
505
|
+
f"experience, set 'execution: cloud' + 'publish: true' (preview)."
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def exit_code_from(result: RunResult) -> int:
|
|
510
|
+
"""Translate a run's outcome into the ``agentops`` CLI contract.
|
|
511
|
+
|
|
512
|
+
* ``0`` - success, all thresholds passed.
|
|
513
|
+
* ``2`` - invocations succeeded but a threshold failed.
|
|
514
|
+
* ``1`` - runtime errors are raised as exceptions before this is called.
|
|
515
|
+
"""
|
|
516
|
+
return 0 if result.summary.overall_passed else 2
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
# Dataset
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _resolve_dataset_path(config: AgentOpsConfig, options: RunOptions) -> Path:
|
|
525
|
+
candidate = config.dataset
|
|
526
|
+
if candidate.is_absolute() and candidate.exists():
|
|
527
|
+
return candidate
|
|
528
|
+
base = options.config_path.parent
|
|
529
|
+
resolved = (base / candidate).resolve()
|
|
530
|
+
if not resolved.exists():
|
|
531
|
+
raise FileNotFoundError(f"dataset not found: {resolved}")
|
|
532
|
+
return resolved
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
_FRIENDLY_KIND = {
|
|
536
|
+
"foundry_prompt": "foundry agent",
|
|
537
|
+
"foundry_hosted": "foundry agent (hosted)",
|
|
538
|
+
"http_json": "http endpoint",
|
|
539
|
+
"model_direct": "model deployment",
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _friendly_target_kind(kind: str) -> str:
|
|
544
|
+
return _FRIENDLY_KIND.get(kind, kind)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _iter_dataset(path: Path) -> Iterable[Dict[str, Any]]:
|
|
548
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
549
|
+
for line_number, line in enumerate(handle, start=1):
|
|
550
|
+
stripped = line.strip()
|
|
551
|
+
if not stripped:
|
|
552
|
+
continue
|
|
553
|
+
try:
|
|
554
|
+
row = json.loads(stripped)
|
|
555
|
+
except json.JSONDecodeError as exc:
|
|
556
|
+
raise ValueError(
|
|
557
|
+
f"{path}: invalid JSON on line {line_number}: {exc}"
|
|
558
|
+
) from exc
|
|
559
|
+
if not isinstance(row, dict):
|
|
560
|
+
raise ValueError(
|
|
561
|
+
f"{path}: line {line_number} is not a JSON object"
|
|
562
|
+
)
|
|
563
|
+
yield row
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# ---------------------------------------------------------------------------
|
|
567
|
+
# Per-row execution
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _metric_passes(rule: Threshold, value: float) -> bool:
|
|
572
|
+
if rule.value is None or rule.criteria in {"true", "false"}:
|
|
573
|
+
return True
|
|
574
|
+
target_v = float(rule.value)
|
|
575
|
+
c = rule.criteria
|
|
576
|
+
if c == ">=":
|
|
577
|
+
return value >= target_v
|
|
578
|
+
if c == ">":
|
|
579
|
+
return value > target_v
|
|
580
|
+
if c == "<=":
|
|
581
|
+
return value <= target_v
|
|
582
|
+
if c == "<":
|
|
583
|
+
return value < target_v
|
|
584
|
+
if c == "==":
|
|
585
|
+
return value == target_v
|
|
586
|
+
return True
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _evaluate_row(
|
|
590
|
+
*,
|
|
591
|
+
row: Dict[str, Any],
|
|
592
|
+
index: int,
|
|
593
|
+
total: int,
|
|
594
|
+
target,
|
|
595
|
+
config: AgentOpsConfig,
|
|
596
|
+
evaluators: List[runtime.EvaluatorRuntime],
|
|
597
|
+
timeout: float,
|
|
598
|
+
progress: Callable[[str], None],
|
|
599
|
+
rules_by_metric: Optional[Dict[str, Threshold]] = None,
|
|
600
|
+
) -> RowResult:
|
|
601
|
+
label = style(f"[{index + 1}/{total}]", "dim")
|
|
602
|
+
preview = str(row.get("input", "")).strip().replace("\n", " ")
|
|
603
|
+
if len(preview) > 80:
|
|
604
|
+
preview = preview[:77] + "..."
|
|
605
|
+
progress(f"{label} invoking target: {preview!r}")
|
|
606
|
+
expected = row.get("expected")
|
|
607
|
+
expected_text = str(expected) if expected is not None else None
|
|
608
|
+
|
|
609
|
+
with telemetry.eval_item_span(
|
|
610
|
+
row_index=index,
|
|
611
|
+
input_text=str(row.get("input", "")),
|
|
612
|
+
expected_text=expected_text,
|
|
613
|
+
) as item_span:
|
|
614
|
+
try:
|
|
615
|
+
with telemetry.agent_invoke_span(
|
|
616
|
+
target="agent" if target.kind.startswith("foundry") else "model",
|
|
617
|
+
model=target.deployment,
|
|
618
|
+
agent_id=target.raw if target.kind.startswith("foundry") else None,
|
|
619
|
+
agent_name=target.name,
|
|
620
|
+
agent_version=target.version,
|
|
621
|
+
) as invoke_span:
|
|
622
|
+
invocation = invocations.invoke(target, config, row, timeout=timeout)
|
|
623
|
+
telemetry.set_agent_invoke_result(
|
|
624
|
+
invoke_span,
|
|
625
|
+
response_model=target.deployment,
|
|
626
|
+
)
|
|
627
|
+
except Exception as exc: # noqa: BLE001
|
|
628
|
+
telemetry.set_eval_item_result(item_span, passed=False)
|
|
629
|
+
logger.debug("row %d invocation failed: %s", index, exc)
|
|
630
|
+
progress(f"{label} {style('invocation FAILED', 'bold', 'red')}: {exc}")
|
|
631
|
+
return RowResult(
|
|
632
|
+
row_index=index,
|
|
633
|
+
input=str(row.get("input", "")),
|
|
634
|
+
expected=row.get("expected"),
|
|
635
|
+
response="",
|
|
636
|
+
context=row.get("context"),
|
|
637
|
+
error=str(exc),
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
tool_count = len(invocation.tool_calls) if invocation.tool_calls else 0
|
|
641
|
+
progress(
|
|
642
|
+
f"{label} replied in {style(f'{invocation.latency_seconds:.2f}s', 'cyan')} "
|
|
643
|
+
f"({tool_count} tool call(s)); scoring..."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
metrics: List[RowMetric] = []
|
|
647
|
+
for evaluator in evaluators:
|
|
648
|
+
metric = runtime.run_evaluator(
|
|
649
|
+
evaluator,
|
|
650
|
+
row=row,
|
|
651
|
+
response=invocation.response,
|
|
652
|
+
latency_seconds=invocation.latency_seconds,
|
|
653
|
+
actual_tool_calls=invocation.tool_calls,
|
|
654
|
+
)
|
|
655
|
+
metrics.append(metric)
|
|
656
|
+
|
|
657
|
+
rule = (rules_by_metric or {}).get(metric.name)
|
|
658
|
+
metric_passed = (
|
|
659
|
+
None
|
|
660
|
+
if metric.value is None or rule is None
|
|
661
|
+
else _metric_passes(rule, float(metric.value))
|
|
662
|
+
)
|
|
663
|
+
telemetry.record_evaluator_span(
|
|
664
|
+
evaluator_name=evaluator.preset.name,
|
|
665
|
+
builtin_name=metric.name,
|
|
666
|
+
source=(
|
|
667
|
+
"local"
|
|
668
|
+
if evaluator.preset.class_name == "_latency"
|
|
669
|
+
else "azure-ai-evaluation"
|
|
670
|
+
),
|
|
671
|
+
score=float(metric.value) if metric.value is not None else 0.0,
|
|
672
|
+
threshold=rule.value if rule is not None else None,
|
|
673
|
+
criteria=rule.criteria if rule is not None else None,
|
|
674
|
+
passed=metric_passed,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
telemetry.set_eval_item_result(
|
|
678
|
+
item_span,
|
|
679
|
+
passed=all(metric.error is None for metric in metrics),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
rules = rules_by_metric or {}
|
|
683
|
+
|
|
684
|
+
def _format_metric(m: RowMetric) -> str:
|
|
685
|
+
if isinstance(m.value, (int, float)):
|
|
686
|
+
rule = rules.get(m.name)
|
|
687
|
+
text = f"{m.value:.2f}"
|
|
688
|
+
if rule is None:
|
|
689
|
+
# No user threshold for this metric: keep value neutral
|
|
690
|
+
# so the line stays readable.
|
|
691
|
+
return f"{m.name}={text}"
|
|
692
|
+
color = "green" if _metric_passes(rule, float(m.value)) else "red"
|
|
693
|
+
return f"{m.name}={style(text, color)}"
|
|
694
|
+
if m.error:
|
|
695
|
+
return f"{m.name}={style('ERR', 'red')}"
|
|
696
|
+
return f"{m.name}={style('n/a', 'dim')}"
|
|
697
|
+
|
|
698
|
+
scored = ", ".join(_format_metric(m) for m in metrics)
|
|
699
|
+
progress(f"{label} scored: {scored}")
|
|
700
|
+
|
|
701
|
+
return RowResult(
|
|
702
|
+
row_index=index,
|
|
703
|
+
input=str(row.get("input", "")),
|
|
704
|
+
expected=row.get("expected"),
|
|
705
|
+
response=invocation.response,
|
|
706
|
+
context=row.get("context"),
|
|
707
|
+
latency_seconds=invocation.latency_seconds,
|
|
708
|
+
tool_calls=invocation.tool_calls,
|
|
709
|
+
metrics=metrics,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
# Aggregation
|
|
715
|
+
# ---------------------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _aggregate_metrics(rows: List[RowResult]) -> Dict[str, float]:
|
|
719
|
+
by_metric: Dict[str, List[float]] = {}
|
|
720
|
+
for row in rows:
|
|
721
|
+
for metric in row.metrics:
|
|
722
|
+
if metric.value is None:
|
|
723
|
+
continue
|
|
724
|
+
by_metric.setdefault(metric.name, []).append(metric.value)
|
|
725
|
+
aggregate: Dict[str, float] = {}
|
|
726
|
+
for name, values in by_metric.items():
|
|
727
|
+
if values:
|
|
728
|
+
aggregate[name] = statistics.fmean(values)
|
|
729
|
+
return aggregate
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _summarize(
|
|
733
|
+
rows: List[RowResult],
|
|
734
|
+
threshold_results,
|
|
735
|
+
) -> RunSummary:
|
|
736
|
+
items_total = len(rows)
|
|
737
|
+
items_passed_all = sum(
|
|
738
|
+
1
|
|
739
|
+
for row in rows
|
|
740
|
+
if row.error is None and all(m.error is None for m in row.metrics)
|
|
741
|
+
)
|
|
742
|
+
items_pass_rate = items_passed_all / items_total if items_total else 0.0
|
|
743
|
+
thresholds_total = len(threshold_results)
|
|
744
|
+
thresholds_passed = sum(1 for t in threshold_results if t.passed)
|
|
745
|
+
threshold_pass_rate = (
|
|
746
|
+
thresholds_passed / thresholds_total if thresholds_total else 1.0
|
|
747
|
+
)
|
|
748
|
+
overall = items_total > 0 and threshold_pass_rate == 1.0 and items_passed_all > 0
|
|
749
|
+
return RunSummary(
|
|
750
|
+
items_total=items_total,
|
|
751
|
+
items_passed_all=items_passed_all,
|
|
752
|
+
items_pass_rate=items_pass_rate,
|
|
753
|
+
thresholds_total=thresholds_total,
|
|
754
|
+
thresholds_passed=thresholds_passed,
|
|
755
|
+
threshold_pass_rate=threshold_pass_rate,
|
|
756
|
+
overall_passed=overall,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# ---------------------------------------------------------------------------
|
|
761
|
+
# Persistence
|
|
762
|
+
# ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _persist(result: RunResult, output_dir: Path) -> None:
|
|
766
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
767
|
+
results_path = output_dir / "results.json"
|
|
768
|
+
report_path = output_dir / "report.md"
|
|
769
|
+
|
|
770
|
+
payload = result.model_dump(mode="json")
|
|
771
|
+
results_path.write_text(
|
|
772
|
+
json.dumps(payload, indent=2, ensure_ascii=False),
|
|
773
|
+
encoding="utf-8",
|
|
774
|
+
)
|
|
775
|
+
report_path.write_text(reporter.render(result), encoding="utf-8")
|