ragfly-cli 1.16.0__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.
ragfly_cli/cli.py ADDED
@@ -0,0 +1,918 @@
1
+ """
2
+ CLI principal de RAGfly — Cliente local y Cloud.
3
+
4
+ Comandos locales:
5
+ ragfly version — Versión del cliente
6
+ ragfly estado — Estado de la BD local
7
+ ragfly setup — Configuración inicial
8
+ ragfly escanear — Escanear directorio de documentos
9
+ ragfly procesar — Procesar documentos (CHUNKEAR; el cloud vectoriza)
10
+ ragfly sync — Sincronizar con el cloud
11
+ ragfly api — API local para integración
12
+ ragfly gui — Interfaz gráfica nativa (PySide6)
13
+
14
+ Comandos cloud:
15
+ ragfly login — Autenticar contra el cloud
16
+ ragfly logout — Cerrar sesión
17
+ ragfly cloud me — Ver contexto activo
18
+ ragfly cloud documento listar/ver
19
+ ragfly cloud espacio listar/ver
20
+ ragfly cloud cola ver/ejecuciones
21
+ ragfly cloud habilidad listar/ver/ejecutar
22
+ """
23
+
24
+ import json
25
+ import sys
26
+
27
+ import click
28
+ import httpx
29
+ from rich.console import Console
30
+ from rich.table import Table
31
+ from rich.panel import Panel
32
+
33
+ from . import __version__
34
+ from ._http import default_headers
35
+
36
+ console = Console()
37
+ err_console = Console(stderr=True)
38
+
39
+
40
+ # ════════════════════════════════════════════════════════════════════════════
41
+ # Grupo raíz
42
+ # ════════════════════════════════════════════════════════════════════════════
43
+
44
+ @click.group()
45
+ def app():
46
+ """RAGfly — Cliente local y Cloud."""
47
+ pass
48
+
49
+
50
+ # ════════════════════════════════════════════════════════════════════════════
51
+ # Comandos globales: login / logout
52
+ # ════════════════════════════════════════════════════════════════════════════
53
+
54
+ @app.command()
55
+ @click.option("--email", "-e", default=None, help="Email (para uso no interactivo)")
56
+ @click.option("--password-stdin", is_flag=True, help="Lee la contraseña desde stdin")
57
+ def login(email: str | None, password_stdin: bool):
58
+ """Autenticarse contra el cloud y guardar sesión."""
59
+ from .cloud_commands import login as _login, CloudError
60
+
61
+ console.print()
62
+ if not email:
63
+ email = click.prompt(" Email")
64
+
65
+ if password_stdin:
66
+ password = sys.stdin.readline().rstrip("\n")
67
+ else:
68
+ password = click.prompt(" Contraseña", hide_input=True)
69
+
70
+ console.print()
71
+ console.print("[dim] Conectando...[/dim]", end="")
72
+
73
+ try:
74
+ data = _login(email, password)
75
+ except CloudError as e:
76
+ console.print()
77
+ err_console.print(f"[red]✗ {e}[/red]")
78
+ raise SystemExit(e.exit_code)
79
+
80
+ console.print()
81
+ console.print(f"[green]✓ Sesión iniciada como[/green] {email}")
82
+
83
+ grupo = (
84
+ data.get("grupo_activo")
85
+ or data.get("usuario", {}).get("grupo_por_defecto", "")
86
+ or "—"
87
+ )
88
+ entidad = (
89
+ data.get("entidad_activa")
90
+ or data.get("usuario", {}).get("entidad_por_defecto", "")
91
+ or "—"
92
+ )
93
+ console.print(f" Grupo activo: {grupo}")
94
+ console.print(f" Entidad activa: {entidad}")
95
+ console.print(f" Sesión guardada en [dim]keyring del SO[/dim]")
96
+ console.print()
97
+
98
+
99
+ @app.command()
100
+ def logout():
101
+ """Cerrar sesión (elimina el JWT del keyring)."""
102
+ from .cloud_commands import borrar_credenciales, ya_esta_logueado
103
+
104
+ if ya_esta_logueado():
105
+ borrar_credenciales()
106
+ console.print("[green]✓ Sesión cerrada.[/green]")
107
+ else:
108
+ console.print("[yellow]No había sesión activa.[/yellow]")
109
+
110
+
111
+ # ════════════════════════════════════════════════════════════════════════════
112
+ # Sub-grupo: cloud
113
+ # ════════════════════════════════════════════════════════════════════════════
114
+
115
+ @app.group()
116
+ def cloud():
117
+ """Operaciones contra el cloud de RAGfly (requiere ragfly login)."""
118
+ pass
119
+
120
+
121
+ @cloud.command("me")
122
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
123
+ def cloud_me(output: str):
124
+ """Ver el contexto activo del usuario autenticado."""
125
+ from .cloud_commands import cloud_get
126
+ from .oop import CliCommand
127
+
128
+ cmd = CliCommand()
129
+ data = cmd.protegido(cloud_get, "/auth/me")
130
+
131
+ if output == "json":
132
+ console.print(json.dumps(data, indent=2, ensure_ascii=False))
133
+ return
134
+
135
+ console.print()
136
+ # /auth/me retorna campos en el nivel raíz: codigo_usuario, nombre, grupo_activo, etc.
137
+ tabla = Table(show_header=False, border_style="dim")
138
+ tabla.add_column("Campo", style="bold")
139
+ tabla.add_column("Valor")
140
+ tabla.add_row("Usuario", data.get("codigo_usuario") or data.get("email", "—"))
141
+ tabla.add_row("Nombre", data.get("nombre") or data.get("nombre_completo", "—"))
142
+ tabla.add_row("Rol principal", data.get("rol_principal", "—"))
143
+ tabla.add_row("Grupo activo", str(data.get("grupo_activo") or data.get("nombre_grupo", "—")))
144
+ tabla.add_row("Entidad activa", str(data.get("entidad_activa") or data.get("nombre_entidad", "—")))
145
+ console.print(tabla)
146
+ console.print()
147
+
148
+
149
+ # ── cloud grupo ──────────────────────────────────────────────────────────────
150
+
151
+ @cloud.group("grupo")
152
+ def cloud_grupo():
153
+ """Gestionar el grupo activo del cliente (paridad con dropdown del header web)."""
154
+ pass
155
+
156
+
157
+ @cloud_grupo.command("listar")
158
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
159
+ def cloud_grupo_listar(output: str):
160
+ """Listar grupos disponibles para el usuario autenticado."""
161
+ from .cloud_commands import obtener_token
162
+ from .config import get_config
163
+ from .grupo_activo import listar_grupos_disponibles, get_grupo_activo_local
164
+ from .oop import CliCommand
165
+
166
+ cmd = CliCommand()
167
+
168
+ def _accion():
169
+ token = obtener_token()
170
+ return listar_grupos_disponibles(token, get_config().cloud_url)
171
+
172
+ grupos = cmd.protegido(_accion)
173
+ activo = get_grupo_activo_local()
174
+
175
+ if output == "json":
176
+ console.print(json.dumps({"activo": activo, "grupos": grupos}, indent=2, ensure_ascii=False))
177
+ return
178
+
179
+ console.print()
180
+ if not grupos:
181
+ console.print("[yellow]Sin grupos asignados.[/yellow]")
182
+ return
183
+
184
+ t = Table(title="Grupos disponibles", border_style="dim")
185
+ t.add_column("", width=2)
186
+ t.add_column("Código", style="bold")
187
+ t.add_column("Nombre")
188
+ t.add_column("Alias", style="dim")
189
+ for g in grupos:
190
+ marca = "[green]●[/green]" if g["codigo_grupo"] == activo else " "
191
+ t.add_row(marca, g["codigo_grupo"], g.get("nombre_grupo") or "—", g.get("alias_grupo") or "—")
192
+ console.print(t)
193
+ if activo:
194
+ console.print(f" [dim]Grupo activo local: {activo}[/dim]")
195
+ else:
196
+ console.print(" [dim]Sin grupo activo local — usando defecto del usuario.[/dim]")
197
+ console.print()
198
+
199
+
200
+ @cloud_grupo.command("cambiar")
201
+ @click.argument("codigo_grupo")
202
+ def cloud_grupo_cambiar(codigo_grupo: str):
203
+ """Cambiar el grupo activo del cliente. Valida con el backend antes de persistir."""
204
+ from .cloud_commands import obtener_token
205
+ from .config import get_config
206
+ from .grupo_activo import cambiar_grupo_remoto, set_grupo_activo
207
+ from .oop import CliCommand
208
+
209
+ cmd = CliCommand()
210
+
211
+ def _accion():
212
+ token = obtener_token()
213
+ return cambiar_grupo_remoto(codigo_grupo, token, get_config().cloud_url)
214
+
215
+ contexto = cmd.protegido(_accion)
216
+ set_grupo_activo(codigo_grupo)
217
+
218
+ nombre = contexto.get("nombre_grupo") or codigo_grupo
219
+ console.print(f"[green]✓ Grupo activo cambiado a:[/green] [bold]{codigo_grupo}[/bold] ({nombre})")
220
+ entidad = contexto.get("entidad_activa")
221
+ if entidad:
222
+ console.print(f" Entidad activa: {entidad}")
223
+
224
+
225
+ @cloud_grupo.command("limpiar")
226
+ def cloud_grupo_limpiar():
227
+ """Quitar el grupo activo local — vuelve al grupo por defecto del usuario."""
228
+ from .grupo_activo import clear_grupo_activo, get_grupo_activo_local
229
+
230
+ actual = get_grupo_activo_local()
231
+ if not actual:
232
+ console.print("[yellow]No hay grupo activo local configurado.[/yellow]")
233
+ return
234
+ clear_grupo_activo()
235
+ console.print(f"[green]✓ Grupo activo local '{actual}' eliminado.[/green]")
236
+ console.print(" El cliente usará el grupo por defecto del usuario en la próxima request.")
237
+
238
+
239
+ # ── cloud documento ──────────────────────────────────────────────────────────
240
+
241
+ @cloud.group("documento")
242
+ def cloud_documento():
243
+ """Gestionar documentos en el cloud."""
244
+ pass
245
+
246
+
247
+ @cloud_documento.command("listar")
248
+ @click.option("--estado", default=None, help="Filtrar por estado (ej. VECTORIZADO)")
249
+ @click.option("--limite", default=20, show_default=True)
250
+ @click.option("--pagina", default=1, show_default=True)
251
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json", "csv"]), default="tabla")
252
+ def cloud_documento_listar(estado: str | None, limite: int, pagina: int, output: str):
253
+ """Listar documentos del grupo activo."""
254
+ from .cloud_commands import cloud_get
255
+ from .oop import CliCommand
256
+
257
+ params: dict = {"limite": limite, "pagina": pagina}
258
+ if estado:
259
+ params["estado"] = estado
260
+
261
+ cmd = CliCommand()
262
+ data = cmd.protegido(cloud_get, "/documentos/paginado", params=params)
263
+
264
+ items = data.get("items", data) if isinstance(data, dict) else data
265
+
266
+ if output == "json":
267
+ console.print(json.dumps(items, indent=2, ensure_ascii=False, default=str))
268
+ return
269
+
270
+ if output == "csv":
271
+ console.print("codigo,nombre,estado,ubicacion")
272
+ for d in items:
273
+ console.print(
274
+ f"{d.get('codigo_documento','')},{d.get('nombre_documento','')}"
275
+ f",{d.get('codigo_estado_doc','')},{d.get('nombre_ubicacion','')}"
276
+ )
277
+ return
278
+
279
+ console.print()
280
+ t = Table(title=f"Documentos (pág. {pagina})", border_style="dim")
281
+ t.add_column("Código", style="dim", no_wrap=True)
282
+ t.add_column("Nombre")
283
+ t.add_column("Estado")
284
+ t.add_column("Ubicación")
285
+ t.add_column("Tamaño", justify="right")
286
+
287
+ for d in items:
288
+ estado_val = d.get("codigo_estado_doc", "—")
289
+ color = {"VECTORIZADO": "green", "ESCANEADO": "cyan", "CARGADO": "yellow",
290
+ "REVISAR": "red", "CHUNKEADO": "blue"}.get(estado_val, "white")
291
+ t.add_row(
292
+ str(d.get("codigo_documento", "—")),
293
+ d.get("nombre_documento", "—")[:50],
294
+ f"[{color}]{estado_val}[/{color}]",
295
+ d.get("nombre_ubicacion") or d.get("codigo_ubicacion", "—"),
296
+ _fmt_bytes(d.get("tamano_bytes")),
297
+ )
298
+ console.print(t)
299
+
300
+ total = data.get("total") if isinstance(data, dict) else None
301
+ if total:
302
+ console.print(f" [dim]Total: {total} | Página {pagina}[/dim]")
303
+ console.print()
304
+
305
+
306
+ @cloud_documento.command("ver")
307
+ @click.argument("codigo")
308
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
309
+ def cloud_documento_ver(codigo: str, output: str):
310
+ """Ver detalle de un documento."""
311
+ from .cloud_commands import cloud_get
312
+ from .oop import CliCommand
313
+
314
+ cmd = CliCommand()
315
+ data = cmd.protegido(cloud_get, f"/documentos/{codigo}")
316
+
317
+ if output == "json":
318
+ console.print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
319
+ return
320
+
321
+ console.print()
322
+ t = Table(show_header=False, border_style="dim", title=f"Documento {codigo}")
323
+ t.add_column("Campo", style="bold")
324
+ t.add_column("Valor")
325
+ t.add_row("Código", str(data.get("codigo_documento", "—")))
326
+ t.add_row("Nombre", data.get("nombre_documento", "—"))
327
+ t.add_row("Estado", data.get("codigo_estado_doc", "—"))
328
+ t.add_row("Ubicación", data.get("nombre_ubicacion") or data.get("codigo_ubicacion", "—"))
329
+ t.add_row("Tamaño", _fmt_bytes(data.get("tamano_bytes")))
330
+ t.add_row("Páginas", str(data.get("total_paginas", "—")))
331
+ t.add_row("Chunks", str(data.get("total_chunks", "—")))
332
+ t.add_row("Creado", str(data.get("fecha_creacion", "—"))[:19])
333
+ t.add_row("Procesado", str(data.get("fecha_actualizacion", "—"))[:19])
334
+ if data.get("resumen_documento"):
335
+ t.add_row("Resumen", data["resumen_documento"][:120])
336
+ console.print(t)
337
+ console.print()
338
+
339
+
340
+ # ── cloud espacio ────────────────────────────────────────────────────────────
341
+
342
+ @cloud.group("espacio")
343
+ def cloud_espacio():
344
+ """Gestionar Espacios de Trabajo en el cloud."""
345
+ pass
346
+
347
+
348
+ @cloud_espacio.command("listar")
349
+ @click.option("--limite", default=20, show_default=True)
350
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
351
+ def cloud_espacio_listar(limite: int, output: str):
352
+ """Listar Espacios de Trabajo del grupo activo."""
353
+ from .cloud_commands import cloud_get
354
+ from .oop import CliCommand
355
+
356
+ cmd = CliCommand()
357
+ data = cmd.protegido(cloud_get, "/espacios-trabajo/paginado", params={"limite": limite})
358
+
359
+ items = data.get("items", data) if isinstance(data, dict) else data
360
+
361
+ if output == "json":
362
+ console.print(json.dumps(items, indent=2, ensure_ascii=False, default=str))
363
+ return
364
+
365
+ console.print()
366
+ t = Table(title="Espacios de Trabajo", border_style="dim")
367
+ t.add_column("ID", justify="right", style="dim")
368
+ t.add_column("Nombre")
369
+ t.add_column("Descripción")
370
+ t.add_column("Docs", justify="right")
371
+ t.add_column("Creado")
372
+
373
+ for e in items:
374
+ t.add_row(
375
+ str(e.get("id_espacio", "—")),
376
+ e.get("nombre_espacio", "—")[:40],
377
+ (e.get("descripcion") or "")[:40],
378
+ str(e.get("n_documentos", "—")),
379
+ str(e.get("fecha_creacion", "—"))[:10],
380
+ )
381
+ console.print(t)
382
+ console.print()
383
+
384
+
385
+ @cloud_espacio.command("ver")
386
+ @click.argument("id_espacio", type=int)
387
+ @click.option("--limite", default=20, show_default=True)
388
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
389
+ def cloud_espacio_ver(id_espacio: int, limite: int, output: str):
390
+ """Ver detalle de un Espacio de Trabajo con sus documentos."""
391
+ from .cloud_commands import cloud_get
392
+ from .oop import CliCommand
393
+
394
+ cmd = CliCommand()
395
+ # No hay GET /{id} suelto — buscamos en paginado y filtramos
396
+ espacios_data = cmd.protegido(cloud_get, "/espacios-trabajo/paginado", params={"limite": 200})
397
+ items_esp = espacios_data.get("items", espacios_data) if isinstance(espacios_data, dict) else espacios_data
398
+ espacio = next((e for e in items_esp if e.get("id_espacio") == id_espacio), None)
399
+ if not espacio:
400
+ cmd.salir(f"Espacio #{id_espacio} no encontrado.", exit_code=1)
401
+ docs_data = cmd.protegido(
402
+ cloud_get,
403
+ f"/espacios-trabajo/{id_espacio}/documentos/paginado",
404
+ params={"limite": limite},
405
+ )
406
+
407
+ if output == "json":
408
+ console.print(json.dumps({"espacio": espacio, "documentos": docs_data},
409
+ indent=2, ensure_ascii=False, default=str))
410
+ return
411
+
412
+ console.print()
413
+ info = Table(show_header=False, border_style="dim",
414
+ title=f"Espacio #{id_espacio}")
415
+ info.add_column("Campo", style="bold")
416
+ info.add_column("Valor")
417
+ info.add_row("Nombre", espacio.get("nombre_espacio", "—"))
418
+ info.add_row("Descripción", espacio.get("descripcion") or "—")
419
+ info.add_row("Creado", str(espacio.get("fecha_creacion", "—"))[:19])
420
+ console.print(info)
421
+ console.print()
422
+
423
+ docs = docs_data.get("items", docs_data) if isinstance(docs_data, dict) else docs_data
424
+ if docs:
425
+ t = Table(title=f"Documentos ({len(docs)})", border_style="dim")
426
+ t.add_column("Código", style="dim")
427
+ t.add_column("Nombre")
428
+ t.add_column("Estado")
429
+ t.add_column("Cola", justify="right")
430
+ for d in docs:
431
+ t.add_row(
432
+ str(d.get("codigo_documento", "—")),
433
+ d.get("nombre_documento", "—")[:45],
434
+ d.get("codigo_estado_doc", "—"),
435
+ d.get("estado_cola", "—"),
436
+ )
437
+ console.print(t)
438
+ console.print()
439
+
440
+
441
+ # ── cloud cola ───────────────────────────────────────────────────────────────
442
+
443
+ @cloud.group("cola")
444
+ def cloud_cola():
445
+ """Ver el estado de la cola de procesamiento."""
446
+ pass
447
+
448
+
449
+ @cloud_cola.command("ver")
450
+ @click.option("--proceso", default=None, help="Filtrar por proceso (ej. VECTORIZAR)")
451
+ @click.option("--estado", default=None, help="Filtrar por estado (PENDIENTE, EJECUTANDO, etc.)")
452
+ @click.option("--limite", default=20, show_default=True)
453
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
454
+ def cloud_cola_ver(proceso: str | None, estado: str | None, limite: int, output: str):
455
+ """Ver el estado actual de la cola del pipeline."""
456
+ from .cloud_commands import cloud_get
457
+ from .oop import CliCommand
458
+
459
+ params: dict = {"limite": limite}
460
+ if proceso:
461
+ params["proceso"] = proceso
462
+ if estado:
463
+ params["estado"] = estado
464
+
465
+ cmd = CliCommand()
466
+ data = cmd.protegido(cloud_get, "/cola-estados-docs/paginado", params=params)
467
+
468
+ items = data.get("items", data) if isinstance(data, dict) else data
469
+
470
+ if output == "json":
471
+ console.print(json.dumps(items, indent=2, ensure_ascii=False, default=str))
472
+ return
473
+
474
+ console.print()
475
+ t = Table(title="Cola de procesamiento", border_style="dim")
476
+ t.add_column("ID", justify="right", style="dim")
477
+ t.add_column("Proceso")
478
+ t.add_column("Estado")
479
+ t.add_column("Documento")
480
+ t.add_column("Encolado")
481
+ t.add_column("Error")
482
+
483
+ _est_color = {
484
+ "PENDIENTE": "yellow", "EJECUTANDO": "cyan",
485
+ "TERMINADO": "green", "ERROR": "red",
486
+ }
487
+ for item in items:
488
+ est = item.get("estado_cola", "—")
489
+ t.add_row(
490
+ str(item.get("id_cola", "—")),
491
+ item.get("proceso") or item.get("codigo_habilidad", "—"),
492
+ f"[{_est_color.get(est, 'white')}]{est}[/{_est_color.get(est, 'white')}]",
493
+ str(item.get("codigo_documento", "—")),
494
+ str(item.get("fecha_inicio", item.get("fecha_cola", "—")))[:16],
495
+ (item.get("mensaje_error") or "")[:30],
496
+ )
497
+ console.print(t)
498
+
499
+ total = data.get("total") if isinstance(data, dict) else None
500
+ if total:
501
+ console.print(f" [dim]Total en cola: {total}[/dim]")
502
+ console.print()
503
+
504
+
505
+ @cloud_cola.command("ejecuciones")
506
+ @click.option("--limite", default=10, show_default=True)
507
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
508
+ def cloud_cola_ejecuciones(limite: int, output: str):
509
+ """Ver historial de ejecuciones de habilidades."""
510
+ from .cloud_commands import cloud_get
511
+ from .oop import CliCommand
512
+
513
+ cmd = CliCommand()
514
+ data = cmd.protegido(cloud_get, "/cola-estados-docs/ejecuciones", params={"limite": limite})
515
+
516
+ items = data.get("items", data) if isinstance(data, dict) else data
517
+
518
+ if output == "json":
519
+ console.print(json.dumps(items, indent=2, ensure_ascii=False, default=str))
520
+ return
521
+
522
+ console.print()
523
+ t = Table(title="Historial de ejecuciones", border_style="dim")
524
+ t.add_column("ID", justify="right", style="dim")
525
+ t.add_column("Habilidad")
526
+ t.add_column("Inicio")
527
+ t.add_column("Fin")
528
+ t.add_column("Docs", justify="right")
529
+ t.add_column("OK", justify="right", style="green")
530
+ t.add_column("Err", justify="right", style="red")
531
+ t.add_column("Duración")
532
+
533
+ for e in items:
534
+ t.add_row(
535
+ str(e.get("id_ejecucion") or e.get("id", "—")),
536
+ e.get("codigo_habilidad", "—"),
537
+ str(e.get("fecha_inicio", "—"))[:16],
538
+ str(e.get("fecha_fin", "—"))[:16],
539
+ str(e.get("total_docs", "—")),
540
+ str(e.get("docs_ok", "—")),
541
+ str(e.get("docs_error", "—")),
542
+ e.get("duracion") or "—",
543
+ )
544
+ console.print(t)
545
+ console.print()
546
+
547
+
548
+ # ── cloud habilidad ──────────────────────────────────────────────────────────
549
+
550
+ @cloud.group("habilidad")
551
+ def cloud_habilidad():
552
+ """Gestionar y ejecutar habilidades LLM del catálogo global."""
553
+ pass
554
+
555
+
556
+ @cloud_habilidad.command("listar")
557
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
558
+ def cloud_habilidad_listar(output: str):
559
+ """Listar todas las habilidades disponibles."""
560
+ from .cloud_commands import cloud_get
561
+ from .oop import CliCommand
562
+
563
+ cmd = CliCommand()
564
+ items = cmd.protegido(cloud_get, "/habilidades")
565
+
566
+ if output == "json":
567
+ console.print(json.dumps(items, indent=2, ensure_ascii=False, default=str))
568
+ return
569
+
570
+ console.print()
571
+ t = Table(title="Habilidades disponibles", border_style="dim")
572
+ t.add_column("Código", style="bold")
573
+ t.add_column("Nombre")
574
+ t.add_column("Tipo")
575
+ t.add_column("Salida")
576
+ t.add_column("Modelo")
577
+
578
+ for h in items:
579
+ t.add_row(
580
+ h.get("codigo_habilidad", "—"),
581
+ h.get("nombre_habilidad") or h.get("alias", "—"),
582
+ h.get("aplica_a", "—"),
583
+ h.get("salida_destino", "—"),
584
+ h.get("id_modelo") or "[dim]del invocador[/dim]",
585
+ )
586
+ console.print(t)
587
+ console.print()
588
+
589
+
590
+ @cloud_habilidad.command("ver")
591
+ @click.argument("codigo")
592
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
593
+ def cloud_habilidad_ver(codigo: str, output: str):
594
+ """Ver detalle de una habilidad."""
595
+ from .cloud_commands import cloud_get
596
+ from .oop import CliCommand
597
+
598
+ cmd = CliCommand()
599
+ h = cmd.protegido(cloud_get, f"/habilidades/{codigo}")
600
+
601
+ if output == "json":
602
+ console.print(json.dumps(h, indent=2, ensure_ascii=False, default=str))
603
+ return
604
+
605
+ console.print()
606
+ t = Table(show_header=False, border_style="dim",
607
+ title=f"Habilidad {codigo}")
608
+ t.add_column("Campo", style="bold")
609
+ t.add_column("Valor")
610
+ t.add_row("Código", h.get("codigo_habilidad", "—"))
611
+ t.add_row("Nombre", h.get("nombre_habilidad") or h.get("alias", "—"))
612
+ t.add_row("Tipo", h.get("aplica_a", "—"))
613
+ t.add_row("Modelo", h.get("id_modelo") or "[dim]del invocador[/dim]")
614
+ t.add_row("Salida", h.get("salida_destino", "—"))
615
+ t.add_row("Col. salida", h.get("salida_columna") or "—")
616
+ console.print(t)
617
+
618
+ if h.get("prompt_habilidad"):
619
+ console.print()
620
+ console.print("[bold]Prompt:[/bold]")
621
+ console.print(f" [dim]{h['prompt_habilidad'][:300]}[/dim]")
622
+ if h.get("system_prompt"):
623
+ console.print()
624
+ console.print("[bold]System prompt:[/bold]")
625
+ console.print(f" [dim]{h['system_prompt'][:200]}[/dim]")
626
+ console.print()
627
+
628
+
629
+ @cloud_habilidad.command("ejecutar")
630
+ @click.argument("codigo")
631
+ @click.option("--espacio", type=int, default=None, help="ID del Espacio de Trabajo")
632
+ @click.option("--documento", default=None, help="Código de documento único")
633
+ @click.option("--esperar", is_flag=True, help="Esperar a que termine y mostrar resultado")
634
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
635
+ def cloud_habilidad_ejecutar(
636
+ codigo: str, espacio: int | None, documento: str | None,
637
+ esperar: bool, output: str
638
+ ):
639
+ """Ejecutar una habilidad sobre un Espacio de Trabajo o documento."""
640
+ from .cloud_commands import cloud_post
641
+ from .oop import CliCommand
642
+
643
+ cmd = CliCommand()
644
+ if not espacio and not documento:
645
+ cmd.salir("Debes indicar --espacio <ID> o --documento <CODIGO>", exit_code=1)
646
+
647
+ body: dict = {}
648
+ if espacio:
649
+ body["id_espacio"] = espacio
650
+ if documento:
651
+ body["codigo_documento"] = documento
652
+
653
+ resultado = cmd.protegido(cloud_post, f"/habilidades/{codigo}/ejecutar", body=body)
654
+
655
+ if output == "json":
656
+ console.print(json.dumps(resultado, indent=2, ensure_ascii=False, default=str))
657
+ return
658
+
659
+ # Contrato uniforme (SobreEjecucion): codigo_proceso + detalle.n_items_cola.
660
+ detalle = resultado.get("detalle") or {}
661
+ proceso = resultado.get("codigo_proceso", "—")
662
+ docs = detalle.get("n_items_cola", resultado.get("n_documentos", "—"))
663
+ no_proc = detalle.get("n_no_procesables", 0)
664
+ estado = resultado.get("estado", "PENDIENTE")
665
+
666
+ console.print()
667
+ console.print(f"[green]✓ Encolado[/green]" if resultado.get("aceptada", True)
668
+ else "[red]✗ No aceptada[/red]")
669
+ console.print(f" Proceso : {proceso}")
670
+ console.print(f" Documentos : {docs}")
671
+ if no_proc:
672
+ console.print(f" No procesables: {no_proc}")
673
+ console.print(f" Estado : {estado}")
674
+ if resultado.get("mensaje"):
675
+ console.print(f" [dim]{resultado.get('mensaje')}[/dim]")
676
+ console.print()
677
+ console.print(f" Sigue progreso: [dim]ragfly cloud cola ver[/dim]")
678
+ console.print()
679
+
680
+
681
+ # ════════════════════════════════════════════════════════════════════════════
682
+ # Sub-comando: cloud catalogo (capabilities — contrato multi-interfaz)
683
+ # ════════════════════════════════════════════════════════════════════════════
684
+
685
+ @cloud.command("catalogo")
686
+ @click.option("--tipo", type=click.Choice(["TODO", "FUNCIONES", "HABILIDADES"]),
687
+ default="TODO", show_default=True, help="Qué parte del catálogo listar")
688
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
689
+ def cloud_catalogo(tipo: str, output: str):
690
+ """Catálogo de capabilities: qué puede hacer el usuario (funciones + habilidades).
691
+
692
+ Mismo contrato que consumen el chat y MCP (GET /catalogo). Filtrado por el
693
+ rol, tipo de acceso, grupo y aplicación del usuario.
694
+ """
695
+ from .cloud_commands import cloud_get
696
+ from .oop import CliCommand
697
+
698
+ cmd = CliCommand()
699
+ data = cmd.protegido(cloud_get, "/catalogo", params={"tipo": tipo})
700
+
701
+ if output == "json":
702
+ console.print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
703
+ return
704
+
705
+ funciones = data.get("funciones", [])
706
+ habilidades = data.get("habilidades", [])
707
+
708
+ if funciones:
709
+ console.print()
710
+ t = Table(title=f"Funciones disponibles ({len(funciones)})", border_style="dim")
711
+ t.add_column("Código", style="bold")
712
+ t.add_column("Nombre")
713
+ t.add_column("Resumen")
714
+ t.add_column("Permisos")
715
+ for f in funciones:
716
+ perms = "".join([
717
+ "S" if f.get("perm_select") else "-",
718
+ "I" if f.get("perm_insert") else "-",
719
+ "U" if f.get("perm_update") else "-",
720
+ "D" if f.get("perm_delete") else "-",
721
+ ])
722
+ t.add_row(
723
+ f.get("codigo_funcion", "—"),
724
+ f.get("nombre_funcion") or "—",
725
+ (f.get("descripcion_llm") or f.get("descripcion") or "")[:80],
726
+ perms,
727
+ )
728
+ console.print(t)
729
+
730
+ if habilidades:
731
+ console.print()
732
+ t = Table(title=f"Habilidades disponibles ({len(habilidades)})", border_style="dim")
733
+ t.add_column("Código", style="bold")
734
+ t.add_column("Nombre")
735
+ t.add_column("Resumen")
736
+ for h in habilidades:
737
+ t.add_row(
738
+ h.get("codigo_habilidad", "—"),
739
+ h.get("nombre_habilidad") or "—",
740
+ (h.get("descripcion_llm") or h.get("descripcion") or "")[:80],
741
+ )
742
+ console.print(t)
743
+
744
+ if not funciones and not habilidades:
745
+ console.print("[dim]Sin capabilities disponibles para este contexto.[/dim]")
746
+ console.print()
747
+
748
+
749
+ # ════════════════════════════════════════════════════════════════════════════
750
+ # Sub-grupo: cloud buscar (RAG one-shot)
751
+ # ════════════════════════════════════════════════════════════════════════════
752
+
753
+ @cloud.command("buscar")
754
+ @click.argument("consulta", nargs=-1, required=True)
755
+ @click.option("--limite", type=int, default=10, show_default=True,
756
+ help="Top-K final tras rerank")
757
+ @click.option("--min-similitud", type=float, default=0.0, show_default=True,
758
+ help="Umbral coseno mínimo")
759
+ @click.option("--entidad", default=None, help="Filtrar por código de entidad")
760
+ @click.option("-o", "--output", type=click.Choice(["tabla", "json"]), default="tabla")
761
+ def cloud_buscar(
762
+ consulta: tuple[str, ...], limite: int, min_similitud: float,
763
+ entidad: str | None, output: str,
764
+ ):
765
+ """Búsqueda semántica RAG sobre los documentos vectorizados del grupo."""
766
+ from .cloud_commands import cloud_post
767
+ from .oop import CliCommand
768
+
769
+ q = " ".join(consulta).strip()
770
+ if not q:
771
+ err_console.print("[red]La consulta no puede estar vacía.[/red]")
772
+ raise SystemExit(1)
773
+
774
+ body: dict = {"q": q, "limit": limite, "min_similitud": min_similitud}
775
+ if entidad:
776
+ body["codigo_entidad"] = entidad
777
+
778
+ cmd = CliCommand()
779
+ data = cmd.protegido(cloud_post, "/documentos/buscar-semantico", body=body)
780
+
781
+ if output == "json":
782
+ console.print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
783
+ return
784
+
785
+ items = data.get("resultados") or data.get("items") or data if isinstance(data, list) else (
786
+ data.get("resultados") or data.get("items") or []
787
+ )
788
+ if not items:
789
+ console.print("[yellow]Sin resultados.[/yellow]")
790
+ return
791
+
792
+ tabla = Table(title=f"RAG: {q[:60]}", show_lines=False)
793
+ tabla.add_column("#", style="dim", width=3)
794
+ tabla.add_column("Documento", overflow="fold")
795
+ tabla.add_column("Score", justify="right", width=8)
796
+ tabla.add_column("Fragmento", overflow="fold")
797
+ for i, h in enumerate(items, 1):
798
+ score = h.get("score") or h.get("similitud") or h.get("rerank_score") or 0
799
+ nombre = h.get("nombre_documento") or h.get("codigo_documento") or "—"
800
+ frag = (h.get("texto") or h.get("chunk_texto") or "")[:200]
801
+ tabla.add_row(str(i), str(nombre), f"{float(score):.3f}" if isinstance(score, (int, float)) else str(score), frag)
802
+ console.print(tabla)
803
+
804
+
805
+ # ════════════════════════════════════════════════════════════════════════════
806
+ # Sub-grupo: cloud chat (RAG conversacional)
807
+ # ════════════════════════════════════════════════════════════════════════════
808
+
809
+ @cloud.group("chat")
810
+ def cloud_chat():
811
+ """Conversar con tus documentos vía RAG."""
812
+ pass
813
+
814
+
815
+ @cloud_chat.command("preguntar")
816
+ @click.argument("mensaje", nargs=-1, required=True)
817
+ @click.option("--funcion", default="CHAT-USUARIO", show_default=True,
818
+ help="Código de función del chat")
819
+ @click.option("--conversacion", "id_conversacion", type=int, default=None,
820
+ help="ID de conversación existente (omitir = crear nueva)")
821
+ @click.option("--titulo", default=None, help="Título inicial (al crear nueva)")
822
+ @click.option("-o", "--output", type=click.Choice(["texto", "json"]), default="texto")
823
+ def cloud_chat_preguntar(
824
+ mensaje: tuple[str, ...], funcion: str, id_conversacion: int | None,
825
+ titulo: str | None, output: str,
826
+ ):
827
+ """Hacer una pregunta al chat RAG. Crea conversación si no se indica una."""
828
+ from .cloud_commands import cloud_post, _headers, CLOUD_URL, CloudError
829
+ from .oop import CliCommand
830
+
831
+ contenido = " ".join(mensaje).strip()
832
+ if not contenido:
833
+ err_console.print("[red]El mensaje no puede estar vacío.[/red]")
834
+ raise SystemExit(1)
835
+
836
+ cmd = CliCommand()
837
+
838
+ if not id_conversacion:
839
+ body_conv = {"codigo_funcion": funcion}
840
+ if titulo:
841
+ body_conv["titulo"] = titulo
842
+ nueva = cmd.protegido(cloud_post, "/chat/conversaciones", body=body_conv)
843
+ id_conversacion = int(nueva.get("id_conversacion") or 0)
844
+ if not id_conversacion:
845
+ err_console.print(f"[red]No se pudo crear conversación: {nueva}[/red]")
846
+ raise SystemExit(2)
847
+
848
+ url = f"{CLOUD_URL}/chat/conversaciones/{id_conversacion}/mensajes/stream"
849
+ try:
850
+ with httpx.stream(
851
+ "POST", url, headers=_headers(), json={"contenido": contenido}, timeout=120.0,
852
+ ) as r:
853
+ if r.status_code >= 400:
854
+ err_console.print(f"[red]HTTP {r.status_code}: {r.read()[:300].decode('utf-8', errors='replace')}[/red]")
855
+ raise SystemExit(2)
856
+ partes: list[str] = []
857
+ meta: dict = {}
858
+ for raw in r.iter_lines():
859
+ if not raw or not raw.startswith("data:"):
860
+ continue
861
+ payload = raw[5:].strip()
862
+ if not payload:
863
+ continue
864
+ try:
865
+ evt = json.loads(payload)
866
+ except Exception:
867
+ continue
868
+ if "text" in evt:
869
+ chunk = str(evt["text"])
870
+ partes.append(chunk)
871
+ if output == "texto":
872
+ console.print(chunk, end="", soft_wrap=True)
873
+ elif "done" in evt:
874
+ meta = {k: v for k, v in evt.items() if k != "done"}
875
+ elif "error" in evt:
876
+ err_console.print(f"\n[red]Error del servidor: {evt['error']}[/red]")
877
+ raise SystemExit(2)
878
+ except httpx.RequestError as e:
879
+ raise CloudError(f"No se pudo conectar al servidor: {e}", exit_code=2)
880
+
881
+ if output == "json":
882
+ console.print(json.dumps({
883
+ "id_conversacion": id_conversacion,
884
+ "respuesta": "".join(partes),
885
+ **meta,
886
+ }, indent=2, ensure_ascii=False, default=str))
887
+ else:
888
+ console.print()
889
+ console.print(f"[dim]Conversación #{id_conversacion}[/dim]")
890
+
891
+
892
+ # ════════════════════════════════════════════════════════════════════════════
893
+ # Helpers internos
894
+ # ════════════════════════════════════════════════════════════════════════════
895
+
896
+ def _fmt_bytes(b: int | None) -> str:
897
+ if b is None:
898
+ return "—"
899
+ for unit in ("B", "KB", "MB", "GB"):
900
+ if b < 1024:
901
+ return f"{b:.0f} {unit}"
902
+ b //= 1024
903
+ return f"{b} TB"
904
+
905
+
906
+ @app.command()
907
+ def version():
908
+ """Muestra la versión del cliente y avisa si hay actualización disponible."""
909
+ console.print(f"[bold blue]RAGfly[/bold blue] Cliente v{__version__}")
910
+ try:
911
+ from .version_check import chequear_actualizacion
912
+ aviso = chequear_actualizacion()
913
+ if aviso:
914
+ console.print(f"[yellow]{aviso}[/yellow]")
915
+ except Exception:
916
+ pass # silencioso: no romper version si no hay config/red
917
+
918
+