fluxloop-cli 0.2.26__py3-none-any.whl → 0.2.28__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.
- fluxloop_cli/__init__.py +1 -1
- fluxloop_cli/commands/parse.py +62 -1
- fluxloop_cli/commands/run.py +65 -0
- fluxloop_cli/evaluation/engine/core.py +6 -0
- fluxloop_cli/evaluation/engine/reporting/html.py +364 -3
- fluxloop_cli/evaluation/rules.py +167 -0
- fluxloop_cli/runner.py +175 -20
- fluxloop_cli/templates.py +4 -4
- {fluxloop_cli-0.2.26.dist-info → fluxloop_cli-0.2.28.dist-info}/METADATA +1 -1
- {fluxloop_cli-0.2.26.dist-info → fluxloop_cli-0.2.28.dist-info}/RECORD +13 -13
- {fluxloop_cli-0.2.26.dist-info → fluxloop_cli-0.2.28.dist-info}/WHEEL +0 -0
- {fluxloop_cli-0.2.26.dist-info → fluxloop_cli-0.2.28.dist-info}/entry_points.txt +0 -0
- {fluxloop_cli-0.2.26.dist-info → fluxloop_cli-0.2.28.dist-info}/top_level.txt +0 -0
fluxloop_cli/__init__.py
CHANGED
fluxloop_cli/commands/parse.py
CHANGED
|
@@ -75,6 +75,9 @@ class TraceSummary:
|
|
|
75
75
|
duration_ms: float
|
|
76
76
|
success: bool
|
|
77
77
|
raw: dict
|
|
78
|
+
conversation: Optional[List[Dict[str, Any]]] = None
|
|
79
|
+
conversation_state: Optional[Dict[str, Any]] = None
|
|
80
|
+
termination_reason: Optional[str] = None
|
|
78
81
|
|
|
79
82
|
def to_payload(self) -> dict:
|
|
80
83
|
"""Return a JSON-serialisable representation of the summary entry."""
|
|
@@ -88,6 +91,9 @@ class TraceSummary:
|
|
|
88
91
|
"duration_ms": self.duration_ms,
|
|
89
92
|
"success": self.success,
|
|
90
93
|
"raw": self.raw,
|
|
94
|
+
"conversation": self.conversation,
|
|
95
|
+
"conversation_state": self.conversation_state,
|
|
96
|
+
"termination_reason": self.termination_reason,
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
|
|
@@ -164,6 +170,9 @@ def _load_trace_summaries(path: Path) -> Iterable[TraceSummary]:
|
|
|
164
170
|
output_text=payload.get("output"),
|
|
165
171
|
duration_ms=payload.get("duration_ms", 0.0),
|
|
166
172
|
success=payload.get("success", False),
|
|
173
|
+
conversation=payload.get("conversation"),
|
|
174
|
+
conversation_state=payload.get("conversation_state"),
|
|
175
|
+
termination_reason=payload.get("termination_reason"),
|
|
167
176
|
raw=payload,
|
|
168
177
|
)
|
|
169
178
|
|
|
@@ -214,6 +223,30 @@ def _format_markdown(
|
|
|
214
223
|
f"{_format_json_block({'output': trace.output_text})}\n\n"
|
|
215
224
|
)
|
|
216
225
|
|
|
226
|
+
conversation_section = ""
|
|
227
|
+
if trace.conversation:
|
|
228
|
+
conversation_lines = ["## Conversation\n"]
|
|
229
|
+
for entry in trace.conversation:
|
|
230
|
+
role = (entry.get("role") or "unknown").capitalize()
|
|
231
|
+
content = entry.get("content") or ""
|
|
232
|
+
source = entry.get("source")
|
|
233
|
+
metadata = entry.get("metadata") or {}
|
|
234
|
+
meta_bits: List[str] = []
|
|
235
|
+
if source:
|
|
236
|
+
meta_bits.append(f"source={source}")
|
|
237
|
+
actions = metadata.get("actions") or []
|
|
238
|
+
if actions:
|
|
239
|
+
meta_bits.append("actions=" + ", ".join(actions))
|
|
240
|
+
if metadata.get("closing"):
|
|
241
|
+
meta_bits.append("closing=true")
|
|
242
|
+
persona = metadata.get("persona")
|
|
243
|
+
if persona:
|
|
244
|
+
meta_bits.append(f"persona={persona}")
|
|
245
|
+
meta_suffix = f" _({' ; '.join(meta_bits)})_" if meta_bits else ""
|
|
246
|
+
conversation_lines.append(f"- **{role}**: {content}{meta_suffix}")
|
|
247
|
+
conversation_lines.append("")
|
|
248
|
+
conversation_section = "\n".join(conversation_lines)
|
|
249
|
+
|
|
217
250
|
timeline_lines = ["## Timeline\n"]
|
|
218
251
|
|
|
219
252
|
for index, obs in enumerate(observations_sorted, start=1):
|
|
@@ -238,7 +271,7 @@ def _format_markdown(
|
|
|
238
271
|
if not observations_sorted:
|
|
239
272
|
timeline_lines.append("(no observations recorded)\n")
|
|
240
273
|
|
|
241
|
-
return header + summary_section + "".join(timeline_lines)
|
|
274
|
+
return header + summary_section + conversation_section + "".join(timeline_lines)
|
|
242
275
|
|
|
243
276
|
|
|
244
277
|
def _sort_observations(
|
|
@@ -278,12 +311,40 @@ def _build_structured_record(
|
|
|
278
311
|
"success": summary_payload["success"],
|
|
279
312
|
"summary": summary_payload,
|
|
280
313
|
"timeline": timeline,
|
|
314
|
+
"conversation": trace.conversation or summary_payload.get("conversation") or [],
|
|
281
315
|
}
|
|
282
316
|
|
|
317
|
+
def _maybe_decode_content(value: Any) -> Any:
|
|
318
|
+
if isinstance(value, str):
|
|
319
|
+
text = value.strip()
|
|
320
|
+
if text and text[0] in "{[" and text[-1] in "}]" and len(text) >= 2:
|
|
321
|
+
try:
|
|
322
|
+
return json.loads(text)
|
|
323
|
+
except (json.JSONDecodeError, TypeError):
|
|
324
|
+
return value
|
|
325
|
+
return value
|
|
326
|
+
|
|
283
327
|
record["metrics"] = {
|
|
284
328
|
"observation_count": len(timeline),
|
|
285
329
|
}
|
|
286
330
|
|
|
331
|
+
if trace.conversation_state:
|
|
332
|
+
record["conversation_state"] = trace.conversation_state
|
|
333
|
+
if trace.termination_reason:
|
|
334
|
+
record["termination_reason"] = trace.termination_reason
|
|
335
|
+
if not record["conversation"] and trace.raw.get("conversation"):
|
|
336
|
+
record["conversation"] = trace.raw.get("conversation")
|
|
337
|
+
if record["conversation"]:
|
|
338
|
+
normalized_conversation: List[Dict[str, Any]] = []
|
|
339
|
+
for entry in record["conversation"]:
|
|
340
|
+
if not isinstance(entry, dict):
|
|
341
|
+
normalized_conversation.append(entry)
|
|
342
|
+
continue
|
|
343
|
+
normalized_entry = dict(entry)
|
|
344
|
+
normalized_entry["content"] = _maybe_decode_content(entry.get("content"))
|
|
345
|
+
normalized_conversation.append(normalized_entry)
|
|
346
|
+
record["conversation"] = normalized_conversation
|
|
347
|
+
|
|
287
348
|
return record
|
|
288
349
|
|
|
289
350
|
|
fluxloop_cli/commands/run.py
CHANGED
|
@@ -3,6 +3,7 @@ Run command for executing experiments and simulations.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Optional
|
|
@@ -27,6 +28,16 @@ app = typer.Typer()
|
|
|
27
28
|
console = Console()
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _emit_progress_event(event: str, **payload: object) -> None:
|
|
32
|
+
"""Emit a structured progress event to stderr for machine consumption."""
|
|
33
|
+
data = {"event": event, **payload}
|
|
34
|
+
try:
|
|
35
|
+
print(json.dumps(data, default=str), file=sys.stderr, flush=True)
|
|
36
|
+
except Exception:
|
|
37
|
+
# Fall back to repr if serialization fails
|
|
38
|
+
print({"event": event, **{k: repr(v) for k, v in payload.items()}}, file=sys.stderr, flush=True)
|
|
39
|
+
|
|
40
|
+
|
|
30
41
|
@app.command()
|
|
31
42
|
def experiment(
|
|
32
43
|
config_file: Path = typer.Option(
|
|
@@ -227,9 +238,20 @@ def experiment(
|
|
|
227
238
|
summary.add_row("Output", config.output_directory)
|
|
228
239
|
|
|
229
240
|
console.print(summary)
|
|
241
|
+
|
|
242
|
+
_emit_progress_event(
|
|
243
|
+
"experiment_summary",
|
|
244
|
+
name=config.name,
|
|
245
|
+
iterations=config.iterations,
|
|
246
|
+
personas=len(config.personas),
|
|
247
|
+
total_runs=total_runs,
|
|
248
|
+
multi_turn_enabled=bool(config.multi_turn and config.multi_turn.enabled),
|
|
249
|
+
output_dir=config.output_directory,
|
|
250
|
+
)
|
|
230
251
|
|
|
231
252
|
if dry_run:
|
|
232
253
|
console.print("\n[yellow]Dry run mode - no execution will occur[/yellow]")
|
|
254
|
+
_emit_progress_event("experiment_status", status="dry_run", name=config.name)
|
|
233
255
|
return
|
|
234
256
|
|
|
235
257
|
# Confirm execution
|
|
@@ -243,13 +265,22 @@ def experiment(
|
|
|
243
265
|
f"\nThis will execute {total_runs} runs."
|
|
244
266
|
)
|
|
245
267
|
|
|
268
|
+
_emit_progress_event(
|
|
269
|
+
"experiment_ready",
|
|
270
|
+
name=config.name,
|
|
271
|
+
total_runs=total_runs,
|
|
272
|
+
)
|
|
273
|
+
|
|
246
274
|
if not typer.confirm("Continue?"):
|
|
275
|
+
_emit_progress_event("experiment_status", status="aborted", reason="user_declined", name=config.name)
|
|
247
276
|
raise typer.Abort()
|
|
248
277
|
|
|
249
278
|
# Create runner
|
|
250
279
|
# Run experiment with progress tracking
|
|
251
280
|
console.print("\n[bold green]▶️ Starting experiment...[/bold green]\n")
|
|
252
281
|
|
|
282
|
+
_emit_progress_event("experiment_started", name=config.name, total_runs=total_runs)
|
|
283
|
+
|
|
253
284
|
with Progress(
|
|
254
285
|
SpinnerColumn(),
|
|
255
286
|
TextColumn("[progress.description]{task.description}"),
|
|
@@ -277,6 +308,14 @@ def experiment(
|
|
|
277
308
|
|
|
278
309
|
def _progress_callback():
|
|
279
310
|
progress.advance(main_task)
|
|
311
|
+
task = progress.tasks[main_task]
|
|
312
|
+
_emit_progress_event(
|
|
313
|
+
"run_progress",
|
|
314
|
+
completed=int(task.completed),
|
|
315
|
+
total=int(task.total or 0),
|
|
316
|
+
description=task.description,
|
|
317
|
+
experiment=config.name,
|
|
318
|
+
)
|
|
280
319
|
|
|
281
320
|
def _turn_progress_callback(
|
|
282
321
|
current_turn: int,
|
|
@@ -295,6 +334,13 @@ def experiment(
|
|
|
295
334
|
description=description,
|
|
296
335
|
visible=False,
|
|
297
336
|
)
|
|
337
|
+
_emit_progress_event(
|
|
338
|
+
"turn_progress",
|
|
339
|
+
state="complete",
|
|
340
|
+
current=min(clamped_turn, total),
|
|
341
|
+
total=total,
|
|
342
|
+
experiment=config.name,
|
|
343
|
+
)
|
|
298
344
|
return
|
|
299
345
|
|
|
300
346
|
if not progress.tasks[turn_task].visible:
|
|
@@ -313,6 +359,14 @@ def experiment(
|
|
|
313
359
|
completed=completed,
|
|
314
360
|
description=description,
|
|
315
361
|
)
|
|
362
|
+
_emit_progress_event(
|
|
363
|
+
"turn_progress",
|
|
364
|
+
state="in_progress",
|
|
365
|
+
current=clamped_turn,
|
|
366
|
+
total=total,
|
|
367
|
+
preview=preview,
|
|
368
|
+
experiment=config.name,
|
|
369
|
+
)
|
|
316
370
|
|
|
317
371
|
# Run experiment
|
|
318
372
|
try:
|
|
@@ -324,12 +378,14 @@ def experiment(
|
|
|
324
378
|
)
|
|
325
379
|
except KeyboardInterrupt:
|
|
326
380
|
console.print("\n[yellow]Experiment interrupted by user[/yellow]")
|
|
381
|
+
_emit_progress_event("experiment_status", status="interrupted", name=config.name)
|
|
327
382
|
raise typer.Exit(1)
|
|
328
383
|
except Exception as e:
|
|
329
384
|
console.print(f"\n[red]Error during experiment:[/red] {e}")
|
|
330
385
|
# Debug mode - show full traceback
|
|
331
386
|
if "--debug" in sys.argv:
|
|
332
387
|
console.print_exception()
|
|
388
|
+
_emit_progress_event("experiment_status", status="error", error=str(e), name=config.name)
|
|
333
389
|
raise typer.Exit(1)
|
|
334
390
|
|
|
335
391
|
config.set_resolved_input_count(results.get("input_count", config.get_input_count()))
|
|
@@ -343,6 +399,15 @@ def experiment(
|
|
|
343
399
|
)
|
|
344
400
|
|
|
345
401
|
_display_results(results)
|
|
402
|
+
_emit_progress_event(
|
|
403
|
+
"experiment_status",
|
|
404
|
+
status="completed",
|
|
405
|
+
name=config.name,
|
|
406
|
+
total_runs=results.get("total_runs", total_runs),
|
|
407
|
+
successful=results.get("successful"),
|
|
408
|
+
failed=results.get("failed"),
|
|
409
|
+
success_rate=results.get("success_rate"),
|
|
410
|
+
)
|
|
346
411
|
|
|
347
412
|
|
|
348
413
|
@app.command()
|
|
@@ -217,6 +217,12 @@ def _write_per_trace(results: List[TraceOutcome], path: Path) -> None:
|
|
|
217
217
|
if outcome.trace.get(key) is not None
|
|
218
218
|
},
|
|
219
219
|
}
|
|
220
|
+
if outcome.trace.get("conversation") is not None:
|
|
221
|
+
payload["conversation"] = outcome.trace.get("conversation")
|
|
222
|
+
if outcome.trace.get("conversation_state") is not None:
|
|
223
|
+
payload["conversation_state"] = outcome.trace.get("conversation_state")
|
|
224
|
+
if outcome.trace.get("termination_reason") is not None:
|
|
225
|
+
payload["termination_reason"] = outcome.trace.get("termination_reason")
|
|
220
226
|
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
221
227
|
|
|
222
228
|
|
|
@@ -14,6 +14,39 @@ if TYPE_CHECKING:
|
|
|
14
14
|
from ...config import EvaluationConfig
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
FLUXLOOP_LOGO_DATA_URI = (
|
|
18
|
+
"data:image/png;base64,"
|
|
19
|
+
"iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUA"
|
|
20
|
+
"AAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAE"
|
|
21
|
+
"AAAAAQAAABigAwAEAAAAAQAAABgAAAAAEQ8YrgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4"
|
|
22
|
+
"OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDYuMC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRm"
|
|
23
|
+
"PSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9"
|
|
24
|
+
"IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRh"
|
|
25
|
+
"dGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KGV7h"
|
|
26
|
+
"BwAABlJJREFUSA2VVQtwlFcVPvf+99/9N9lHsnlsaCAh4VEmkKAmhqZMcKmTzuAA6jhbYXA6RpxAHWxt61jHQbtapY8o1VHbxk6xltJaGK1a"
|
|
27
|
+
"rDC1Ztu0IzAkpSVmAIEAee5uNg92N/v4/3uP9266gdpSxzOze+8959zvO/ec+58L8L8k2M0g8C8bBJHOuwZQWyl1ATnO6/7vyUG5+XrQGwAE"
|
|
28
|
+
"Agc19buBGchHGrpQhx3EVLaF7efWmIJswYxoBhOdxORx3cSMkRWnjKz5u/6exveUX2PjSb23tym3R63z8mGCIDIIEqvyvuHlPGu9iOD+FM7E"
|
|
29
|
+
"YpDlo2hBnYaGxpLxw4aJixxQuJpl4iGXSb725onVg35/NwuF1lt5cDVey6tavQ/u+8GVL1su91kOJAkzk/WR/ctKIy+taPCaE8WEpw4QRhs0"
|
|
30
|
+
"K/VFkp6sJwg1nBkX/c0nv6DAFYmCysu1E7yfFt8jI5sQHX+BmcRDbiPz09kULaLZcHT48VtT+U1LNvRf1rO84Mzrq8uUbm1L35tOWtYK6dGN"
|
|
31
|
+
"R3vX/PX6dM2dQBVT5rzm6bAPbfQlxFQUHcR/lXtmuF5xOZOtmPVtP//Q0qWv2hWggfy7Tkdl6afX9W5Ta4fGvyrMGOio/WnLqjcWqVrkC59P"
|
|
32
|
+
"UW5MIvwQKiocqNExpOQ1IszNIhNrlxhv0ZIluxMttfsVoKBkhIoUMMQqtaYFtrCG5tsGaEyjtkeULhoty2VHFlRGL4ta80LYFzfZDpgOv119"
|
|
33
|
+
"YWR9728+cCOerWg/u0tzLf5l+bb+tUYkS5jDAZSSmAKDdMZLCCmjIv2YAfSeu+p7vv9kqPViEJAyqBtQRcnOIm4EpwtIZnqXAvd1jpdzindi"
|
|
34
|
+
"0myjs9wiafMZnBgcsAvawZmGAtOgJZKvKHxqadsZxSWMiCcMQnbqoG2W6p/DygFGYaoOlZNgpFWkpsaiHeWnyg5ElpkeOE0qKjophWaOxGdx"
|
|
35
|
+
"8rywQ5Uu+CbQyBYrE9va07tu7LbWk18v1MseZIK//MI7ay8XAo47EZsU5qiRkukZC+UIZLSLkOIZZcgAhoSdldPYlc3R3VXFVZVjv3IXJTTd"
|
|
36
|
+
"zp023V6sExx2CNF427q+syWs6mmeGj/nAtip9roFT7tBlKj5Ta64JPiMX80BCRKi4WTx78PbiMd7U5FIfzJ8d/Urm549em+p2/bbYiP5TqUt"
|
|
37
|
+
"gR6YiVHOB3UhvlXIuQeTg4+tSEVuee7EmlhjY5fuEbDExTGRA5V/DKIDuRtECP5bUjRxEF9xJCIHLrUvPHXXHw7XMmLtnb46+FS1aYukHLoT"
|
|
38
|
+
"U66FDoF/2/eP1rY8SH7clVxxv9dWUTiVHT6tdHXRqCxy7VwNGIOjWQHtlONECbHuCUuHpTDU5pKFH8Whzkr0PgA2K35yov7RFcU1P3u4uafJ"
|
|
39
|
+
"za0nspSNOlMZpxO1ewWKQNoKgwHZY4pgyqiVKWqEXO8ocGdepZoIa273giL11UippeHK5bMDEKweHGomfe9+zjh3S7Bl3YsTF4bvX+xwby1n"
|
|
40
|
+
"pT0LOL2w2OF7l1C+uoBYR2xkdozWT7+u9u/obbIoyNxAN7JL62vSRON7tNJCsDSRuwUL+ESsjp6H4TPnfA2xrudL+T/BIC1//Mnplr1b32jw"
|
|
41
|
+
"oDX0DR3ie4YmJz+7wLjy+SJj9ksFLNV5x6E7eJesh+TIXSA54HxPKvnzxWMNRy4pUic+B4X8cBsm9q38jorI/HHlffiL5Zh64BNn8XuwSumU"
|
|
42
|
+
"4Deh7L22zsnjtz7Tl1sDyKjnMOdaRe4Uc10wpmkb4iI+viEVmiKL+wpg4rVvMyv7I+z0leu7R/bCdOROnUV8yFednr27+cTU9tuP8/iuiN2Y"
|
|
43
|
+
"SHv5kF8RQOAgJYpDSr4XAayXfbxbkmysnhr0GjcbItn3KO+PHHHurJQtuh9tpAf3lKwhD07v18zRRYIOdTB9xF3kTTRPmVf+7iVv3bzsePBq"
|
|
44
|
+
"tz/IiEyRAlcyn5q5JYBfkoQUmZTHX364ozE9EKxL9haX0HEDMhwwZqUxK98Ji9p5xj5spj2dhb8+v0/5owIPBT/w4HyIQDmCfI8xAEI2sNwx"
|
|
45
|
+
"J59cudaTHm+g2Wy1kO8A5TRipbVj7MJMNzkEuWgxENDIoUPzkedwPvZPFr6rq0PdhI8VDPqZjOKjA5U7b2i4HlWBAISu1WvOKEhw7hu63ve/"
|
|
46
|
+
"5/8B6E/qF9KU55kAAAAASUVORK5CYII="
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
17
50
|
def serialize_trace_outcome(outcome: "TraceOutcome") -> Dict[str, Any]:
|
|
18
51
|
return {
|
|
19
52
|
"trace_id": outcome.trace.get("trace_id"),
|
|
@@ -25,6 +58,9 @@ def serialize_trace_outcome(outcome: "TraceOutcome") -> Dict[str, Any]:
|
|
|
25
58
|
"final_score": outcome.final_score,
|
|
26
59
|
"pass": outcome.passed,
|
|
27
60
|
"reasons": outcome.reasons,
|
|
61
|
+
"conversation": outcome.trace.get("conversation"),
|
|
62
|
+
"conversation_state": outcome.trace.get("conversation_state"),
|
|
63
|
+
"termination_reason": outcome.trace.get("termination_reason"),
|
|
28
64
|
}
|
|
29
65
|
|
|
30
66
|
|
|
@@ -40,6 +76,7 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
40
76
|
<head>
|
|
41
77
|
<meta charset="utf-8" />
|
|
42
78
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
79
|
+
<meta name="report:generated_at" content="[[DATE]]" />
|
|
43
80
|
<title>[[TITLE]]</title>
|
|
44
81
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css">
|
|
45
82
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|
@@ -47,14 +84,29 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
47
84
|
<body class="bg-slate-950 text-slate-100">
|
|
48
85
|
<main class="max-w-6xl mx-auto p-8 space-y-6">
|
|
49
86
|
<header class="space-y-6">
|
|
87
|
+
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
88
|
+
<div class="flex items-center gap-3">
|
|
89
|
+
<img src="[[LOGO_DATA_URI]]" alt="FluxLoop logo" class="h-12 w-12 rounded-full border border-sky-400/40 bg-slate-900/80 p-1.5 shadow-md shadow-sky-900/60" />
|
|
50
90
|
<div>
|
|
51
91
|
<h1 class="text-3xl font-bold">[[TITLE]]</h1>
|
|
52
|
-
|
|
92
|
+
<p class="text-sm text-slate-300">[[SUBTITLE]]</p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<a
|
|
96
|
+
href="https://fluxloop.io/"
|
|
97
|
+
class="inline-flex items-center gap-2 self-start rounded-full border border-sky-400 px-4 py-2 text-sm font-semibold text-sky-200 transition hover:bg-sky-500/10 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2 focus:ring-offset-slate-950"
|
|
98
|
+
target="_blank"
|
|
99
|
+
rel="noopener noreferrer"
|
|
100
|
+
>
|
|
101
|
+
<img src="[[LOGO_DATA_URI]]" alt="" aria-hidden="true" class="h-5 w-5 rounded-full border border-sky-400/50 bg-slate-950/70 p-0.5" />
|
|
102
|
+
<span>Built with FluxLoop</span>
|
|
103
|
+
</a>
|
|
53
104
|
</div>
|
|
54
105
|
<nav class="flex flex-wrap gap-2" role="tablist" aria-label="Report sections">
|
|
55
106
|
<button class="tab-button px-4 py-2 rounded-full bg-sky-500/20 border border-sky-400 text-sky-200 font-semibold" data-tab-button="summary">Executive Summary</button>
|
|
56
107
|
<button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="personas">Persona Insights</button>
|
|
57
108
|
<button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="analysis">Deep Analysis</button>
|
|
109
|
+
<button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="conversation">Conversation View</button>
|
|
58
110
|
<button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="traces">Trace Explorer</button>
|
|
59
111
|
</nav>
|
|
60
112
|
</header>
|
|
@@ -62,6 +114,11 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
62
114
|
<section data-tab-panel="summary" class="tab-panel space-y-6">
|
|
63
115
|
<div id="summaryCards" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"></div>
|
|
64
116
|
|
|
117
|
+
<section class="space-y-4">
|
|
118
|
+
<h2 class="text-xl font-semibold">Evaluation Goal</h2>
|
|
119
|
+
<div id="evaluationGoal" class="whitespace-pre-wrap rounded-xl border border-slate-800 bg-slate-900 p-4 text-sm text-slate-300"></div>
|
|
120
|
+
</section>
|
|
121
|
+
|
|
65
122
|
<section class="space-y-4">
|
|
66
123
|
<h2 class="text-xl font-semibold">Success Criteria</h2>
|
|
67
124
|
<div id="criteriaList" class="space-y-3"></div>
|
|
@@ -95,6 +152,22 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
95
152
|
<div id="analysisContent" class="space-y-4"></div>
|
|
96
153
|
</section>
|
|
97
154
|
|
|
155
|
+
<section data-tab-panel="conversation" class="tab-panel hidden space-y-4">
|
|
156
|
+
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
157
|
+
<div class="w-full md:w-1/2">
|
|
158
|
+
<label for="conversationTraceSelect" class="block text-sm text-slate-300 font-medium mb-1">Select Trace</label>
|
|
159
|
+
<select id="conversationTraceSelect" class="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400">
|
|
160
|
+
<option value="">Choose a trace</option>
|
|
161
|
+
</select>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="text-xs text-slate-400">
|
|
164
|
+
Messages are ordered chronologically. Expand each turn to view captured actions.
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div id="conversationSummary" class="text-sm text-slate-300"></div>
|
|
168
|
+
<div id="conversationTimeline" class="space-y-3"></div>
|
|
169
|
+
</section>
|
|
170
|
+
|
|
98
171
|
<section data-tab-panel="traces" class="tab-panel hidden space-y-4">
|
|
99
172
|
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
|
100
173
|
<div>
|
|
@@ -136,6 +209,7 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
136
209
|
const state = {
|
|
137
210
|
activeTab: "summary",
|
|
138
211
|
personaFilter: "all",
|
|
212
|
+
conversationTraceId: (Array.isArray(perTrace) && perTrace.length > 0 && (perTrace[0].trace_id ?? perTrace[0].iteration ?? null)) || null,
|
|
139
213
|
};
|
|
140
214
|
|
|
141
215
|
function setActiveTab(tab) {
|
|
@@ -214,6 +288,240 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
214
288
|
.join("");
|
|
215
289
|
}
|
|
216
290
|
|
|
291
|
+
function initConversationSelect() {
|
|
292
|
+
const select = document.getElementById("conversationTraceSelect");
|
|
293
|
+
if (!select) return;
|
|
294
|
+
const options = Array.isArray(perTrace) ? perTrace : [];
|
|
295
|
+
if (!options.length) {
|
|
296
|
+
select.innerHTML = `<option value="">No traces available</option>`;
|
|
297
|
+
select.disabled = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
select.innerHTML = options
|
|
301
|
+
.map((trace) => {
|
|
302
|
+
const label = trace.trace_id || `Iteration ${trace.iteration ?? ""}` || "Trace";
|
|
303
|
+
const value = trace.trace_id || `iteration-${trace.iteration ?? 0}`;
|
|
304
|
+
return `<option value="${value}">${label}</option>`;
|
|
305
|
+
})
|
|
306
|
+
.join("");
|
|
307
|
+
if (state.conversationTraceId) {
|
|
308
|
+
select.value = state.conversationTraceId;
|
|
309
|
+
} else {
|
|
310
|
+
state.conversationTraceId = options[0].trace_id || `iteration-${options[0].iteration ?? 0}`;
|
|
311
|
+
select.value = state.conversationTraceId;
|
|
312
|
+
}
|
|
313
|
+
select.addEventListener("change", (event) => {
|
|
314
|
+
state.conversationTraceId = event.target.value || null;
|
|
315
|
+
renderConversationTimeline();
|
|
316
|
+
});
|
|
317
|
+
renderConversationTimeline();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function escapeHtml(value) {
|
|
321
|
+
return String(value).replace(/[&<>"']/g, (char) => {
|
|
322
|
+
switch (char) {
|
|
323
|
+
case "&":
|
|
324
|
+
return "&";
|
|
325
|
+
case "<":
|
|
326
|
+
return "<";
|
|
327
|
+
case ">":
|
|
328
|
+
return ">";
|
|
329
|
+
case '"':
|
|
330
|
+
return """;
|
|
331
|
+
case "'":
|
|
332
|
+
return "'";
|
|
333
|
+
default:
|
|
334
|
+
return char;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function formatPlainText(value) {
|
|
340
|
+
return escapeHtml(value || "").replace(/\n/g, "<br>");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function tryParseJson(text) {
|
|
344
|
+
if (typeof text !== "string") return text;
|
|
345
|
+
const trimmed = text.trim();
|
|
346
|
+
if (
|
|
347
|
+
!trimmed ||
|
|
348
|
+
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
349
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) === false
|
|
350
|
+
) {
|
|
351
|
+
return text;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(trimmed);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
return text;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function renderTranscriptObject(obj) {
|
|
361
|
+
const transcript = Array.isArray(obj.transcript) ? obj.transcript : [];
|
|
362
|
+
if (!transcript.length) {
|
|
363
|
+
return `
|
|
364
|
+
<pre class="mt-3 bg-slate-900/70 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 whitespace-pre-wrap">${escapeHtml(JSON.stringify(obj, null, 2))}</pre>
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
const threadId = obj.thread_id ? `<p class="text-xs text-slate-400">Thread: ${escapeHtml(obj.thread_id)}</p>` : "";
|
|
368
|
+
const provider = obj.provider ? `<p class="text-xs text-slate-400">Provider: ${escapeHtml(obj.provider)}</p>` : "";
|
|
369
|
+
const exchanges = transcript
|
|
370
|
+
.map((turn, index) => {
|
|
371
|
+
const user = turn.user
|
|
372
|
+
? `<p class="text-sm text-slate-200"><span class="font-semibold">Human:</span> ${formatPlainText(turn.user)}</p>`
|
|
373
|
+
: "";
|
|
374
|
+
const assistant = turn.assistant
|
|
375
|
+
? `<p class="text-sm text-sky-200 mt-2"><span class="font-semibold">AI:</span> ${formatPlainText(
|
|
376
|
+
turn.assistant
|
|
377
|
+
)}</p>`
|
|
378
|
+
: "";
|
|
379
|
+
return `
|
|
380
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/70 p-3 space-y-2">
|
|
381
|
+
<p class="text-xs text-slate-400 uppercase tracking-wide">Exchange ${index + 1}</p>
|
|
382
|
+
${user}
|
|
383
|
+
${assistant}
|
|
384
|
+
</div>
|
|
385
|
+
`;
|
|
386
|
+
})
|
|
387
|
+
.join("");
|
|
388
|
+
return `
|
|
389
|
+
<div class="mt-3 space-y-3">
|
|
390
|
+
${threadId}
|
|
391
|
+
${provider}
|
|
392
|
+
<div class="space-y-3">${exchanges}</div>
|
|
393
|
+
</div>
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function formatConversationContent(rawContent) {
|
|
398
|
+
if (rawContent == null) return "";
|
|
399
|
+
let value = rawContent;
|
|
400
|
+
if (typeof value === "string") {
|
|
401
|
+
value = tryParseJson(value);
|
|
402
|
+
}
|
|
403
|
+
if (typeof value === "string") {
|
|
404
|
+
return `<p class="text-sm text-slate-200 whitespace-pre-wrap">${formatPlainText(value)}</p>`;
|
|
405
|
+
}
|
|
406
|
+
if (Array.isArray(value)) {
|
|
407
|
+
return `
|
|
408
|
+
<pre class="mt-3 bg-slate-900/70 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 whitespace-pre-wrap">${escapeHtml(
|
|
409
|
+
JSON.stringify(value, null, 2)
|
|
410
|
+
)}</pre>
|
|
411
|
+
`;
|
|
412
|
+
}
|
|
413
|
+
if (typeof value === "object") {
|
|
414
|
+
if (value && Array.isArray(value.transcript)) {
|
|
415
|
+
return renderTranscriptObject(value);
|
|
416
|
+
}
|
|
417
|
+
return `
|
|
418
|
+
<pre class="mt-3 bg-slate-900/70 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 whitespace-pre-wrap">${escapeHtml(
|
|
419
|
+
JSON.stringify(value, null, 2)
|
|
420
|
+
)}</pre>
|
|
421
|
+
`;
|
|
422
|
+
}
|
|
423
|
+
return `<p class="text-sm text-slate-200">${escapeHtml(String(value))}</p>`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatTraceSummary(trace) {
|
|
427
|
+
if (!trace) return "";
|
|
428
|
+
const parts = [];
|
|
429
|
+
if (trace.trace_id) parts.push(`Trace ID: ${trace.trace_id}`);
|
|
430
|
+
if (trace.iteration != null) parts.push(`Iteration: ${trace.iteration}`);
|
|
431
|
+
if (trace.persona) parts.push(`Persona: ${trace.persona}`);
|
|
432
|
+
const termination = trace.termination_reason;
|
|
433
|
+
if (termination) parts.push(`Termination: ${termination}`);
|
|
434
|
+
return parts.join(" · ");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function renderConversationTimeline() {
|
|
438
|
+
const container = document.getElementById("conversationTimeline");
|
|
439
|
+
const summaryEl = document.getElementById("conversationSummary");
|
|
440
|
+
if (!container) return;
|
|
441
|
+
container.innerHTML = "";
|
|
442
|
+
if (!Array.isArray(perTrace) || !perTrace.length || !state.conversationTraceId) {
|
|
443
|
+
if (summaryEl) summaryEl.textContent = "No conversation data available.";
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const trace = perTrace.find(
|
|
447
|
+
(item) =>
|
|
448
|
+
item.trace_id === state.conversationTraceId ||
|
|
449
|
+
`iteration-${item.iteration ?? 0}` === state.conversationTraceId
|
|
450
|
+
);
|
|
451
|
+
if (!trace) {
|
|
452
|
+
if (summaryEl) summaryEl.textContent = "Conversation data not found for the selected trace.";
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (summaryEl) {
|
|
456
|
+
summaryEl.textContent = formatTraceSummary(trace);
|
|
457
|
+
}
|
|
458
|
+
const conversation = Array.isArray(trace.conversation) ? trace.conversation : [];
|
|
459
|
+
if (!conversation.length) {
|
|
460
|
+
container.innerHTML = `<p class="text-sm text-slate-400">No conversation recorded for this trace.</p>`;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
conversation.forEach((entry, index) => {
|
|
464
|
+
const role = entry.role || "unknown";
|
|
465
|
+
const isAssistant = role === "assistant";
|
|
466
|
+
const isUser = role === "user";
|
|
467
|
+
const bubbleClasses = [
|
|
468
|
+
"rounded-xl",
|
|
469
|
+
"p-4",
|
|
470
|
+
"border",
|
|
471
|
+
"shadow-inner",
|
|
472
|
+
isAssistant ? "bg-sky-500/10 border-sky-400/40" : "bg-slate-900 border-slate-800",
|
|
473
|
+
].join(" ");
|
|
474
|
+
const roleLabel = isAssistant ? "AI" : isUser ? "Human" : role.toUpperCase();
|
|
475
|
+
const source = entry.source ? `<span class="text-xs uppercase tracking-wide text-slate-400">${escapeHtml(entry.source)}</span>` : "";
|
|
476
|
+
const metadata = entry.metadata || {};
|
|
477
|
+
const actions = Array.isArray(metadata.actions) ? metadata.actions.map((action) => escapeHtml(action)) : [];
|
|
478
|
+
const closing = metadata.closing ? " • Closing turn" : "";
|
|
479
|
+
const persona = metadata.persona ? ` • Persona: ${escapeHtml(metadata.persona)}` : "";
|
|
480
|
+
const detailLabel = actions.length ? actions.join(", ") : "No recorded actions for this turn.";
|
|
481
|
+
const detailContent = actions.length
|
|
482
|
+
? actions.map((item) => `<li>${item}</li>`).join("")
|
|
483
|
+
: "";
|
|
484
|
+
const formattedContent = formatConversationContent(entry.content);
|
|
485
|
+
const detailsMarkup = `
|
|
486
|
+
<details class="mt-3 bg-slate-900/60 border border-slate-800 rounded-lg">
|
|
487
|
+
<summary class="cursor-pointer text-xs text-slate-300 px-3 py-2">Turn details</summary>
|
|
488
|
+
<div class="px-4 py-3 text-xs text-slate-300">
|
|
489
|
+
<p>${detailLabel}</p>
|
|
490
|
+
${actions.length ? `<ul class="list-disc list-inside mt-2 space-y-1 text-slate-400">${detailContent}</ul>` : ""}
|
|
491
|
+
</div>
|
|
492
|
+
</details>
|
|
493
|
+
`;
|
|
494
|
+
container.innerHTML += `
|
|
495
|
+
<article class="${bubbleClasses}">
|
|
496
|
+
<header class="flex items-center justify-between gap-2 mb-2">
|
|
497
|
+
<div class="flex items-center gap-2">
|
|
498
|
+
<span class="text-sm font-semibold">${roleLabel}</span>
|
|
499
|
+
${source}
|
|
500
|
+
</div>
|
|
501
|
+
<span class="text-xs text-slate-400">Turn ${entry.turn_index ?? index + 1}${closing}${persona}</span>
|
|
502
|
+
</header>
|
|
503
|
+
${formattedContent}
|
|
504
|
+
${detailsMarkup}
|
|
505
|
+
</article>
|
|
506
|
+
`;
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function renderEvaluationGoal() {
|
|
511
|
+
const container = document.getElementById("evaluationGoal");
|
|
512
|
+
if (!container) return;
|
|
513
|
+
const goal = summary?.evaluation_goal;
|
|
514
|
+
if (!goal) {
|
|
515
|
+
container.textContent = "No evaluation goal provided.";
|
|
516
|
+
container.classList.remove("text-slate-300");
|
|
517
|
+
container.classList.add("text-slate-400");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
container.classList.remove("text-slate-400");
|
|
521
|
+
container.classList.add("text-slate-300");
|
|
522
|
+
container.textContent = goal;
|
|
523
|
+
}
|
|
524
|
+
|
|
217
525
|
function renderCriteria() {
|
|
218
526
|
const container = document.getElementById("criteriaList");
|
|
219
527
|
if (!container || !criteria || !Object.keys(criteria).length) {
|
|
@@ -458,12 +766,14 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
458
766
|
|
|
459
767
|
initTabs();
|
|
460
768
|
renderSummaryCards();
|
|
769
|
+
renderEvaluationGoal();
|
|
461
770
|
renderCriteria();
|
|
462
771
|
renderRecommendations();
|
|
463
772
|
renderScoreChart();
|
|
464
773
|
renderTopReasons();
|
|
465
774
|
renderPersonaSummary();
|
|
466
775
|
renderAnalysisContent();
|
|
776
|
+
initConversationSelect();
|
|
467
777
|
initTraceFilter();
|
|
468
778
|
</script>
|
|
469
779
|
</body>
|
|
@@ -471,6 +781,45 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
|
|
|
471
781
|
"""
|
|
472
782
|
|
|
473
783
|
|
|
784
|
+
def _resolve_experiment_label(summary: Dict[str, Any], output_path: Path) -> Optional[str]:
|
|
785
|
+
def _stringify(value: Any) -> Optional[str]:
|
|
786
|
+
if value is None:
|
|
787
|
+
return None
|
|
788
|
+
if isinstance(value, (list, tuple, set)):
|
|
789
|
+
parts = [str(item) for item in value if item not in (None, "")]
|
|
790
|
+
if not parts:
|
|
791
|
+
return None
|
|
792
|
+
return ", ".join(parts)
|
|
793
|
+
if isinstance(value, str):
|
|
794
|
+
return value.strip() or None
|
|
795
|
+
return str(value)
|
|
796
|
+
|
|
797
|
+
for key in ("experiment_id", "experiment_name"):
|
|
798
|
+
candidate = _stringify(summary.get(key))
|
|
799
|
+
if candidate:
|
|
800
|
+
return candidate
|
|
801
|
+
|
|
802
|
+
experiment_meta = summary.get("experiment")
|
|
803
|
+
if isinstance(experiment_meta, dict):
|
|
804
|
+
for key in ("name", "id", "label"):
|
|
805
|
+
candidate = _stringify(experiment_meta.get(key))
|
|
806
|
+
if candidate:
|
|
807
|
+
return candidate
|
|
808
|
+
|
|
809
|
+
output_dir = output_path.parent
|
|
810
|
+
if output_dir != output_path:
|
|
811
|
+
experiment_dir = output_dir.parent
|
|
812
|
+
if experiment_dir and experiment_dir != output_dir:
|
|
813
|
+
candidate = _stringify(experiment_dir.name)
|
|
814
|
+
if candidate:
|
|
815
|
+
return candidate
|
|
816
|
+
candidate = _stringify(output_dir.name)
|
|
817
|
+
if candidate:
|
|
818
|
+
return candidate
|
|
819
|
+
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
|
|
474
823
|
def select_html_template(options: "EvaluationOptions", config: "EvaluationConfig") -> Tuple[str, Optional[str]]:
|
|
475
824
|
if options.report_template:
|
|
476
825
|
template_text = load_template_from_path(options.report_template)
|
|
@@ -497,9 +846,20 @@ def write_html_report(
|
|
|
497
846
|
success_criteria = summary.get("success_criteria_results") or {}
|
|
498
847
|
analysis = summary.get("analysis") or {}
|
|
499
848
|
|
|
849
|
+
generated_at = datetime.now(UTC)
|
|
850
|
+
generated_iso = generated_at.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
851
|
+
human_readable = generated_at.strftime("%Y-%m-%d %H:%M:%SZ")
|
|
852
|
+
experiment_label = _resolve_experiment_label(summary, output_path)
|
|
853
|
+
subtitle_parts = [f"Generated on {human_readable}"]
|
|
854
|
+
if experiment_label:
|
|
855
|
+
subtitle_parts.append(f"Experiment: {experiment_label}")
|
|
856
|
+
subtitle = " · ".join(subtitle_parts)
|
|
857
|
+
|
|
500
858
|
replacements = {
|
|
501
|
-
"[[TITLE]]":
|
|
502
|
-
"[[DATE]]":
|
|
859
|
+
"[[TITLE]]": "FluxLoop Evaluation Report",
|
|
860
|
+
"[[DATE]]": generated_iso,
|
|
861
|
+
"[[SUBTITLE]]": subtitle,
|
|
862
|
+
"[[LOGO_DATA_URI]]": FLUXLOOP_LOGO_DATA_URI,
|
|
503
863
|
"[[SUMMARY_JSON]]": json.dumps(summary, ensure_ascii=False),
|
|
504
864
|
"[[PER_TRACE_JSON]]": json.dumps(per_trace_payload, ensure_ascii=False),
|
|
505
865
|
"[[CRITERIA_JSON]]": json.dumps(success_criteria, ensure_ascii=False),
|
|
@@ -515,6 +875,7 @@ def write_html_report(
|
|
|
515
875
|
|
|
516
876
|
__all__ = [
|
|
517
877
|
"DEFAULT_TEMPLATE",
|
|
878
|
+
"FLUXLOOP_LOGO_DATA_URI",
|
|
518
879
|
"serialize_trace_outcome",
|
|
519
880
|
"load_template_from_path",
|
|
520
881
|
"select_html_template",
|
fluxloop_cli/evaluation/rules.py
CHANGED
|
@@ -189,6 +189,172 @@ def _evaluate_latency_under(context: RuleContext, definition: RuleDefinition) ->
|
|
|
189
189
|
return RuleResult(rule=definition, score=score, reason=reason)
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def _coerce_usage_values(data: Dict[str, Any]) -> Dict[str, float]:
|
|
193
|
+
usage: Dict[str, float] = {}
|
|
194
|
+
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
|
195
|
+
value = data.get(key)
|
|
196
|
+
if isinstance(value, (int, float)):
|
|
197
|
+
usage[key] = float(value)
|
|
198
|
+
return usage
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _usage_from_candidate(candidate: Any) -> Dict[str, float]:
|
|
202
|
+
if not isinstance(candidate, dict):
|
|
203
|
+
return {}
|
|
204
|
+
direct = _coerce_usage_values(candidate)
|
|
205
|
+
if direct:
|
|
206
|
+
return direct
|
|
207
|
+
token_usage = candidate.get("token_usage")
|
|
208
|
+
if isinstance(token_usage, dict):
|
|
209
|
+
values = _coerce_usage_values(token_usage)
|
|
210
|
+
if values:
|
|
211
|
+
return values
|
|
212
|
+
nested = candidate.get("usage")
|
|
213
|
+
if isinstance(nested, dict):
|
|
214
|
+
return _coerce_usage_values(nested)
|
|
215
|
+
return {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _entry_token_usage(entry: Dict[str, Any]) -> Dict[str, float]:
|
|
219
|
+
candidates = [entry]
|
|
220
|
+
output = entry.get("output")
|
|
221
|
+
if isinstance(output, dict):
|
|
222
|
+
candidates.append(output)
|
|
223
|
+
messages = output.get("messages")
|
|
224
|
+
if isinstance(messages, dict):
|
|
225
|
+
candidates.append(messages)
|
|
226
|
+
response_metadata = messages.get("response_metadata")
|
|
227
|
+
if isinstance(response_metadata, dict):
|
|
228
|
+
candidates.append(response_metadata)
|
|
229
|
+
raw = entry.get("raw")
|
|
230
|
+
if isinstance(raw, dict):
|
|
231
|
+
candidates.append(raw)
|
|
232
|
+
raw_output = raw.get("output")
|
|
233
|
+
if isinstance(raw_output, dict):
|
|
234
|
+
candidates.append(raw_output)
|
|
235
|
+
response_metadata = raw_output.get("response_metadata")
|
|
236
|
+
if isinstance(response_metadata, dict):
|
|
237
|
+
candidates.append(response_metadata)
|
|
238
|
+
messages = raw_output.get("messages")
|
|
239
|
+
if isinstance(messages, dict):
|
|
240
|
+
candidates.append(messages)
|
|
241
|
+
response_metadata = messages.get("response_metadata")
|
|
242
|
+
if isinstance(response_metadata, dict):
|
|
243
|
+
candidates.append(response_metadata)
|
|
244
|
+
metadata = raw.get("metadata")
|
|
245
|
+
if isinstance(metadata, dict):
|
|
246
|
+
candidates.append(metadata)
|
|
247
|
+
response_metadata = raw.get("response_metadata")
|
|
248
|
+
if isinstance(response_metadata, dict):
|
|
249
|
+
candidates.append(response_metadata)
|
|
250
|
+
for candidate in candidates:
|
|
251
|
+
usage = _usage_from_candidate(candidate)
|
|
252
|
+
if usage:
|
|
253
|
+
return usage
|
|
254
|
+
return {}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _collect_token_usage(trace: Dict[str, Any]) -> Optional[Dict[str, float]]:
|
|
258
|
+
totals = {"prompt_tokens": 0.0, "completion_tokens": 0.0, "total_tokens": 0.0}
|
|
259
|
+
found = False
|
|
260
|
+
|
|
261
|
+
summary = trace.get("summary")
|
|
262
|
+
if isinstance(summary, dict):
|
|
263
|
+
summary_usage = {}
|
|
264
|
+
if isinstance(summary.get("token_usage"), dict):
|
|
265
|
+
summary_usage = _usage_from_candidate(summary["token_usage"])
|
|
266
|
+
if not summary_usage:
|
|
267
|
+
raw = summary.get("raw")
|
|
268
|
+
if isinstance(raw, dict) and isinstance(raw.get("token_usage"), dict):
|
|
269
|
+
summary_usage = _usage_from_candidate(raw["token_usage"])
|
|
270
|
+
if summary_usage:
|
|
271
|
+
found = True
|
|
272
|
+
for key, value in summary_usage.items():
|
|
273
|
+
totals[key] += value
|
|
274
|
+
|
|
275
|
+
timeline = trace.get("timeline")
|
|
276
|
+
if isinstance(timeline, list):
|
|
277
|
+
for entry in timeline:
|
|
278
|
+
if not isinstance(entry, dict):
|
|
279
|
+
continue
|
|
280
|
+
usage = _entry_token_usage(entry)
|
|
281
|
+
if not usage:
|
|
282
|
+
continue
|
|
283
|
+
found = True
|
|
284
|
+
for key, value in usage.items():
|
|
285
|
+
totals[key] += value
|
|
286
|
+
|
|
287
|
+
if not found:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
prompt = totals["prompt_tokens"]
|
|
291
|
+
completion = totals["completion_tokens"]
|
|
292
|
+
total = totals["total_tokens"]
|
|
293
|
+
if total <= 0:
|
|
294
|
+
total = prompt + completion
|
|
295
|
+
return {
|
|
296
|
+
"prompt": prompt,
|
|
297
|
+
"completion": completion,
|
|
298
|
+
"total": total,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _coerce_budget_value(value: Any, label: str) -> Optional[float]:
|
|
303
|
+
if value is None:
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
budget = float(value)
|
|
307
|
+
except (TypeError, ValueError) as exc: # noqa: BLE001
|
|
308
|
+
raise ValueError(f"token_usage_under {label} must be numeric") from exc
|
|
309
|
+
if budget <= 0:
|
|
310
|
+
raise ValueError(f"token_usage_under {label} must be greater than 0")
|
|
311
|
+
return budget
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _evaluate_token_usage_under(context: RuleContext, definition: RuleDefinition) -> RuleResult:
|
|
315
|
+
usage = _collect_token_usage(context.trace)
|
|
316
|
+
if usage is None:
|
|
317
|
+
return RuleResult(
|
|
318
|
+
rule=definition,
|
|
319
|
+
score=0.0,
|
|
320
|
+
reason="token usage data not available",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
max_total = _coerce_budget_value(definition.params.get("max_total_tokens"), "max_total_tokens")
|
|
324
|
+
max_prompt = _coerce_budget_value(
|
|
325
|
+
definition.params.get("max_prompt_tokens"),
|
|
326
|
+
"max_prompt_tokens",
|
|
327
|
+
)
|
|
328
|
+
max_completion = _coerce_budget_value(
|
|
329
|
+
definition.params.get("max_completion_tokens"),
|
|
330
|
+
"max_completion_tokens",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if max_total is None and max_prompt is None and max_completion is None:
|
|
334
|
+
raise ValueError("token_usage_under requires at least one max_*_tokens parameter")
|
|
335
|
+
|
|
336
|
+
score = 1.0
|
|
337
|
+
reasons: List[str] = []
|
|
338
|
+
|
|
339
|
+
def apply_budget(actual: float, budget: Optional[float], label: str) -> None:
|
|
340
|
+
nonlocal score
|
|
341
|
+
if budget is None:
|
|
342
|
+
return
|
|
343
|
+
if actual <= budget:
|
|
344
|
+
return
|
|
345
|
+
ratio = budget / actual if actual > 0 else 0.0
|
|
346
|
+
ratio = max(0.0, min(1.0, ratio))
|
|
347
|
+
score = min(score, ratio)
|
|
348
|
+
reasons.append(f"{label} {actual:.0f} exceeds budget {budget:.0f}")
|
|
349
|
+
|
|
350
|
+
apply_budget(usage["total"], max_total, "total tokens")
|
|
351
|
+
apply_budget(usage["prompt"], max_prompt, "prompt tokens")
|
|
352
|
+
apply_budget(usage["completion"], max_completion, "completion tokens")
|
|
353
|
+
|
|
354
|
+
reason = "; ".join(reasons) if reasons else None
|
|
355
|
+
return RuleResult(rule=definition, score=score, reason=reason)
|
|
356
|
+
|
|
357
|
+
|
|
192
358
|
def _evaluate_similarity(context: RuleContext, definition: RuleDefinition) -> RuleResult:
|
|
193
359
|
target = definition.params.get("target", "output")
|
|
194
360
|
expected_path = definition.params.get("expected_path")
|
|
@@ -233,6 +399,7 @@ RULE_DISPATCH = {
|
|
|
233
399
|
"not_contains": _evaluate_not_contains,
|
|
234
400
|
"matches_regex": _evaluate_matches_regex,
|
|
235
401
|
"latency_under": _evaluate_latency_under,
|
|
402
|
+
"token_usage_under": _evaluate_token_usage_under,
|
|
236
403
|
"similarity": _evaluate_similarity,
|
|
237
404
|
"success": _evaluate_success_flag,
|
|
238
405
|
}
|
fluxloop_cli/runner.py
CHANGED
|
@@ -13,7 +13,7 @@ import os
|
|
|
13
13
|
import time
|
|
14
14
|
from datetime import datetime
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
|
|
17
17
|
|
|
18
18
|
import fluxloop
|
|
19
19
|
import yaml
|
|
@@ -375,6 +375,8 @@ class ExperimentRunner:
|
|
|
375
375
|
if trace_id:
|
|
376
376
|
observations = self._load_observations_for_trace(trace_id)
|
|
377
377
|
|
|
378
|
+
seen_observation_keys: Set[Tuple[Optional[str], Optional[str], Optional[str]]] = set()
|
|
379
|
+
|
|
378
380
|
final_output = self._extract_final_output(callback_messages, observations)
|
|
379
381
|
if final_output is not None:
|
|
380
382
|
result = final_output
|
|
@@ -413,6 +415,38 @@ class ExperimentRunner:
|
|
|
413
415
|
if observations:
|
|
414
416
|
trace_entry["observation_count"] = len(observations)
|
|
415
417
|
|
|
418
|
+
# Build normalized conversation transcript
|
|
419
|
+
conversation: List[Dict[str, Any]] = []
|
|
420
|
+
turn_index = 1
|
|
421
|
+
conversation.append(
|
|
422
|
+
self._make_conversation_entry(
|
|
423
|
+
turn_index=turn_index,
|
|
424
|
+
role="user",
|
|
425
|
+
content=input_text,
|
|
426
|
+
source="input",
|
|
427
|
+
persona=persona.name if persona else None,
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
actions = self._summarize_observation_actions(observations, seen_observation_keys)
|
|
431
|
+
assistant_text = self._ensure_text(result)
|
|
432
|
+
trace_entry["output"] = assistant_text
|
|
433
|
+
conversation.append(
|
|
434
|
+
self._make_conversation_entry(
|
|
435
|
+
turn_index=turn_index,
|
|
436
|
+
role="assistant",
|
|
437
|
+
content=assistant_text,
|
|
438
|
+
source="agent",
|
|
439
|
+
actions=actions or None,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
trace_entry["conversation"] = conversation
|
|
443
|
+
trace_entry["conversation_state"] = {
|
|
444
|
+
"turns": [
|
|
445
|
+
{"role": "user", "content": input_text},
|
|
446
|
+
{"role": "assistant", "content": assistant_text},
|
|
447
|
+
]
|
|
448
|
+
}
|
|
449
|
+
|
|
416
450
|
self.results["traces"].append(trace_entry)
|
|
417
451
|
|
|
418
452
|
except Exception as e:
|
|
@@ -490,6 +524,19 @@ class ExperimentRunner:
|
|
|
490
524
|
"context": {},
|
|
491
525
|
}
|
|
492
526
|
|
|
527
|
+
normalized_conversation: List[Dict[str, Any]] = []
|
|
528
|
+
turn_index = 1
|
|
529
|
+
normalized_conversation.append(
|
|
530
|
+
self._make_conversation_entry(
|
|
531
|
+
turn_index=turn_index,
|
|
532
|
+
role="user",
|
|
533
|
+
content=input_text,
|
|
534
|
+
source="input",
|
|
535
|
+
persona=persona.name if persona else None,
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
seen_observation_keys: Set[Tuple[Optional[str], Optional[str], Optional[str]]] = set()
|
|
539
|
+
|
|
493
540
|
trace_name = f"{self.config.name}_iter{iteration}"
|
|
494
541
|
if persona:
|
|
495
542
|
trace_name += f"_persona_{persona.name}"
|
|
@@ -569,14 +616,29 @@ class ExperimentRunner:
|
|
|
569
616
|
assistant_output if isinstance(assistant_output, str) else str(assistant_output),
|
|
570
617
|
)
|
|
571
618
|
|
|
572
|
-
|
|
619
|
+
assistant_text = self._ensure_text(assistant_output)
|
|
620
|
+
final_output = assistant_text
|
|
573
621
|
|
|
574
622
|
conversation_state["turns"].append(
|
|
575
623
|
{
|
|
576
624
|
"role": "assistant",
|
|
577
|
-
"content":
|
|
625
|
+
"content": assistant_text,
|
|
578
626
|
}
|
|
579
627
|
)
|
|
628
|
+
new_actions = self._summarize_observation_actions(
|
|
629
|
+
observations,
|
|
630
|
+
seen_observation_keys,
|
|
631
|
+
)
|
|
632
|
+
normalized_conversation.append(
|
|
633
|
+
self._make_conversation_entry(
|
|
634
|
+
turn_index=turn_index,
|
|
635
|
+
role="assistant",
|
|
636
|
+
content=assistant_text,
|
|
637
|
+
source="agent",
|
|
638
|
+
actions=new_actions or None,
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
turn_index += 1
|
|
580
642
|
|
|
581
643
|
turn_count += 1
|
|
582
644
|
ctx.add_metadata("turn_count", turn_count)
|
|
@@ -606,13 +668,24 @@ class ExperimentRunner:
|
|
|
606
668
|
decision.termination_reason or "supervisor_terminate"
|
|
607
669
|
)
|
|
608
670
|
if decision.closing_user_message:
|
|
671
|
+
closing_text = self._ensure_text(decision.closing_user_message)
|
|
609
672
|
conversation_state["turns"].append(
|
|
610
673
|
{
|
|
611
674
|
"role": "user",
|
|
612
|
-
"content":
|
|
675
|
+
"content": closing_text,
|
|
613
676
|
"closing": True,
|
|
614
677
|
}
|
|
615
678
|
)
|
|
679
|
+
normalized_conversation.append(
|
|
680
|
+
self._make_conversation_entry(
|
|
681
|
+
turn_index=turn_index,
|
|
682
|
+
role="user",
|
|
683
|
+
content=closing_text,
|
|
684
|
+
source="supervisor",
|
|
685
|
+
persona=persona.name if persona else None,
|
|
686
|
+
closing=True,
|
|
687
|
+
)
|
|
688
|
+
)
|
|
616
689
|
ctx.add_metadata("termination_reason", termination_reason)
|
|
617
690
|
break
|
|
618
691
|
|
|
@@ -627,12 +700,22 @@ class ExperimentRunner:
|
|
|
627
700
|
type(next_user_message).__name__,
|
|
628
701
|
next_user_message if isinstance(next_user_message, str) else str(next_user_message),
|
|
629
702
|
)
|
|
703
|
+
next_user_text = self._ensure_text(next_user_message)
|
|
630
704
|
conversation_state["turns"].append(
|
|
631
705
|
{
|
|
632
706
|
"role": "user",
|
|
633
|
-
"content":
|
|
707
|
+
"content": next_user_text,
|
|
634
708
|
}
|
|
635
709
|
)
|
|
710
|
+
normalized_conversation.append(
|
|
711
|
+
self._make_conversation_entry(
|
|
712
|
+
turn_index=turn_index,
|
|
713
|
+
role="user",
|
|
714
|
+
content=next_user_text,
|
|
715
|
+
source="supervisor" if decision.raw_response else "user",
|
|
716
|
+
persona=persona.name if persona else None,
|
|
717
|
+
)
|
|
718
|
+
)
|
|
636
719
|
current_user_input = next_user_message
|
|
637
720
|
|
|
638
721
|
EventBuffer.get_instance().flush()
|
|
@@ -664,7 +747,8 @@ class ExperimentRunner:
|
|
|
664
747
|
"duration_ms": duration_ms,
|
|
665
748
|
"success": True,
|
|
666
749
|
"termination_reason": termination_reason,
|
|
667
|
-
"conversation":
|
|
750
|
+
"conversation": normalized_conversation,
|
|
751
|
+
"conversation_state": conversation_state,
|
|
668
752
|
}
|
|
669
753
|
if last_decision and last_decision.raw_response:
|
|
670
754
|
trace_entry["supervisor_response"] = last_decision.raw_response
|
|
@@ -969,6 +1053,75 @@ class ExperimentRunner:
|
|
|
969
1053
|
"kwargs": kwargs,
|
|
970
1054
|
}
|
|
971
1055
|
|
|
1056
|
+
@staticmethod
|
|
1057
|
+
def _ensure_text(value: Any) -> str:
|
|
1058
|
+
"""Best-effort conversion of arbitrary values into displayable text."""
|
|
1059
|
+
if value is None:
|
|
1060
|
+
return ""
|
|
1061
|
+
if isinstance(value, str):
|
|
1062
|
+
return value
|
|
1063
|
+
try:
|
|
1064
|
+
return json.dumps(value, ensure_ascii=False)
|
|
1065
|
+
except (TypeError, ValueError):
|
|
1066
|
+
return str(value)
|
|
1067
|
+
|
|
1068
|
+
@staticmethod
|
|
1069
|
+
def _make_conversation_entry(
|
|
1070
|
+
*,
|
|
1071
|
+
turn_index: int,
|
|
1072
|
+
role: str,
|
|
1073
|
+
content: Any,
|
|
1074
|
+
source: str,
|
|
1075
|
+
persona: Optional[str] = None,
|
|
1076
|
+
closing: bool = False,
|
|
1077
|
+
actions: Optional[List[str]] = None,
|
|
1078
|
+
) -> Dict[str, Any]:
|
|
1079
|
+
"""Compose a normalized conversation entry."""
|
|
1080
|
+
entry: Dict[str, Any] = {
|
|
1081
|
+
"turn_index": turn_index,
|
|
1082
|
+
"role": role,
|
|
1083
|
+
"content": ExperimentRunner._ensure_text(content),
|
|
1084
|
+
"source": source,
|
|
1085
|
+
}
|
|
1086
|
+
metadata: Dict[str, Any] = {}
|
|
1087
|
+
if persona:
|
|
1088
|
+
metadata["persona"] = persona
|
|
1089
|
+
if closing:
|
|
1090
|
+
metadata["closing"] = True
|
|
1091
|
+
if actions:
|
|
1092
|
+
metadata["actions"] = actions
|
|
1093
|
+
|
|
1094
|
+
entry["metadata"] = metadata
|
|
1095
|
+
return entry
|
|
1096
|
+
|
|
1097
|
+
@staticmethod
|
|
1098
|
+
def _summarize_observation_actions(
|
|
1099
|
+
observations: Sequence[Dict[str, Any]],
|
|
1100
|
+
seen: Set[Tuple[Optional[str], Optional[str], Optional[str]]],
|
|
1101
|
+
) -> List[str]:
|
|
1102
|
+
"""Return new action descriptors discovered in observations, tracking seen items."""
|
|
1103
|
+
actions: List[str] = []
|
|
1104
|
+
for obs in sorted(
|
|
1105
|
+
observations,
|
|
1106
|
+
key=lambda item: (
|
|
1107
|
+
item.get("start_time") or "",
|
|
1108
|
+
item.get("end_time") or "",
|
|
1109
|
+
item.get("name") or "",
|
|
1110
|
+
),
|
|
1111
|
+
):
|
|
1112
|
+
key = (
|
|
1113
|
+
obs.get("id"),
|
|
1114
|
+
obs.get("start_time"),
|
|
1115
|
+
obs.get("name"),
|
|
1116
|
+
)
|
|
1117
|
+
if key in seen:
|
|
1118
|
+
continue
|
|
1119
|
+
seen.add(key)
|
|
1120
|
+
obs_type = obs.get("type") or "event"
|
|
1121
|
+
obs_name = obs.get("name") or obs_type
|
|
1122
|
+
actions.append(f"{obs_type}:{obs_name}")
|
|
1123
|
+
return actions
|
|
1124
|
+
|
|
972
1125
|
async def _coerce_result_to_text(self, value: Any) -> Optional[str]:
|
|
973
1126
|
"""Best-effort conversion of agent result into text for summaries.
|
|
974
1127
|
|
|
@@ -1035,20 +1188,22 @@ class ExperimentRunner:
|
|
|
1035
1188
|
|
|
1036
1189
|
with summary_path.open("w", encoding="utf-8") as summary_file:
|
|
1037
1190
|
for trace in self.results["traces"]:
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
)
|
|
1191
|
+
summary_payload = {
|
|
1192
|
+
"trace_id": trace.get("trace_id"),
|
|
1193
|
+
"iteration": trace.get("iteration"),
|
|
1194
|
+
"persona": trace.get("persona"),
|
|
1195
|
+
"input": trace.get("input"),
|
|
1196
|
+
"output": trace.get("output"),
|
|
1197
|
+
"duration_ms": trace.get("duration_ms"),
|
|
1198
|
+
"success": trace.get("success"),
|
|
1199
|
+
}
|
|
1200
|
+
if trace.get("conversation") is not None:
|
|
1201
|
+
summary_payload["conversation"] = trace.get("conversation")
|
|
1202
|
+
if trace.get("conversation_state") is not None:
|
|
1203
|
+
summary_payload["conversation_state"] = trace.get("conversation_state")
|
|
1204
|
+
if trace.get("termination_reason") is not None:
|
|
1205
|
+
summary_payload["termination_reason"] = trace.get("termination_reason")
|
|
1206
|
+
summary_file.write(json.dumps(summary_payload) + "\n")
|
|
1052
1207
|
|
|
1053
1208
|
def _save_experiment_observations(self) -> None:
|
|
1054
1209
|
"""Copy matching observations from the offline store into the experiment directory."""
|
fluxloop_cli/templates.py
CHANGED
|
@@ -216,13 +216,13 @@ def create_evaluation_config() -> str:
|
|
|
216
216
|
rules:
|
|
217
217
|
- check: output_not_empty
|
|
218
218
|
|
|
219
|
-
- name:
|
|
219
|
+
- name: token_budget
|
|
220
220
|
type: rule_based
|
|
221
221
|
enabled: true
|
|
222
222
|
weight: 0.2
|
|
223
223
|
rules:
|
|
224
|
-
- check:
|
|
225
|
-
|
|
224
|
+
- check: token_usage_under
|
|
225
|
+
max_total_tokens: 4000
|
|
226
226
|
|
|
227
227
|
- name: keyword_quality
|
|
228
228
|
type: rule_based
|
|
@@ -340,7 +340,7 @@ def create_evaluation_config() -> str:
|
|
|
340
340
|
performance:
|
|
341
341
|
all_traces_successful: true
|
|
342
342
|
avg_response_time:
|
|
343
|
-
enabled:
|
|
343
|
+
enabled: false
|
|
344
344
|
threshold_ms: 2000
|
|
345
345
|
max_response_time:
|
|
346
346
|
enabled: false
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
fluxloop_cli/__init__.py,sha256=
|
|
1
|
+
fluxloop_cli/__init__.py,sha256=Yy0ysK8xslb79WaB0apu48ZBwviey964qWc5uDat8Mc,143
|
|
2
2
|
fluxloop_cli/arg_binder.py,sha256=6-i81kK6kmR8inNvIp0GeGY51M5Jere-hAdignMl2bE,12765
|
|
3
3
|
fluxloop_cli/config_loader.py,sha256=PYy0CfGVbU8jpPbx4sJzOu7i3BbrkQMNaRiSOp_uX9g,10307
|
|
4
4
|
fluxloop_cli/config_schema.py,sha256=JZJRcMFun5hp3vKLAyek7W3NvISyzRzZt0BZAeSU38I,2415
|
|
@@ -9,9 +9,9 @@ fluxloop_cli/input_generator.py,sha256=ldlVdPSDfGsP9zO2RALk7QmZjkIvUzTaxDgwOjuPB
|
|
|
9
9
|
fluxloop_cli/llm_generator.py,sha256=SosP5DeZuhBLEM6bj7BDp-7mckvVhtNJMEk2ZgV143M,12894
|
|
10
10
|
fluxloop_cli/main.py,sha256=apLSNIgXv-V269ttKvd-t5za2UUjePswmR5KZGhOLPE,2772
|
|
11
11
|
fluxloop_cli/project_paths.py,sha256=FoHp-g3aY1nytxGys85Oy3wJ6gmiKU6FVOwkgTtlHNA,4128
|
|
12
|
-
fluxloop_cli/runner.py,sha256=
|
|
12
|
+
fluxloop_cli/runner.py,sha256=ee-ue9wmN7X3VKuIk1Sg3z_vYiKwypWkKxFsTccH6rA,50199
|
|
13
13
|
fluxloop_cli/target_loader.py,sha256=ACCu2izqGKoOrEiNnAajH0FgLZcw3j1pWn5rAhEuWFU,5528
|
|
14
|
-
fluxloop_cli/templates.py,sha256=
|
|
14
|
+
fluxloop_cli/templates.py,sha256=SvJrz0xqZ7VxkXnQ4FgffiClLuno3tMAG5qNJF4SwKc,19277
|
|
15
15
|
fluxloop_cli/validators.py,sha256=_bLXmxUSzVrDtLjqyTba0bDqamRIaOUHhV4xZ7K36Xw,1155
|
|
16
16
|
fluxloop_cli/commands/__init__.py,sha256=i8xTg7uE9B6ExA0EP_j2h9DZWhD-eC94a-7khXIzRwo,243
|
|
17
17
|
fluxloop_cli/commands/config.py,sha256=9moP68pcNBnJYGMcptgdU1UW-6WlI-Nan6eykPdwl8I,12121
|
|
@@ -19,21 +19,21 @@ fluxloop_cli/commands/doctor.py,sha256=svoz9wbLv5Byj9G1Y9GI5lYpVdoG2e7IzfG7tZxuM
|
|
|
19
19
|
fluxloop_cli/commands/evaluate.py,sha256=itxb3TyfqV44DaCczC3oC94cJ2rmz2cIpY-794eXhvQ,7229
|
|
20
20
|
fluxloop_cli/commands/generate.py,sha256=6tBanI8gj5Zzwz2cv1YeW60bYiO7iWivD0AgOcJ-GUU,7172
|
|
21
21
|
fluxloop_cli/commands/init.py,sha256=eugHrsOTaXQmX5jxRNGSO3C3SpRVI5BCSqP9GqmGHPQ,8156
|
|
22
|
-
fluxloop_cli/commands/parse.py,sha256=
|
|
22
|
+
fluxloop_cli/commands/parse.py,sha256=5dgoHt6x2h7SBf6AOlbafoIWz3psEBrJdZyErIvgSgw,15417
|
|
23
23
|
fluxloop_cli/commands/record.py,sha256=IfoXdFAentyerBrIdQeweYr_kM7DjGks2aoKjjqCZ9Q,5139
|
|
24
|
-
fluxloop_cli/commands/run.py,sha256=
|
|
24
|
+
fluxloop_cli/commands/run.py,sha256=OMT9XrDBDZACyQ_8tlUiqSIRq6QROH25Uc1PHZoEHyU,16849
|
|
25
25
|
fluxloop_cli/commands/status.py,sha256=ERZrWoSP3V7dz5A_TEE5b8E0nGwsPggP4nXw4tLOzxE,7841
|
|
26
26
|
fluxloop_cli/evaluation/__init__.py,sha256=voMYkA4S5Gb3GyvpTU8JnKu0novOyZBEag1cYEqYi1Q,759
|
|
27
27
|
fluxloop_cli/evaluation/artifacts.py,sha256=zDVB-SSQZ1JYWLPHwrfz5AuCv46pUFZfEfFY_5doAjQ,2176
|
|
28
28
|
fluxloop_cli/evaluation/config.py,sha256=r_rBpx9uYNZtdJVbJnsgohIATHTaqKX03hg4Vb051KM,24656
|
|
29
29
|
fluxloop_cli/evaluation/llm.py,sha256=-0jHKPbaZTF5obO8x0zmKXnLWLg5zDqK4ryM0IDMVuE,14769
|
|
30
|
-
fluxloop_cli/evaluation/rules.py,sha256=
|
|
30
|
+
fluxloop_cli/evaluation/rules.py,sha256=CflbgpqHnguxy6YBBpIHwiS-3SyU0HAxU3obKbawQi4,14372
|
|
31
31
|
fluxloop_cli/evaluation/engine/__init__.py,sha256=08untLwO5PlFSdqqsupsXAnFBcCEUaw22wcVBVZlJxA,145
|
|
32
32
|
fluxloop_cli/evaluation/engine/analysis.py,sha256=dESfsHBSfL9jcEFVcnD7UMxU_G_cozqsx_adAfZmjk4,15432
|
|
33
|
-
fluxloop_cli/evaluation/engine/core.py,sha256=
|
|
33
|
+
fluxloop_cli/evaluation/engine/core.py,sha256=kS8-yqMwF57hycITdKjrmLy3sbBOv-bDzj1QhM5AQTA,13676
|
|
34
34
|
fluxloop_cli/evaluation/engine/success.py,sha256=BVPi-W-RJtymGXhzOfS2x0VF3UTAaoZ44LEoIy2flIQ,10930
|
|
35
35
|
fluxloop_cli/evaluation/engine/reporting/__init__.py,sha256=1e-lBuUuZ5n4fFnNTN-eyeHI-W0XGNaSCUb1kLzeIYs,1634
|
|
36
|
-
fluxloop_cli/evaluation/engine/reporting/html.py,sha256=
|
|
36
|
+
fluxloop_cli/evaluation/engine/reporting/html.py,sha256=xG4l5wIXF4_T9Wjt9mcjOdp-yE1y3EADij4IPpyndSQ,37387
|
|
37
37
|
fluxloop_cli/evaluation/engine/reporting/markdown.py,sha256=HSe15m6_rPCnd-4QNKDVy0qlqfzgHOPgyQzrkXzXDfI,7296
|
|
38
38
|
fluxloop_cli/evaluation/prompts/__init__.py,sha256=0pQGcZkZXM4ZgZU4B1Lvdc6W2ii0KUNr5g0vcns8F8k,1326
|
|
39
39
|
fluxloop_cli/evaluation/prompts/base.py,sha256=SL-wYCwCdfFr2VqgaXVQB6pR2PcVSf9O-aHhJ3mrtoI,1319
|
|
@@ -41,8 +41,8 @@ fluxloop_cli/evaluation/prompts/information_completeness.py,sha256=BjZ-wEIANWWBQ
|
|
|
41
41
|
fluxloop_cli/evaluation/prompts/intent_recognition.py,sha256=QlroUq1y0wwWsT2qcS1NXdo7OwNqUu4Tw2xx7Frr50Q,1572
|
|
42
42
|
fluxloop_cli/evaluation/prompts/response_clarity.py,sha256=1OYyx39UfS-YR__VdqhLejHNxH1NBVFm7Bha3MuMN9Y,1418
|
|
43
43
|
fluxloop_cli/evaluation/prompts/response_consistency.py,sha256=tyO3Wsf2J_CvErCuNc2bw-UQWqRW_BupaBj3CS66_Yk,1550
|
|
44
|
-
fluxloop_cli-0.2.
|
|
45
|
-
fluxloop_cli-0.2.
|
|
46
|
-
fluxloop_cli-0.2.
|
|
47
|
-
fluxloop_cli-0.2.
|
|
48
|
-
fluxloop_cli-0.2.
|
|
44
|
+
fluxloop_cli-0.2.28.dist-info/METADATA,sha256=Vs5b3XsEL5irrC2_8DzahTy1bfD-O3JznpzTqB5hxsY,6329
|
|
45
|
+
fluxloop_cli-0.2.28.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
46
|
+
fluxloop_cli-0.2.28.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
|
|
47
|
+
fluxloop_cli-0.2.28.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
|
|
48
|
+
fluxloop_cli-0.2.28.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|