@innvisor/conny-ai 9.7.0 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +17 -1
  3. package/conny_app.py +8 -2
  4. package/conny_cli.py +103 -11
  5. package/conny_core/evolution.py +112 -0
  6. package/conny_core/first_turn_ops.py +16 -20
  7. package/conny_core/prompt_ops.py +62 -0
  8. package/conny_demo_voice.py +1 -1
  9. package/conny_doctor.py +287 -2
  10. package/conny_domino.py +2 -2
  11. package/conny_generator.py +1 -1
  12. package/conny_init.py +234 -41
  13. package/conny_runtime_ops.py +198 -6
  14. package/conny_ultra_config.py +25 -11
  15. package/conny_utils.py +21 -3
  16. package/ecosystem.config.js +11 -1
  17. package/install.sh +78 -22
  18. package/npm/conny.js +73 -17
  19. package/package.json +13 -3
  20. package/run.sh +7 -0
  21. package/src/conny/admin/dashboard.py +35 -4
  22. package/src/conny/admin_memory.py +93 -0
  23. package/src/conny/api/routes.py +26 -9
  24. package/src/conny/channels/cli.py +30 -9
  25. package/src/conny/demo/handler.py +23 -23
  26. package/src/conny/personas/generator.py +1 -1
  27. package/src/conny/production/domino.py +2 -2
  28. package/src/conny/production/guard.py +4 -4
  29. package/src/core/admin_engines.py +51 -48
  30. package/src/core/globals.py +110 -9
  31. package/src/core/production_monitor.py +63 -38
  32. package/src/core/runtime.py +343 -305
  33. package/src/domain/prompts/prospect_pitch.py +11 -11
  34. package/src/domain/send_guard.py +4 -4
  35. package/src/interfaces/web/app.py +91 -27
  36. package/src/interfaces/web/demo_admin_commands.py +165 -0
  37. package/src/interfaces/web/demo_handler.py +178 -34
  38. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +0 -22
  39. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +0 -11
  40. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +0 -11
  41. package/brand-assets/cl-nica-las-am-ricas/manifest.json +0 -22
  42. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +0 -11
  43. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +0 -11
  44. package/brand-assets/conny-demo/manifest.json +0 -22
  45. package/brand-assets/conny-demo/processed/business-identity.txt +0 -7
  46. package/brand-assets/conny-demo/raw/business-identity.txt +0 -7
  47. package/fix_init.py +0 -27
  48. package/verify_conversation_impl.py +0 -48
package/conny_init.py CHANGED
@@ -12,13 +12,75 @@ import shutil
12
12
  import urllib.request
13
13
  import urllib.error
14
14
  import subprocess
15
+ import socket
15
16
  from deep_translator import GoogleTranslator
16
17
 
17
- CURRENT_LANG = 'es'
18
+ CURRENT_LANG = os.environ.get("CONNY_INIT_LANG", "en").lower()
19
+
20
+ TRANSLATIONS = {
21
+ "en": {
22
+ "Selección de idioma": "Language selection",
23
+ "Idioma preferido para Conny": "Preferred language for Conny",
24
+ "¿Comenzar configuración?": "Start guided setup?",
25
+ "Operación cancelada.": "Setup cancelled.",
26
+ "Tiempo estimado: ~3 min": "Estimated time: ~3 min",
27
+ "Identidad": "Identity",
28
+ "Nombre del negocio": "Business name",
29
+ "Dominio e Industria": "Domain and industry",
30
+ "Sector Principal": "Primary sector",
31
+ "Canales de Conectividad": "Connectivity channels",
32
+ "Canal Principal de Acceso": "Primary access channel",
33
+ "Gateway público": "Public gateway",
34
+ "¿Cómo deseas configurar el enlace público (BASE_URL)?": "How do you want to configure the public link (BASE_URL)?",
35
+ "Generar túnel automático (localhost.run)": "Generate automatic tunnel (localhost.run)",
36
+ "Configurar enlace manualmente (ingresar URL personalizada)": "Configure manually (enter a custom URL)",
37
+ "Introduce la URL pública de tu webhook": "Enter your public webhook URL",
38
+ "Levantando túnel seguro hacia localhost.run...": "Starting secure tunnel through localhost.run...",
39
+ "Túnel activo:": "Tunnel active:",
40
+ "No pude obtener un túnel automático. Ingresa una URL manual.": "I could not get an automatic tunnel. Enter a manual URL.",
41
+ "Dashboard web": "Web dashboard",
42
+ "¿Cómo quieres exponer la página web de Conny?": "How do you want to expose Conny's web dashboard?",
43
+ "Solo local (localhost)": "Local only (localhost)",
44
+ "Red/IP externa de este dispositivo": "Network/external IP for this device",
45
+ "URL pública personalizada": "Custom public URL",
46
+ "No configurar dashboard ahora": "Do not configure dashboard now",
47
+ "Introduce la URL pública del dashboard": "Enter the public dashboard URL",
48
+ "Motor de Inteligencia": "Intelligence engine",
49
+ "Tipo de proveedor": "Provider type",
50
+ "Proveedor Cloud": "Cloud provider",
51
+ "Modelo Específico": "Specific model",
52
+ "Modelo de Ollama": "Ollama model",
53
+ "Modelo NIM": "NIM model",
54
+ "Humanización y Tono": "Humanization and tone",
55
+ "Perfil de Voz": "Voice profile",
56
+ "Infraestructura y Secretos": "Infrastructure and secrets",
57
+ "Verificación Final": "Final verification",
58
+ "¿Procesar e Implementar Infraestructura?": "Provision and deploy infrastructure?",
59
+ "Cancelado.": "Cancelled.",
60
+ "Creando recursos...": "Creating resources...",
61
+ "Resumen de Configuración": "Configuration summary",
62
+ "Negocio:": "Business:",
63
+ "Sector:": "Sector:",
64
+ "Canal:": "Channel:",
65
+ "Proveedor:": "Provider:",
66
+ "Modelo:": "Model:",
67
+ "Voz:": "Voice:",
68
+ "Secretos:": "Secrets:",
69
+ "Directorio:": "Directory:",
70
+ "Control:": "Control:",
71
+ "Check:": "Check:",
72
+ "Lanzar:": "Launch:",
73
+ "Infraestructura Desplegada Exitosamente": "Infrastructure deployed successfully",
74
+ "Validando API key...": "Validating API key...",
75
+ "✅ API key validada correctamente.": "✅ API key validated successfully.",
76
+ }
77
+ }
18
78
 
19
79
  def _t(text):
20
80
  if CURRENT_LANG == 'es' or not text:
21
81
  return text
82
+ if CURRENT_LANG in TRANSLATIONS and text in TRANSLATIONS[CURRENT_LANG]:
83
+ return TRANSLATIONS[CURRENT_LANG][text]
22
84
  try:
23
85
  # Limpiar texto de colores si es necesario, o traducir directo
24
86
  return GoogleTranslator(source='es', target=CURRENT_LANG).translate(text)
@@ -30,6 +92,12 @@ from datetime import datetime
30
92
  sys.path.insert(0, str(Path(__file__).parent))
31
93
 
32
94
  from conny_tui_select import select_menu as _orig_select_menu, confirm as _orig_confirm, text_input as _orig_text_input
95
+ try:
96
+ from conny_runtime_ops import mirror_instance_env_to_base, set_active_instance, start_localhost_run_tunnel
97
+ except Exception:
98
+ mirror_instance_env_to_base = None
99
+ set_active_instance = None
100
+ start_localhost_run_tunnel = None
33
101
 
34
102
  def select_menu(options, title="", **kwargs):
35
103
  if title: title = _t(title)
@@ -61,7 +129,8 @@ C_ACCENT = "\033[38;5;159m" # Cyan
61
129
  B1 = "\033[1m"
62
130
  R = "\033[0m"
63
131
 
64
- INSTANCES_DIR = Path(os.environ.get("INSTANCES_DIR", str(Path.home() / ".conny-instances")))
132
+ CONNY_HOME = Path(os.environ.get("CONNY_HOME", str(Path.home() / ".conny")))
133
+ INSTANCES_DIR = Path(os.environ.get("INSTANCES_DIR", str(CONNY_HOME / "instances")))
65
134
  CONNY_DIR = Path(os.environ.get("CONNY_DIR", os.path.dirname(os.path.abspath(__file__))))
66
135
 
67
136
  SECTORS = [
@@ -93,8 +162,8 @@ TONES = [
93
162
  ]
94
163
 
95
164
  LANGUAGES = [
96
- ("es", "Español"),
97
165
  ("en", "English"),
166
+ ("es", "Español"),
98
167
  ("pt", "Português"),
99
168
  ]
100
169
 
@@ -138,13 +207,105 @@ def check_gpu():
138
207
 
139
208
  def validate_api_key(provider, key):
140
209
  # Dummy validation with spinner
141
- sys.stdout.write(f" {C_MUTED}Validando API key...{R}")
210
+ sys.stdout.write(f" {C_MUTED}{_t('Validando API key...')}{R}")
142
211
  sys.stdout.flush()
143
212
  time.sleep(1.5)
144
- sys.stdout.write(f"\r {C_SUCCESS}✅ API key validada correctamente.{R} \n")
213
+ sys.stdout.write(f"\r {C_SUCCESS}{_t('✅ API key validada correctamente.')}{R} \n")
145
214
  return True
146
215
 
147
216
 
217
+ def _next_available_port() -> int:
218
+ existing_ports = []
219
+ if INSTANCES_DIR.exists():
220
+ for d in INSTANCES_DIR.iterdir():
221
+ env = d / ".env"
222
+ if not env.exists():
223
+ continue
224
+ for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
225
+ if line.startswith("PORT="):
226
+ try:
227
+ existing_ports.append(int(line.split("=", 1)[1].strip()))
228
+ except Exception:
229
+ pass
230
+ return max(existing_ports + [8003]) + 1
231
+
232
+
233
+ def _configure_gateway(port: int) -> dict:
234
+ options = [
235
+ "Generar túnel automático (localhost.run)",
236
+ "Configurar enlace manualmente (ingresar URL personalizada)",
237
+ ]
238
+ choice = select_menu(options, title="¿Cómo deseas configurar el enlace público (BASE_URL)?")
239
+ if choice == 0 and start_localhost_run_tunnel:
240
+ print(f"\n {C_MUTED}{_t('Levantando túnel seguro hacia localhost.run...')}{R}")
241
+ result = start_localhost_run_tunnel(port)
242
+ if result.get("ok") and result.get("url"):
243
+ print(f" {C_SUCCESS}✓ {_t('Túnel activo:')} {result['url']}{R}")
244
+ return {
245
+ "mode": "localhost.run",
246
+ "base_url": str(result["url"]).rstrip("/"),
247
+ "tunnel_pid": str(result.get("pid") or ""),
248
+ "tunnel_command": str(result.get("command") or ""),
249
+ }
250
+ print(f" {C_WARNING}⚠ {_t('No pude obtener un túnel automático. Ingresa una URL manual.')}{R}")
251
+
252
+ base_url = text_input(
253
+ "Introduce la URL pública de tu webhook",
254
+ default=os.environ.get("BASE_URL", ""),
255
+ required=False,
256
+ ).strip()
257
+ return {
258
+ "mode": "manual",
259
+ "base_url": base_url.rstrip("/"),
260
+ "tunnel_pid": "",
261
+ "tunnel_command": "",
262
+ }
263
+
264
+
265
+ def _local_ip() -> str:
266
+ try:
267
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
268
+ sock.connect(("8.8.8.8", 80))
269
+ return sock.getsockname()[0]
270
+ except Exception:
271
+ return "127.0.0.1"
272
+
273
+
274
+ def _configure_dashboard(port: int) -> dict:
275
+ options = [
276
+ "Solo local (localhost)",
277
+ "Red/IP externa de este dispositivo",
278
+ "URL pública personalizada",
279
+ "No configurar dashboard ahora",
280
+ ]
281
+ choice = select_menu(options, title="¿Cómo quieres exponer la página web de Conny?")
282
+ if choice == 0:
283
+ return {
284
+ "host": "127.0.0.1",
285
+ "dashboard_url": f"http://localhost:{port}/dashboard",
286
+ "public_dashboard_url": "",
287
+ }
288
+ if choice == 1:
289
+ ip = _local_ip()
290
+ return {
291
+ "host": "0.0.0.0",
292
+ "dashboard_url": f"http://{ip}:{port}/dashboard",
293
+ "public_dashboard_url": f"http://{ip}:{port}/dashboard",
294
+ }
295
+ if choice == 2:
296
+ url = text_input("Introduce la URL pública del dashboard", default="", required=False).strip().rstrip("/")
297
+ return {
298
+ "host": "0.0.0.0",
299
+ "dashboard_url": url,
300
+ "public_dashboard_url": url,
301
+ }
302
+ return {
303
+ "host": "127.0.0.1",
304
+ "dashboard_url": "",
305
+ "public_dashboard_url": "",
306
+ }
307
+
308
+
148
309
  def run_wizard():
149
310
 
150
311
  clear()
@@ -198,24 +359,23 @@ def run_wizard():
198
359
  print(f" {line}")
199
360
 
200
361
  print()
201
- print(f" {C_PRIMARY}{B1}Conny CLI {VERSION}{R} · {C_ACCENT}Autonomous Dynamic Receptionist{R} · {C_MUTED}⏱ Tiempo estimado: ~3 min{R}")
362
+ print(f" {C_PRIMARY}{B1}Conny CLI {VERSION}{R} · {C_ACCENT}Autonomous Dynamic Receptionist{R} · {C_MUTED}⏱ {_t('Tiempo estimado: ~3 min')}{R}")
202
363
  print(f" {C_MUTED}{'─' * 70}{R}")
203
364
  print()
204
365
 
205
366
 
206
367
 
207
368
  if not confirm("¿Comenzar configuración?"):
208
- print(f"\n {C_MUTED}Operación cancelada.{R}\n")
369
+ print(f"\n {C_MUTED}{_t('Operación cancelada.')}{R}\n")
209
370
  return
210
371
 
211
- TOTAL_STEPS = 7
372
+ TOTAL_STEPS = 9
212
373
  ctx = ""
213
374
 
214
375
  # 0. Language (Optional pre-step)
215
376
  clear()
216
377
  print(f"\n {C_PRIMARY}🌐 {_t('Selección de idioma')}{R}")
217
378
  i = select_menu([l[1] for l in LANGUAGES], title="Idioma preferido para Conny")
218
- i = select_menu([l[1] for l in LANGUAGES], title="Idioma preferido para Conny")
219
379
  lang = LANGUAGES[i][0]
220
380
  global CURRENT_LANG
221
381
  CURRENT_LANG = lang
@@ -241,9 +401,20 @@ def run_wizard():
241
401
  i = select_menu([c[1] for c in CHANNELS], title="Canal Principal de Acceso")
242
402
  channel = CHANNELS[i][0]
243
403
 
244
- # 4. Intelligence
404
+ # 4. Gateway / Webhook
405
+ port = _next_available_port()
406
+ clear()
407
+ step_header(4, TOTAL_STEPS, "Gateway público", f"{ctx}Canal: {channel} | Puerto: {port}")
408
+ gateway = _configure_gateway(port)
409
+
410
+ # 5. Web dashboard
245
411
  clear()
246
- step_header(4, TOTAL_STEPS, "Motor de Inteligencia", ctx)
412
+ step_header(5, TOTAL_STEPS, "Dashboard web", f"{ctx}Puerto: {port}")
413
+ dashboard = _configure_dashboard(port)
414
+
415
+ # 6. Intelligence
416
+ clear()
417
+ step_header(6, TOTAL_STEPS, "Motor de Inteligencia", ctx)
247
418
 
248
419
  # Level 1
249
420
  llm_types = [
@@ -322,15 +493,15 @@ def run_wizard():
322
493
  provider_id = "manual"
323
494
  model_id = text_input("Model ID")
324
495
 
325
- # 5. Personality
496
+ # 7. Personality
326
497
  clear()
327
- step_header(5, TOTAL_STEPS, "Humanización y Tono", ctx)
498
+ step_header(7, TOTAL_STEPS, "Humanización y Tono", ctx)
328
499
  i = select_menu([t[1] for t in TONES], title="Perfil de Voz")
329
500
  tone = TONES[i][0]
330
501
 
331
- # 6. Credentials
502
+ # 8. Credentials
332
503
  clear()
333
- step_header(6, TOTAL_STEPS, "Infraestructura y Secretos", ctx)
504
+ step_header(8, TOTAL_STEPS, "Infraestructura y Secretos", ctx)
334
505
 
335
506
  secrets_map = {}
336
507
 
@@ -371,9 +542,9 @@ def run_wizard():
371
542
  secrets_map["WA_CLOUD_TOKEN"] = text_input("WA Cloud API Token", is_password=True)
372
543
  secrets_map["WA_PHONE_NUMBER_ID"] = text_input("Phone Number ID")
373
544
 
374
- # 7. Confirmation
545
+ # 9. Confirmation
375
546
  clear()
376
- step_header(7, TOTAL_STEPS, "Verificación Final")
547
+ step_header(9, TOTAL_STEPS, "Verificación Final")
377
548
 
378
549
  print(f" ┌────────────────────────────────────────────────────────┐")
379
550
  print(f" │ {C_PRIMARY}{B1}Resumen de Configuración{R} │")
@@ -385,56 +556,58 @@ def run_wizard():
385
556
  print(f" │ {C_MUTED}Modelo:{R} {model_id.ljust(44)}│")
386
557
  print(f" │ {C_MUTED}Voz:{R} {tone.ljust(44)}│")
387
558
  print(f" │ {C_MUTED}Secretos:{R} {str(len(secrets_map)).ljust(44)}│")
559
+ print(f" │ {C_MUTED}BASE_URL:{R} {(gateway.get('base_url') or 'pending').ljust(44)[:44]}│")
560
+ print(f" │ {C_MUTED}Dashboard:{R} {(dashboard.get('dashboard_url') or 'local only').ljust(44)[:44]}│")
388
561
  print(f" └────────────────────────────────────────────────────────┘\n")
389
562
 
390
563
  if not confirm("¿Procesar e Implementar Infraestructura?"):
391
564
  print(f"\n {C_MUTED}Cancelado.{R}\n")
392
565
  return
393
566
 
394
- print(f"\n {C_PRIMARY}Creando recursos...{R}")
395
- _create(name, instance_id, sector, channel, provider_id, model_id, tone, secrets_map, lang)
567
+ print(f"\n {C_PRIMARY}{_t('Creando recursos...')}{R}")
568
+ _create(name, instance_id, sector, channel, provider_id, model_id, tone, secrets_map, lang, port, gateway, dashboard)
396
569
 
397
570
  print(f"""
398
- {C_SUCCESS}{B1}🚀 Infraestructura Desplegada Exitosamente{R}
571
+ {C_SUCCESS}{B1}🚀 {_t('Infraestructura Desplegada Exitosamente')}{R}
399
572
 
400
- {C_MUTED}Directorio:{R} {INSTANCES_DIR / instance_id}
401
- {C_MUTED}Control:{R} {C_PRIMARY}conny status {instance_id}{R}
402
- {C_MUTED}Check:{R} {C_PRIMARY}conny doctor{R}
403
- {C_MUTED}Lanzar:{R} {C_PRIMARY}pm2 start {INSTANCES_DIR / instance_id}/run.sh --name conny-{instance_id}{R}
573
+ {C_MUTED}{_t('Directorio:')}{R} {INSTANCES_DIR / instance_id}
574
+ {C_MUTED}{_t('Control:')}{R} {C_PRIMARY}conny status {instance_id}{R}
575
+ {C_MUTED}{_t('Check:')}{R} {C_PRIMARY}conny doctor{R}
576
+ {C_MUTED}{_t('Lanzar:')}{R} {C_PRIMARY}pm2 start {INSTANCES_DIR / instance_id}/run.sh --name conny-{instance_id}{R}
404
577
  """)
405
578
 
406
579
  # Post-onboarding commands
407
580
  if is_local and provider_id == "ollama":
408
581
  print(f" {C_WARNING}💡 Asegúrate de descargar el modelo: `ollama run {model_id}`{R}\n")
409
582
 
410
- def _create(name, iid, sector, channel, provider, model_id, tone, secrets_map, lang):
583
+ def _create(name, iid, sector, channel, provider, model_id, tone, secrets_map, lang, port, gateway, dashboard):
411
584
  idir = INSTANCES_DIR / iid
412
585
  idir.mkdir(parents=True, exist_ok=True)
413
586
 
414
- existing_ports = []
415
- if INSTANCES_DIR.exists():
416
- for d in INSTANCES_DIR.iterdir():
417
- env = d / ".env"
418
- if env.exists():
419
- for line in env.read_text().splitlines():
420
- if line.startswith("PORT="):
421
- try:
422
- parts = line.split("=")
423
- if len(parts) > 1:
424
- existing_ports.append(int(parts[1]))
425
- except: pass
426
-
427
- port = max(existing_ports + [8003]) + 1
428
587
  webhook_secret = f"conny_{iid}_{secrets.token_hex(6)}"
588
+ base_url = str((gateway or {}).get("base_url") or "").rstrip("/")
589
+ tunnel_command = str((gateway or {}).get("tunnel_command") or "").replace('"', '\\"')
590
+ dashboard = dashboard or {}
591
+ host = str(dashboard.get("host") or "127.0.0.1").strip()
592
+ dashboard_url = str(dashboard.get("dashboard_url") or "").rstrip("/")
593
+ public_dashboard_url = str(dashboard.get("public_dashboard_url") or "").rstrip("/")
429
594
 
430
595
  env_lines = [
431
596
  f"INSTANCE_ID={iid}",
432
597
  f"PORT={port}",
598
+ f"HOST={host}",
599
+ f"BASE_URL={base_url}",
600
+ f"PUBLIC_BASE_URL={base_url}",
601
+ f"DASHBOARD_URL={dashboard_url}",
602
+ f"PUBLIC_DASHBOARD_URL={public_dashboard_url}",
433
603
  "DEMO_MODE=false",
434
604
  f"PLATFORM={channel}",
435
605
  f"SECTOR={sector}",
436
606
  f"BUSINESS_NAME=\"{name}\"",
437
607
  f"WEBHOOK_SECRET={webhook_secret}",
608
+ f"TUNNEL_PROVIDER={(gateway or {}).get('mode', '')}",
609
+ f"TUNNEL_PID={(gateway or {}).get('tunnel_pid', '')}",
610
+ f"TUNNEL_COMMAND=\"{tunnel_command}\"",
438
611
  f"LLM_PROVIDER={provider}",
439
612
  f"LLM_MODEL={model_id}",
440
613
  "DEBUG=false"
@@ -473,7 +646,10 @@ llm:
473
646
 
474
647
  for f in core:
475
648
  src = CONNY_DIR / f
476
- if src.exists(): shutil.copy2(src, idir / f)
649
+ if src.exists():
650
+ dst = idir / f
651
+ dst.parent.mkdir(parents=True, exist_ok=True)
652
+ shutil.copy2(src, dst)
477
653
 
478
654
  for d in ["soul", "teachings", "memory_store", "knowledge_gaps", "integrations/vault", "logs"]:
479
655
  (idir / d).mkdir(parents=True, exist_ok=True)
@@ -486,10 +662,27 @@ llm:
486
662
  "status": "configured",
487
663
  "timestamp": datetime.now().isoformat(),
488
664
  "instance": iid,
489
- "provider": provider
665
+ "provider": provider,
666
+ "port": port,
667
+ "base_url": base_url,
668
+ "dashboard": {
669
+ "host": host,
670
+ "url": dashboard_url,
671
+ "public_url": public_dashboard_url,
672
+ },
673
+ "tunnel": {
674
+ "provider": (gateway or {}).get("mode", ""),
675
+ "pid": (gateway or {}).get("tunnel_pid", ""),
676
+ "command": (gateway or {}).get("tunnel_command", ""),
677
+ },
490
678
  }
491
679
  (idir / "conny.state.json").write_text(json.dumps(state, indent=2))
492
680
 
681
+ if set_active_instance:
682
+ set_active_instance(iid)
683
+ if mirror_instance_env_to_base:
684
+ mirror_instance_env_to_base(iid)
685
+
493
686
  def _slug(name):
494
687
  s = name.lower().strip()
495
688
  for a, b in [("á","a"),("é","e"),("í","i"),("ó","o"),("ú","u"),("ñ","n")]: