fluxloop-cli 0.2.31__tar.gz → 0.2.32__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 (78) hide show
  1. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/PKG-INFO +2 -1
  2. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/__init__.py +1 -1
  3. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/evaluate.py +8 -2
  4. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/config.py +41 -2
  5. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/aggregator.py +72 -14
  6. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/generator.py +49 -6
  7. fluxloop_cli-0.2.32/fluxloop_cli/evaluation/report/pdf_exporter.py +71 -0
  8. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/pipeline.py +27 -5
  9. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/renderer.py +26 -3
  10. fluxloop_cli-0.2.32/fluxloop_cli/evaluation/templates/report.html.j2 +7072 -0
  11. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/input_generator.py +15 -18
  12. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/templates.py +1 -1
  13. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/PKG-INFO +2 -1
  14. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/SOURCES.txt +2 -0
  15. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/requires.txt +1 -0
  16. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/pyproject.toml +8 -1
  17. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_evaluate_command.py +7 -1
  18. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/README.md +0 -0
  19. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/arg_binder.py +0 -0
  20. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/__init__.py +0 -0
  21. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/config.py +0 -0
  22. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/doctor.py +0 -0
  23. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/generate.py +0 -0
  24. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/init.py +0 -0
  25. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/parse.py +0 -0
  26. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/record.py +0 -0
  27. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/run.py +0 -0
  28. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/status.py +0 -0
  29. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/config_loader.py +0 -0
  30. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/config_schema.py +0 -0
  31. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/constants.py +0 -0
  32. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/conversation_supervisor.py +0 -0
  33. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/environment.py +0 -0
  34. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/__init__.py +0 -0
  35. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/artifacts.py +0 -0
  36. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
  37. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
  38. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/core.py +0 -0
  39. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
  40. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
  41. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
  42. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/success.py +0 -0
  43. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/llm.py +0 -0
  44. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
  45. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/base.py +0 -0
  46. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
  47. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
  48. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
  49. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
  50. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/__init__.py +0 -0
  51. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/rules.py +0 -0
  52. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/llm_generator.py +0 -0
  53. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/main.py +0 -0
  54. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/project_paths.py +0 -0
  55. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/runner.py +0 -0
  56. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/target_loader.py +0 -0
  57. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/testing/__init__.py +0 -0
  58. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/testing/pytest_plugin.py +0 -0
  59. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/testing/types.py +0 -0
  60. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/validators.py +0 -0
  61. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  62. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  63. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/top_level.txt +0 -0
  64. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/setup.cfg +0 -0
  65. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_analysis_recommendations.py +0 -0
  66. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_arg_binder.py +0 -0
  67. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_config_command.py +0 -0
  68. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_conversation_supervisor.py +0 -0
  69. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_evaluation_llm.py +0 -0
  70. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_init_pytest_template.py +0 -0
  71. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_input_generator.py +0 -0
  72. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_prompt_library.py +0 -0
  73. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_pytest_plugin.py +0 -0
  74. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_run_command.py +0 -0
  75. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_runner_multi_turn.py +0 -0
  76. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_success_criteria.py +0 -0
  77. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_target_loader.py +0 -0
  78. {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/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.32
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.32"
6
6
 
7
7
  from .main import app
8
8
 
@@ -181,8 +181,14 @@ def experiment(
181
181
  ]
182
182
  console.print("\n".join(message_lines))
183
183
 
184
- report_path = asyncio.run(pipeline.run(trace_summaries))
185
- console.print(f"\n✅ Report ready: [bold cyan]{report_path}[/bold cyan]")
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
+ )
186
192
 
187
193
 
188
194
 
@@ -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
 
@@ -620,9 +620,7 @@ class TraceEvaluator:
620
620
  if isinstance(goal, dict):
621
621
  goal = goal.get("text", str(goal))
622
622
 
623
- output_lang = self.eval_config.get("report", {}).get("output", {}).get("language", "en")
624
- if not isinstance(output_lang, str): # Handle if output is complex dict
625
- output_lang = "en"
623
+ output_lang = self._resolve_output_language(persona.get("language"))
626
624
 
627
625
  return PT_SYSTEM_PROMPT.format(
628
626
  evaluation_goal=goal,
@@ -632,6 +630,33 @@ class TraceEvaluator:
632
630
  output_language=output_lang
633
631
  )
634
632
 
633
+ def _resolve_output_language(self, persona_language: Optional[str]) -> str:
634
+ """
635
+ Determine the best output language with backwards compatibility.
636
+
637
+ Preference order:
638
+ 1. Explicit language inside report.output.language
639
+ 2. Legacy report.language (if added later)
640
+ 3. Persona language metadata
641
+ 4. Default to English
642
+ """
643
+ report_cfg = self.eval_config.get("report") or {}
644
+ output_cfg = report_cfg.get("output")
645
+
646
+ if isinstance(output_cfg, dict):
647
+ lang = output_cfg.get("language")
648
+ if isinstance(lang, str) and lang.strip():
649
+ return lang.strip()
650
+
651
+ legacy_lang = report_cfg.get("language")
652
+ if isinstance(legacy_lang, str) and legacy_lang.strip():
653
+ return legacy_lang.strip()
654
+
655
+ if isinstance(persona_language, str) and persona_language.strip():
656
+ return persona_language.strip()
657
+
658
+ return "en"
659
+
635
660
 
636
661
  class OverallEvaluator:
637
662
  """Analyzes aggregated results (LLM-OV)."""
@@ -700,9 +725,7 @@ class OverallEvaluator:
700
725
  if isinstance(goal, dict):
701
726
  goal = goal.get("text", str(goal))
702
727
 
703
- output_lang = self.eval_config.get("report", {}).get("output", {}).get("language", "en")
704
- if not isinstance(output_lang, str):
705
- output_lang = "en"
728
+ output_lang = self._resolve_output_language()
706
729
 
707
730
  return OV_PROMPT_TEMPLATE.format(
708
731
  evaluation_goal=goal,
@@ -714,3 +737,23 @@ class OverallEvaluator:
714
737
  output_language=output_lang
715
738
  )
716
739
 
740
+ def _resolve_output_language(self) -> str:
741
+ """
742
+ Determine the preferred language for overall evaluation prompts.
743
+
744
+ Mirrors TraceEvaluator logic but without persona metadata fallback.
745
+ """
746
+ report_cfg = self.eval_config.get("report") or {}
747
+ output_cfg = report_cfg.get("output")
748
+
749
+ if isinstance(output_cfg, dict):
750
+ lang = output_cfg.get("language")
751
+ if isinstance(lang, str) and lang.strip():
752
+ return lang.strip()
753
+
754
+ legacy_lang = report_cfg.get("language")
755
+ if isinstance(legacy_lang, str) and legacy_lang.strip():
756
+ return legacy_lang.strip()
757
+
758
+ return "en"
759
+
@@ -0,0 +1,71 @@
1
+ """Utilities for exporting the rendered HTML report as a PDF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ try: # pragma: no cover - import guard exercised via tests/log statements
10
+ from weasyprint import CSS, HTML # type: ignore
11
+ except ModuleNotFoundError: # pragma: no cover - handled gracefully at runtime
12
+ HTML = None # type: ignore
13
+ CSS = None # type: ignore
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class PdfExportError(RuntimeError):
19
+ """Raised when PDF generation fails."""
20
+
21
+
22
+ class ReportPdfExporter:
23
+ """Converts the generated HTML report into a PDF file."""
24
+
25
+ def __init__(self, output_dir: Path) -> None:
26
+ self.output_dir = output_dir
27
+
28
+ def export(self, html_report: Path) -> Optional[Path]:
29
+ """Render ``report.html`` to ``report.pdf`` if WeasyPrint is available."""
30
+
31
+ if HTML is None:
32
+ logger.info(
33
+ "WeasyPrint is not installed; skipping PDF export for %s", html_report
34
+ )
35
+ return None
36
+
37
+ if not html_report.exists():
38
+ raise PdfExportError(f"HTML report not found at {html_report}")
39
+
40
+ pdf_path = self.output_dir / "report.pdf"
41
+ try:
42
+ stylesheets = [CSS(string=self._default_portrait_css())] if CSS else None
43
+ HTML(filename=str(html_report), base_url=str(html_report.parent)).write_pdf(
44
+ target=str(pdf_path),
45
+ stylesheets=stylesheets,
46
+ )
47
+ logger.info("📄 PDF report generated at %s", pdf_path)
48
+ except Exception as exc: # pragma: no cover - depends on external renderer
49
+ raise PdfExportError(f"Failed to generate PDF: {exc}") from exc
50
+
51
+ return pdf_path
52
+
53
+ def _default_portrait_css(self) -> str:
54
+ """Ensure the PDF renders in portrait mode for better readability."""
55
+
56
+ return """
57
+ @page {
58
+ size: A4 portrait;
59
+ margin: 15mm;
60
+ }
61
+
62
+ html, body {
63
+ width: 100%;
64
+ height: auto;
65
+ }
66
+
67
+ .page-break {
68
+ page-break-after: always;
69
+ }
70
+ """
71
+
@@ -4,16 +4,26 @@ Orchestration pipeline for generating evaluation reports.
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from dataclasses import dataclass
7
8
  from pathlib import Path
8
- from typing import Any, Dict, List
9
+ from typing import Any, Dict, List, Optional
9
10
 
10
11
  from .aggregator import StatsAggregator
11
12
  from .generator import OverallEvaluator, ReportLLMClient, TraceEvaluator
13
+ from .pdf_exporter import PdfExportError, ReportPdfExporter
12
14
  from .renderer import ReportRenderer
13
15
 
14
16
  logger = logging.getLogger(__name__)
15
17
 
16
18
 
19
+ @dataclass
20
+ class ReportArtifacts:
21
+ """Artifacts produced by the evaluation report pipeline."""
22
+
23
+ html_path: Path
24
+ pdf_path: Optional[Path] = None
25
+
26
+
17
27
  class ReportPipeline:
18
28
  """
19
29
  Orchestrates the 5-stage evaluation pipeline:
@@ -35,8 +45,9 @@ class ReportPipeline:
35
45
  self.aggregator = StatsAggregator(config)
36
46
  self.overall_evaluator = OverallEvaluator(self.client, config)
37
47
  self.renderer = ReportRenderer(output_dir)
48
+ self.pdf_exporter = ReportPdfExporter(output_dir)
38
49
 
39
- async def run(self, trace_summaries: List[Dict[str, Any]]) -> Path:
50
+ async def run(self, trace_summaries: List[Dict[str, Any]]) -> ReportArtifacts:
40
51
  """
41
52
  Run the full pipeline.
42
53
 
@@ -65,10 +76,21 @@ class ReportPipeline:
65
76
 
66
77
  # Stage 4 & 5: Render
67
78
  logger.info("Stage 4 & 5: Rendering HTML report...")
68
- report_path = self.renderer.render(rule_based_data, llm_ov_data, trace_summaries, self.config)
79
+ report_path = self.renderer.render(
80
+ rule_based_data, llm_ov_data, trace_summaries, self.config
81
+ )
69
82
 
70
- logger.info(f"✅ Pipeline Complete! Report: {report_path}")
71
- return report_path
83
+ pdf_path: Optional[Path] = None
84
+ try:
85
+ pdf_path = self.pdf_exporter.export(report_path)
86
+ except PdfExportError as exc:
87
+ logger.warning("PDF export failed: %s", exc)
88
+
89
+ logger.info("✅ Pipeline Complete! Report: %s", report_path)
90
+ if pdf_path:
91
+ logger.info("✅ PDF ready: %s", pdf_path)
92
+
93
+ return ReportArtifacts(html_path=report_path, pdf_path=pdf_path)
72
94
 
73
95
  async def _run_pt_evaluations(self, traces: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
74
96
  """Run TraceEvaluator concurrently."""
@@ -110,9 +110,17 @@ class ReportRenderer:
110
110
  # Extract variations from traces if possible, otherwise placeholder
111
111
  # In fluxloop execution, we might not have 'generated.yaml' equivalent handy here unless passed.
112
112
  # We'll approximate from input config.
113
+ goal_value = eval_cfg.get("evaluation_goal", "Evaluate AI agent performance")
114
+ if isinstance(goal_value, dict):
115
+ goal_value = goal_value.get("text") or ""
116
+ if goal_value is None:
117
+ goal_value = ""
118
+ goal_value = str(goal_value).strip()
119
+ if not goal_value:
120
+ goal_value = "Evaluate AI agent performance"
113
121
 
114
122
  return {
115
- "goal": eval_cfg.get("evaluation_goal", "Evaluate AI agent performance"),
123
+ "goal": goal_value,
116
124
  "test_design": {
117
125
  "total_traces": rule_based.get("meta", {}).get("total_traces", 0),
118
126
  "personas_count": len(personas),
@@ -360,12 +368,27 @@ class ReportRenderer:
360
368
  turns.append({"role": "user", "content": t["input"], "turn_index": 0})
361
369
  if t.get("output"):
362
370
  turns.append({"role": "assistant", "content": str(t["output"]), "turn_index": 1})
363
-
371
+
372
+ summary_section = t.get("summary") or {}
373
+ success_flag = t.get("success")
374
+ if success_flag is None:
375
+ success_flag = summary_section.get("success")
376
+ if isinstance(success_flag, str):
377
+ success_flag = success_flag.lower() == "true"
378
+ duration_ms = t.get("duration_ms")
379
+ if duration_ms is None:
380
+ duration_ms = summary_section.get("duration_ms")
381
+ if isinstance(duration_ms, str) and duration_ms.isdigit():
382
+ duration_ms = float(duration_ms)
383
+
364
384
  convs[tid] = {
365
385
  "trace_id": tid,
366
386
  "persona": t.get("persona", ""),
367
387
  "turn_count": len([x for x in turns if x["role"] == "user"]),
368
- "turns": turns
388
+ "turns": turns,
389
+ "duration_ms": duration_ms,
390
+ "success": success_flag,
391
+ "overall_eval": t.get("overall_eval") or summary_section.get("overall_eval"),
369
392
  }
370
393
  return convs
371
394