fluxloop-cli 0.2.31__tar.gz → 0.2.33__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.
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/PKG-INFO +2 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/__init__.py +1 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/evaluate.py +88 -6
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/parse.py +5 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/artifacts.py +43 -2
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/config.py +41 -2
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/aggregator.py +89 -15
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/generator.py +188 -8
- fluxloop_cli-0.2.33/fluxloop_cli/evaluation/report/pdf_exporter.py +71 -0
- fluxloop_cli-0.2.33/fluxloop_cli/evaluation/report/pipeline.py +168 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/renderer.py +80 -19
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/rules.py +2 -111
- fluxloop_cli-0.2.33/fluxloop_cli/evaluation/templates/report.html.j2 +7072 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/input_generator.py +15 -18
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/runner.py +7 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/templates.py +9 -7
- fluxloop_cli-0.2.33/fluxloop_cli/token_usage.py +182 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/PKG-INFO +2 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/SOURCES.txt +3 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/requires.txt +1 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/pyproject.toml +8 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_evaluate_command.py +14 -5
- fluxloop_cli-0.2.31/fluxloop_cli/evaluation/report/pipeline.py +0 -83
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/README.md +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/arg_binder.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/config.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/doctor.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/init.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/record.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/run.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/config_loader.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/config_schema.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/constants.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/environment.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/core.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/success.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/llm.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/base.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/main.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/project_paths.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/testing/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/testing/pytest_plugin.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/testing/types.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/setup.cfg +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_analysis_recommendations.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_arg_binder.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_evaluation_llm.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_init_pytest_template.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_prompt_library.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_pytest_plugin.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_run_command.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_runner_multi_turn.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_success_criteria.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_target_loader.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_testing_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fluxloop-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.33
|
|
4
4
|
Summary: FluxLoop CLI for running agent simulations
|
|
5
5
|
Author-email: FluxLoop Team <team@fluxloop.dev>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -28,6 +28,7 @@ Requires-Dist: python-dotenv>=1.0.0
|
|
|
28
28
|
Requires-Dist: fluxloop>=0.1.0
|
|
29
29
|
Requires-Dist: ruamel.yaml>=0.17.0
|
|
30
30
|
Requires-Dist: Jinja2>=3.0
|
|
31
|
+
Requires-Dist: weasyprint>=61.0
|
|
31
32
|
Provides-Extra: dev
|
|
32
33
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
33
34
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import shutil
|
|
9
9
|
from dataclasses import asdict
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Optional
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
12
|
|
|
13
13
|
import typer
|
|
14
14
|
import yaml
|
|
@@ -16,11 +16,12 @@ from rich.console import Console
|
|
|
16
16
|
|
|
17
17
|
from ..environment import load_env_chain
|
|
18
18
|
from ..evaluation import load_evaluation_config
|
|
19
|
-
from ..evaluation.artifacts import load_per_trace_records
|
|
19
|
+
from ..evaluation.artifacts import load_per_trace_records, load_trace_summary_records
|
|
20
20
|
from ..evaluation.report.pipeline import ReportPipeline
|
|
21
21
|
|
|
22
22
|
console = Console()
|
|
23
23
|
app = typer.Typer(help="Evaluate experiment outputs and generate interactive reports.")
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _load_yaml_file(path: Optional[Path]) -> dict:
|
|
@@ -62,6 +63,72 @@ def _prepare_output_directory(path: Path, overwrite: bool) -> None:
|
|
|
62
63
|
path.mkdir(parents=True, exist_ok=True)
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
def _load_generated_inputs_data(input_config: Dict[str, any], project_root: Path) -> Dict[str, any]:
|
|
67
|
+
"""
|
|
68
|
+
Load generated inputs (variations) from the configured inputs file, if available.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
inputs_file_value = input_config.get("inputs_file") or "inputs/generated.yaml"
|
|
72
|
+
inputs_path = Path(inputs_file_value)
|
|
73
|
+
if not inputs_path.is_absolute():
|
|
74
|
+
inputs_path = (project_root / inputs_path).resolve()
|
|
75
|
+
|
|
76
|
+
if not inputs_path.exists():
|
|
77
|
+
logger.debug("Generated inputs file not found at %s", inputs_path)
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with inputs_path.open("r", encoding="utf-8") as handle:
|
|
82
|
+
payload = yaml.safe_load(handle) or {}
|
|
83
|
+
except Exception as exc: # noqa: BLE001
|
|
84
|
+
logger.warning("Failed to load generated inputs file %s: %s", inputs_path, exc)
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
inputs_list: List[Dict[str, any]] = []
|
|
88
|
+
generation_cfg: Dict[str, any] = {}
|
|
89
|
+
if isinstance(payload, dict):
|
|
90
|
+
inputs_list = payload.get("inputs") or payload.get("variations") or []
|
|
91
|
+
generation_cfg = payload.get("generation_config") or {}
|
|
92
|
+
elif isinstance(payload, list):
|
|
93
|
+
inputs_list = payload
|
|
94
|
+
else:
|
|
95
|
+
logger.debug("Generated inputs file %s did not contain a supported structure", inputs_path)
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
variations: List[Dict[str, str]] = []
|
|
99
|
+
for entry in inputs_list:
|
|
100
|
+
if not isinstance(entry, dict):
|
|
101
|
+
continue
|
|
102
|
+
text = entry.get("input")
|
|
103
|
+
if not text:
|
|
104
|
+
continue
|
|
105
|
+
metadata = entry.get("metadata") or {}
|
|
106
|
+
persona = entry.get("persona") or metadata.get("persona")
|
|
107
|
+
strategy = entry.get("strategy") or metadata.get("variation_strategy") or metadata.get("strategy")
|
|
108
|
+
|
|
109
|
+
variations.append(
|
|
110
|
+
{
|
|
111
|
+
"persona": (persona or "unknown").strip(),
|
|
112
|
+
"strategy": (strategy or "base").strip(),
|
|
113
|
+
"input": text.strip(),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
generator_model = generation_cfg.get("model") or generation_cfg.get("generator_model")
|
|
118
|
+
provider = generation_cfg.get("provider")
|
|
119
|
+
if generator_model and provider and "/" not in str(generator_model):
|
|
120
|
+
generator_model = f"{provider}/{generator_model}"
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"path": str(inputs_path),
|
|
124
|
+
"variations": variations,
|
|
125
|
+
"generator_model": generator_model or input_config.get("input_generation", {})
|
|
126
|
+
.get("llm", {})
|
|
127
|
+
.get("model"),
|
|
128
|
+
"strategies": generation_cfg.get("strategies") or input_config.get("variation_strategies", []),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
65
132
|
@app.command()
|
|
66
133
|
def experiment(
|
|
67
134
|
experiment_dir: Path = typer.Argument(
|
|
@@ -120,6 +187,7 @@ def experiment(
|
|
|
120
187
|
raise typer.BadParameter(f"Experiment directory not found: {resolved_experiment_dir}")
|
|
121
188
|
|
|
122
189
|
config_path = config.resolve() if config.is_absolute() else (Path.cwd() / config).resolve()
|
|
190
|
+
project_root = _resolve_project_root(config_path)
|
|
123
191
|
|
|
124
192
|
if per_trace is not None:
|
|
125
193
|
per_trace_path = per_trace.resolve() if per_trace.is_absolute() else (Path.cwd() / per_trace).resolve()
|
|
@@ -127,10 +195,15 @@ def experiment(
|
|
|
127
195
|
per_trace_path = resolved_experiment_dir / "per_trace_analysis" / "per_trace.jsonl"
|
|
128
196
|
|
|
129
197
|
per_trace_records = load_per_trace_records(resolved_experiment_dir, per_trace_path)
|
|
130
|
-
|
|
131
|
-
if not
|
|
198
|
+
trace_records = [record.trace for record in per_trace_records]
|
|
199
|
+
if not trace_records:
|
|
132
200
|
raise typer.BadParameter("No traces found in per-trace artifacts.")
|
|
133
201
|
|
|
202
|
+
trace_summary_path = resolved_experiment_dir / "trace_summary.jsonl"
|
|
203
|
+
trace_summaries = load_trace_summary_records(resolved_experiment_dir, trace_summary_path)
|
|
204
|
+
if not trace_summaries:
|
|
205
|
+
raise typer.BadParameter("No traces found in trace summary artifacts.")
|
|
206
|
+
|
|
134
207
|
try:
|
|
135
208
|
evaluation_config = load_evaluation_config(config_path)
|
|
136
209
|
except FileNotFoundError as exc:
|
|
@@ -160,11 +233,13 @@ def experiment(
|
|
|
160
233
|
|
|
161
234
|
input_config = _load_yaml_file(input_config_path)
|
|
162
235
|
project_config = _load_yaml_file(project_config_path)
|
|
236
|
+
generated_inputs = _load_generated_inputs_data(input_config, project_root)
|
|
163
237
|
|
|
164
238
|
config_bundle = {
|
|
165
239
|
"name": project_config.get("name") or resolved_experiment_dir.name,
|
|
166
240
|
"evaluation": asdict(evaluation_config),
|
|
167
241
|
"input": input_config,
|
|
242
|
+
"generated_inputs": generated_inputs,
|
|
168
243
|
}
|
|
169
244
|
|
|
170
245
|
pipeline = ReportPipeline(
|
|
@@ -177,12 +252,19 @@ def experiment(
|
|
|
177
252
|
f"📊 Evaluating experiment at [cyan]{resolved_experiment_dir}[/cyan]",
|
|
178
253
|
f"⚙️ Config: [magenta]{config_path}[/magenta]",
|
|
179
254
|
f"🧵 Per-trace data: [blue]{per_trace_path}[/blue]",
|
|
255
|
+
f"📄 Trace summary: [blue]{trace_summary_path}[/blue]",
|
|
180
256
|
f"📁 Output: [green]{output_dir}[/green]",
|
|
181
257
|
]
|
|
182
258
|
console.print("\n".join(message_lines))
|
|
183
259
|
|
|
184
|
-
|
|
185
|
-
console.print(f"\n✅ Report ready: [bold cyan]{
|
|
260
|
+
artifacts = asyncio.run(pipeline.run(trace_records, trace_summaries))
|
|
261
|
+
console.print(f"\n✅ Report ready: [bold cyan]{artifacts.html_path}[/bold cyan]")
|
|
262
|
+
if artifacts.pdf_path:
|
|
263
|
+
console.print(f"🖨️ PDF ready: [bold green]{artifacts.pdf_path}[/bold green]")
|
|
264
|
+
else:
|
|
265
|
+
console.print(
|
|
266
|
+
"⚠️ PDF export skipped (WeasyPrint is unavailable or PDF rendering failed)."
|
|
267
|
+
)
|
|
186
268
|
|
|
187
269
|
|
|
188
270
|
|
|
@@ -78,6 +78,7 @@ class TraceSummary:
|
|
|
78
78
|
conversation: Optional[List[Dict[str, Any]]] = None
|
|
79
79
|
conversation_state: Optional[Dict[str, Any]] = None
|
|
80
80
|
termination_reason: Optional[str] = None
|
|
81
|
+
token_usage: Optional[Dict[str, Any]] = None
|
|
81
82
|
|
|
82
83
|
def to_payload(self) -> dict:
|
|
83
84
|
"""Return a JSON-serialisable representation of the summary entry."""
|
|
@@ -94,6 +95,7 @@ class TraceSummary:
|
|
|
94
95
|
"conversation": self.conversation,
|
|
95
96
|
"conversation_state": self.conversation_state,
|
|
96
97
|
"termination_reason": self.termination_reason,
|
|
98
|
+
"token_usage": self.token_usage,
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
|
|
@@ -173,6 +175,7 @@ def _load_trace_summaries(path: Path) -> Iterable[TraceSummary]:
|
|
|
173
175
|
conversation=payload.get("conversation"),
|
|
174
176
|
conversation_state=payload.get("conversation_state"),
|
|
175
177
|
termination_reason=payload.get("termination_reason"),
|
|
178
|
+
token_usage=payload.get("token_usage"),
|
|
176
179
|
raw=payload,
|
|
177
180
|
)
|
|
178
181
|
|
|
@@ -313,6 +316,8 @@ def _build_structured_record(
|
|
|
313
316
|
"timeline": timeline,
|
|
314
317
|
"conversation": trace.conversation or summary_payload.get("conversation") or [],
|
|
315
318
|
}
|
|
319
|
+
if trace.token_usage:
|
|
320
|
+
record["token_usage"] = trace.token_usage
|
|
316
321
|
|
|
317
322
|
def _maybe_decode_content(value: Any) -> Any:
|
|
318
323
|
if isinstance(value, str):
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Utilities for working with structured per-trace
|
|
2
|
+
Utilities for working with structured experiment artifacts (per-trace + summaries).
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
@@ -31,6 +31,10 @@ def _default_per_trace_path(experiment_dir: Path) -> Path:
|
|
|
31
31
|
return experiment_dir / "per_trace_analysis" / "per_trace.jsonl"
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def _default_trace_summary_path(experiment_dir: Path) -> Path:
|
|
35
|
+
return experiment_dir / "trace_summary.jsonl"
|
|
36
|
+
|
|
37
|
+
|
|
34
38
|
def load_per_trace_records(
|
|
35
39
|
experiment_dir: Path,
|
|
36
40
|
source_path: Optional[Path] = None,
|
|
@@ -70,5 +74,42 @@ def load_per_trace_records(
|
|
|
70
74
|
return records
|
|
71
75
|
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
def load_trace_summary_records(
|
|
78
|
+
experiment_dir: Path,
|
|
79
|
+
source_path: Optional[Path] = None,
|
|
80
|
+
) -> List[Dict[str, Any]]:
|
|
81
|
+
"""
|
|
82
|
+
Load trace summary entries produced by `fluxloop run`.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of dictionaries mirroring each line inside ``trace_summary.jsonl``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
path = source_path or _default_trace_summary_path(experiment_dir)
|
|
89
|
+
if not path.exists():
|
|
90
|
+
raise FileNotFoundError(
|
|
91
|
+
f"Trace summary artifacts not found at {path}. "
|
|
92
|
+
"Ensure `fluxloop run` completed successfully."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
records: List[Dict[str, Any]] = []
|
|
96
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
97
|
+
for line_no, line in enumerate(handle, start=1):
|
|
98
|
+
stripped = line.strip()
|
|
99
|
+
if not stripped:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
payload = json.loads(stripped)
|
|
103
|
+
except json.JSONDecodeError as exc:
|
|
104
|
+
raise ValueError(f"Invalid JSON in {path} at line {line_no}: {exc}") from exc
|
|
105
|
+
if not isinstance(payload, dict):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Trace summary entry at line {line_no} must be a JSON object, "
|
|
108
|
+
f"but got {type(payload).__name__}"
|
|
109
|
+
)
|
|
110
|
+
records.append(payload)
|
|
111
|
+
|
|
112
|
+
return records
|
|
113
|
+
|
|
74
114
|
|
|
115
|
+
__all__ = ["PerTraceRecord", "load_per_trace_records", "load_trace_summary_records"]
|
|
@@ -229,6 +229,8 @@ class EvaluationConfig:
|
|
|
229
229
|
additional_analysis: AdditionalAnalysisConfig = field(default_factory=AdditionalAnalysisConfig)
|
|
230
230
|
report: ReportConfig = field(default_factory=ReportConfig)
|
|
231
231
|
advanced: AdvancedConfig = field(default_factory=AdvancedConfig)
|
|
232
|
+
metrics: Dict[str, Any] = field(default_factory=dict)
|
|
233
|
+
efficiency: Dict[str, Any] = field(default_factory=dict)
|
|
232
234
|
|
|
233
235
|
def set_source_dir(self, source_dir: Path) -> None:
|
|
234
236
|
"""Remember the directory where this config file was loaded from."""
|
|
@@ -383,12 +385,45 @@ def _parse_limits(raw: Any) -> LimitsConfig:
|
|
|
383
385
|
)
|
|
384
386
|
|
|
385
387
|
|
|
388
|
+
def _parse_metrics(raw: Any) -> Dict[str, Any]:
|
|
389
|
+
if raw is None:
|
|
390
|
+
return {}
|
|
391
|
+
if not isinstance(raw, dict):
|
|
392
|
+
raise ValueError("metrics must be a mapping")
|
|
393
|
+
normalized: Dict[str, Any] = {}
|
|
394
|
+
for key, value in raw.items():
|
|
395
|
+
if not isinstance(value, dict):
|
|
396
|
+
raise ValueError(f"metrics.{key} must be a mapping")
|
|
397
|
+
normalized[key] = dict(value)
|
|
398
|
+
return normalized
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _parse_efficiency(raw: Any) -> Dict[str, Any]:
|
|
402
|
+
if raw is None:
|
|
403
|
+
return {}
|
|
404
|
+
if not isinstance(raw, dict):
|
|
405
|
+
raise ValueError("efficiency must be a mapping")
|
|
406
|
+
normalized: Dict[str, Any] = {}
|
|
407
|
+
for key, value in raw.items():
|
|
408
|
+
if not isinstance(value, dict):
|
|
409
|
+
raise ValueError(f"efficiency.{key} must be a mapping")
|
|
410
|
+
normalized[key] = dict(value)
|
|
411
|
+
return normalized
|
|
412
|
+
|
|
413
|
+
|
|
386
414
|
def _parse_evaluation_goal(raw: Any) -> EvaluationGoalConfig:
|
|
387
415
|
if raw is None:
|
|
388
416
|
return EvaluationGoalConfig()
|
|
417
|
+
|
|
418
|
+
if isinstance(raw, str):
|
|
419
|
+
# Backwards compatibility for older templates that used a bare string.
|
|
420
|
+
return EvaluationGoalConfig(text=raw.strip())
|
|
421
|
+
|
|
389
422
|
if not isinstance(raw, dict):
|
|
390
|
-
raise ValueError("evaluation_goal must be a mapping")
|
|
391
|
-
|
|
423
|
+
raise ValueError("evaluation_goal must be a mapping or string")
|
|
424
|
+
|
|
425
|
+
text_value = raw.get("text")
|
|
426
|
+
text = str(text_value) if text_value is not None else ""
|
|
392
427
|
return EvaluationGoalConfig(text=text)
|
|
393
428
|
|
|
394
429
|
|
|
@@ -711,6 +746,8 @@ def load_evaluation_config(path: Path) -> EvaluationConfig:
|
|
|
711
746
|
additional_analysis = _parse_additional_analysis(data.get("additional_analysis"))
|
|
712
747
|
report = _parse_report(data.get("report"))
|
|
713
748
|
advanced = _parse_advanced(data.get("advanced"))
|
|
749
|
+
metrics = _parse_metrics(data.get("metrics"))
|
|
750
|
+
efficiency = _parse_efficiency(data.get("efficiency"))
|
|
714
751
|
|
|
715
752
|
config_dir = path.parent.resolve()
|
|
716
753
|
|
|
@@ -723,6 +760,8 @@ def load_evaluation_config(path: Path) -> EvaluationConfig:
|
|
|
723
760
|
additional_analysis=additional_analysis,
|
|
724
761
|
report=report,
|
|
725
762
|
advanced=advanced,
|
|
763
|
+
metrics=metrics,
|
|
764
|
+
efficiency=efficiency,
|
|
726
765
|
)
|
|
727
766
|
|
|
728
767
|
config.set_source_dir(config_dir)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Rule-based aggregation and statistics for evaluation reports.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import logging
|
|
6
7
|
import statistics
|
|
7
8
|
from datetime import datetime
|
|
@@ -115,6 +116,7 @@ class StatsAggregator:
|
|
|
115
116
|
if not conversation and "conversation_state" in trace_summary:
|
|
116
117
|
conversation = trace_summary.get("conversation_state", {}).get("turns", [])
|
|
117
118
|
|
|
119
|
+
raw_output = trace_summary.get("output") or trace_summary.get("final_output")
|
|
118
120
|
return {
|
|
119
121
|
"trace_id": pt_result.get("trace_id"),
|
|
120
122
|
"persona": trace_summary.get("persona", "unknown"),
|
|
@@ -124,7 +126,7 @@ class StatsAggregator:
|
|
|
124
126
|
"overall_eval": self._classify_overall_success(metrics),
|
|
125
127
|
"primary_issue": self._find_primary_issue(metrics),
|
|
126
128
|
"duration_ms": trace_summary.get("duration_ms", 0),
|
|
127
|
-
"output_tokens": self.
|
|
129
|
+
"output_tokens": self._estimate_output_tokens(conversation, raw_output),
|
|
128
130
|
"conversation_turns": self._count_conversation_turns(conversation),
|
|
129
131
|
}
|
|
130
132
|
|
|
@@ -174,14 +176,34 @@ class StatsAggregator:
|
|
|
174
176
|
return metric
|
|
175
177
|
return None
|
|
176
178
|
|
|
177
|
-
def
|
|
179
|
+
def _estimate_output_tokens(
|
|
180
|
+
self,
|
|
181
|
+
conversation: List[Dict[str, Any]],
|
|
182
|
+
raw_output: Optional[Any],
|
|
183
|
+
) -> int:
|
|
184
|
+
"""Approximate output tokens from assistant turns, with fallbacks."""
|
|
185
|
+
|
|
178
186
|
total_chars = 0
|
|
179
187
|
for turn in conversation:
|
|
180
|
-
if turn.get("role")
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
if turn.get("role") != "assistant":
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
content = turn.get("content", "")
|
|
192
|
+
if isinstance(content, dict):
|
|
193
|
+
content = json.dumps(content, ensure_ascii=False)
|
|
194
|
+
elif not isinstance(content, str):
|
|
195
|
+
content = str(content)
|
|
196
|
+
|
|
197
|
+
total_chars += len(content)
|
|
198
|
+
|
|
199
|
+
if total_chars == 0 and raw_output:
|
|
200
|
+
fallback = raw_output
|
|
201
|
+
if isinstance(fallback, dict):
|
|
202
|
+
fallback = fallback.get("output") or fallback.get("final_output")
|
|
203
|
+
if isinstance(fallback, str):
|
|
204
|
+
total_chars = len(fallback)
|
|
205
|
+
|
|
206
|
+
return total_chars // 4 if total_chars else 0
|
|
185
207
|
|
|
186
208
|
def _count_conversation_turns(self, conversation: List[Dict[str, Any]]) -> int:
|
|
187
209
|
return sum(1 for t in conversation if t.get("role") == "user")
|
|
@@ -475,23 +497,59 @@ class StatsAggregator:
|
|
|
475
497
|
"avg_turns": statistics.mean(d["turns"]) if d["turns"] else 0
|
|
476
498
|
}
|
|
477
499
|
|
|
500
|
+
def _slug(text: str) -> str:
|
|
501
|
+
return (
|
|
502
|
+
text.lower()
|
|
503
|
+
.replace(" ", "_")
|
|
504
|
+
.replace("-", "_")
|
|
505
|
+
.replace(".", "")
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _label(text: str) -> str:
|
|
509
|
+
return text.replace("_", " ").title()
|
|
510
|
+
|
|
478
511
|
# Comparison (first 2 personas)
|
|
479
512
|
p1, p2 = persona_names[0], persona_names[1]
|
|
480
513
|
comparisons = []
|
|
481
514
|
|
|
482
|
-
|
|
515
|
+
class_cycle = ["persona-a", "persona-b", "persona-c"]
|
|
516
|
+
|
|
517
|
+
for metric, label in [
|
|
518
|
+
("avg_tokens", "Output Tokens"),
|
|
519
|
+
("avg_latency", "Latency"),
|
|
520
|
+
("avg_turns", "Conversation Depth"),
|
|
521
|
+
]:
|
|
483
522
|
v1 = avgs[p1][metric]
|
|
484
523
|
v2 = avgs[p2][metric]
|
|
485
524
|
|
|
486
525
|
gap = ((v1 - v2) / v2 * 100) if v2 > 0 else 0
|
|
487
526
|
max_val = max(v1, v2) if max(v1, v2) > 0 else 1
|
|
488
527
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
528
|
+
bars = []
|
|
529
|
+
for idx, (persona_name, value) in enumerate(
|
|
530
|
+
[
|
|
531
|
+
(p1, v1),
|
|
532
|
+
(p2, v2),
|
|
533
|
+
]
|
|
534
|
+
):
|
|
535
|
+
css_class = class_cycle[idx] if idx < len(class_cycle) else f"persona-{idx}"
|
|
536
|
+
bars.append(
|
|
537
|
+
{
|
|
538
|
+
"persona": persona_name,
|
|
539
|
+
"label": _label(persona_name),
|
|
540
|
+
"class": css_class,
|
|
541
|
+
"value": str(round(value, 1)),
|
|
542
|
+
"width": int(value / max_val * 100),
|
|
543
|
+
}
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
comparisons.append(
|
|
547
|
+
{
|
|
548
|
+
"label": label,
|
|
549
|
+
"bars": bars,
|
|
550
|
+
"gap": f"{gap:+.1f}%",
|
|
551
|
+
}
|
|
552
|
+
)
|
|
495
553
|
|
|
496
554
|
return {"comparisons": comparisons, "persona_averages": avgs}
|
|
497
555
|
|
|
@@ -520,7 +578,7 @@ class StatsAggregator:
|
|
|
520
578
|
"tag": analysis.get("tag", "Unknown"),
|
|
521
579
|
"failed_badges": [{"icon": "✗", "label": m} for m in base["failed_metrics"]],
|
|
522
580
|
"issue_summary": analysis.get("issue_summary", ""),
|
|
523
|
-
"conversation_timeline": analysis.get("conversation_timeline", []),
|
|
581
|
+
"conversation_timeline": self._format_timeline(analysis.get("conversation_timeline", [])),
|
|
524
582
|
"root_cause": analysis.get("root_cause", ""),
|
|
525
583
|
"quick_fixes": analysis.get("quick_fixes", [])
|
|
526
584
|
})
|
|
@@ -566,3 +624,19 @@ class StatsAggregator:
|
|
|
566
624
|
badges.append({"status": "fail", "icon": "✗", "label": label})
|
|
567
625
|
return badges
|
|
568
626
|
|
|
627
|
+
def _format_timeline(self, timeline: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
628
|
+
formatted: List[Dict[str, Any]] = []
|
|
629
|
+
for entry in timeline:
|
|
630
|
+
if not isinstance(entry, dict):
|
|
631
|
+
continue
|
|
632
|
+
text = entry.get("text") or entry.get("summary") or ""
|
|
633
|
+
formatted.append(
|
|
634
|
+
{
|
|
635
|
+
"turn": entry.get("turn"),
|
|
636
|
+
"role": entry.get("role", "assistant"),
|
|
637
|
+
"text": text,
|
|
638
|
+
"is_highlight": entry.get("is_highlight", False),
|
|
639
|
+
}
|
|
640
|
+
)
|
|
641
|
+
return formatted
|
|
642
|
+
|