fluxloop-cli 0.3.3__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/PKG-INFO +1 -1
  2. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/__init__.py +1 -1
  3. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/__init__.py +4 -0
  4. fluxloop_cli-0.3.4/fluxloop_cli/commands/data.py +657 -0
  5. fluxloop_cli-0.3.4/fluxloop_cli/commands/evaluate.py +188 -0
  6. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/inputs.py +17 -0
  7. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/personas.py +14 -4
  8. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/sync.py +41 -0
  9. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/test.py +52 -0
  10. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/conversation_supervisor.py +63 -0
  11. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/main.py +4 -0
  12. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/turns.py +23 -0
  13. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/PKG-INFO +1 -1
  14. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/SOURCES.txt +2 -0
  15. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/pyproject.toml +1 -1
  16. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/README.md +0 -0
  17. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/api_utils.py +0 -0
  18. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/arg_binder.py +0 -0
  19. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/auth_manager.py +0 -0
  20. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/apikeys.py +0 -0
  21. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/auth.py +0 -0
  22. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/bundles.py +0 -0
  23. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/config.py +0 -0
  24. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/context.py +0 -0
  25. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/criteria.py +0 -0
  26. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/generate.py +0 -0
  27. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/init.py +0 -0
  28. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/local_context.py +0 -0
  29. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/projects.py +0 -0
  30. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/run.py +0 -0
  31. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/scenarios.py +0 -0
  32. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/status.py +0 -0
  33. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/common_options.py +0 -0
  34. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/config_loader.py +0 -0
  35. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/config_schema.py +0 -0
  36. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/constants.py +0 -0
  37. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/context_manager.py +0 -0
  38. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/environment.py +0 -0
  39. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/http_client.py +0 -0
  40. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/input_generator.py +0 -0
  41. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/llm_generator.py +0 -0
  42. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/project_paths.py +0 -0
  43. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/runner.py +0 -0
  44. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/target_loader.py +0 -0
  45. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/templates.py +0 -0
  46. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/__init__.py +0 -0
  47. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/pytest_plugin.py +0 -0
  48. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/types.py +0 -0
  49. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/token_usage.py +0 -0
  50. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/validators.py +0 -0
  51. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  52. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  53. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/requires.txt +0 -0
  54. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/top_level.txt +0 -0
  55. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/setup.cfg +0 -0
  56. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_analysis_recommendations.py +0 -0
  57. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_arg_binder.py +0 -0
  58. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_config_command.py +0 -0
  59. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_conversation_supervisor.py +0 -0
  60. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_evaluate_command.py +0 -0
  61. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_evaluation_llm.py +0 -0
  62. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_init_pytest_template.py +0 -0
  63. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_input_generator.py +0 -0
  64. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_prompt_library.py +0 -0
  65. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_pytest_plugin.py +0 -0
  66. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_run_command.py +0 -0
  67. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_runner_multi_turn.py +0 -0
  68. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_success_criteria.py +0 -0
  69. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_target_loader.py +0 -0
  70. {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_testing_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.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
@@ -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)