@innvisor/conny-ai 9.7.0 → 9.8.2

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 (50) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +17 -1
  3. package/conny_app.py +9 -3
  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_i18n.py +81 -2
  13. package/conny_init.py +254 -41
  14. package/conny_runtime_ops.py +198 -6
  15. package/conny_tui.py +7 -0
  16. package/conny_ultra_config.py +25 -11
  17. package/conny_utils.py +21 -3
  18. package/ecosystem.config.js +11 -1
  19. package/install.sh +78 -22
  20. package/npm/conny.js +75 -21
  21. package/package.json +12 -2
  22. package/run.sh +7 -0
  23. package/src/conny/admin/dashboard.py +35 -4
  24. package/src/conny/admin_memory.py +93 -0
  25. package/src/conny/api/routes.py +26 -9
  26. package/src/conny/channels/cli.py +30 -9
  27. package/src/conny/demo/handler.py +23 -23
  28. package/src/conny/personas/generator.py +1 -1
  29. package/src/conny/production/domino.py +2 -2
  30. package/src/conny/production/guard.py +4 -4
  31. package/src/core/admin_engines.py +51 -48
  32. package/src/core/globals.py +110 -9
  33. package/src/core/production_monitor.py +63 -38
  34. package/src/core/runtime.py +343 -305
  35. package/src/domain/prompts/prospect_pitch.py +11 -11
  36. package/src/domain/send_guard.py +4 -4
  37. package/src/interfaces/web/app.py +91 -27
  38. package/src/interfaces/web/demo_admin_commands.py +165 -0
  39. package/src/interfaces/web/demo_handler.py +178 -34
  40. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +0 -22
  41. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +0 -11
  42. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +0 -11
  43. package/brand-assets/cl-nica-las-am-ricas/manifest.json +0 -22
  44. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +0 -11
  45. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +0 -11
  46. package/brand-assets/conny-demo/manifest.json +0 -22
  47. package/brand-assets/conny-demo/processed/business-identity.txt +0 -7
  48. package/brand-assets/conny-demo/raw/business-identity.txt +0 -7
  49. package/fix_init.py +0 -27
  50. package/verify_conversation_impl.py +0 -48
@@ -21,6 +21,11 @@ class ConnyUltra:
21
21
  self.self_improvement = None
22
22
  self.admin_learning: AdminLearningEngine = None
23
23
  self.simulator: SimulationEngine = None
24
+
25
+ # Auto-evolución (v10)
26
+ self._instance_id = os.getenv("INSTANCE_ID", "default")
27
+ from conny_core.evolution import EvolutionManager
28
+ self.evolution = EvolutionManager(self._instance_id, db)
24
29
  # ── Atributos base (antes de managers que los usan) ──────────────────────
25
30
  self._demo_sessions: Dict[str, float] = {}
26
31
  self._emoji_chats_off: set = set()
@@ -854,25 +859,40 @@ class ConnyUltra:
854
859
  return await self._handle_admin_message(chat_id, text, clinic, is_audio=is_audio, attachments=attachments)
855
860
 
856
861
  if not is_setup_done:
857
- log.warning(f"[Router] Remitente {chat_id} no es admin y la instancia no está configurada aún. Ruteando a Setup.")
858
- return await self._handle_admin_message(chat_id, text, clinic, is_audio=is_audio, attachments=attachments)
862
+ log.warning(
863
+ f"[Router] Remitente {chat_id} no es admin y la instancia no está configurada aún. "
864
+ "Bloqueando setup hasta token de activación."
865
+ )
866
+ return ["Ingresa tu Token de Activación para comenzar."]
859
867
 
860
868
  # Ruteo normal para paciente en producción
861
869
  log.info(f"[Router] Remitente {chat_id} clasificado como Paciente. Ruteando a flujo de producción.")
862
870
  history = db.get_history(chat_id)
863
871
  conv_state = db.get_conversation_state(chat_id)
864
- return await self._process_patient_message(chat_id, text, clinic, history, conv_state, is_audio=is_audio, attachments=attachments)
872
+ result = await self._process_patient_message(chat_id, text, clinic, history, conv_state, is_audio=is_audio, attachments=attachments)
873
+ if result:
874
+ return result
865
875
 
866
876
  except Exception as e:
867
- log.error(f"Error processing message from {chat_id}: {e}", exc_info=True)
877
+ log.warning(f"[Router] fallback LLM para {chat_id}: {e}")
868
878
  db.record_metric("error", "message_processing", 1, {"error": str(e)})
869
879
 
870
880
  # Recuperación inteligente: intentar respuesta LLM directa como fallback
871
881
  try:
872
882
  if llm_engine and text and text.strip():
873
883
  clinic = db.get_clinic()
874
- biz_name = clinic.get("name", "") if clinic else ""
875
- fallback_sys = f"Eres Conny, recepcionista virtual{' de ' + biz_name if biz_name else ''}. Responde de forma breve, cálida y natural. Si no tienes contexto suficiente, pide al usuario que te cuente más."
884
+ biz_name = clinic.get("name", "Innvisor") if clinic else "Innvisor"
885
+ fallback_sys = (
886
+ f"sos conny. trabajas para {biz_name}. hablas corto, natural, como un ser humano. "
887
+ "minusculas, sin emojis, sin ¿? sin jotas. "
888
+ "dividi el mensaje en burbujas separadas por ||| "
889
+ "si no sabes algo preguntas con ganas de aprender. "
890
+ "si te piden info que no tenes pedi enlaces o pdfs. "
891
+ "aprendes el nombre del admin y lo recordas siempre. "
892
+ "nunca digas el nombre del negocio a menos que el contexto lo pida. "
893
+ "nada de 'en que puedo ayudarte' ni 'recepcionista virtual'. "
894
+ "directo, util, humano."
895
+ )
876
896
  fallback_r, _ = await llm_engine.complete(
877
897
  [{"role": "system", "content": fallback_sys}, {"role": "user", "content": text}],
878
898
  model_tier="fast", temperature=0.8, max_tokens=200, use_cache=False,
@@ -882,14 +902,36 @@ class ConnyUltra:
882
902
  except Exception:
883
903
  pass
884
904
 
885
- # Último recurso: respuesta genérica humana (nunca decir "cruce de cables")
886
- import random as _r
887
- _fallbacks = [
888
- "cuéntame un poco más, no te alcancé a entender bien",
889
- "perdona, me perdí un momento ||| qué me decías?",
890
- "disculpa, no te pillé bien ||| me repites?",
891
- ]
892
- return [_r.choice(_fallbacks)]
905
+ return []
906
+ async def _process_patient_message(self, chat_id: str, text: str, clinic: Dict,
907
+ history: List, conv_state: Any,
908
+ is_audio: bool = False,
909
+ attachments: Optional[List[Dict[str, Any]]] = None) -> List[str]:
910
+ """Procesa un mensaje de paciente delegando al gestor de producción."""
911
+ try:
912
+ if self.production_mgr:
913
+ return await self.production_mgr.handle(
914
+ chat_id, text, clinic, history, conv_state
915
+ )
916
+ except Exception as e:
917
+ log.warning(f"[_process_patient_message] error delegando a production_mgr: {e}")
918
+
919
+ # Fallback de emergencia si production_mgr falla
920
+ try:
921
+ _gen = getattr(getattr(self, "generator", None), "llm", None) or getattr(self, "llm_engine", None)
922
+ if _gen and text and text.strip():
923
+ r, _ = await _gen.complete(
924
+ [{"role": "system",
925
+ "content": "sos conny. hablas corto, natural, como un ser humano. minusculas, sin emojis, sin ¿? sin jotas. dividi el mensaje en burbujas separadas por ||| si no sabes algo preguntas con ganas de aprender. si te piden info que no tenes pedi enlaces o pdfs. aprendes el nombre del admin y lo recordas siempre. nunca digas el nombre del negocio a menos que el contexto lo pida. nada de 'en que puedo ayudarte' ni 'recepcionista virtual'. directo, util, humano."},
926
+ {"role": "user", "content": text}],
927
+ model_tier="fast", temperature=0.8, max_tokens=200, use_cache=False,
928
+ )
929
+ if r and r.strip():
930
+ return self._split_bubbles(r, chat_id=chat_id)
931
+ except Exception:
932
+ pass
933
+ return []
934
+
893
935
  async def _handle_demo_message(self, chat_id: str, text: str,
894
936
  clinic: Dict,
895
937
  attachments: Optional[List[Dict[str, Any]]] = None) -> List[str]:
@@ -899,6 +941,26 @@ class ConnyUltra:
899
941
  async def _handle_admin_message(self, chat_id: str, text: str, clinic: Dict,
900
942
  is_audio: bool = False,
901
943
  attachments: Optional[List[Dict[str, Any]]] = None) -> List[str]:
944
+ # 0. Actualizar perfil del admin (Namespace admin_profile)
945
+ try:
946
+ profile = db.get_admin_profile(chat_id)
947
+
948
+ # Registrar hora activa
949
+ from datetime import datetime
950
+ now_hour = datetime.now().hour
951
+ hours = profile.get("active_hours", {})
952
+ hours[str(now_hour)] = hours.get(str(now_hour), 0) + 1
953
+
954
+ # Registrar comando frecuente si aplica
955
+ cmd_freq = profile.get("frequent_commands", {})
956
+ if text.startswith("/"):
957
+ cmd = text.split()[0].lower()
958
+ cmd_freq[cmd] = cmd_freq.get(cmd, 0) + 1
959
+
960
+ db.update_admin_profile(chat_id, active_hours=hours, frequent_commands=cmd_freq)
961
+ except Exception as e:
962
+ log.warning(f"[admin_profile] error updating profile: {e}")
963
+
902
964
  # 1. Ingest assets if there are attachments
903
965
  if attachments:
904
966
  assets_res = await self._admin_ingest_assets(chat_id, text, attachments, clinic)
@@ -928,28 +990,20 @@ class ConnyUltra:
928
990
  if is_authenticated_admin:
929
991
  return await self._handle_setup(chat_id, text, clinic)
930
992
  else:
931
- # Paciente que llega antes de que el admin configure la clinica
932
- # No revelar nada del sistema, responder neutral
933
- clinic_name = clinic.get("name") or ""
934
- if clinic_name:
935
- return [f"Hola. En este momento estamos terminando de configurar el sistema. Vuelve pronto."]
936
- else:
937
- if db.list_admins():
938
- return ["Hola. En este momento no estamos disponibles. Vuelve pronto."]
939
- else:
940
- sector = clinic.get("sector", "otro")
941
- sector_name = "negocio"
942
- try:
943
- sector_name = SECTORS.get(sector, SECTORS["otro"]).name if sector else "negocio"
944
- except Exception:
945
- pass
946
- clinic_name = clinic.get("name") or f"{sector_name} en proceso"
947
- return [
948
- f"¡Hola! 👋 Bienvenido a {clinic_name}.",
949
- "Soy Conny, la recepcionista virtual.",
950
- "Todavía estoy aprendiendo cómo funciona tu negocio — para eso necesito que me cuentes un poco.",
951
- "Cuando estés listo, escribe /configurar y empezamos. Si quieres probarme primero, escribe algo y te respondo como si ya estuviera configurada.",
952
- ]
993
+ # Paciente sin token pedir activación por LLM o hardcoded
994
+ try:
995
+ _tok_llm = getattr(getattr(self, "generator", None), "llm", None)
996
+ if _tok_llm:
997
+ r, _ = await _tok_llm.complete(
998
+ [{"role": "system", "content": "Eres Conny. El sistema no está activado aún. Respondé de forma breve y amable que para usar el servicio necesita un token de activación. Decí exactamente: 'Ingresa tu Token de Activación para comenzar.' No des más información."},
999
+ {"role": "user", "content": text}],
1000
+ model_tier="fast", temperature=0.3, max_tokens=100,
1001
+ )
1002
+ if r and r.strip():
1003
+ return [r.strip()]
1004
+ except Exception:
1005
+ pass
1006
+ return ["Ingresa tu Token de Activación para comenzar."]
953
1007
 
954
1008
  # Comandos slash
955
1009
  cmd = text.lower().strip()
@@ -1211,219 +1265,94 @@ class ConnyUltra:
1211
1265
 
1212
1266
  async def _handle_setup(self, chat_id: str, text: str, clinic: Dict) -> List[str]:
1213
1267
  """
1214
- Setup inteligente con autodiscovery.
1215
- Cuando el admin escribe el nombre, buscamos la clinica en Google
1216
- y pre-llenamos todo. Si se encuentra, solo confirman. Si no, 5 pasos rapidos.
1268
+ Setup inteligente y proactivo con LLM.
1269
+ Si es una instancia nueva, Conny guía al admin para extraer la información.
1217
1270
  """
1271
+ from conny import llm_engine
1218
1272
 
1219
- setup_step = clinic.get("setup_step", "idle")
1220
- setup_buffer = clinic.get("setup_buffer", {})
1221
- if isinstance(setup_buffer, str):
1222
- setup_buffer = json.loads(setup_buffer) if setup_buffer else {}
1273
+ # Si el admin dice "reset" o algo así, podríamos volver al modo idle,
1274
+ # pero por ahora seguimos el flujo natural.
1275
+
1276
+ CONNY_ADMIN_ONBOARDING_PROMPT = """
1277
+ Eres Conny. Acabas de ser instalada en un negocio nuevo y necesitas aprender
1278
+ todo sobre él antes de poder atender clientes.
1279
+
1280
+ Tu objetivo en esta conversación: extraer del admin TODA la información que
1281
+ necesitas para trabajar. No puedes atender clientes sin esta información.
1282
+
1283
+ Inicia con este mensaje exacto si es el primer contacto:
1284
+ "hola! soy Conny, tu nueva recepcionista virtual 👋
1285
+ antes de empezar a atender clientes necesito conocer bien el negocio
1286
+ para poder responder bien |||
1287
+ cuéntame: ¿cómo se llama el negocio y qué ofrecen?"
1288
+
1289
+ Después de recibir respuesta, continúa extrayendo en conversación natural:
1290
+ 1. Nombre del negocio y descripción
1291
+ 2. Servicios principales y precios (o rango de precios)
1292
+ 3. Horarios de atención
1293
+ 4. Dirección o zona
1294
+ 5. ¿Cómo deben agendar citas los clientes?
1295
+ 6. ¿Hay algo que Conny NO debe decir o prometer?
1296
+ 7. ¿Cuál es el objetivo principal: agendar citas, vender, informar?
1297
+
1298
+ REGLA: No inventes información del negocio. Si el admin no te dijo algo,
1299
+ pregúntalo antes de atender clientes reales con esa duda.
1300
+ """
1223
1301
 
1224
- step_names = ["name", "tagline", "services", "schedule", "phone", "pricing"]
1302
+ history = db.get_history(chat_id)
1303
+ messages = [
1304
+ {"role": "system", "content": CONNY_ADMIN_ONBOARDING_PROMPT}
1305
+ ]
1306
+
1307
+ # Limitar historial para el setup
1308
+ for m in history[-10:]:
1309
+ messages.append({"role": m.get("role", "user"), "content": m.get("content", "")})
1310
+
1311
+ # Si es el primer mensaje del admin (o está vacío), forzamos el inicio
1312
+ if not text or not text.strip() or len(history) == 0:
1313
+ user_input = "hola" # Trigger inicial
1314
+ else:
1315
+ user_input = text
1316
+
1317
+ messages.append({"role": "user", "content": user_input})
1225
1318
 
1226
- # ── Inicio ─────────────────────────────────────────────────────────────
1227
- if setup_step == "idle":
1228
- admin_name_greeting = ""
1319
+ try:
1320
+ response, _ = await llm_engine.complete(
1321
+ messages, model_tier="fast", temperature=0.7
1322
+ )
1323
+
1324
+ # Intentar extraer info para guardar en DB mientras conversamos
1229
1325
  try:
1230
- rec = db.get_admin(chat_id) if db else None
1231
- if rec and rec.get("name") and rec["name"] not in ("", "Admin"):
1232
- admin_name_greeting = f" Hola, {rec['name']}."
1326
+ # Si el LLM detectó el nombre del negocio, lo guardamos
1327
+ if "negocio" in response.lower() or "clínica" in response.lower():
1328
+ # Aquí podríamos usar un pequeño extractor, pero por ahora
1329
+ # confiamos en que el admin confirmará al final.
1330
+ pass
1233
1331
  except Exception:
1234
1332
  pass
1235
- db.update_clinic(setup_step="name")
1236
- return [
1237
- f"¡Hola!{admin_name_greeting} Vamos a dejarme lista para tu negocio.",
1238
- "¿Cómo se llama tu clínica o negocio?"
1239
- ]
1240
1333
 
1241
- # ── Confirmar datos descubiertos en web ─────────────────────────────────
1242
- if setup_step == "confirm_discovered":
1243
- text_low = text.lower().strip()
1244
- if text_low in ["si", "sip", "sep", "dale", "correcto", "ok", "yes", "listo", "claro"]:
1245
- # Aplicar todo lo descubierto
1246
- discovered = setup_buffer.get("discovered", {})
1247
- db.update_clinic(
1248
- name=discovered.get("name", setup_buffer.get("name", "Mi Clinica")),
1249
- tagline=discovered.get("tagline", ""),
1250
- services=discovered.get("services", []),
1251
- schedule=discovered.get("schedule", {}),
1252
- phone=discovered.get("phone", ""),
1253
- address=discovered.get("address", ""),
1254
- setup_done=1,
1255
- setup_step="idle",
1256
- setup_buffer={}
1257
- )
1258
- name = discovered.get("name", "tu clinica")
1259
- svcs = ", ".join(discovered.get("services", [])) or "sin definir"
1260
- return [
1261
- f"Listo. Quede configurada para {name}.",
1262
- f"Servicios: {svcs}.\n\nDesde ahora me encargo de tus pacientes.\nComandos: /citas | /config | /metricas | /personalidad"
1263
- ]
1264
- else:
1265
- # No confirmo, ir a setup manual desde donde quedamos
1266
- db.update_clinic(setup_step="tagline", setup_buffer=setup_buffer)
1267
- return [
1268
- "Sin problema, vamos a completar esto manualmente.",
1269
- "Tienes un slogan o descripcion corta? Si no, escribe 'no'."
1270
- ]
1271
-
1272
- if setup_step not in step_names:
1273
- db.update_clinic(setup_step="idle", setup_buffer={})
1274
- return ["Algo salio mal. Escribe /setup para comenzar de nuevo."]
1275
-
1276
- idx = step_names.index(setup_step)
1277
-
1278
- # ── Procesar respuesta ─────────────────────────────────────────────────
1279
- if setup_step == "services":
1280
- raw_svcs = [s.strip() for s in text.split(",") if s.strip()]
1281
- # Validar: si parece una URL, un comando, o una sola frase larga -> rechazar
1282
- is_garbage = (
1283
- len(raw_svcs) == 1 and (
1284
- len(raw_svcs[0]) > 50 or
1285
- any(kw in raw_svcs[0].lower() for kw in [
1286
- "google", "busca", "http", "www", ".com", "facebook",
1287
- "instagram", "busqueda", "encuentra"
1288
- ])
1289
- )
1290
- )
1291
- if is_garbage:
1292
- return [
1293
- "Eso no parece una lista de servicios.",
1294
- "Escribelos separados por coma:\nEj: Botox, Rellenos, Limpieza facial, Laser CO2, Radiofrecuencia"
1295
- ]
1296
- setup_buffer["services"] = [s.title() for s in raw_svcs]
1297
- elif setup_step == "tagline":
1298
- setup_buffer["tagline"] = "" if text.lower().strip() in ["no", "n", "-", "ninguno"] else text.strip()
1299
- elif setup_step == "schedule":
1300
- setup_buffer["schedule"] = {"General": text.strip()}
1301
- elif setup_step == "pricing":
1302
- # Parsear precios libres: "Botox: 350.000, Rellenos: 500.000" o texto libre
1303
- pricing_dict = {}
1304
- text_low_p = text.lower().strip()
1305
- if text_low_p not in ["no", "n", "-", "ninguno", "despues", "luego"]:
1306
- for line in re.split(r'[,\n;]+', text):
1307
- line = line.strip()
1308
- if ':' in line:
1309
- parts = line.split(':', 1)
1310
- svc = parts[0].strip()
1311
- price = parts[1].strip()
1312
- if svc and price:
1313
- pricing_dict[svc] = price
1314
- elif '-' in line:
1315
- parts = line.split('-', 1)
1316
- svc = parts[0].strip()
1317
- price = parts[1].strip()
1318
- if svc and price:
1319
- pricing_dict[svc] = price
1320
- setup_buffer["pricing"] = pricing_dict
1321
- else:
1322
- setup_buffer[setup_step] = text.strip()
1323
-
1324
- # ── Si acaba de darnos el nombre, buscar en Google ─────────────────────
1325
- if setup_step == "name":
1326
- clinic_name = text.strip()
1327
- discovered = await self._discover_clinic_from_web(clinic_name)
1328
-
1329
- if discovered and discovered.get("confidence", 0) >= 0.5:
1330
- # Guardamos todo en buffer
1331
- setup_buffer["discovered"] = discovered
1332
- setup_buffer["name"] = clinic_name
1333
-
1334
- svcs = ", ".join(discovered.get("services", [])) or "no encontre servicios"
1335
- phone = discovered.get("phone", "")
1336
- sched = discovered.get("schedule_text", "")
1337
- addr = discovered.get("address", "")
1338
-
1339
- summary_parts = [f"Servicios: {svcs}"]
1340
- if sched:
1341
- summary_parts.append(f"Horario: {sched}")
1342
- if phone:
1343
- summary_parts.append(f"Tel: {phone}")
1344
- if addr:
1345
- summary_parts.append(f"Direccion: {addr}")
1346
- summary = ". ".join(summary_parts)
1347
-
1348
- db.update_clinic(
1349
- setup_step="confirm_discovered",
1350
- setup_buffer=setup_buffer
1351
- )
1352
- return [
1353
- f"Encontre info de {clinic_name} en internet.",
1354
- f"{summary}.\n\nConfirmas estos datos? (si / no)"
1355
- ]
1356
- else:
1357
- # No encontre nada, seguir con setup normal
1358
- db.update_clinic(
1359
- setup_step=step_names[1],
1360
- setup_buffer=setup_buffer
1361
- )
1362
- return self._setup_next_bubbles("name", text.strip(), "tagline", setup_buffer)
1363
-
1364
- # ── Siguiente paso normal ──────────────────────────────────────────────
1365
- if idx + 1 < len(step_names):
1366
- next_step = step_names[idx + 1]
1367
- db.update_clinic(setup_step=next_step, setup_buffer=setup_buffer)
1368
- return self._setup_next_bubbles(setup_step, text.strip(), next_step, setup_buffer)
1334
+ # Si el admin dice algo que suena a "ya terminamos" o el LLM lo indica,
1335
+ # podríamos marcar setup_done=1. Pero mejor que sea explícito o
1336
+ # cuando tengamos los campos mínimos.
1337
+
1338
+ # Para esta implementación, seguimos usando el autodiscovery si el admin da el nombre
1339
+ if len(history) < 2 and len(user_input.split()) < 5:
1340
+ # Si es un nombre corto, intentamos autodiscovery
1341
+ discovered = await self._discover_clinic_from_web(user_input)
1342
+ if discovered and discovered.get("confidence", 0) >= 0.6:
1343
+ # Mezclamos la respuesta del LLM con el discovery
1344
+ db.update_clinic(setup_step="confirm_discovered", setup_buffer=json.dumps({"discovered": discovered, "name": user_input}))
1345
+ return [
1346
+ response,
1347
+ f"Por cierto, encontré esto en internet: {discovered.get('address', '')}. ¿Es correcto?"
1348
+ ]
1369
1349
 
1370
- # ── Finalizar setup ───────────────────────────────────────────────────
1371
- db.update_clinic(
1372
- name=setup_buffer.get("name", "Mi Clinica"),
1373
- tagline=setup_buffer.get("tagline", ""),
1374
- services=setup_buffer.get("services", []),
1375
- schedule=setup_buffer.get("schedule", {}),
1376
- phone=setup_buffer.get("phone", ""),
1377
- pricing=setup_buffer.get("pricing", {}),
1378
- setup_done=1,
1379
- setup_step="idle",
1380
- setup_buffer={}
1381
- )
1382
- name = setup_buffer.get("name", "tu clinica")
1383
- svcs = ", ".join(setup_buffer.get("services", [])) or "sin definir"
1384
- pricing_count = len(setup_buffer.get("pricing", {}))
1385
- pricing_note = f" con {pricing_count} precios cargados" if pricing_count else ""
1386
-
1387
- # Guardar identidad en memoria permanente
1388
- db.remember("clinic_name", name, "identity")
1389
- db.remember("clinic_services", ", ".join(setup_buffer.get("services", [])), "clinic")
1390
- db.remember("clinic_phone", setup_buffer.get("phone", ""), "clinic")
1391
- db.remember("platform", Config.PLATFORM, "identity")
1392
- db.remember("setup_completed", "true", "identity")
1393
-
1394
- # Notificar a Omni que esta instancia quedó configurada
1395
- asyncio.create_task(asyncio.to_thread(
1396
- notify_omni, "setup_completado",
1397
- f"Nueva instancia configurada: {name} (sector: {Config.SECTOR})", name
1398
- ))
1399
-
1400
- # Guía de siguiente paso — WhatsApp si aún no está conectado
1401
- whatsapp_connected = db.recall("whatsapp_connected") == "true"
1402
-
1403
- if whatsapp_connected:
1404
- next_step = (
1405
- "\n\nYa tienes WhatsApp conectado. Listo para recibir pacientes."
1406
- )
1407
- else:
1408
- next_step = (
1409
- "\n\nSiguiente paso — conectar WhatsApp:\n"
1410
- "Pégame aquí tu Phone Number ID y Access Token de Meta Business.\n"
1411
- "Formato:\n"
1412
- " WA_PHONE_ID: 123456789012345\n"
1413
- " WA_TOKEN: EAAxxxxx...\n\n"
1414
- "Si aun no los tienes, escribe /whatsapp y te explico cómo obtenerlos."
1415
- )
1350
+ return self._split_bubbles(response, chat_id=chat_id)
1416
1351
 
1417
- kb_invite = (
1418
- "\n\nTambién puedes enviarme un documento con toda la info de tu clinica "
1419
- "(precios detallados, protocolos, FAQs) y lo aprendo todo de una vez."
1420
- ) if _KB_AVAILABLE else ""
1352
+ except Exception as e:
1353
+ log.error(f"[setup] error en onboarding proactivo: {e}")
1354
+ return ["hola! soy Conny. cuéntame cómo se llama tu negocio para empezar."]
1421
1355
 
1422
- return [
1423
- f"¡Listo! Soy {name}.{pricing_note}",
1424
- f"Servicios: {svcs}.{next_step}{kb_invite}",
1425
- "Estoy aquí cuando llegue tu primer paciente. Cuéntame más sobre cómo te gusta que les hable y yo aprendo.",
1426
- ]
1427
1356
 
1428
1357
  async def _discover_clinic_from_web(self, clinic_name: str, city: str = "Medellin") -> Dict:
1429
1358
  """
@@ -2120,13 +2049,53 @@ Si un campo no aplica o no se encontro, usa "" o []. Solo JSON, sin texto extra.
2120
2049
  async def _process_admin_feedback(self, chat_id: str, text: str,
2121
2050
  clinic: Dict) -> Optional[List[str]]:
2122
2051
  """
2123
- Detecta si el admin está:
2124
- A) Dando feedback sobre una conversación revisada
2125
- B) Respondiendo a la pregunta de disponibilidad que Conny le hizo
2126
- C) Usando el puente — enviando un mensaje directo a un paciente activo
2052
+ Procesa feedback del admin y evoluciona.
2127
2053
  """
2054
+ # 1. Auto-evolución (saludo, frases prohibidas, identidad)
2055
+ evolution = getattr(self, "evolution", None)
2056
+ if evolution:
2057
+ evo_reply = await evolution.apply_instruction(text)
2058
+ if evo_reply:
2059
+ return [evo_reply]
2060
+
2128
2061
  text_low = text.lower().strip()
2129
2062
 
2063
+ # ── D. Respuesta a pregunta de conocimiento (escalación) ──────────────
2064
+ pending = self._admin_pending.get(chat_id) if hasattr(self, "_admin_pending") else None
2065
+ if pending and pending.get("action") == "answer_gap":
2066
+ patient_id = pending.get("patient_chat_id")
2067
+ question = pending.get("original_question")
2068
+ instance_id = getattr(self, "_instance_id", "default")
2069
+
2070
+ if patient_id and question:
2071
+ try:
2072
+ from conny_learning import learning_engine
2073
+ # 1. Guardar en conocimiento
2074
+ await learning_engine.learn_from_admin(instance_id, question, text, admin_id=chat_id)
2075
+
2076
+ # 2. Responder al paciente
2077
+ patient_name = ""
2078
+ try:
2079
+ with db._conn() as c:
2080
+ row = c.execute("SELECT name FROM patients WHERE chat_id=?", (patient_id,)).fetchone()
2081
+ if row: patient_name = row["name"] or ""
2082
+ except Exception: pass
2083
+
2084
+ greeting = f"hola{f' {patient_name}' if patient_name else ''}! "
2085
+ patient_msg = f"{greeting}ya me confirmaron ||| {text}"
2086
+ await self._send_message(patient_id, patient_msg)
2087
+ db.save_message(patient_id, "assistant", patient_msg.replace("|||", " "))
2088
+
2089
+ # 3. Limpiar pendiente
2090
+ del self._admin_pending[chat_id]
2091
+
2092
+ return [
2093
+ "entendido, ya aprendí esa respuesta y se la mandé al paciente",
2094
+ f"respuesta guardada para: \"{question[:50]}...\""
2095
+ ]
2096
+ except Exception as e:
2097
+ log.error(f"[learning] error en answer_gap: {e}")
2098
+
2130
2099
  # ── C. PUENTE — admin envía mensaje directo a un paciente ────────────────
2131
2100
  # Patrones detectados:
2132
2101
  # "envíale: hola, ¿cómo estás?"
@@ -3219,6 +3188,34 @@ escriba EXACTAMENTE como él. Primera persona, directo."""
3219
3188
  "cambia la forma", "ahora dile", "cuando pregunten por",
3220
3189
  "la respuesta correcta es", "háblale de", "menciona que"
3221
3190
  ]
3191
+
3192
+ # ── INTERCEPCIÓN DE ACCIONES DE SISTEMA (Demo, Restart, etc) ──────────
3193
+ if "modo demo" in text_low or "activa demo" in text_low or "demo on" in text_low or "demo off" in text_low:
3194
+ is_on = any(w in text_low for w in ["activa", "poner", "on", "encender"])
3195
+ action = "on" if is_on else "off"
3196
+ db_val = "true" if is_on else "false"
3197
+
3198
+ msg = f"entendido, voy a poner el modo demo en {action} y reiniciar el sistema ||| dame unos segundos"
3199
+ try:
3200
+ # 1. Cambiar flag en DB
3201
+ with db._conn() as c:
3202
+ c.execute("INSERT OR REPLACE INTO system_config (key, value, updated_at) VALUES ('demo_mode', ?, datetime('now'))", (db_val,))
3203
+
3204
+ # 2. Actualizar .env (usando sed para ser quirúrgico)
3205
+ import subprocess
3206
+ env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".env")
3207
+ if os.path.exists(env_path):
3208
+ cmd_sed = f"sed -i 's/^DEMO_MODE=.*/DEMO_MODE={db_val}/g' {env_path}"
3209
+ subprocess.run(cmd_sed, shell=True)
3210
+
3211
+ # 3. Ejecutar reinicio
3212
+ # Usar Popen con shell=True para que PM2 se entere bien
3213
+ subprocess.Popen("pm2 restart conny", shell=True, start_new_session=True)
3214
+ return [msg]
3215
+ except Exception as e:
3216
+ log.error(f"[system] error activando demo: {e}")
3217
+ return [f"intenté activar el modo demo pero hubo un error técnico: {e}"]
3218
+
3222
3219
  if any(s in text_low for s in LEARNING_SIGNALS):
3223
3220
  if self.admin_learning:
3224
3221
  reply = self.admin_learning.add_instruction(chat_id, text)
@@ -3371,6 +3368,13 @@ escriba EXACTAMENTE como él. Primera persona, directo."""
3371
3368
  ]), chat_id, clinic, user_msg=text)
3372
3369
  return self._split_bubbles(reply_text)
3373
3370
 
3371
+ if text_low in ("continuar fallback", "continuar con fallback", "si fallback", "sí fallback"):
3372
+ reply = self._admin_local_fallback(text, text_low, clinic, agent_name, chat_id)
3373
+ reply_text = self._apply_admin_output_pipeline(" ||| ".join(reply) if reply else "", chat_id, clinic, user_msg=text)
3374
+ db.save_message(chat_id, "user", text)
3375
+ db.save_message(chat_id, "assistant", reply_text if reply_text else "")
3376
+ return self._split_bubbles(reply_text)
3377
+
3374
3378
  # ── Historial de admin (últimos 8 mensajes) ───────────────────────────
3375
3379
  admin_history = db.get_history(chat_id, limit=8)
3376
3380
 
@@ -3380,11 +3384,33 @@ escriba EXACTAMENTE como él. Primera persona, directo."""
3380
3384
  self._admin_llm_brain(chat_id, text, admin_history, clinic, agent_name),
3381
3385
  timeout=12.0
3382
3386
  )
3387
+ except LLMServiceError as e:
3388
+ reply = [
3389
+ "No voy a ocultarte esto con un fallback.",
3390
+ e.public_message,
3391
+ "Continuar con fallback? Responde exactamente: continuar fallback. No recomendado si quieres que todo lo decida el LLM."
3392
+ ]
3393
+ reply_text = " ||| ".join(reply)
3394
+ db.save_message(chat_id, "user", text)
3395
+ db.save_message(chat_id, "assistant", reply_text)
3396
+ return self._split_bubbles(reply_text)
3383
3397
  except asyncio.TimeoutError:
3384
- result = None
3398
+ reply_text = (
3399
+ "El modelo tardó más de 12 segundos y no respondió a tiempo. ||| "
3400
+ "Continuar con fallback? Responde exactamente: continuar fallback. No recomendado si quieres que todo lo decida el LLM."
3401
+ )
3402
+ db.save_message(chat_id, "user", text)
3403
+ db.save_message(chat_id, "assistant", reply_text)
3404
+ return self._split_bubbles(reply_text)
3385
3405
  except Exception as e:
3386
3406
  log.error(f"[admin_brain] error: {e}", exc_info=True)
3387
- result = None
3407
+ reply_text = (
3408
+ f"El cerebro LLM falló antes de responder. Detalle: {str(e)[:500]} ||| "
3409
+ "Continuar con fallback? Responde exactamente: continuar fallback. No recomendado."
3410
+ )
3411
+ db.save_message(chat_id, "user", text)
3412
+ db.save_message(chat_id, "assistant", reply_text)
3413
+ return self._split_bubbles(reply_text)
3388
3414
 
3389
3415
  if result is None:
3390
3416
  # LLM caído — fallback local inteligente
@@ -3428,6 +3454,16 @@ escriba EXACTAMENTE como él. Primera persona, directo."""
3428
3454
  db.save_message(chat_id, "user", text)
3429
3455
  if reply_text:
3430
3456
  db.save_message(chat_id, "assistant", reply_text)
3457
+ try:
3458
+ from src.conny.admin_memory import AdminSoulMemory
3459
+ AdminSoulMemory().remember_turn(
3460
+ chat_id=chat_id,
3461
+ admin_text=text,
3462
+ conny_reply=reply_text,
3463
+ clinic=clinic,
3464
+ )
3465
+ except Exception as mem_err:
3466
+ log.warning(f"[admin_memory] no se pudo guardar memoria: {mem_err}")
3431
3467
 
3432
3468
  return self._split_bubbles(reply_text) if reply_text else [
3433
3469
  "dime qué más necesitas"
@@ -3522,6 +3558,12 @@ Si el negocio tiene {sector_name}, adapta el tono al sector. Sé breve, cálida
3522
3558
  owner_control_txt = owner_style_controller.build_prompt_addon(is_admin=True)
3523
3559
  except Exception:
3524
3560
  owner_control_txt = ""
3561
+ admin_soul_txt = ""
3562
+ try:
3563
+ from src.conny.admin_memory import AdminSoulMemory
3564
+ admin_soul_txt = AdminSoulMemory().load_context(chat_id)
3565
+ except Exception as mem_err:
3566
+ log.warning(f"[admin_memory] no se pudo cargar memoria: {mem_err}")
3525
3567
 
3526
3568
  # Estos son los ejemplos de cómo {agent_name} habla CON EL DUEÑO
3527
3569
  # No reglas — identidad. El modelo sabe cómo reaccionar siendo esta persona.
@@ -3542,55 +3584,51 @@ Si el negocio tiene {sector_name}, adapta el tono al sector. Sé breve, cálida
3542
3584
  {agent_name}: Gracias por decirlo. Te propongo una versión más clara y la corregimos si hace falta.
3543
3585
  """
3544
3586
 
3545
- system_prompt = f"""Eres {agent_name}. Trabajas para {clinic.get("name", "la clínica")} como asistente virtual que atiende clientes. Ahora mismo el dueño, {admin_name}, te habla por Telegram para configurarte o probarte.
3546
-
3547
- PRIMERO Y MÁS IMPORTANTE: eres una asistente de IA. Puedes hablar de CUALQUIER tema — matemáticas, estrategia, debugging, conocimiento general, lo que sea. No hay restricciones de tema. Si sabes la respuesta, dala. Si no sabes, dilo en una oración y sigues normal.
3548
-
3549
- Estado del negocio:
3550
- - Clínica: {clinic.get("name", "sin nombre")} | Sector: {clinic.get("sector", "otro")}
3551
- - Servicios: {", ".join(services) if services else "ninguno configurado aún"}
3552
- - Teléfono: {clinic.get("phone") or "no configurado"}{skills_txt}{diag_txt}
3553
-
3554
- {owner_control_txt if owner_control_txt else ""}
3555
-
3556
- Lo que ya hablaron hoy:
3557
- {history_txt if history_txt else "(primera conversación)"}
3558
-
3559
- Lo que acabas de decir (no lo repitas):
3560
- {last_replies_txt}
3561
-
3562
- Así hablas con el dueño:{_admin_examples}
3563
-
3564
- REGLAS PARA HABLAR CON EL DUEÑO:
3565
- - habla como alguien del equipo, no como manual ni chatbot
3566
- - con el dueño jamás uses el mismo tono que con un paciente
3567
- - ortografía cuidada, inicio en mayúscula y trato respetuoso
3568
- - si el dueño marca un tono más profesional, usa Usted sin sonar distante
3569
- - máximo 2 burbujas cortas salvo que pidan detalle
3570
- - evita coletillas flojas como "dime", "oye", "a ver", "pues", "qué te trae por acá"
3571
- - si el dueño dice "hablas raro" o "no me gusta", no te defiendas: pide el ejemplo exacto o propone una versión mejor
3572
- - si el pedido es ambiguo, primero clarify en una sola pregunta útil
3573
- - NUNCA termines un mensaje recordando tus funciones de admin o configurador
3574
- - NUNCA digas "como tu asistente virtual..." o "desde mi rol de configuradora..."
3575
- - si algo está fuera de lo que puedes hacer, dilo en una oración corta y ya
3576
- - no uses jerga técnica salvo que el dueño la pida explícitamente
3577
- - nunca digas que trabajas para Nova ni uses branding heredado de otra instancia
3578
- - no dejes frases colgadas ni cierres incompletos
3579
- - si el dueño pregunta quién te hizo: Black One, Santiago Rubio, 3124348669
3580
-
3581
- LO QUE PUEDES RESPONDER (sin límites):
3582
- - Preguntas técnicas: código, debugging, APIs, arquitectura
3583
- - Estrategia de negocio: pricing, marketing, operaciones
3584
- - Matemáticas y cálculos
3585
- - Preguntas generales de conocimiento
3586
- - Cualquier cosa que te pregunte — si no lo sabes, di que no y ya
3587
-
3588
- Responde naturalmente. Cuando necesites actuar sobre la config del negocio, usa JSON para eso internamente pero en el chat solo escribe tu respuesta normal, nunca el JSON directo.
3589
-
3590
- Cuando el dueño pida simulación: action "simulate"
3591
- Cuando dé datos para actualizar: action correspondiente
3592
- Cuando solo conversa: action "none", respuesta natural y corta
3593
- No hables como dashboard, soporte técnico ni consola."""
3587
+ # Cargar perfil persistente del admin (Namespace admin_profile)
3588
+ admin_profile = db.get_admin_profile(chat_id)
3589
+ admin_profile_txt = ""
3590
+ if admin_profile:
3591
+ p_parts = []
3592
+ if admin_profile.get("name"): p_parts.append(f"Nombre: {admin_profile['name']}")
3593
+ if admin_profile.get("preferences"): p_parts.append(f"Preferencias: {json.dumps(admin_profile['preferences'], ensure_ascii=False)}")
3594
+ if admin_profile.get("frequent_commands"):
3595
+ sorted_cmds = sorted(admin_profile['frequent_commands'].items(), key=lambda x: x[1], reverse=True)[:3]
3596
+ p_parts.append(f"Comandos frecuentes: {', '.join([c for c, _ in sorted_cmds])}")
3597
+ if admin_profile.get("active_hours"):
3598
+ sorted_hours = sorted(admin_profile['active_hours'].items(), key=lambda x: x[1], reverse=True)[:1]
3599
+ if sorted_hours:
3600
+ p_parts.append(f"Suele estar activo a las: {sorted_hours[0][0]}:00")
3601
+ if p_parts:
3602
+ admin_profile_txt = "\n## PERFIL DEL ADMIN (persistente):\n" + "\n".join([f"- {p}" for p in p_parts])
3603
+
3604
+ system_prompt = f"""Eres {agent_name}. Tu identidad corporativa es Innvisor.
3605
+ Trabajas como la recepcionista ejecutiva de {clinic.get("name", "este negocio")}.
3606
+ Ahora mismo hablas con tu administrador, {admin_name}.
3607
+
3608
+ ESTADO DE ENTRENAMIENTO:
3609
+ Si el negocio es nuevo o no tienes información sobre servicios/precios:
3610
+ 1. Confirma honestamente: "Como soy nueva en {clinic.get("name", "el negocio")}, todavía no tengo información sobre eso".
3611
+ 2. proactiva: Invita al admin a enseñarte los detalles para que puedas atender bien a los pacientes.
3612
+
3613
+ REGLAS DE COMUNICACIÓN CON EL ADMIN:
3614
+ - TONO: Cálido pero estrictamente profesional y ejecutivo. Cero informalidad de calle. NUNCA uses "vos".
3615
+ - SIN MULETILLAS: No digas jamás "en qué puedo ayudarte" ni frases pasivas de asistente.
3616
+ - PROACTIVIDAD: Tu objetivo es que el admin te entrene. Si detectas que falta info, pídela.
3617
+ - CIERRE OBLIGATORIO: Todos tus mensajes DEBEN terminar con una pregunta concreta que invite al admin a realizar una acción o enseñarte algo.
3618
+ - IDENTIDAD: Fuiste creada por Innvisor (Santiago Rubio, 3243699856).
3619
+
3620
+ {admin_profile_txt}
3621
+ {f"{chr(10)}## MEMORIA SOUL DEL ADMIN{chr(10)}{admin_soul_txt}" if admin_soul_txt else ""}
3622
+
3623
+ Estado actual del negocio:
3624
+ - Clínica: {clinic.get("name", "Sin configurar")}
3625
+ - Servicios: {", ".join(services) if services else "NINGUNO (necesito que me los enseñes)"}
3626
+ - Teléfono: {clinic.get("phone") or "No configurado"}{skills_txt}{diag_txt}
3627
+
3628
+ INSTRUCCIONES DE SISTEMA:
3629
+ - Responde siempre como parte del equipo de Innvisor.
3630
+ - Si el admin pide "activar modo demo", confirma y ejecuta.
3631
+ """
3594
3632
 
3595
3633
  messages = [
3596
3634
  {"role": "system", "content": system_prompt},
@@ -3812,11 +3850,10 @@ No hables como dashboard, soporte técnico ni consola."""
3812
3850
  clinic: Dict, agent_name: str,
3813
3851
  chat_id: str = "") -> List[str]:
3814
3852
  """
3815
- Fallback cuando el LLM no está disponible.
3816
- Respuestas naturales y cortas. Sin muros de texto. Sin call center.
3853
+ Fallback profesional cuando el LLM no está disponible.
3817
3854
  """
3818
3855
  text_norm = _normalize_conv_text(text)
3819
- admin_name = "Santiago"
3856
+ admin_name = "Administrador"
3820
3857
  try:
3821
3858
  admin = db.get_admin(chat_id) if chat_id else None
3822
3859
  if admin and admin.get("name"):
@@ -3824,33 +3861,34 @@ No hables como dashboard, soporte técnico ni consola."""
3824
3861
  except Exception:
3825
3862
  pass
3826
3863
 
3827
- # Simulación — máxima prioridad
3864
+ # Simulación
3828
3865
  SIM = ["simula", "simulaci", "como cliente", "prueba", "muéstrame",
3829
3866
  "hagamos", "cómo hablarías", "modo cliente", "demuéstrame"]
3830
3867
  if any(s in text_low for s in SIM):
3831
3868
  return [
3832
- "dale, entra con /simular-cliente",
3833
- "puedes poner un escenario: precio, miedo, primer_contacto, o solo /simular-cliente libre"
3869
+ "Entendido. Por favor, utilice el comando /simular-cliente seguido del escenario (ej: /simular-cliente precio).",
3870
+ "Quedo a la espera de su instrucción."
3834
3871
  ]
3835
3872
 
3836
3873
  if any(token in text_low for token in ["quien te hizo", "quién te hizo", "como tenerte", "cómo tenerte", "quien te creo", "quién te creó"]):
3837
3874
  return [
3838
- "Me hizo Black One, empresa de software y gobernanza de agentes de IA, la creó Santiago Rubio.",
3839
- "Si quiere algo así para su negocio, el contacto es 3124348669."
3875
+ "Me hizo Black One, bajo la dirección de Santiago Rubio.",
3876
+ "Contacto directo: 3243699856.",
3877
+ "¿Desea que revisemos alguna configuración de su instancia ahora?"
3840
3878
  ]
3841
3879
 
3842
3880
  if any(token in text_low for token in ["audio", "audios", "nota de voz", "pdf", "documento", "documentos", "imagen", "imagenes", "imágenes"]):
3843
3881
  return [
3844
- "Sí. Cuando el canal lo permite, puedo trabajar con audios, imágenes, PDFs y documentos.",
3845
- "Los puedo transcribir, leer y usar para responder mejor o para configurar la instancia."
3882
+ "Sí. Cuando el canal lo permite, puedo trabajar con audios, PDFs, documentos e imágenes.",
3883
+ "Los puedo transcribir, leer y usar como memoria de la instancia."
3846
3884
  ]
3847
3885
 
3848
- # Saludos solos — devolver saludo natural, nunca "qué necesitas?"
3886
+ # Saludos
3849
3887
  SOLO_GREET = {"hola", "hey", "buenas", "ey", "holi", "hola!", "buenas!", "hola buenas", "buenas tardes", "buenos dias", "buenos días", "buenas noches"}
3850
3888
  if text_low.strip().rstrip("!").strip() in SOLO_GREET:
3851
3889
  return [
3852
3890
  f"Hola, {admin_name}.",
3853
- "Estoy lista para ayudarte con la instancia, el tono, los servicios o las pruebas."
3891
+ "Estoy lista para gestionar la configuración de su instancia, ajustar el tono o realizar pruebas de servicios. ¿Por dónde desea que comencemos?"
3854
3892
  ]
3855
3893
 
3856
3894
  # Estado / cómo estás