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.
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/PKG-INFO +1 -1
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/__init__.py +1 -1
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/__init__.py +4 -0
- fluxloop_cli-0.3.4/fluxloop_cli/commands/data.py +657 -0
- fluxloop_cli-0.3.4/fluxloop_cli/commands/evaluate.py +188 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/inputs.py +17 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/personas.py +14 -4
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/sync.py +41 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/test.py +52 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/conversation_supervisor.py +63 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/main.py +4 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/turns.py +23 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/PKG-INFO +1 -1
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/SOURCES.txt +2 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/pyproject.toml +1 -1
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/README.md +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/api_utils.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/arg_binder.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/auth_manager.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/apikeys.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/auth.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/bundles.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/config.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/context.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/criteria.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/init.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/local_context.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/projects.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/run.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/scenarios.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/common_options.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/config_loader.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/config_schema.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/constants.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/context_manager.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/environment.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/http_client.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/input_generator.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/project_paths.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/runner.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/templates.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/__init__.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/pytest_plugin.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/testing/types.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/token_usage.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/requires.txt +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/setup.cfg +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_analysis_recommendations.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_arg_binder.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_conversation_supervisor.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_evaluate_command.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_evaluation_llm.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_init_pytest_template.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_prompt_library.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_pytest_plugin.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_run_command.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_runner_multi_turn.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_success_criteria.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_target_loader.py +0 -0
- {fluxloop_cli-0.3.3 → fluxloop_cli-0.3.4}/tests/test_testing_types.py +0 -0
|
@@ -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)
|