fluxloop-cli 0.3.3__py3-none-any.whl → 0.3.4__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 CHANGED
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.3.3"
5
+ __version__ = "0.3.4"
6
6
 
7
7
  from .main import app
8
8
 
@@ -7,6 +7,8 @@ from . import (
7
7
  config,
8
8
  context,
9
9
  criteria,
10
+ data,
11
+ evaluate,
10
12
  generate,
11
13
  init,
12
14
  inputs,
@@ -27,6 +29,8 @@ __all__ = [
27
29
  "config",
28
30
  "context",
29
31
  "criteria",
32
+ "data",
33
+ "evaluate",
30
34
  "generate",
31
35
  "init",
32
36
  "inputs",
@@ -0,0 +1,657 @@
1
+ """
2
+ Project data management commands for FluxLoop CLI.
3
+
4
+ Provides commands for uploading, listing, and managing project data (Knowledge/Dataset).
5
+ Implements the data push → confirm → (optional) bind workflow.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import mimetypes
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import httpx
17
+ import typer
18
+ from rich.console import Console
19
+ from rich.progress import Progress, SpinnerColumn, TextColumn
20
+ from rich.table import Table
21
+
22
+ from ..api_utils import handle_api_error, resolve_api_url
23
+ from ..context_manager import (
24
+ get_current_scenario_id,
25
+ get_current_web_project_id,
26
+ )
27
+ from ..http_client import create_authenticated_client
28
+
29
+ app = typer.Typer(help="Manage project data (Knowledge & Datasets)")
30
+ console = Console()
31
+
32
+
33
+ # Extension-based auto-detection for data category
34
+ DATASET_EXTENSIONS = {".csv", ".json", ".jsonl", ".xlsx", ".xls", ".tsv"}
35
+ DOCUMENT_EXTENSIONS = {".pdf", ".docx", ".doc", ".md", ".txt", ".html", ".htm"}
36
+
37
+
38
+ def _infer_data_category(file_path: Path, override: Optional[str] = None) -> str:
39
+ """
40
+ Infer data category from file extension or explicit override.
41
+
42
+ Args:
43
+ file_path: Path to the file.
44
+ override: Explicit category override ('document' or 'dataset').
45
+
46
+ Returns:
47
+ 'KNOWLEDGE' for documents, 'DATASET' for structured data.
48
+ """
49
+ if override:
50
+ override_lower = override.lower()
51
+ if override_lower == "dataset":
52
+ return "DATASET"
53
+ elif override_lower in {"document", "knowledge"}:
54
+ return "KNOWLEDGE"
55
+
56
+ ext = file_path.suffix.lower()
57
+ if ext in DATASET_EXTENSIONS:
58
+ return "DATASET"
59
+ return "KNOWLEDGE"
60
+
61
+
62
+ def _infer_mime_type(file_path: Path) -> str:
63
+ """Infer MIME type from file extension."""
64
+ mime_type, _ = mimetypes.guess_type(str(file_path))
65
+ return mime_type or "application/octet-stream"
66
+
67
+
68
+ def _compute_content_hash(file_path: Path) -> str:
69
+ """Compute SHA-256 hash of file contents."""
70
+ sha256 = hashlib.sha256()
71
+ with open(file_path, "rb") as f:
72
+ for chunk in iter(lambda: f.read(8192), b""):
73
+ sha256.update(chunk)
74
+ return sha256.hexdigest()
75
+
76
+
77
+ def _infer_file_type(file_path: Path) -> str:
78
+ """Infer file type from extension."""
79
+ ext = file_path.suffix.lower().lstrip(".")
80
+ return ext or "unknown"
81
+
82
+
83
+ @app.command()
84
+ def push(
85
+ file: Path = typer.Argument(..., help="File to upload to project data library"),
86
+ as_type: Optional[str] = typer.Option(
87
+ None,
88
+ "--as",
89
+ help="Data category: 'document' (Knowledge) or 'dataset'. Auto-detected if not specified.",
90
+ ),
91
+ scenario: Optional[str] = typer.Option(
92
+ None,
93
+ "--scenario",
94
+ help="Scenario ID to bind after upload (uses current context if not specified)",
95
+ ),
96
+ bind: bool = typer.Option(
97
+ False,
98
+ "--bind",
99
+ help="Bind to current scenario after upload",
100
+ ),
101
+ project_id: Optional[str] = typer.Option(
102
+ None,
103
+ "--project-id",
104
+ help="Project ID (defaults to current context)",
105
+ ),
106
+ api_url: Optional[str] = typer.Option(
107
+ None,
108
+ "--api-url",
109
+ help="FluxLoop API base URL",
110
+ ),
111
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Minimal output"),
112
+ ):
113
+ """
114
+ Upload a file to the project data library.
115
+
116
+ The file is automatically categorized as KNOWLEDGE (documents) or DATASET
117
+ based on its extension. Use --as to override the auto-detection.
118
+
119
+ Examples:
120
+ # Upload a document (auto-detected)
121
+ fluxloop data push spec.pdf
122
+
123
+ # Upload as dataset explicitly
124
+ fluxloop data push users.csv --as dataset
125
+
126
+ # Upload and bind to current scenario
127
+ fluxloop data push test_data.json --bind
128
+
129
+ # Upload and bind to specific scenario
130
+ fluxloop data push requirements.md --scenario abc123
131
+ """
132
+ # Validate file exists
133
+ file = file.expanduser().resolve()
134
+ if not file.exists():
135
+ console.print(f"[red]✗[/red] File not found: {file}")
136
+ raise typer.Exit(1)
137
+ if not file.is_file():
138
+ console.print(f"[red]✗[/red] Not a file: {file}")
139
+ raise typer.Exit(1)
140
+
141
+ # Resolve project
142
+ api_url = resolve_api_url(api_url)
143
+ if not project_id:
144
+ project_id = get_current_web_project_id()
145
+ if not project_id:
146
+ console.print("[yellow]No Web Project selected.[/yellow]")
147
+ console.print("[dim]Select one with: fluxloop projects select <id>[/dim]")
148
+ raise typer.Exit(1)
149
+
150
+ # Infer data category
151
+ data_category = _infer_data_category(file, as_type)
152
+ category_display = "Dataset" if data_category == "DATASET" else "Document"
153
+
154
+ # Get file metadata
155
+ filename = file.name
156
+ file_size = file.stat().st_size
157
+ mime_type = _infer_mime_type(file)
158
+ file_type = _infer_file_type(file)
159
+
160
+ if not quiet:
161
+ console.print(f"[cyan]Uploading {filename}...[/cyan]")
162
+ console.print(f" Type: {category_display} ({data_category})")
163
+ console.print(f" Size: {file_size:,} bytes")
164
+
165
+ try:
166
+ client = create_authenticated_client(api_url, use_jwt=True)
167
+
168
+ # Step 1: Create data record and get upload URL
169
+ create_payload = {
170
+ "filename": filename,
171
+ "file_type": file_type,
172
+ "mime_type": mime_type,
173
+ "file_size": file_size,
174
+ "data_category": data_category,
175
+ "processing_profile": "auto",
176
+ }
177
+
178
+ with Progress(
179
+ SpinnerColumn(),
180
+ TextColumn("[progress.description]{task.description}"),
181
+ console=console,
182
+ transient=True,
183
+ ) as progress:
184
+ task = progress.add_task("Creating upload...", total=None)
185
+
186
+ resp = client.post(
187
+ f"/api/projects/{project_id}/data",
188
+ json=create_payload,
189
+ )
190
+ handle_api_error(resp, "data upload")
191
+ create_result = resp.json()
192
+
193
+ data_record = create_result.get("data", {})
194
+ data_id = data_record.get("id")
195
+ upload_info = create_result.get("upload", {})
196
+ upload_url = upload_info.get("upload_url")
197
+ upload_headers = upload_info.get("headers") or {}
198
+
199
+ if not data_id or not upload_url:
200
+ console.print("[red]✗[/red] Failed to get upload URL")
201
+ raise typer.Exit(1)
202
+
203
+ progress.update(task, description="Uploading file...")
204
+
205
+ # Step 2: Upload file to signed URL
206
+ with open(file, "rb") as f:
207
+ file_bytes = f.read()
208
+
209
+ # Compute content hash
210
+ content_hash = hashlib.sha256(file_bytes).hexdigest()
211
+
212
+ upload_resp = httpx.put(
213
+ upload_url,
214
+ content=file_bytes,
215
+ headers=upload_headers,
216
+ timeout=120.0,
217
+ )
218
+ if not upload_resp.is_success:
219
+ console.print(f"[red]✗[/red] Upload failed: {upload_resp.status_code}")
220
+ raise typer.Exit(1)
221
+
222
+ progress.update(task, description="Confirming upload...")
223
+
224
+ # Step 3: Confirm upload
225
+ confirm_payload = {
226
+ "file_size": file_size,
227
+ "mime_type": mime_type,
228
+ "content_hash": content_hash,
229
+ }
230
+
231
+ confirm_resp = client.post(
232
+ f"/api/projects/{project_id}/data/{data_id}/confirm",
233
+ json=confirm_payload,
234
+ )
235
+ handle_api_error(confirm_resp, "upload confirmation")
236
+ confirmed_data = confirm_resp.json()
237
+
238
+ if not quiet:
239
+ console.print(f"[green]✓[/green] Uploaded: {filename}")
240
+ console.print(f" Data ID: [bold]{data_id}[/bold]")
241
+ console.print(f" Status: {confirmed_data.get('processing_status', 'queued')}")
242
+
243
+ # Step 4: (Optional) Bind to scenario
244
+ scenario_id = scenario
245
+ if bind and not scenario_id:
246
+ scenario_id = get_current_scenario_id()
247
+
248
+ if scenario_id:
249
+ with Progress(
250
+ SpinnerColumn(),
251
+ TextColumn("[progress.description]{task.description}"),
252
+ console=console,
253
+ transient=True,
254
+ ) as progress:
255
+ task = progress.add_task("Binding to scenario...", total=None)
256
+
257
+ bind_payload = {"data_id": data_id}
258
+ bind_resp = client.post(
259
+ f"/api/scenarios/{scenario_id}/data/bind",
260
+ json=bind_payload,
261
+ )
262
+
263
+ if bind_resp.status_code == 404:
264
+ console.print(f"[yellow]⚠[/yellow] Scenario not found: {scenario_id}")
265
+ elif bind_resp.status_code == 409:
266
+ if not quiet:
267
+ console.print(f"[dim]Already bound to scenario[/dim]")
268
+ elif bind_resp.is_success:
269
+ if not quiet:
270
+ console.print(f"[green]✓[/green] Bound to scenario: {scenario_id}")
271
+ else:
272
+ console.print(f"[yellow]⚠[/yellow] Binding failed: {bind_resp.status_code}")
273
+
274
+ except Exception as e:
275
+ console.print(f"[red]✗[/red] Upload failed: {e}", style="bold red")
276
+ raise typer.Exit(1)
277
+
278
+
279
+ @app.command("list")
280
+ def list_data(
281
+ project_id: Optional[str] = typer.Option(
282
+ None,
283
+ "--project-id",
284
+ help="Project ID (defaults to current context)",
285
+ ),
286
+ category: Optional[str] = typer.Option(
287
+ None,
288
+ "--category",
289
+ help="Filter by category: 'document' or 'dataset'",
290
+ ),
291
+ api_url: Optional[str] = typer.Option(
292
+ None,
293
+ "--api-url",
294
+ help="FluxLoop API base URL",
295
+ ),
296
+ ):
297
+ """
298
+ List all data in the project library.
299
+
300
+ Shows both KNOWLEDGE (documents) and DATASET entries with their processing status.
301
+ """
302
+ api_url = resolve_api_url(api_url)
303
+
304
+ if not project_id:
305
+ project_id = get_current_web_project_id()
306
+ if not project_id:
307
+ console.print("[yellow]No Web Project selected.[/yellow]")
308
+ console.print("[dim]Select one with: fluxloop projects select <id>[/dim]")
309
+ raise typer.Exit(1)
310
+
311
+ try:
312
+ client = create_authenticated_client(api_url, use_jwt=True)
313
+
314
+ resp = client.get(f"/api/projects/{project_id}/data")
315
+ handle_api_error(resp, "project data list")
316
+
317
+ data = resp.json()
318
+ items = data.get("items", [])
319
+
320
+ # Filter by category if specified
321
+ if category:
322
+ category_filter = "DATASET" if category.lower() == "dataset" else "KNOWLEDGE"
323
+ items = [item for item in items if item.get("data_category") == category_filter]
324
+
325
+ if not items:
326
+ console.print("[yellow]No data found.[/yellow]")
327
+ console.print("[dim]Upload with: fluxloop data push <file>[/dim]")
328
+ return
329
+
330
+ # Create table
331
+ table = Table(title=f"Project Data Library ({len(items)} items)")
332
+ table.add_column("ID", style="cyan", max_width=12)
333
+ table.add_column("Filename", style="bold")
334
+ table.add_column("Category", style="magenta")
335
+ table.add_column("Status", style="green")
336
+ table.add_column("Size", justify="right")
337
+
338
+ for item in items:
339
+ data_id = item.get("id", "N/A")
340
+ # Truncate ID for display
341
+ display_id = data_id[:8] + "..." if len(data_id) > 11 else data_id
342
+
343
+ filename = item.get("filename") or "N/A"
344
+ category_val = item.get("data_category", "KNOWLEDGE")
345
+ category_display = "Dataset" if category_val == "DATASET" else "Document"
346
+ status = item.get("processing_status", "unknown")
347
+
348
+ # Format file size
349
+ file_size = item.get("file_size")
350
+ if file_size:
351
+ if file_size >= 1024 * 1024:
352
+ size_str = f"{file_size / (1024 * 1024):.1f} MB"
353
+ elif file_size >= 1024:
354
+ size_str = f"{file_size / 1024:.1f} KB"
355
+ else:
356
+ size_str = f"{file_size} B"
357
+ else:
358
+ size_str = "-"
359
+
360
+ # Color status
361
+ status_style = {
362
+ "completed": "[green]completed[/green]",
363
+ "processing": "[yellow]processing[/yellow]",
364
+ "queued": "[blue]queued[/blue]",
365
+ "pending": "[dim]pending[/dim]",
366
+ "failed": "[red]failed[/red]",
367
+ }.get(status, status)
368
+
369
+ table.add_row(display_id, filename, category_display, status_style, size_str)
370
+
371
+ console.print(table)
372
+ console.print()
373
+ console.print("[dim]View details: fluxloop data show <id>[/dim]")
374
+
375
+ except Exception as e:
376
+ console.print(f"[red]✗[/red] List failed: {e}", style="bold red")
377
+ raise typer.Exit(1)
378
+
379
+
380
+ @app.command()
381
+ def show(
382
+ data_id: str = typer.Argument(..., help="Data ID to show details"),
383
+ project_id: Optional[str] = typer.Option(
384
+ None,
385
+ "--project-id",
386
+ help="Project ID (defaults to current context)",
387
+ ),
388
+ api_url: Optional[str] = typer.Option(
389
+ None,
390
+ "--api-url",
391
+ help="FluxLoop API base URL",
392
+ ),
393
+ ):
394
+ """
395
+ Show details of a specific data record.
396
+ """
397
+ api_url = resolve_api_url(api_url)
398
+
399
+ if not project_id:
400
+ project_id = get_current_web_project_id()
401
+ if not project_id:
402
+ console.print("[yellow]No Web Project selected.[/yellow]")
403
+ console.print("[dim]Select one with: fluxloop projects select <id>[/dim]")
404
+ raise typer.Exit(1)
405
+
406
+ try:
407
+ client = create_authenticated_client(api_url, use_jwt=True)
408
+
409
+ # Get data list and find the matching record
410
+ # (API doesn't have a direct GET by ID, so we list and filter)
411
+ resp = client.get(f"/api/projects/{project_id}/data")
412
+ handle_api_error(resp, "project data")
413
+
414
+ data = resp.json()
415
+ items = data.get("items", [])
416
+
417
+ # Find matching record (support partial ID match)
418
+ matching = [
419
+ item for item in items
420
+ if item.get("id", "").startswith(data_id) or item.get("id") == data_id
421
+ ]
422
+
423
+ if not matching:
424
+ console.print(f"[red]✗[/red] Data not found: {data_id}")
425
+ raise typer.Exit(1)
426
+
427
+ if len(matching) > 1:
428
+ console.print(f"[yellow]⚠[/yellow] Multiple matches found. Please use a longer ID prefix.")
429
+ for item in matching[:5]:
430
+ console.print(f" - {item.get('id')}: {item.get('filename')}")
431
+ raise typer.Exit(1)
432
+
433
+ item = matching[0]
434
+
435
+ # Display details
436
+ console.print()
437
+ console.print(f"[bold blue]Data: {item.get('id')}[/bold blue]")
438
+ console.print()
439
+
440
+ console.print(f"[bold]Filename:[/bold] {item.get('filename', 'N/A')}")
441
+ console.print(f"[bold]Category:[/bold] {item.get('data_category', 'KNOWLEDGE')}")
442
+ console.print(f"[bold]Status:[/bold] {item.get('processing_status', 'unknown')}")
443
+
444
+ file_size = item.get("file_size")
445
+ if file_size:
446
+ console.print(f"[bold]Size:[/bold] {file_size:,} bytes")
447
+
448
+ mime_type = item.get("mime_type")
449
+ if mime_type:
450
+ console.print(f"[bold]MIME Type:[/bold] {mime_type}")
451
+
452
+ content_hash = item.get("content_hash")
453
+ if content_hash:
454
+ console.print(f"[bold]Content Hash:[/bold] {content_hash[:16]}...")
455
+
456
+ created_at = item.get("created_at")
457
+ if created_at:
458
+ console.print(f"[bold]Created:[/bold] {created_at}")
459
+
460
+ # Show processing error if any
461
+ error = item.get("processing_error")
462
+ if error:
463
+ console.print()
464
+ console.print(f"[red][bold]Error:[/bold] {error}[/red]")
465
+
466
+ # Show summary excerpt if available
467
+ summary = item.get("extracted_summary")
468
+ if summary:
469
+ console.print()
470
+ console.print("[bold]Summary:[/bold]")
471
+ # Truncate long summaries
472
+ if len(summary) > 500:
473
+ console.print(f"[dim]{summary[:500]}...[/dim]")
474
+ else:
475
+ console.print(f"[dim]{summary}[/dim]")
476
+
477
+ except Exception as e:
478
+ console.print(f"[red]✗[/red] Show failed: {e}", style="bold red")
479
+ raise typer.Exit(1)
480
+
481
+
482
+ @app.command()
483
+ def bind(
484
+ data_id: str = typer.Argument(..., help="Data ID to bind"),
485
+ scenario_id: Optional[str] = typer.Option(
486
+ None,
487
+ "--scenario",
488
+ "-s",
489
+ help="Scenario ID (defaults to current context)",
490
+ ),
491
+ role: Optional[str] = typer.Option(
492
+ None,
493
+ "--role",
494
+ help="Data role (e.g., 'input', 'expected', 'ground_truth')",
495
+ ),
496
+ api_url: Optional[str] = typer.Option(
497
+ None,
498
+ "--api-url",
499
+ help="FluxLoop API base URL",
500
+ ),
501
+ ):
502
+ """
503
+ Bind project data to a scenario.
504
+
505
+ Creates an association between data in the project library and a scenario,
506
+ allowing the scenario to use this data for testing.
507
+ """
508
+ api_url = resolve_api_url(api_url)
509
+
510
+ if not scenario_id:
511
+ scenario_id = get_current_scenario_id()
512
+ if not scenario_id:
513
+ console.print("[yellow]No scenario selected.[/yellow]")
514
+ console.print("[dim]Select one with: fluxloop scenarios select <id>[/dim]")
515
+ raise typer.Exit(1)
516
+
517
+ try:
518
+ client = create_authenticated_client(api_url, use_jwt=True)
519
+
520
+ bind_payload: Dict[str, Any] = {"data_id": data_id}
521
+
522
+ if role:
523
+ bind_payload["binding_meta"] = {"role": role}
524
+
525
+ console.print(f"[cyan]Binding data to scenario...[/cyan]")
526
+
527
+ resp = client.post(
528
+ f"/api/scenarios/{scenario_id}/data/bind",
529
+ json=bind_payload,
530
+ )
531
+
532
+ if resp.status_code == 409:
533
+ console.print(f"[yellow]⚠[/yellow] Data already bound to this scenario")
534
+ return
535
+
536
+ handle_api_error(resp, "data binding")
537
+
538
+ console.print(f"[green]✓[/green] Data bound to scenario")
539
+ console.print(f" Data ID: {data_id}")
540
+ console.print(f" Scenario ID: {scenario_id}")
541
+
542
+ except Exception as e:
543
+ console.print(f"[red]✗[/red] Binding failed: {e}", style="bold red")
544
+ raise typer.Exit(1)
545
+
546
+
547
+ @app.command()
548
+ def unbind(
549
+ binding_id: str = typer.Argument(..., help="Binding ID to remove"),
550
+ scenario_id: Optional[str] = typer.Option(
551
+ None,
552
+ "--scenario",
553
+ "-s",
554
+ help="Scenario ID (defaults to current context)",
555
+ ),
556
+ api_url: Optional[str] = typer.Option(
557
+ None,
558
+ "--api-url",
559
+ help="FluxLoop API base URL",
560
+ ),
561
+ ):
562
+ """
563
+ Remove a data binding from a scenario.
564
+ """
565
+ api_url = resolve_api_url(api_url)
566
+
567
+ if not scenario_id:
568
+ scenario_id = get_current_scenario_id()
569
+ if not scenario_id:
570
+ console.print("[yellow]No scenario selected.[/yellow]")
571
+ console.print("[dim]Select one with: fluxloop scenarios select <id>[/dim]")
572
+ raise typer.Exit(1)
573
+
574
+ try:
575
+ client = create_authenticated_client(api_url, use_jwt=True)
576
+
577
+ console.print(f"[cyan]Removing data binding...[/cyan]")
578
+
579
+ resp = client.delete(
580
+ f"/api/scenarios/{scenario_id}/data/bind/{binding_id}",
581
+ )
582
+
583
+ handle_api_error(resp, "data unbinding")
584
+
585
+ console.print(f"[green]✓[/green] Binding removed")
586
+
587
+ except Exception as e:
588
+ console.print(f"[red]✗[/red] Unbinding failed: {e}", style="bold red")
589
+ raise typer.Exit(1)
590
+
591
+
592
+ @app.command()
593
+ def reprocess(
594
+ data_id: str = typer.Argument(..., help="Data ID to reprocess"),
595
+ as_type: Optional[str] = typer.Option(
596
+ None,
597
+ "--as",
598
+ help="Change data category: 'document' or 'dataset'",
599
+ ),
600
+ project_id: Optional[str] = typer.Option(
601
+ None,
602
+ "--project-id",
603
+ help="Project ID (defaults to current context)",
604
+ ),
605
+ api_url: Optional[str] = typer.Option(
606
+ None,
607
+ "--api-url",
608
+ help="FluxLoop API base URL",
609
+ ),
610
+ ):
611
+ """
612
+ Reprocess data with a different category or to fix processing errors.
613
+
614
+ Useful when:
615
+ - Initial processing failed
616
+ - You want to change the data category (document ↔ dataset)
617
+ """
618
+ api_url = resolve_api_url(api_url)
619
+
620
+ if not project_id:
621
+ project_id = get_current_web_project_id()
622
+ if not project_id:
623
+ console.print("[yellow]No Web Project selected.[/yellow]")
624
+ console.print("[dim]Select one with: fluxloop projects select <id>[/dim]")
625
+ raise typer.Exit(1)
626
+
627
+ try:
628
+ client = create_authenticated_client(api_url, use_jwt=True)
629
+
630
+ reprocess_payload: Dict[str, Any] = {}
631
+
632
+ if as_type:
633
+ as_type_lower = as_type.lower()
634
+ if as_type_lower == "dataset":
635
+ reprocess_payload["data_category"] = "DATASET"
636
+ reprocess_payload["processing_profile"] = "dataset"
637
+ elif as_type_lower in {"document", "knowledge"}:
638
+ reprocess_payload["data_category"] = "KNOWLEDGE"
639
+ reprocess_payload["processing_profile"] = "document"
640
+
641
+ console.print(f"[cyan]Reprocessing data...[/cyan]")
642
+
643
+ resp = client.post(
644
+ f"/api/projects/{project_id}/data/{data_id}/reprocess",
645
+ json=reprocess_payload,
646
+ )
647
+
648
+ handle_api_error(resp, "data reprocess")
649
+
650
+ result = resp.json()
651
+ console.print(f"[green]✓[/green] Reprocessing queued")
652
+ console.print(f" Data ID: {data_id}")
653
+ console.print(f" Status: {result.get('processing_status', 'queued')}")
654
+
655
+ except Exception as e:
656
+ console.print(f"[red]✗[/red] Reprocess failed: {e}", style="bold red")
657
+ raise typer.Exit(1)
@@ -0,0 +1,188 @@
1
+ """
2
+ Trigger server-side evaluation for an experiment.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime, timezone
8
+ import time
9
+ from typing import List, Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from ..api_utils import handle_api_error, resolve_api_url
15
+ from ..context_manager import get_current_web_project_id
16
+ from ..http_client import create_authenticated_client, post_with_retry
17
+
18
+
19
+ app = typer.Typer(help="Trigger evaluation for an experiment (server-side).")
20
+ console = Console()
21
+
22
+ def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
23
+ if not value:
24
+ return None
25
+ try:
26
+ if value.endswith("Z"):
27
+ value = value[:-1] + "+00:00"
28
+ return datetime.fromisoformat(value)
29
+ except ValueError:
30
+ return None
31
+
32
+
33
+ @app.callback(invoke_without_command=True)
34
+ def main(
35
+ experiment_id: str = typer.Option(..., "--experiment-id", help="Experiment ID"),
36
+ project_id: Optional[str] = typer.Option(
37
+ None, "--project-id", help="Project ID (auto-detected from context)"
38
+ ),
39
+ config_id: Optional[str] = typer.Option(
40
+ None, "--config-id", help="Evaluation config ID (optional)"
41
+ ),
42
+ run_ids: Optional[List[str]] = typer.Option(
43
+ None, "--run-id", help="Specific run IDs to evaluate (repeatable)"
44
+ ),
45
+ force_rerun: bool = typer.Option(
46
+ False, "--force-rerun", help="Force re-run even if job exists"
47
+ ),
48
+ wait: bool = typer.Option(
49
+ False, "--wait", help="Wait for the evaluation job to complete"
50
+ ),
51
+ timeout: int = typer.Option(
52
+ 600, "--timeout", help="Max seconds to wait for completion"
53
+ ),
54
+ poll_interval: int = typer.Option(
55
+ 3, "--poll-interval", help="Polling interval in seconds"
56
+ ),
57
+ api_url: Optional[str] = typer.Option(
58
+ None, "--api-url", help="FluxLoop API base URL"
59
+ ),
60
+ ):
61
+ """
62
+ Trigger server-side evaluation for an experiment.
63
+
64
+ Uses the current logged-in user (JWT). If no config is provided, the server
65
+ will auto-select or use scenario defaults.
66
+ """
67
+ api_url = resolve_api_url(api_url)
68
+
69
+ if not project_id:
70
+ project_id = get_current_web_project_id()
71
+ if not project_id:
72
+ console.print("[yellow]No Web Project selected.[/yellow]")
73
+ console.print("[dim]Select one with: fluxloop projects select <id>[/dim]")
74
+ raise typer.Exit(1)
75
+
76
+ payload = {
77
+ "project_id": project_id,
78
+ "experiment_id": experiment_id,
79
+ "config_id": config_id,
80
+ "run_ids": run_ids,
81
+ "force_rerun": force_rerun,
82
+ "source": "cli",
83
+ }
84
+ payload = {k: v for k, v in payload.items() if v is not None}
85
+
86
+ client = create_authenticated_client(api_url, use_jwt=True)
87
+ resp = post_with_retry(client, "/api/evaluations", payload=payload)
88
+ handle_api_error(resp, "evaluation")
89
+ result = resp.json()
90
+
91
+ console.print("[green]✓[/green] Evaluation triggered")
92
+ console.print(f" id: {result.get('evaluation_id')}")
93
+ console.print(f" status: {result.get('status')}")
94
+
95
+ if not wait:
96
+ return
97
+
98
+ evaluation_id = result.get("evaluation_id")
99
+ if not evaluation_id:
100
+ console.print("[red]✗[/red] Missing evaluation_id in response.")
101
+ raise typer.Exit(1)
102
+
103
+ console.print("[dim]Waiting for evaluation job to complete...[/dim]")
104
+ start = time.monotonic()
105
+ warned = False
106
+ last_status = None
107
+
108
+ while True:
109
+ if time.monotonic() - start > timeout:
110
+ console.print("[red]✗[/red] Timed out waiting for evaluation job.")
111
+ raise typer.Exit(1)
112
+
113
+ resp = client.get(
114
+ f"/api/experiments/{experiment_id}/evaluations",
115
+ params={"project_id": project_id},
116
+ )
117
+ handle_api_error(resp, "evaluation jobs")
118
+ jobs = resp.json()
119
+ job = next((j for j in jobs if j.get("id") == evaluation_id), None)
120
+
121
+ if not job:
122
+ if last_status != "missing":
123
+ console.print("[yellow]⚠[/yellow] Evaluation job not visible yet. Retrying...")
124
+ last_status = "missing"
125
+ time.sleep(poll_interval)
126
+ continue
127
+
128
+ status = job.get("status") or "queued"
129
+ progress = job.get("progress") or {}
130
+ if status != last_status:
131
+ status_line = f" status: {status}"
132
+ total = progress.get("total")
133
+ completed = progress.get("completed")
134
+ failed = progress.get("failed")
135
+ if total is not None:
136
+ status_line += f" ({completed or 0}/{total}"
137
+ if failed:
138
+ status_line += f", failed {failed}"
139
+ status_line += ")"
140
+ console.print(status_line)
141
+ last_status = status
142
+
143
+ if status in ("completed", "partial", "failed", "cancelled"):
144
+ break
145
+
146
+ if status == "queued" and not warned:
147
+ created_at = _parse_iso_datetime(job.get("created_at"))
148
+ locked_at = _parse_iso_datetime(job.get("locked_at"))
149
+ if created_at and not locked_at:
150
+ age_seconds = (datetime.now(timezone.utc) - created_at).total_seconds()
151
+ if age_seconds > 30:
152
+ console.print(
153
+ "[yellow]⚠[/yellow] Evaluation job still queued. "
154
+ "Worker may not be running or backlog is high."
155
+ )
156
+ warned = True
157
+
158
+ time.sleep(poll_interval)
159
+
160
+ if status in ("completed", "partial"):
161
+ insights_resp = client.get(
162
+ f"/api/experiments/{experiment_id}/insights",
163
+ params={"project_id": project_id},
164
+ )
165
+ handle_api_error(insights_resp, "insights")
166
+ insights = insights_resp.json()
167
+ insight_headline = None
168
+ if insights:
169
+ content = insights[0].get("content") or {}
170
+ summary = content.get("summary") or {}
171
+ insight_headline = summary.get("headline")
172
+
173
+ recos_resp = client.get(
174
+ f"/api/experiments/{experiment_id}/recommendations",
175
+ params={"project_id": project_id},
176
+ )
177
+ handle_api_error(recos_resp, "recommendations")
178
+ recos = recos_resp.json()
179
+ reco_headline = None
180
+ if recos:
181
+ content = recos[0].get("content") or {}
182
+ summary = content.get("summary") or {}
183
+ reco_headline = summary.get("headline")
184
+
185
+ if insight_headline:
186
+ console.print(f"[green]Insights[/green]: {insight_headline}")
187
+ if reco_headline:
188
+ console.print(f"[green]Recommendations[/green]: {reco_headline}")
@@ -57,6 +57,17 @@ def synthesize(
57
57
  count_per_persona: Optional[int] = typer.Option(
58
58
  None, "--count-per-persona", help="Number of inputs per persona"
59
59
  ),
60
+ include_data_context: Optional[bool] = typer.Option(
61
+ None,
62
+ "--include-data-context/--no-include-data-context",
63
+ help="Include scenario data summary as grounding context (default: true)",
64
+ ),
65
+ grounding_test_ratio: Optional[float] = typer.Option(
66
+ None,
67
+ "--grounding-ratio",
68
+ "--grounding-test-ratio",
69
+ help="Ratio of grounding tests when data context is available (0-1, default 0.3)",
70
+ ),
60
71
  bundle_version_id: Optional[str] = typer.Option(
61
72
  None, "--bundle-version-id", help="Bundle version ID to use for synthesis"
62
73
  ),
@@ -144,6 +155,12 @@ def synthesize(
144
155
  if count_per_persona is not None:
145
156
  payload["count_per_persona"] = count_per_persona
146
157
 
158
+ if include_data_context is not None:
159
+ payload["include_data_context"] = include_data_context
160
+
161
+ if grounding_test_ratio is not None:
162
+ payload["grounding_test_ratio"] = grounding_test_ratio
163
+
147
164
  if bundle_version_id:
148
165
  payload["bundle_version_id"] = bundle_version_id
149
166
 
@@ -102,17 +102,22 @@ def suggest(
102
102
  table.add_column("Description")
103
103
 
104
104
  for persona in personas:
105
- difficulty = persona.get("difficulty", "unknown")
105
+ attrs = persona.get("attributes") or {}
106
+ difficulty = attrs.get("difficulty") or persona.get("difficulty", "unknown")
106
107
  difficulty_display = {
107
108
  "easy": "[green]Easy[/green]",
108
109
  "medium": "[yellow]Medium[/yellow]",
109
110
  "hard": "[red]Hard[/red]",
110
111
  }.get(difficulty, difficulty)
112
+ description = (
113
+ attrs.get("character_summary")
114
+ or persona.get("description", "N/A")
115
+ )
111
116
 
112
117
  table.add_row(
113
118
  difficulty_display,
114
119
  persona.get("name", "N/A"),
115
- persona.get("description", "N/A"),
120
+ description,
116
121
  )
117
122
 
118
123
  console.print(table)
@@ -177,18 +182,23 @@ def list_personas(
177
182
  table.add_column("Description")
178
183
 
179
184
  for persona in personas:
180
- difficulty = persona.get("difficulty", "unknown")
185
+ attrs = persona.get("attributes") or {}
186
+ difficulty = attrs.get("difficulty") or persona.get("difficulty", "unknown")
181
187
  difficulty_display = {
182
188
  "easy": "[green]Easy[/green]",
183
189
  "medium": "[yellow]Medium[/yellow]",
184
190
  "hard": "[red]Hard[/red]",
185
191
  }.get(difficulty, difficulty)
192
+ description = (
193
+ attrs.get("character_summary")
194
+ or persona.get("description", "N/A")
195
+ )
186
196
 
187
197
  table.add_row(
188
198
  persona.get("id", "N/A"),
189
199
  persona.get("name", "N/A"),
190
200
  difficulty_display,
191
- persona.get("description", "N/A")[:50] + "..." if len(persona.get("description", "")) > 50 else persona.get("description", "N/A"),
201
+ description[:50] + "..." if len(description) > 50 else description,
192
202
  )
193
203
 
194
204
  console.print(table)
@@ -361,6 +361,34 @@ def _write_criteria(criteria_dir: Path, criteria_pack: List[Dict[str, Any]]) ->
361
361
  )
362
362
 
363
363
 
364
+ def _write_contracts(contracts_dir: Path, contracts: List[Dict[str, Any]]) -> None:
365
+ """Write contracts to individual YAML files.
366
+
367
+ Contract structure from backend:
368
+ {
369
+ "type": "must" | "should" | "must_not",
370
+ "description": "...",
371
+ "category": "response" | "safety" | "ux" | ...
372
+ }
373
+ """
374
+ contracts_dir.mkdir(parents=True, exist_ok=True)
375
+ for idx, item in enumerate(contracts):
376
+ if not isinstance(item, dict):
377
+ continue
378
+ contract_type = item.get("type") or "contract"
379
+ category = item.get("category") or ""
380
+ if category:
381
+ name = f"{contract_type}_{category}_{idx + 1}"
382
+ else:
383
+ name = f"{contract_type}_{idx + 1}"
384
+ filename = _slugify(name)
385
+ path = contracts_dir / f"{filename}.yaml"
386
+ path.write_text(
387
+ yaml.safe_dump(item, sort_keys=False, allow_unicode=True),
388
+ encoding="utf-8",
389
+ )
390
+
391
+
364
392
  def _extract_input_text(messages: Any) -> str:
365
393
  if isinstance(messages, list):
366
394
  for entry in messages:
@@ -578,9 +606,20 @@ def pull(
578
606
  _write_json(sync_dir / "criteria.json", {"items": criteria_pack})
579
607
  _write_criteria(state_dir / "criteria", criteria_pack if isinstance(criteria_pack, list) else [])
580
608
 
609
+ # Save scenario_context (scenario info + contracts)
610
+ scenario_context = data.get("scenario_context") or {}
611
+ scenario_info = scenario_context.get("scenario") if isinstance(scenario_context, dict) else {}
612
+ contracts = scenario_context.get("contracts", []) if isinstance(scenario_context, dict) else []
613
+ if scenario_info:
614
+ _write_json(sync_dir / "scenario.json", scenario_info)
615
+ if contracts:
616
+ _write_json(sync_dir / "contracts.json", {"items": contracts})
617
+ _write_contracts(state_dir / "contracts", contracts if isinstance(contracts, list) else [])
618
+
581
619
  sync_state = {
582
620
  "project_id": data.get("bundle_version", {}).get("project_id") or project_id,
583
621
  "bundle_version_id": data.get("bundle_version", {}).get("id"),
622
+ "scenario_id": scenario_info.get("id") if scenario_info else None,
584
623
  "inputs_file": inputs_file,
585
624
  "pulled_at": data.get("sync_meta", {}).get("pulled_at"),
586
625
  "api_url": api_url,
@@ -589,6 +628,8 @@ def pull(
589
628
 
590
629
  if not quiet:
591
630
  console.print(f"[green]✓[/green] Pulled {len(inputs_payload['inputs'])} inputs")
631
+ if contracts:
632
+ console.print(f"[green]✓[/green] Pulled {len(contracts)} contracts")
592
633
  console.print(f"[green]✓[/green] Saved inputs to {inputs_path}")
593
634
  console.print(f"[green]✓[/green] Saved sync metadata to {_sync_state_paths(scenario_root)[0]}")
594
635
 
@@ -18,9 +18,11 @@ from ..config_loader import load_experiment_config, load_project_config
18
18
  from ..constants import DEFAULT_CONFIG_PATH, FLUXLOOP_DIR_NAME, SCENARIOS_DIR_NAME, STATE_DIR_NAME
19
19
  from ..project_paths import resolve_config_path
20
20
  from ..runner import ExperimentRunner
21
+ from fluxloop.schemas import MultiTurnConfig
21
22
  from ..turns import (
22
23
  TurnRecorder,
23
24
  format_warning_for_display,
25
+ load_contracts,
24
26
  load_criteria_items,
25
27
  load_guardrails_from_config,
26
28
  render_result_markdown,
@@ -58,6 +60,31 @@ def main(
58
60
  "--stream-after-upload/--stream-during-run",
59
61
  help="Stream turns only after upload completes (default: during run)",
60
62
  ),
63
+ multi_turn: Optional[bool] = typer.Option(
64
+ None,
65
+ "--multi-turn/--no-multi-turn",
66
+ help="Enable or disable multi-turn supervisor loop",
67
+ ),
68
+ max_turns: Optional[int] = typer.Option(
69
+ None,
70
+ "--max-turns",
71
+ help="Maximum number of turns per conversation (requires --multi-turn)",
72
+ ),
73
+ supervisor_provider: Optional[str] = typer.Option(
74
+ None,
75
+ "--supervisor-provider",
76
+ help="Supervisor LLM provider (openai, anthropic)",
77
+ ),
78
+ supervisor_model: Optional[str] = typer.Option(
79
+ None,
80
+ "--supervisor-model",
81
+ help="Supervisor LLM model (e.g., gpt-5-mini, claude-sonnet-4-5-20250514)",
82
+ ),
83
+ supervisor_api_key: Optional[str] = typer.Option(
84
+ None,
85
+ "--supervisor-api-key",
86
+ help="API key for supervisor LLM (or use OPENAI_API_KEY/ANTHROPIC_API_KEY env)",
87
+ ),
61
88
  smoke: bool = typer.Option(False, "--smoke", help="Run a smoke test"),
62
89
  full: bool = typer.Option(False, "--full", help="Run full test"),
63
90
  quiet: bool = typer.Option(False, "--quiet", help="Minimal output"),
@@ -94,6 +121,21 @@ def main(
94
121
  require_inputs_file=False,
95
122
  )
96
123
 
124
+ # Apply multi-turn options
125
+ if any(v is not None for v in [multi_turn, max_turns, supervisor_provider, supervisor_model, supervisor_api_key]):
126
+ if config.multi_turn is None:
127
+ config.multi_turn = MultiTurnConfig()
128
+ if multi_turn is not None:
129
+ config.multi_turn.enabled = multi_turn
130
+ if max_turns is not None:
131
+ config.multi_turn.max_turns = max_turns
132
+ if supervisor_provider is not None:
133
+ config.multi_turn.supervisor.provider = supervisor_provider
134
+ if supervisor_model is not None:
135
+ config.multi_turn.supervisor.model = supervisor_model
136
+ if supervisor_api_key is not None:
137
+ config.multi_turn.supervisor.api_key = supervisor_api_key
138
+
97
139
  # Check if inputs file exists
98
140
  if config.inputs_file:
99
141
  source_dir = config.get_source_dir()
@@ -141,6 +183,7 @@ def main(
141
183
  recorder = TurnRecorder(runner.output_dir / "turns.jsonl", guardrails)
142
184
  state_dir = scenario_root / STATE_DIR_NAME
143
185
  criteria_items = load_criteria_items(state_dir / "criteria")
186
+ contracts = load_contracts(state_dir / "contracts")
144
187
  result_path = runner.output_dir / "result.md"
145
188
  result_path.write_text("", encoding="utf-8")
146
189
  latest_path = write_latest_result_link(scenario_root, result_path)
@@ -425,6 +468,15 @@ def main(
425
468
  console.print(
426
469
  f"결과: {summary.total_turns} turns, {summary.warning_turns} warning turns"
427
470
  )
471
+ if contracts:
472
+ console.print("\n[Contracts]")
473
+ for contract in contracts:
474
+ ctype = contract.get("type", "contract")
475
+ desc = contract.get("description", "")
476
+ category = contract.get("category", "")
477
+ prefix = {"must": "✓", "should": "○", "must_not": "✗"}.get(ctype, "•")
478
+ cat_label = f" [{category}]" if category else ""
479
+ console.print(f" {prefix} {ctype.upper()}{cat_label}: {desc}")
428
480
  if criteria_items:
429
481
  console.print("\n[평가 기준]")
430
482
  for item in criteria_items:
@@ -149,6 +149,8 @@ class ConversationSupervisor:
149
149
  return self._mock_decision(conversation_state)
150
150
  if provider == "openai":
151
151
  response_text = await self._call_openai(prompt)
152
+ elif provider == "anthropic":
153
+ response_text = await self._call_anthropic(prompt)
152
154
  else:
153
155
  raise ValueError(f"Unsupported supervisor provider: {self.config.provider}")
154
156
 
@@ -279,6 +281,67 @@ class ConversationSupervisor:
279
281
 
280
282
  return content
281
283
 
284
+ async def _call_anthropic(self, prompt: str) -> str:
285
+ """Call Anthropic messages endpoint."""
286
+
287
+ api_key = (
288
+ self.config.api_key
289
+ or self.config.metadata.get("api_key")
290
+ or os.getenv("FLUXLOOP_SUPERVISOR_API_KEY")
291
+ or os.getenv("ANTHROPIC_API_KEY")
292
+ )
293
+
294
+ if not api_key:
295
+ raise RuntimeError(
296
+ "Anthropic supervisor requires an API key. "
297
+ "Set multi_turn.supervisor.api_key or FLUXLOOP_SUPERVISOR_API_KEY/ANTHROPIC_API_KEY."
298
+ )
299
+
300
+ messages: List[Dict[str, str]] = [{"role": "user", "content": prompt}]
301
+
302
+ payload: Dict[str, Any] = {
303
+ "model": self.config.model or "claude-sonnet-4-5-20250514",
304
+ "max_tokens": 1024,
305
+ "messages": messages,
306
+ }
307
+ if self.config.system_prompt:
308
+ payload["system"] = self.config.system_prompt
309
+ if self.config.temperature is not None:
310
+ payload["temperature"] = self.config.temperature
311
+
312
+ async with httpx.AsyncClient(timeout=60) as client:
313
+ response = await client.post(
314
+ "https://api.anthropic.com/v1/messages",
315
+ headers={
316
+ "x-api-key": api_key,
317
+ "anthropic-version": "2023-06-01",
318
+ "Content-Type": "application/json",
319
+ },
320
+ json=payload,
321
+ )
322
+
323
+ if response.status_code >= 400:
324
+ raise RuntimeError(
325
+ f"Anthropic supervisor request failed ({response.status_code}): {response.text}"
326
+ )
327
+
328
+ data = response.json()
329
+ content_blocks = data.get("content") or []
330
+ if not content_blocks:
331
+ raise RuntimeError("Anthropic supervisor response contained no content.")
332
+
333
+ # Extract text from content blocks
334
+ text_parts = []
335
+ for block in content_blocks:
336
+ if block.get("type") == "text":
337
+ text_parts.append(block.get("text", ""))
338
+
339
+ content = "".join(text_parts)
340
+ if not content:
341
+ raise RuntimeError("Anthropic supervisor response missing text content.")
342
+
343
+ return content
344
+
282
345
  def _parse_decision(self, content: str) -> SupervisorDecision:
283
346
  """Extract JSON payload from supervisor response and convert to decision."""
284
347
 
fluxloop_cli/main.py CHANGED
@@ -17,6 +17,8 @@ from .commands import (
17
17
  config,
18
18
  context,
19
19
  criteria,
20
+ data,
21
+ evaluate,
20
22
  generate,
21
23
  init,
22
24
  inputs,
@@ -66,6 +68,7 @@ app.add_typer(config.app, name="config", help="Manage configuration")
66
68
  app.add_typer(generate.app, name="generate", help="Generate input datasets")
67
69
  app.add_typer(sync.app, name="sync", help="Sync bundles and upload results")
68
70
  app.add_typer(criteria.app, name="criteria", help="Show pulled evaluation criteria")
71
+ app.add_typer(evaluate.app, name="evaluate", help="Trigger server-side evaluation")
69
72
  app.add_typer(test.app, name="test", help="Run pull -> run -> upload test workflow")
70
73
  app.add_typer(auth.app, name="auth", help="Manage authentication")
71
74
  app.add_typer(apikeys.app, name="apikeys", help="Manage API Keys for sync operations")
@@ -76,6 +79,7 @@ app.add_typer(local_context.app, name="context", help="Manage local working cont
76
79
  app.add_typer(personas.app, name="personas", help="Manage test personas")
77
80
  app.add_typer(inputs.app, name="inputs", help="Synthesize and manage test inputs")
78
81
  app.add_typer(bundles.app, name="bundles", help="Manage test bundles")
82
+ app.add_typer(data.app, name="data", help="Manage project data (Knowledge & Datasets)")
79
83
 
80
84
 
81
85
  def version_callback(value: bool):
fluxloop_cli/turns.py CHANGED
@@ -78,6 +78,29 @@ def load_criteria_items(criteria_dir: Path) -> List[str]:
78
78
  return items
79
79
 
80
80
 
81
+ def load_contracts(contracts_dir: Path) -> List[Dict[str, Any]]:
82
+ """Load contracts from YAML files in contracts directory.
83
+
84
+ Contract structure:
85
+ {
86
+ "type": "must" | "should" | "must_not",
87
+ "description": "...",
88
+ "category": "response" | "safety" | "ux" | ...
89
+ }
90
+
91
+ Returns:
92
+ List of contract dictionaries.
93
+ """
94
+ if not contracts_dir.exists():
95
+ return []
96
+ contracts: List[Dict[str, Any]] = []
97
+ for path in sorted(contracts_dir.glob("*.yaml")):
98
+ payload = yaml.safe_load(path.read_text()) or {}
99
+ if isinstance(payload, dict):
100
+ contracts.append(payload)
101
+ return contracts
102
+
103
+
81
104
  def _slugify(value: str) -> str:
82
105
  value = value.strip().lower()
83
106
  value = re.sub(r"[^a-z0-9_-]+", "_", value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: FluxLoop CLI for running agent simulations
5
5
  Author-email: FluxLoop Team <team@fluxloop.dev>
6
6
  License: Apache-2.0
@@ -1,4 +1,4 @@
1
- fluxloop_cli/__init__.py,sha256=knPR9CZHtyDIQa-LiVPEB_HyDsZGCtcwxVIpKdYr5pc,142
1
+ fluxloop_cli/__init__.py,sha256=ERNY4HfqVBiouvq4533yUMZVFVih-RaJCDFKXbw_Un0,142
2
2
  fluxloop_cli/api_utils.py,sha256=5286hlpGBbBPrpTID0B2YGwZ7aIVL4V6S_mUKJUS30s,5849
3
3
  fluxloop_cli/arg_binder.py,sha256=8nWMWtItzL0z0UNIfWTjy2kyZa4Fbf2w2RChBcU9-_U,12661
4
4
  fluxloop_cli/auth_manager.py,sha256=9O8rHVMY4oJS3f9mfL19PWzSWjMKcLgLAugdWqtj8s0,12272
@@ -7,42 +7,44 @@ fluxloop_cli/config_loader.py,sha256=lZ_JSJT6Vmt-RpiNCUWBRsbqFZYTknOUZ4XjuPDEctc
7
7
  fluxloop_cli/config_schema.py,sha256=c78ZTGPp4biVEQ1DSTl-wZxUAPsR3gke6RwPvR-CxYQ,2232
8
8
  fluxloop_cli/constants.py,sha256=uAVI5xaGRFWizQFLptac8MI8Y805Nw87Fhz-UTJFUIw,1622
9
9
  fluxloop_cli/context_manager.py,sha256=vg8-Tyu_Dk1iMWKPn3dd-3mQ3e2M9UGoAC47Hsq7vog,19794
10
- fluxloop_cli/conversation_supervisor.py,sha256=2GZLyryj6_2O22BVLhRarT24NoQHtxIG-_YrH0SscEY,11539
10
+ fluxloop_cli/conversation_supervisor.py,sha256=4mSYBQCMJ0leebnLYyJOhfuf7knI5yDV_R1LCG2ZJu8,13831
11
11
  fluxloop_cli/environment.py,sha256=mUvb4okk4CMeCDK48J6rv44Mvo3uV1Ab6e-EMHlc2Eo,1957
12
12
  fluxloop_cli/http_client.py,sha256=9SIReADgqp8s3YOPa845j8TdKYI67yVnXgDjGxIx114,4309
13
13
  fluxloop_cli/input_generator.py,sha256=xGASayR3rgdfqtcMCIXHVzI6RpRfMK6EWfgwojvcWjM,8668
14
14
  fluxloop_cli/llm_generator.py,sha256=K8Qn658j59YdfAMO301ZNVgHqTCs0lHgitoPAIyfnPg,12939
15
- fluxloop_cli/main.py,sha256=H0POgtyKmAIwQQNFHiDuWhsjyDxXSJ6PMg7ZUwEujwE,4077
15
+ fluxloop_cli/main.py,sha256=i5JGxXAOtJ6lpp9s2kh3a0I5X5i4yD4V0iIoz1FsqLU,4273
16
16
  fluxloop_cli/project_paths.py,sha256=ZxdrGPsNS9csUcgocdWUKlHfuJLJp0Zf3OjYZXh3ONk,9504
17
17
  fluxloop_cli/runner.py,sha256=c9HdOVh7urXUjCC4YB9FmkZiyd6yndg7nX9fc-qzpp8,58101
18
18
  fluxloop_cli/target_loader.py,sha256=ACCu2izqGKoOrEiNnAajH0FgLZcw3j1pWn5rAhEuWFU,5528
19
19
  fluxloop_cli/templates.py,sha256=Vc0DU8jouuTo965mej2SkFDCFcqlNvCAOtAtftqPgA4,19958
20
20
  fluxloop_cli/token_usage.py,sha256=uhFLLj7XmjX1KqpsoYMzdpjLAu3L_IzPE6Wn42eb4Mo,5767
21
- fluxloop_cli/turns.py,sha256=ss3plXmW3cHZ92lF0YarPHyNoLCN6UD7iDN0rzpWYlw,8781
21
+ fluxloop_cli/turns.py,sha256=A5ideezc6qF8lwA-Ix3hNqnZi2SDVNbsQXfm_XEaJeE,9451
22
22
  fluxloop_cli/validators.py,sha256=_bLXmxUSzVrDtLjqyTba0bDqamRIaOUHhV4xZ7K36Xw,1155
23
- fluxloop_cli/commands/__init__.py,sha256=s_1AllEdgmxuFU-6YITH0GoQBFluD5t81NxzLCl2jeU,516
23
+ fluxloop_cli/commands/__init__.py,sha256=POaiw0YHFAUULUMw9KwlI3lByk2Cwjz691Zk1hycgrU,568
24
24
  fluxloop_cli/commands/apikeys.py,sha256=WW76OYZcnMuA45j5DENkhipK8kbrco91uDLvOw4QWsQ,9324
25
25
  fluxloop_cli/commands/auth.py,sha256=YrHfjHjtM3SxAIBr4ohRMfJtNm2RXPmo6D8UXCt-REY,12849
26
26
  fluxloop_cli/commands/bundles.py,sha256=Ohs1YKhYNtB5QtlcV_wGVFwpTPxX0cjDlsLm2nA1IW8,13643
27
27
  fluxloop_cli/commands/config.py,sha256=M2e6Kt6-faKoLTfbe5PI0-5RbNlDtSrlsEgVK9ZXPKM,13665
28
28
  fluxloop_cli/commands/context.py,sha256=w2fjNAlj_dWPc5hlu1b2e8aiwZfsLd1fdY_rtm8nt0o,3675
29
29
  fluxloop_cli/commands/criteria.py,sha256=EI9due-7DxeO79h16Cp3ANUEeB-mGcUsXWnn_eqXGtU,2072
30
+ fluxloop_cli/commands/data.py,sha256=BfN5kf_LbFPgiPLzPS5KhLC69a-biqhqLn5M90i-xFA,21861
31
+ fluxloop_cli/commands/evaluate.py,sha256=xIUbk166pYWK6CQwljHmq1ko-E7qRlj5lvGBs-yDtqI,6620
30
32
  fluxloop_cli/commands/generate.py,sha256=6tBanI8gj5Zzwz2cv1YeW60bYiO7iWivD0AgOcJ-GUU,7172
31
33
  fluxloop_cli/commands/init.py,sha256=_rJmlr0vU7na-b4Tu--4pzuUoQA3RKBo2YxL8WJzrnk,9399
32
- fluxloop_cli/commands/inputs.py,sha256=Gp-ZJJKXddOaRNIeWm8pHThqnnRNzTmH4hmLHmEhJTA,20041
34
+ fluxloop_cli/commands/inputs.py,sha256=ietOkhdveNLwu-75IV4JWCCpux-cr7RNAe2Wqxv8eQo,20705
33
35
  fluxloop_cli/commands/local_context.py,sha256=wD60pbrbqUrC7U6wHyEnJ6Ggg6JQ0VNizFEbalBX7rI,6300
34
- fluxloop_cli/commands/personas.py,sha256=ZkHR0JW1Too-kdDGCl2zFRAGMMQkk90HoAgrIlBnv4Y,7931
36
+ fluxloop_cli/commands/personas.py,sha256=5AYqHdsdTDAVgg1x1ojOlP0Igsn-Ri2rsCAaRy7Rqy0,8288
35
37
  fluxloop_cli/commands/projects.py,sha256=MJvG4isb49GkeFTOVQnktfVXlQMwijUlf-WFhPTMy8Y,8733
36
38
  fluxloop_cli/commands/run.py,sha256=PCW-W7-nTrpMjuLhtag6qQdJP3ZOCgve4jKSeLE2QJY,16404
37
39
  fluxloop_cli/commands/scenarios.py,sha256=1_4nFr5Q3Q_S7eMkLSTZX8Y7lLG_rMXO8US5tSR0gqE,18014
38
40
  fluxloop_cli/commands/status.py,sha256=B3MoMUAxRrwdxUBOlnQvQoJF-FMKKCCjdR8RK3t7kmc,7916
39
- fluxloop_cli/commands/sync.py,sha256=9UhsD6NJuoWw1Axu3pVcc_vWbJ172UA2_U6-hqbkfwg,29194
40
- fluxloop_cli/commands/test.py,sha256=vJ-GnimkErUwlGmDmMWgI9A6uFauxHEw8hAP9Dsp21M,18780
41
+ fluxloop_cli/commands/sync.py,sha256=UukrXl4Mg5eb-G3ioG0Cl_FgCKJE3tRInbyeHoY60bM,30911
42
+ fluxloop_cli/commands/test.py,sha256=bx_Q1Skw_zPQVRh7GIlFJI6T2qRomcWyHHOJNgI8bns,21015
41
43
  fluxloop_cli/testing/__init__.py,sha256=3FZ0JzA6gbeDAOlvplEiNDplbOXmNT-TYQ-4C7eHUlo,482
42
44
  fluxloop_cli/testing/pytest_plugin.py,sha256=LcQaxEa3qr5SS4eM5ujRsmd8j0PaqjICpXFZG4fbF2w,12896
43
45
  fluxloop_cli/testing/types.py,sha256=CcpoSnV-eOBc_Ngvg6ZRpeb-Bnz4eT2zBdGz0-icVB4,6190
44
- fluxloop_cli-0.3.3.dist-info/METADATA,sha256=GlRRHbVJlUXBDWwO5cEyRhpA7YX-clzvV4aVHpageP4,7296
45
- fluxloop_cli-0.3.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
46
- fluxloop_cli-0.3.3.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
47
- fluxloop_cli-0.3.3.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
48
- fluxloop_cli-0.3.3.dist-info/RECORD,,
46
+ fluxloop_cli-0.3.4.dist-info/METADATA,sha256=aPjDxA-PBnl11DyK_ClmX2bR44HVGDuSomd155yzE4o,7296
47
+ fluxloop_cli-0.3.4.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
48
+ fluxloop_cli-0.3.4.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
49
+ fluxloop_cli-0.3.4.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
50
+ fluxloop_cli-0.3.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.2)
2
+ Generator: setuptools (82.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5