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.
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/PKG-INFO +2 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/__init__.py +1 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/evaluate.py +8 -2
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/config.py +41 -2
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/aggregator.py +72 -14
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/generator.py +49 -6
- fluxloop_cli-0.2.32/fluxloop_cli/evaluation/report/pdf_exporter.py +71 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/pipeline.py +27 -5
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/renderer.py +26 -3
- fluxloop_cli-0.2.32/fluxloop_cli/evaluation/templates/report.html.j2 +7072 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/input_generator.py +15 -18
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/templates.py +1 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/PKG-INFO +2 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/SOURCES.txt +2 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/requires.txt +1 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/pyproject.toml +8 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_evaluate_command.py +7 -1
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/README.md +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/arg_binder.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/config.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/doctor.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/init.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/parse.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/record.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/run.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/config_loader.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/config_schema.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/constants.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/environment.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/artifacts.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/core.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/engine/success.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/llm.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/base.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/report/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/evaluation/rules.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/main.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/project_paths.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/runner.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/testing/__init__.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/testing/pytest_plugin.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/testing/types.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/setup.cfg +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_analysis_recommendations.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_arg_binder.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_conversation_supervisor.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_evaluation_llm.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_init_pytest_template.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_prompt_library.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_pytest_plugin.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_run_command.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_runner_multi_turn.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_success_criteria.py +0 -0
- {fluxloop_cli-0.2.31 → fluxloop_cli-0.2.32}/tests/test_target_loader.py +0 -0
- {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.
|
|
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"
|
|
@@ -181,8 +181,14 @@ def experiment(
|
|
|
181
181
|
]
|
|
182
182
|
console.print("\n".join(message_lines))
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
console.print(f"\n✅ Report ready: [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
|
-
|
|
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.
|
|
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
|
|
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")
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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.
|
|
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.
|
|
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]]) ->
|
|
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(
|
|
79
|
+
report_path = self.renderer.render(
|
|
80
|
+
rule_based_data, llm_ov_data, trace_summaries, self.config
|
|
81
|
+
)
|
|
69
82
|
|
|
70
|
-
|
|
71
|
-
|
|
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":
|
|
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
|
|