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.
Files changed (80) hide show
  1. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/PKG-INFO +1 -2
  2. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/__init__.py +1 -1
  3. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/evaluate.py +81 -11
  4. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/parse.py +5 -0
  5. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/artifacts.py +43 -2
  6. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/aggregator.py +17 -1
  7. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/generator.py +139 -2
  8. fluxloop_cli-0.2.34/fluxloop_cli/evaluation/report/pipeline.py +156 -0
  9. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/renderer.py +54 -16
  10. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/rules.py +2 -111
  11. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/runner.py +7 -1
  12. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/templates.py +8 -6
  13. fluxloop_cli-0.2.34/fluxloop_cli/token_usage.py +182 -0
  14. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/PKG-INFO +1 -2
  15. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/SOURCES.txt +1 -1
  16. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/requires.txt +0 -1
  17. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/pyproject.toml +1 -2
  18. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_evaluate_command.py +8 -10
  19. fluxloop_cli-0.2.32/fluxloop_cli/evaluation/report/pdf_exporter.py +0 -71
  20. fluxloop_cli-0.2.32/fluxloop_cli/evaluation/report/pipeline.py +0 -105
  21. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/README.md +0 -0
  22. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/arg_binder.py +0 -0
  23. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/__init__.py +0 -0
  24. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/config.py +0 -0
  25. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/doctor.py +0 -0
  26. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/generate.py +0 -0
  27. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/init.py +0 -0
  28. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/record.py +0 -0
  29. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/run.py +0 -0
  30. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/commands/status.py +0 -0
  31. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/config_loader.py +0 -0
  32. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/config_schema.py +0 -0
  33. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/constants.py +0 -0
  34. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/conversation_supervisor.py +0 -0
  35. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/environment.py +0 -0
  36. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/__init__.py +0 -0
  37. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/config.py +0 -0
  38. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
  39. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
  40. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/core.py +0 -0
  41. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
  42. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
  43. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
  44. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/engine/success.py +0 -0
  45. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/llm.py +0 -0
  46. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
  47. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/base.py +0 -0
  48. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
  49. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
  50. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
  51. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
  52. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/report/__init__.py +0 -0
  53. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/evaluation/templates/report.html.j2 +0 -0
  54. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/input_generator.py +0 -0
  55. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/llm_generator.py +0 -0
  56. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/main.py +0 -0
  57. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/project_paths.py +0 -0
  58. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/target_loader.py +0 -0
  59. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/testing/__init__.py +0 -0
  60. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/testing/pytest_plugin.py +0 -0
  61. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/testing/types.py +0 -0
  62. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli/validators.py +0 -0
  63. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  64. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  65. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/fluxloop_cli.egg-info/top_level.txt +0 -0
  66. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/setup.cfg +0 -0
  67. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_analysis_recommendations.py +0 -0
  68. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_arg_binder.py +0 -0
  69. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_config_command.py +0 -0
  70. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_conversation_supervisor.py +0 -0
  71. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_evaluation_llm.py +0 -0
  72. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_init_pytest_template.py +0 -0
  73. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_input_generator.py +0 -0
  74. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_prompt_library.py +0 -0
  75. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_pytest_plugin.py +0 -0
  76. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_run_command.py +0 -0
  77. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_runner_multi_turn.py +0 -0
  78. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_success_criteria.py +0 -0
  79. {fluxloop_cli-0.2.32 → fluxloop_cli-0.2.34}/tests/test_target_loader.py +0 -0
  80. {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.32
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"
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.32"
5
+ __version__ = "0.2.34"
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,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 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"]
@@ -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(trace, ensure_ascii=False, indent=2)
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
+