fluxloop-cli 0.2.32__tar.gz → 0.2.34__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.32 → fluxloop_cli-0.2.34}/PKG-INFO +1 -2
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/__init__.py +1 -1
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/evaluate.py +81 -11
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/parse.py +5 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/artifacts.py +43 -2
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/aggregator.py +17 -1
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/generator.py +139 -2
- fluxloop_cli-0.2.34/fluxloop_cli/evaluation/report/pipeline.py +156 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/renderer.py +54 -16
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/rules.py +2 -111
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/runner.py +7 -1
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/templates.py +8 -6
- fluxloop_cli-0.2.34/fluxloop_cli/token_usage.py +182 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/PKG-INFO +1 -2
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/SOURCES.txt +1 -1
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/requires.txt +0 -1
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/pyproject.toml +1 -2
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_evaluate_command.py +8 -10
- fluxloop_cli-0.2.32/fluxloop_cli/evaluation/report/pdf_exporter.py +0 -71
- fluxloop_cli-0.2.32/fluxloop_cli/evaluation/report/pipeline.py +0 -105
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/README.md +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/arg_binder.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/__init__.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/config.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/doctor.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/init.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/record.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/run.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/config_loader.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/config_schema.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/constants.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/environment.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/__init__.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/config.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/core.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/success.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/llm.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/base.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/__init__.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/templates/report.html.j2 +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/input_generator.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/main.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/project_paths.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/testing/__init__.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/testing/pytest_plugin.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/testing/types.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/setup.cfg +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_analysis_recommendations.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_arg_binder.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_evaluation_llm.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_init_pytest_template.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_prompt_library.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_pytest_plugin.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_run_command.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_runner_multi_turn.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_success_criteria.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_target_loader.py +0 -0
- {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/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.34
|
|
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,7 +28,6 @@ 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
|
|
32
31
|
Provides-Extra: dev
|
|
33
32
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
33
|
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,18 +252,13 @@ 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
|
-
artifacts = asyncio.run(pipeline.run(trace_summaries))
|
|
260
|
+
artifacts = asyncio.run(pipeline.run(trace_records, trace_summaries))
|
|
185
261
|
console.print(f"\n✅ Report ready: [bold cyan]{artifacts.html_path}[/bold cyan]")
|
|
186
|
-
if artifacts.pdf_path:
|
|
187
|
-
console.print(f"🖨️ PDF ready: [bold green]{artifacts.pdf_path}[/bold green]")
|
|
188
|
-
else:
|
|
189
|
-
console.print(
|
|
190
|
-
"⚠️ PDF export skipped (WeasyPrint is unavailable or PDF rendering failed)."
|
|
191
|
-
)
|
|
192
262
|
|
|
193
263
|
|
|
194
264
|
|
|
@@ -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"]
|
|
@@ -578,7 +578,7 @@ class StatsAggregator:
|
|
|
578
578
|
"tag": analysis.get("tag", "Unknown"),
|
|
579
579
|
"failed_badges": [{"icon": "✗", "label": m} for m in base["failed_metrics"]],
|
|
580
580
|
"issue_summary": analysis.get("issue_summary", ""),
|
|
581
|
-
"conversation_timeline": analysis.get("conversation_timeline", []),
|
|
581
|
+
"conversation_timeline": self._format_timeline(analysis.get("conversation_timeline", [])),
|
|
582
582
|
"root_cause": analysis.get("root_cause", ""),
|
|
583
583
|
"quick_fixes": analysis.get("quick_fixes", [])
|
|
584
584
|
})
|
|
@@ -624,3 +624,19 @@ class StatsAggregator:
|
|
|
624
624
|
badges.append({"status": "fail", "icon": "✗", "label": label})
|
|
625
625
|
return badges
|
|
626
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
|
+
|
|
@@ -4,7 +4,7 @@ LLM-based evaluators for report generation (Per-Trace and Overall).
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
@@ -592,8 +592,9 @@ class TraceEvaluator:
|
|
|
592
592
|
persona = self.personas.get(persona_name, {"name": persona_name, "description": "", "characteristics": []})
|
|
593
593
|
|
|
594
594
|
system_prompt = self._build_system_prompt(persona)
|
|
595
|
+
prompt_payload = self._build_prompt_payload(trace)
|
|
595
596
|
user_message = PT_USER_MESSAGE_TEMPLATE.format(
|
|
596
|
-
trace_json=json.dumps(
|
|
597
|
+
trace_json=json.dumps(prompt_payload, ensure_ascii=False, indent=2)
|
|
597
598
|
)
|
|
598
599
|
|
|
599
600
|
model = self.eval_config.get("advanced", {}).get("llm_judge", {}).get("model", "gpt-4o")
|
|
@@ -657,6 +658,142 @@ class TraceEvaluator:
|
|
|
657
658
|
|
|
658
659
|
return "en"
|
|
659
660
|
|
|
661
|
+
def _build_prompt_payload(self, trace: Dict[str, Any]) -> Dict[str, Any]:
|
|
662
|
+
"""Create a compact payload for prompt construction."""
|
|
663
|
+
|
|
664
|
+
allowed_fields = [
|
|
665
|
+
"trace_id",
|
|
666
|
+
"iteration",
|
|
667
|
+
"persona",
|
|
668
|
+
"input",
|
|
669
|
+
"output",
|
|
670
|
+
"final_output",
|
|
671
|
+
"duration_ms",
|
|
672
|
+
"success",
|
|
673
|
+
"token_usage",
|
|
674
|
+
"observation_count",
|
|
675
|
+
]
|
|
676
|
+
|
|
677
|
+
payload = {field: trace.get(field) for field in allowed_fields if field in trace}
|
|
678
|
+
|
|
679
|
+
conversation = trace.get("conversation")
|
|
680
|
+
if not conversation and isinstance(trace.get("conversation_state"), dict):
|
|
681
|
+
conversation = trace["conversation_state"].get("turns")
|
|
682
|
+
simplified_conversation = self._simplify_conversation(conversation)
|
|
683
|
+
if simplified_conversation:
|
|
684
|
+
payload["conversation"] = simplified_conversation
|
|
685
|
+
|
|
686
|
+
timeline = trace.get("timeline")
|
|
687
|
+
if isinstance(timeline, list):
|
|
688
|
+
simplified_timeline = [
|
|
689
|
+
self._simplify_timeline_entry(entry)
|
|
690
|
+
for entry in timeline
|
|
691
|
+
if isinstance(entry, dict)
|
|
692
|
+
]
|
|
693
|
+
simplified_timeline = [entry for entry in simplified_timeline if entry]
|
|
694
|
+
if simplified_timeline:
|
|
695
|
+
payload["timeline"] = simplified_timeline
|
|
696
|
+
|
|
697
|
+
metadata = trace.get("metadata")
|
|
698
|
+
if isinstance(metadata, dict):
|
|
699
|
+
payload["metadata"] = self._sanitize_mapping(metadata)
|
|
700
|
+
|
|
701
|
+
return payload
|
|
702
|
+
|
|
703
|
+
def _simplify_conversation(self, conversation: Optional[List[Any]]) -> Optional[List[Dict[str, str]]]:
|
|
704
|
+
if not conversation or not isinstance(conversation, list):
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
simplified: List[Dict[str, str]] = []
|
|
708
|
+
for entry in conversation:
|
|
709
|
+
if not isinstance(entry, dict):
|
|
710
|
+
continue
|
|
711
|
+
role = entry.get("role") or "assistant"
|
|
712
|
+
content = self._normalize_content(entry.get("content"))
|
|
713
|
+
if content:
|
|
714
|
+
simplified.append({"role": role, "content": content})
|
|
715
|
+
return simplified or None
|
|
716
|
+
|
|
717
|
+
def _normalize_content(self, content: Any) -> Optional[str]:
|
|
718
|
+
if content is None:
|
|
719
|
+
return None
|
|
720
|
+
if isinstance(content, str):
|
|
721
|
+
text = content.strip()
|
|
722
|
+
if text.startswith("{") and text.endswith("}"):
|
|
723
|
+
try:
|
|
724
|
+
parsed = json.loads(text)
|
|
725
|
+
return self._normalize_content(parsed)
|
|
726
|
+
except json.JSONDecodeError:
|
|
727
|
+
return text
|
|
728
|
+
return text
|
|
729
|
+
if isinstance(content, dict):
|
|
730
|
+
transcript = content.get("transcript")
|
|
731
|
+
if isinstance(transcript, list):
|
|
732
|
+
lines: List[str] = []
|
|
733
|
+
for item in transcript:
|
|
734
|
+
if not isinstance(item, dict):
|
|
735
|
+
continue
|
|
736
|
+
user_text = item.get("user")
|
|
737
|
+
assistant_text = item.get("assistant")
|
|
738
|
+
if user_text:
|
|
739
|
+
lines.append(f"User: {user_text}")
|
|
740
|
+
if assistant_text:
|
|
741
|
+
lines.append(f"Assistant: {assistant_text}")
|
|
742
|
+
if lines:
|
|
743
|
+
return "\n".join(lines)
|
|
744
|
+
text_value = content.get("text")
|
|
745
|
+
if isinstance(text_value, str):
|
|
746
|
+
return text_value
|
|
747
|
+
if "content" in content and isinstance(content["content"], str):
|
|
748
|
+
return content["content"]
|
|
749
|
+
return json.dumps(content, ensure_ascii=False)
|
|
750
|
+
if isinstance(content, list):
|
|
751
|
+
joined = "\n".join(
|
|
752
|
+
part for part in (self._normalize_content(item) for item in content) if part
|
|
753
|
+
)
|
|
754
|
+
return joined or None
|
|
755
|
+
return str(content)
|
|
756
|
+
|
|
757
|
+
def _simplify_timeline_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
758
|
+
simplified = {
|
|
759
|
+
"type": entry.get("type"),
|
|
760
|
+
"name": entry.get("name"),
|
|
761
|
+
"start_time": entry.get("start_time"),
|
|
762
|
+
"end_time": entry.get("end_time"),
|
|
763
|
+
"duration_ms": entry.get("duration_ms"),
|
|
764
|
+
"level": entry.get("level"),
|
|
765
|
+
}
|
|
766
|
+
actions = entry.get("actions")
|
|
767
|
+
if isinstance(actions, (list, str)) and actions:
|
|
768
|
+
simplified["actions"] = actions
|
|
769
|
+
status = entry.get("status")
|
|
770
|
+
if status:
|
|
771
|
+
simplified["status"] = status
|
|
772
|
+
return {k: v for k, v in simplified.items() if v is not None}
|
|
773
|
+
|
|
774
|
+
def _sanitize_mapping(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
775
|
+
"""Remove heavy/raw fields recursively."""
|
|
776
|
+
|
|
777
|
+
cleaned: Dict[str, Any] = {}
|
|
778
|
+
for key, value in data.items():
|
|
779
|
+
if key == "raw" or value is None:
|
|
780
|
+
continue
|
|
781
|
+
cleaned[key] = self._strip_raw_fields(value)
|
|
782
|
+
return cleaned
|
|
783
|
+
|
|
784
|
+
def _strip_raw_fields(self, value: Any, depth: int = 0) -> Any:
|
|
785
|
+
if depth > 3:
|
|
786
|
+
return value
|
|
787
|
+
if isinstance(value, dict):
|
|
788
|
+
return {
|
|
789
|
+
k: self._strip_raw_fields(v, depth + 1)
|
|
790
|
+
for k, v in value.items()
|
|
791
|
+
if k != "raw" and v is not None
|
|
792
|
+
}
|
|
793
|
+
if isinstance(value, list):
|
|
794
|
+
return [self._strip_raw_fields(item, depth + 1) for item in value]
|
|
795
|
+
return value
|
|
796
|
+
|
|
660
797
|
|
|
661
798
|
class OverallEvaluator:
|
|
662
799
|
"""Analyzes aggregated results (LLM-OV)."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Orchestration pipeline for generating evaluation reports.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .aggregator import StatsAggregator
|
|
12
|
+
from .generator import OverallEvaluator, ReportLLMClient, TraceEvaluator
|
|
13
|
+
from .renderer import ReportRenderer
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ReportArtifacts:
|
|
20
|
+
"""Artifacts produced by the evaluation report pipeline."""
|
|
21
|
+
|
|
22
|
+
html_path: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReportPipeline:
|
|
26
|
+
"""
|
|
27
|
+
Orchestrates the 5-stage evaluation pipeline:
|
|
28
|
+
1. Per-Trace Analysis (LLM-PT)
|
|
29
|
+
2. Rule-Based Aggregation
|
|
30
|
+
3. Overall Analysis (LLM-OV)
|
|
31
|
+
4. Data Preparation
|
|
32
|
+
5. Report Rendering
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: Dict[str, Any], output_dir: Path, api_key: str = None):
|
|
36
|
+
self.config = config
|
|
37
|
+
self.output_dir = output_dir
|
|
38
|
+
self.client = ReportLLMClient(api_key=api_key)
|
|
39
|
+
|
|
40
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
self.trace_evaluator = TraceEvaluator(self.client, config)
|
|
43
|
+
self.aggregator = StatsAggregator(config)
|
|
44
|
+
self.overall_evaluator = OverallEvaluator(self.client, config)
|
|
45
|
+
self.renderer = ReportRenderer(output_dir)
|
|
46
|
+
|
|
47
|
+
async def run(
|
|
48
|
+
self,
|
|
49
|
+
trace_records: List[Dict[str, Any]],
|
|
50
|
+
summary_records: Optional[List[Dict[str, Any]]] = None,
|
|
51
|
+
) -> ReportArtifacts:
|
|
52
|
+
"""
|
|
53
|
+
Run the full pipeline.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
trace_records: Detailed trace payloads (per-trace artifacts) used for LLM-PT
|
|
57
|
+
summary_records: Lightweight trace summaries (from `trace_summary.jsonl`). When omitted,
|
|
58
|
+
``trace_records`` are re-used for aggregation.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Path to the generated HTML report
|
|
62
|
+
"""
|
|
63
|
+
logger.info("🚀 Starting Evaluation Report Pipeline")
|
|
64
|
+
traces_for_rules = summary_records or trace_records
|
|
65
|
+
|
|
66
|
+
# Stage 1: Per-Trace Analysis (Parallel)
|
|
67
|
+
logger.info(f"Stage 1: Running LLM-PT on {len(trace_records)} traces...")
|
|
68
|
+
pt_results = await self._run_pt_evaluations(trace_records)
|
|
69
|
+
|
|
70
|
+
# Stage 2: Aggregation
|
|
71
|
+
logger.info("Stage 2: Aggregating statistics...")
|
|
72
|
+
rule_based_data = self.aggregator.aggregate(pt_results, traces_for_rules)
|
|
73
|
+
|
|
74
|
+
# Stage 3: Overall Analysis
|
|
75
|
+
logger.info("Stage 3: Running LLM-OV analysis...")
|
|
76
|
+
llm_ov_data = await self.overall_evaluator.analyze(rule_based_data)
|
|
77
|
+
if "error" in llm_ov_data:
|
|
78
|
+
logger.warning(f"LLM-OV finished with error: {llm_ov_data['error']}")
|
|
79
|
+
# Continue rendering even if OV fails (partial report is better than none)
|
|
80
|
+
|
|
81
|
+
# Stage 4 & 5: Render
|
|
82
|
+
logger.info("Stage 4 & 5: Rendering HTML report...")
|
|
83
|
+
report_path = self.renderer.render(
|
|
84
|
+
rule_based_data, llm_ov_data, traces_for_rules, self.config
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
logger.info("✅ Pipeline Complete! Report: %s", report_path)
|
|
88
|
+
return ReportArtifacts(html_path=report_path)
|
|
89
|
+
|
|
90
|
+
async def _run_pt_evaluations(self, traces: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
91
|
+
"""Run TraceEvaluator concurrently."""
|
|
92
|
+
limit = self._resolve_concurrency_limit()
|
|
93
|
+
semaphore = asyncio.Semaphore(limit)
|
|
94
|
+
|
|
95
|
+
async def _evaluate_with_retry(trace: Dict[str, Any], index: int, total: int) -> Dict[str, Any]:
|
|
96
|
+
attempt = 0
|
|
97
|
+
max_attempts = self._resolve_retry_limit()
|
|
98
|
+
while True:
|
|
99
|
+
attempt += 1
|
|
100
|
+
async with semaphore:
|
|
101
|
+
logger.debug(
|
|
102
|
+
"PT[%s/%s] Evaluating trace %s (attempt %s)",
|
|
103
|
+
index,
|
|
104
|
+
total,
|
|
105
|
+
trace.get("trace_id", "unknown")[:8],
|
|
106
|
+
attempt,
|
|
107
|
+
)
|
|
108
|
+
result = await self.trace_evaluator.evaluate_trace(trace)
|
|
109
|
+
error = result.get("error")
|
|
110
|
+
if not error:
|
|
111
|
+
return result
|
|
112
|
+
if attempt >= max_attempts:
|
|
113
|
+
logger.error(
|
|
114
|
+
"PT[%s/%s] Trace %s failed after %s attempts: %s",
|
|
115
|
+
index,
|
|
116
|
+
total,
|
|
117
|
+
trace.get("trace_id", "unknown"),
|
|
118
|
+
attempt,
|
|
119
|
+
error,
|
|
120
|
+
)
|
|
121
|
+
return result
|
|
122
|
+
sleep_seconds = min(5 * attempt, 30)
|
|
123
|
+
logger.warning(
|
|
124
|
+
"PT[%s/%s] Trace %s attempt %s failed: %s — retrying in %.1fs",
|
|
125
|
+
index,
|
|
126
|
+
total,
|
|
127
|
+
trace.get("trace_id", "unknown")[:8],
|
|
128
|
+
attempt,
|
|
129
|
+
error,
|
|
130
|
+
sleep_seconds,
|
|
131
|
+
)
|
|
132
|
+
await asyncio.sleep(sleep_seconds)
|
|
133
|
+
|
|
134
|
+
tasks = [
|
|
135
|
+
_evaluate_with_retry(trace, idx + 1, len(traces))
|
|
136
|
+
for idx, trace in enumerate(traces)
|
|
137
|
+
]
|
|
138
|
+
return list(await asyncio.gather(*tasks))
|
|
139
|
+
|
|
140
|
+
def _resolve_concurrency_limit(self) -> int:
|
|
141
|
+
"""Determine max concurrent LLM calls."""
|
|
142
|
+
eval_cfg = self.config.get("evaluation", {})
|
|
143
|
+
advanced = eval_cfg.get("advanced", {})
|
|
144
|
+
limit = advanced.get("llm_concurrency")
|
|
145
|
+
if isinstance(limit, int) and limit > 0:
|
|
146
|
+
return max(1, min(limit, 16))
|
|
147
|
+
return 4
|
|
148
|
+
|
|
149
|
+
def _resolve_retry_limit(self) -> int:
|
|
150
|
+
eval_cfg = self.config.get("evaluation", {})
|
|
151
|
+
advanced = eval_cfg.get("advanced", {})
|
|
152
|
+
retries = advanced.get("llm_retries")
|
|
153
|
+
if isinstance(retries, int) and retries > 0:
|
|
154
|
+
return min(max(1, retries), 5)
|
|
155
|
+
return 3
|
|
156
|
+
|