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 +1 -1
- fluxloop_cli/commands/__init__.py +4 -0
- fluxloop_cli/commands/data.py +657 -0
- fluxloop_cli/commands/evaluate.py +188 -0
- fluxloop_cli/commands/inputs.py +17 -0
- fluxloop_cli/commands/personas.py +14 -4
- fluxloop_cli/commands/sync.py +41 -0
- fluxloop_cli/commands/test.py +52 -0
- fluxloop_cli/conversation_supervisor.py +63 -0
- fluxloop_cli/main.py +4 -0
- fluxloop_cli/turns.py +23 -0
- {fluxloop_cli-0.3.3.dist-info → fluxloop_cli-0.3.4.dist-info}/METADATA +1 -1
- {fluxloop_cli-0.3.3.dist-info → fluxloop_cli-0.3.4.dist-info}/RECORD +16 -14
- {fluxloop_cli-0.3.3.dist-info → fluxloop_cli-0.3.4.dist-info}/WHEEL +1 -1
- {fluxloop_cli-0.3.3.dist-info → fluxloop_cli-0.3.4.dist-info}/entry_points.txt +0 -0
- {fluxloop_cli-0.3.3.dist-info → fluxloop_cli-0.3.4.dist-info}/top_level.txt +0 -0
fluxloop_cli/__init__.py
CHANGED
|
@@ -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}")
|
fluxloop_cli/commands/inputs.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
description[:50] + "..." if len(description) > 50 else description,
|
|
192
202
|
)
|
|
193
203
|
|
|
194
204
|
console.print(table)
|
fluxloop_cli/commands/sync.py
CHANGED
|
@@ -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
|
|
fluxloop_cli/commands/test.py
CHANGED
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
fluxloop_cli/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
40
|
-
fluxloop_cli/commands/test.py,sha256=
|
|
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.
|
|
45
|
-
fluxloop_cli-0.3.
|
|
46
|
-
fluxloop_cli-0.3.
|
|
47
|
-
fluxloop_cli-0.3.
|
|
48
|
-
fluxloop_cli-0.3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|