fluxloop-cli 0.2.30__py3-none-any.whl → 0.2.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. fluxloop_cli/__init__.py +1 -1
  2. fluxloop_cli/arg_binder.py +2 -3
  3. fluxloop_cli/commands/config.py +0 -1
  4. fluxloop_cli/commands/evaluate.py +89 -114
  5. fluxloop_cli/commands/init.py +1 -1
  6. fluxloop_cli/commands/record.py +0 -1
  7. fluxloop_cli/commands/run.py +0 -1
  8. fluxloop_cli/commands/status.py +1 -2
  9. fluxloop_cli/config_loader.py +0 -2
  10. fluxloop_cli/evaluation/artifacts.py +1 -1
  11. fluxloop_cli/evaluation/config.py +41 -2
  12. fluxloop_cli/evaluation/report/__init__.py +0 -0
  13. fluxloop_cli/evaluation/report/aggregator.py +626 -0
  14. fluxloop_cli/evaluation/report/generator.py +759 -0
  15. fluxloop_cli/evaluation/report/pdf_exporter.py +71 -0
  16. fluxloop_cli/evaluation/report/pipeline.py +105 -0
  17. fluxloop_cli/evaluation/report/renderer.py +394 -0
  18. fluxloop_cli/evaluation/rules.py +1 -1
  19. fluxloop_cli/evaluation/templates/report.html.j2 +7072 -0
  20. fluxloop_cli/input_generator.py +138 -15
  21. fluxloop_cli/llm_generator.py +23 -23
  22. fluxloop_cli/main.py +0 -2
  23. fluxloop_cli/project_paths.py +0 -1
  24. fluxloop_cli/runner.py +2 -1
  25. fluxloop_cli/templates.py +201 -252
  26. fluxloop_cli/testing/pytest_plugin.py +13 -2
  27. {fluxloop_cli-0.2.30.dist-info → fluxloop_cli-0.2.32.dist-info}/METADATA +5 -3
  28. fluxloop_cli-0.2.32.dist-info/RECORD +58 -0
  29. fluxloop_cli-0.2.30.dist-info/RECORD +0 -51
  30. {fluxloop_cli-0.2.30.dist-info → fluxloop_cli-0.2.32.dist-info}/WHEEL +0 -0
  31. {fluxloop_cli-0.2.30.dist-info → fluxloop_cli-0.2.32.dist-info}/entry_points.txt +0 -0
  32. {fluxloop_cli-0.2.30.dist-info → fluxloop_cli-0.2.32.dist-info}/top_level.txt +0 -0
fluxloop_cli/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.30"
5
+ __version__ = "0.2.32"
6
6
 
7
7
  from .main import app
8
8
 
@@ -7,6 +7,8 @@ import json
7
7
  from pathlib import Path
8
8
  from typing import Any, Callable, Dict, Optional, Sequence
9
9
 
10
+ from fluxloop.schemas import ExperimentConfig, ReplayArgsConfig, PersonaConfig
11
+
10
12
 
11
13
  class _AttrDict(dict):
12
14
  """Dictionary that also supports attribute access for keys."""
@@ -26,8 +28,6 @@ class _AttrDict(dict):
26
28
  except KeyError as exc: # pragma: no cover
27
29
  raise AttributeError(item) from exc
28
30
 
29
- from fluxloop.schemas import ExperimentConfig, ReplayArgsConfig, PersonaConfig
30
-
31
31
 
32
32
  class _AwaitableNone:
33
33
  """Simple awaitable that resolves to ``None``."""
@@ -278,7 +278,6 @@ class ArgBinder:
278
278
 
279
279
  def _record(args: Any, kwargs: Any) -> None:
280
280
  messages.append((args, kwargs))
281
- pretty = args[0] if len(args) == 1 and not kwargs else {"args": args, "kwargs": kwargs}
282
281
 
283
282
  def send(*args: Any, **kwargs: Any) -> _AwaitableNone:
284
283
  _record(args, kwargs)
@@ -14,7 +14,6 @@ from rich.syntax import Syntax
14
14
  from rich.table import Table
15
15
 
16
16
  from ..config_loader import load_experiment_config
17
- from ..templates import create_env_file, create_gitignore, create_sample_agent
18
17
  from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
19
18
  from ..config_schema import CONFIG_SECTION_FILENAMES
20
19
  from ..project_paths import (
@@ -1,19 +1,65 @@
1
- """Evaluate command for scoring experiment outputs."""
1
+ """Evaluate command for generating interactive reports."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
6
+ import logging
5
7
  import os
8
+ import shutil
9
+ from dataclasses import asdict
6
10
  from pathlib import Path
7
11
  from typing import Optional
8
12
 
9
13
  import typer
14
+ import yaml
10
15
  from rich.console import Console
11
16
 
12
17
  from ..environment import load_env_chain
13
- from ..evaluation import EvaluationOptions, load_evaluation_config, run_evaluation
18
+ from ..evaluation import load_evaluation_config
19
+ from ..evaluation.artifacts import load_per_trace_records
20
+ from ..evaluation.report.pipeline import ReportPipeline
14
21
 
15
22
  console = Console()
16
- app = typer.Typer(help="Evaluate experiment outputs and generate reports.")
23
+ app = typer.Typer(help="Evaluate experiment outputs and generate interactive reports.")
24
+
25
+
26
+ def _load_yaml_file(path: Optional[Path]) -> dict:
27
+ if not path or not path.exists():
28
+ return {}
29
+ with path.open("r", encoding="utf-8") as handle:
30
+ data = yaml.safe_load(handle) or {}
31
+ if isinstance(data, dict):
32
+ return data
33
+ return {}
34
+
35
+
36
+ def _resolve_project_root(config_path: Path) -> Path:
37
+ config_dir = config_path.parent
38
+ return config_dir.parent if config_dir.name == "configs" else config_dir
39
+
40
+
41
+ def _find_config_file(config_path: Path, filename: str) -> Optional[Path]:
42
+ config_dir = config_path.parent
43
+ project_root = _resolve_project_root(config_path)
44
+ candidates = [
45
+ config_dir / filename,
46
+ project_root / "configs" / filename,
47
+ project_root / filename,
48
+ ]
49
+ for candidate in candidates:
50
+ if candidate.exists():
51
+ return candidate
52
+ return None
53
+
54
+
55
+ def _prepare_output_directory(path: Path, overwrite: bool) -> None:
56
+ if path.exists():
57
+ if not overwrite:
58
+ raise typer.BadParameter(
59
+ f"Output directory already exists: {path}. Use --overwrite to replace it."
60
+ )
61
+ shutil.rmtree(path)
62
+ path.mkdir(parents=True, exist_ok=True)
17
63
 
18
64
 
19
65
  @app.command()
@@ -33,7 +79,7 @@ def experiment(
33
79
  help="Path to evaluation configuration file",
34
80
  ),
35
81
  output: Path = typer.Option(
36
- Path("evaluation"),
82
+ Path("evaluation_report"),
37
83
  "--output",
38
84
  "-o",
39
85
  help="Output directory name (relative to the experiment directory)",
@@ -46,41 +92,9 @@ def experiment(
46
92
  llm_api_key: Optional[str] = typer.Option(
47
93
  None,
48
94
  "--llm-api-key",
49
- help="LLM API key for judge evaluators (optional)",
95
+ help="LLM API key for report generation (optional)",
50
96
  envvar="FLUXLOOP_LLM_API_KEY",
51
97
  ),
52
- sample_rate: Optional[float] = typer.Option(
53
- None,
54
- "--sample-rate",
55
- help="Override LLM evaluation sample rate (0.0-1.0)",
56
- ),
57
- max_llm_calls: Optional[int] = typer.Option(
58
- None,
59
- "--max-llm",
60
- help="Maximum number of LLM evaluations to run",
61
- ),
62
- report: Optional[str] = typer.Option(
63
- None,
64
- "--report",
65
- help="Report output format to generate (md, html, both)",
66
- metavar="FORMAT",
67
- ),
68
- report_template: Optional[Path] = typer.Option(
69
- None,
70
- "--report-template",
71
- help="Path to custom HTML report template",
72
- exists=False,
73
- file_okay=True,
74
- dir_okay=False,
75
- ),
76
- baseline: Optional[Path] = typer.Option(
77
- None,
78
- "--baseline",
79
- help="Path to baseline summary.json file for comparisons",
80
- exists=False,
81
- file_okay=True,
82
- dir_okay=False,
83
- ),
84
98
  per_trace: Optional[Path] = typer.Option(
85
99
  None,
86
100
  "--per-trace",
@@ -93,59 +107,29 @@ def experiment(
93
107
  ),
94
108
  ):
95
109
  """
96
- Evaluate experiment outputs and generate aggregate reports.
110
+ Evaluate experiment outputs and generate an interactive HTML report.
97
111
  """
98
112
 
113
+ logging.basicConfig(
114
+ level=logging.DEBUG if verbose else logging.INFO,
115
+ format="%(message)s",
116
+ )
117
+
99
118
  resolved_experiment_dir = experiment_dir.resolve()
100
119
  if not resolved_experiment_dir.is_dir():
101
120
  raise typer.BadParameter(f"Experiment directory not found: {resolved_experiment_dir}")
102
121
 
103
- if not config.is_absolute():
104
- config_path = (Path.cwd() / config).resolve()
105
- else:
106
- config_path = config
107
-
108
- if sample_rate is not None and not 0.0 <= sample_rate <= 1.0:
109
- raise typer.BadParameter("--sample-rate must be between 0.0 and 1.0")
110
-
111
- if max_llm_calls is not None and max_llm_calls < 0:
112
- raise typer.BadParameter("--max-llm must be a non-negative integer")
113
-
114
- report_format: Optional[str] = None
115
- if report is not None:
116
- candidate = report.lower()
117
- if candidate not in {"md", "html", "both"}:
118
- raise typer.BadParameter("--report must be one of: md, html, both")
119
- report_format = candidate
120
-
121
- report_template_path: Optional[Path] = None
122
- if report_template is not None:
123
- resolved_template = report_template
124
- if not resolved_template.is_absolute():
125
- resolved_template = (Path.cwd() / resolved_template).resolve()
126
- if not resolved_template.exists():
127
- raise typer.BadParameter(f"Report template not found: {resolved_template}")
128
- report_template_path = resolved_template
129
-
130
- baseline_path: Optional[Path] = None
131
- if baseline is not None:
132
- resolved_baseline = baseline
133
- if not resolved_baseline.is_absolute():
134
- resolved_baseline = (Path.cwd() / resolved_baseline).resolve()
135
- baseline_path = resolved_baseline
122
+ config_path = config.resolve() if config.is_absolute() else (Path.cwd() / config).resolve()
136
123
 
137
124
  if per_trace is not None:
138
- per_trace_path = per_trace
139
- if not per_trace_path.is_absolute():
140
- per_trace_path = (Path.cwd() / per_trace_path).resolve()
125
+ per_trace_path = per_trace.resolve() if per_trace.is_absolute() else (Path.cwd() / per_trace).resolve()
141
126
  else:
142
127
  per_trace_path = resolved_experiment_dir / "per_trace_analysis" / "per_trace.jsonl"
143
128
 
144
- if not per_trace_path.exists():
145
- raise typer.BadParameter(
146
- f"Structured per-trace artifacts not found at {per_trace_path}. "
147
- "Run `fluxloop parse` before evaluating or provide --per-trace."
148
- )
129
+ 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:
132
+ raise typer.BadParameter("No traces found in per-trace artifacts.")
149
133
 
150
134
  try:
151
135
  evaluation_config = load_evaluation_config(config_path)
@@ -168,52 +152,43 @@ def experiment(
168
152
  if llm_api_key is None:
169
153
  llm_api_key = os.getenv("FLUXLOOP_LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
170
154
 
171
- output_dir = output
172
- if not output_dir.is_absolute():
173
- output_dir = resolved_experiment_dir / output_dir
155
+ output_dir = output if output.is_absolute() else (resolved_experiment_dir / output)
156
+ _prepare_output_directory(output_dir, overwrite)
174
157
 
175
- options = EvaluationOptions(
176
- output_dir=output_dir,
177
- overwrite=overwrite,
178
- llm_api_key=llm_api_key,
179
- sample_rate=sample_rate,
180
- max_llm_calls=max_llm_calls,
181
- verbose=verbose,
182
- report_format=report_format, # type: ignore[arg-type]
183
- report_template=report_template_path,
184
- baseline_path=baseline_path,
185
- per_trace_path=per_trace_path,
186
- )
158
+ input_config_path = _find_config_file(config_path, "input.yaml")
159
+ project_config_path = _find_config_file(config_path, "project.yaml")
187
160
 
188
- effective_report_format = report_format or evaluation_config.report.output
189
- template_display = (
190
- str(report_template_path)
191
- if report_template_path is not None
192
- else evaluation_config.report.template_path or "default"
193
- )
194
- baseline_display = (
195
- str(baseline_path)
196
- if baseline_path is not None
197
- else evaluation_config.additional_analysis.comparison.baseline_path
161
+ input_config = _load_yaml_file(input_config_path)
162
+ project_config = _load_yaml_file(project_config_path)
163
+
164
+ config_bundle = {
165
+ "name": project_config.get("name") or resolved_experiment_dir.name,
166
+ "evaluation": asdict(evaluation_config),
167
+ "input": input_config,
168
+ }
169
+
170
+ pipeline = ReportPipeline(
171
+ config=config_bundle,
172
+ output_dir=output_dir,
173
+ api_key=llm_api_key,
198
174
  )
199
175
 
200
176
  message_lines = [
201
177
  f"📊 Evaluating experiment at [cyan]{resolved_experiment_dir}[/cyan]",
202
178
  f"⚙️ Config: [magenta]{config_path}[/magenta]",
203
- f"📁 Output: [green]{output_dir}[/green]",
204
- f"📝 Report: [yellow]{effective_report_format.upper()}[/yellow] (template: [cyan]{template_display}[/cyan])",
205
179
  f"🧵 Per-trace data: [blue]{per_trace_path}[/blue]",
180
+ f"📁 Output: [green]{output_dir}[/green]",
206
181
  ]
207
- if baseline_display:
208
- message_lines.append(f"📈 Baseline: [cyan]{baseline_display}[/cyan]")
209
-
210
182
  console.print("\n".join(message_lines))
211
183
 
212
- summary = run_evaluation(resolved_experiment_dir, evaluation_config, options)
184
+ artifacts = asyncio.run(pipeline.run(trace_summaries))
185
+ 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
+ )
213
192
 
214
- if verbose:
215
- console.print("\n[bold]Summary[/bold]")
216
- for key, value in summary.items():
217
- console.print(f"• {key}: {value}")
218
193
 
219
194
 
@@ -188,7 +188,7 @@ def project(
188
188
  console.print("4. Generate inputs: [green]fluxloop generate inputs[/green]")
189
189
  console.print("5. Run the experiment: [green]fluxloop run experiment[/green]")
190
190
  console.print("6. Parse outputs: [green]fluxloop parse experiment[/green]")
191
- console.print("7. Evaluate results (optional): [green]fluxloop evaluate experiment[/green]")
191
+ console.print("7. Generate the interactive report (optional): [green]fluxloop evaluate experiment[/green]")
192
192
  console.print("8. Diagnose environment anytime: [green]fluxloop doctor[/green]")
193
193
 
194
194
 
@@ -16,7 +16,6 @@ from ..project_paths import (
16
16
  resolve_project_dir,
17
17
  resolve_config_section_path,
18
18
  )
19
- from ..config_schema import CONFIG_SECTION_FILENAMES
20
19
 
21
20
 
22
21
  app = typer.Typer()
@@ -9,7 +9,6 @@ from typing import Callable, Optional
9
9
 
10
10
  import typer
11
11
  from rich.console import Console
12
- from rich.live import Live
13
12
  from rich.panel import Panel
14
13
  from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeRemainingColumn
15
14
  from rich.table import Table
@@ -15,7 +15,6 @@ from ..config_schema import CONFIG_SECTION_FILENAMES, CONFIG_REQUIRED_KEYS
15
15
  from ..project_paths import (
16
16
  resolve_config_path,
17
17
  resolve_config_directory,
18
- resolve_root_dir,
19
18
  resolve_project_relative,
20
19
  )
21
20
 
@@ -79,7 +78,7 @@ def check(
79
78
  from fluxloop.client import FluxLoopClient
80
79
 
81
80
  config = get_config()
82
- client = FluxLoopClient()
81
+ _client = FluxLoopClient() # noqa: F841 - instantiated for potential future use
83
82
 
84
83
  # Try to connect (this would need a health endpoint)
85
84
  status_table.add_row(
@@ -12,7 +12,6 @@ from pydantic import ValidationError
12
12
  from .project_paths import resolve_config_path
13
13
  from .config_schema import (
14
14
  CONFIG_SECTION_FILENAMES,
15
- CONFIG_SECTION_ORDER,
16
15
  CONFIG_REQUIRED_KEYS,
17
16
  iter_section_paths,
18
17
  is_legacy_config,
@@ -52,7 +51,6 @@ def load_experiment_config(
52
51
 
53
52
  for section_path in iter_section_paths(project_root):
54
53
  if not section_path.exists():
55
- key = section_path.name
56
54
  logical_key = _section_key_from_filename(section_path.name)
57
55
  if logical_key in CONFIG_REQUIRED_KEYS:
58
56
  missing_required.append(section_path.name)
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
  import json
8
8
  from dataclasses import dataclass
9
9
  from pathlib import Path
10
- from typing import Any, Dict, Iterable, List, Optional
10
+ from typing import Any, Dict, List, Optional
11
11
 
12
12
 
13
13
  @dataclass
@@ -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)
File without changes