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.
Files changed (80) hide show
  1. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/PKG-INFO +2 -1
  2. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/__init__.py +1 -1
  3. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/evaluate.py +88 -6
  4. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/parse.py +5 -0
  5. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/artifacts.py +43 -2
  6. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/config.py +41 -2
  7. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/aggregator.py +89 -15
  8. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/generator.py +188 -8
  9. fluxloop_cli-0.2.33/fluxloop_cli/evaluation/report/pdf_exporter.py +71 -0
  10. fluxloop_cli-0.2.33/fluxloop_cli/evaluation/report/pipeline.py +168 -0
  11. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/renderer.py +80 -19
  12. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/rules.py +2 -111
  13. fluxloop_cli-0.2.33/fluxloop_cli/evaluation/templates/report.html.j2 +7072 -0
  14. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/input_generator.py +15 -18
  15. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/runner.py +7 -1
  16. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/templates.py +9 -7
  17. fluxloop_cli-0.2.33/fluxloop_cli/token_usage.py +182 -0
  18. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/PKG-INFO +2 -1
  19. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/SOURCES.txt +3 -0
  20. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/requires.txt +1 -0
  21. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/pyproject.toml +8 -1
  22. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_evaluate_command.py +14 -5
  23. fluxloop_cli-0.2.31/fluxloop_cli/evaluation/report/pipeline.py +0 -83
  24. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/README.md +0 -0
  25. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/arg_binder.py +0 -0
  26. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/__init__.py +0 -0
  27. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/config.py +0 -0
  28. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/doctor.py +0 -0
  29. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/generate.py +0 -0
  30. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/init.py +0 -0
  31. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/record.py +0 -0
  32. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/run.py +0 -0
  33. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/commands/status.py +0 -0
  34. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/config_loader.py +0 -0
  35. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/config_schema.py +0 -0
  36. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/constants.py +0 -0
  37. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/conversation_supervisor.py +0 -0
  38. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/environment.py +0 -0
  39. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/__init__.py +0 -0
  40. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
  41. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
  42. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/core.py +0 -0
  43. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
  44. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
  45. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
  46. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/engine/success.py +0 -0
  47. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/llm.py +0 -0
  48. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
  49. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/base.py +0 -0
  50. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
  51. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
  52. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
  53. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
  54. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/evaluation/report/__init__.py +0 -0
  55. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/llm_generator.py +0 -0
  56. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/main.py +0 -0
  57. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/project_paths.py +0 -0
  58. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/target_loader.py +0 -0
  59. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/testing/__init__.py +0 -0
  60. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/testing/pytest_plugin.py +0 -0
  61. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/testing/types.py +0 -0
  62. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli/validators.py +0 -0
  63. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  64. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  65. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/fluxloop_cli.egg-info/top_level.txt +0 -0
  66. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/setup.cfg +0 -0
  67. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_analysis_recommendations.py +0 -0
  68. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_arg_binder.py +0 -0
  69. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_config_command.py +0 -0
  70. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_conversation_supervisor.py +0 -0
  71. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_evaluation_llm.py +0 -0
  72. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_init_pytest_template.py +0 -0
  73. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_input_generator.py +0 -0
  74. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_prompt_library.py +0 -0
  75. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_pytest_plugin.py +0 -0
  76. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_run_command.py +0 -0
  77. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_runner_multi_turn.py +0 -0
  78. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_success_criteria.py +0 -0
  79. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.33}/tests/test_target_loader.py +0 -0
  80. {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.31
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"
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.31"
5
+ __version__ = "0.2.33"
6
6
 
7
7
  from .main import app
8
8
 
@@ -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
- trace_summaries = [record.trace for record in per_trace_records]
131
- if not trace_summaries:
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
- report_path = asyncio.run(pipeline.run(trace_summaries))
185
- console.print(f"\n✅ Report ready: [bold cyan]{report_path}[/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 artifacts produced by `fluxloop parse`.
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
- __all__ = ["PerTraceRecord", "load_per_trace_records"]
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
- text = str(raw.get("text", "")) if raw.get("text") is not None else ""
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._count_output_tokens(conversation),
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 _count_output_tokens(self, conversation: List[Dict[str, Any]]) -> int:
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") == "assistant":
181
- content = turn.get("content", "")
182
- if isinstance(content, str):
183
- total_chars += len(content)
184
- return total_chars // 4
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
- for metric, label in [("avg_tokens", "Output Tokens"), ("avg_latency", "Latency"), ("avg_turns", "Conversation Depth")]:
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
- comparisons.append({
490
- "label": label,
491
- p1: {"value": str(round(v1, 1)), "width": int(v1/max_val*100)},
492
- p2: {"value": str(round(v2, 1)), "width": int(v2/max_val*100)},
493
- "gap": f"{gap:+.1f}%"
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
+