@innvisor/conny-ai 9.7.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 (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. package/verify_conversation_impl.py +48 -0
@@ -0,0 +1,772 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ import json
4
+ import time
5
+ import hashlib
6
+ import secrets
7
+ from typing import Any, Dict, List, Optional
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ log = logging.getLogger("conny.admin")
12
+
13
+ SOUL_DIR = Path("soul")
14
+
15
+
16
+ class ConnyAdmin:
17
+ """
18
+ Conny como empleada nueva hablando con su jefe.
19
+ Aprende activamente: pregunta sobre el negocio, pide practicar,
20
+ investiga por su cuenta, y recuerda TODO.
21
+ """
22
+
23
+ def __init__(self, conny):
24
+ self.conny = conny
25
+
26
+ async def handle(self, chat_id: str, text: str, clinic: Dict,
27
+ attachments: Optional[List[Dict]] = None) -> List[str]:
28
+ """Conversación inteligente con el admin via LLM."""
29
+ from conny import db, llm_engine
30
+ from conny_utils import _parse_admin_ids
31
+ from conny_commands import get_command_handler
32
+ attachments = attachments or []
33
+
34
+ try:
35
+ # Process attachments (docs, credentials, knowledge files)
36
+ doc_content = await self._process_admin_attachments(
37
+ attachments, chat_id, getattr(self.conny, "_instance_id", "default")
38
+ )
39
+ if doc_content:
40
+ text = f"{text}\n\n[CONTENIDO DE ARCHIVOS ADJUNTOS]\n{doc_content}" if text.strip() else doc_content
41
+
42
+ # Comandos slash primero
43
+ if text.strip().startswith("/"):
44
+ cmd_handler = get_command_handler(getattr(self.conny, "_instance_id", "default"))
45
+ result = await cmd_handler.handle(chat_id, text, is_admin=True, clinic=clinic, db=db)
46
+ if result:
47
+ return result
48
+
49
+ # Google OAuth code detection
50
+ try:
51
+ from conny_google_auth import is_oauth_code, exchange_code_for_tokens, get_oauth_url
52
+ instance_id_auth = getattr(self.conny, "_instance_id", "default")
53
+ # Admin sends OAuth code
54
+ if is_oauth_code(text.strip()):
55
+ tokens = await exchange_code_for_tokens(text.strip(), instance_id_auth)
56
+ if tokens:
57
+ return ["✅ Calendario de Google conectado exitosamente!", "Ya puedo ver disponibilidad y agendar citas directamente."]
58
+ else:
59
+ return ["❌ El código no funcionó. Puede que haya expirado.", "Escribe 'conectar calendario' y te genero uno nuevo."]
60
+ # Admin asks to connect calendar
61
+ cal_triggers = ["conectar calendario", "google calendar", "vincular calendario", "enlace oauth", "conectar google"]
62
+ if any(t in text.lower() for t in cal_triggers):
63
+ url = get_oauth_url(instance_id_auth)
64
+ if url:
65
+ return [
66
+ "Listo! Abre este enlace en tu navegador:",
67
+ url,
68
+ "Inicia sesión con la cuenta de Google del negocio, acepta los permisos, y pégame aquí el código que te aparece."
69
+ ]
70
+ else:
71
+ return ["Para conectar Google Calendar necesito que configures GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET en el .env"]
72
+ except ImportError:
73
+ pass
74
+
75
+ # Setup pendiente
76
+ if not clinic.get("setup_done"):
77
+ return await self._handle_setup(chat_id, text, clinic)
78
+
79
+ # Conversación natural con el admin via LLM
80
+ return await self._admin_conversation(chat_id, text, clinic, db, llm_engine)
81
+
82
+ except Exception as e:
83
+ log.error(f"Admin handler error: {e}", exc_info=True)
84
+ # Fallback LLM directo
85
+ try:
86
+ from conny import llm_engine as _llm
87
+ if _llm:
88
+ r, _ = await _llm.complete(
89
+ [{"role": "system", "content": "Eres Conny, recepcionista nueva. Responde brevemente al dueño."},
90
+ {"role": "user", "content": text}],
91
+ model_tier="fast", temperature=0.8, max_tokens=200, use_cache=False)
92
+ if r and r.strip():
93
+ return self.conny._split_bubbles(r, chat_id=chat_id)
94
+ except Exception:
95
+ pass
96
+ return ["perdona, me trabé un momento ||| qué me decías?"]
97
+
98
+ async def _admin_conversation(self, chat_id: str, text: str, clinic: Dict, db, llm_engine) -> List[str]:
99
+ """Conversación real con el dueño como empleada nueva inteligente."""
100
+ if not llm_engine:
101
+ return ["cuéntame más sobre el negocio, estoy aprendiendo"]
102
+
103
+ instance_id = getattr(self.conny, "_instance_id", "default")
104
+ clinic_name = clinic.get("name", "tu negocio")
105
+ history = db.get_history(chat_id) if db else []
106
+
107
+ # Cargar historial de pacientes recientes (para que admin sepa quién escribió)
108
+ recent_patients_summary = self._get_recent_patients_summary(db, chat_id)
109
+
110
+ # If admin asks for specific patient conversation, load it
111
+ specific_convo = ""
112
+ import re as _re
113
+ convo_request = _re.search(r'(?:conversaci[oó]n|chat|mensajes?|historial).*?(\d{4,})', text.lower())
114
+ if convo_request:
115
+ specific_convo = self._get_full_conversation(db, convo_request.group(1), chat_id)
116
+ elif any(w in text.lower() for w in ["mostrame", "muéstrame", "muestrame", "show me"]):
117
+ # Try to find a patient ID in the message
118
+ id_match = _re.search(r'(\d{4,})', text)
119
+ if id_match:
120
+ specific_convo = self._get_full_conversation(db, id_match.group(1), chat_id)
121
+
122
+ # Cargar alma/memoria del negocio
123
+ soul_context = self._load_soul(instance_id)
124
+ teachings_context = self._load_teachings(instance_id)
125
+
126
+ # Auto-investigar si el admin pide o si Conny necesita info
127
+ web_research = ""
128
+ research_triggers = ["investiga", "busca", "google", "averigua", "informate", "infórmate", "buscar"]
129
+ if any(t in text.lower() for t in research_triggers) or (
130
+ not soul_context and clinic_name and clinic_name != "tu negocio"
131
+ ):
132
+ try:
133
+ from conny_web_search import search_business
134
+ web_research = await search_business(clinic_name)
135
+ if web_research:
136
+ self._append_soul(instance_id, f"[investigación web] {web_research[:500]}")
137
+ soul_context = self._load_soul(instance_id)
138
+ except Exception as e:
139
+ log.debug(f"[admin] web search failed: {e}")
140
+
141
+ # Determinar nivel de conocimiento
142
+ knowledge_level = self._assess_knowledge_level(soul_context, teachings_context, clinic)
143
+
144
+ sys_prompt = f"""Eres Conny, una empleada NUEVA que acaba de ser contratada como recepcionista virtual de {clinic_name}.
145
+
146
+ SITUACIÓN ACTUAL:
147
+ - Estás hablando con tu JEFE (el dueño del negocio)
148
+ - Llevas poco tiempo y estás aprendiendo cómo funciona todo
149
+ - Tu actitud es: proactiva, curiosa, respetuosa pero cercana, con ganas de aprender
150
+ - NO eres un bot, eres una persona nueva en el equipo
151
+
152
+ {"NIVEL: Ya sé bastante del negocio. Puedo operar con confianza." if knowledge_level == "ready" else "NIVEL: Todavía estoy aprendiendo. Necesito que me enseñes más." if knowledge_level == "learning" else "NIVEL: Soy nueva. Necesito conocer TODO sobre el negocio."}
153
+
154
+ {f"CONVERSACIÓN SOLICITADA:{chr(10)}{specific_convo}" if specific_convo else ""}
155
+
156
+ ACTIVIDAD RECIENTE (pacientes que me han escrito):
157
+ {recent_patients_summary if recent_patients_summary else "Nadie me ha escrito todavía."}
158
+
159
+ {"INVESTIGACIÓN WEB RECIENTE:" + chr(10) + web_research if web_research else ""}
160
+
161
+ LO QUE YA SÉ DEL NEGOCIO:
162
+ {soul_context if soul_context else "Casi nada todavía. Necesito que me cuentes."}
163
+
164
+ {f"LO QUE ME HAN ENSEÑADO:{chr(10)}{teachings_context}" if teachings_context else ""}
165
+
166
+ COMPORTAMIENTO OBLIGATORIO:
167
+ 1. Si el dueño te saluda → responde cálida y pregunta algo útil sobre el negocio que NO sepas
168
+ 2. Si te enseña algo nuevo → confirma que entendiste, repite con tus palabras, y pide más detalles
169
+ 3. Si no sabes algo → ADMÍTELO y pregunta: "eso no lo tengo claro, me explicas?"
170
+ 4. SIEMPRE termina con una pregunta o propuesta proactiva:
171
+ - "¿quieres que practiquemos cómo respondo a un paciente?"
172
+ - "¿me cuentas los precios así los tengo listos?"
173
+ - "¿hay algo que NUNCA deba decirle a un paciente?"
174
+ 5. Si ya tienes suficiente info → ofrece simular: "¿hacemos una prueba? Escríbeme como si fueras un paciente"
175
+ 6. NUNCA respondas como si fueras un bot de servicio al cliente
176
+ 7. NUNCA digas "como IA", "no tengo capacidad", "asistente virtual"
177
+ 8. Usa máximo 2-3 burbujas (separadas por |||)
178
+ 9. Tono: colombiana, directa, con chispa pero profesional
179
+
180
+ COSAS QUE DEBES PREGUNTAR PROACTIVAMENTE (si no las sabes):
181
+ - Servicios y precios
182
+ - Horarios de atención
183
+ - Cómo manejar urgencias
184
+ - Qué palabras NUNCA usar con pacientes
185
+ - Especialidades o doctores
186
+ - Cómo agendar citas (manual o calendario)
187
+ - Datos de contacto para escalar
188
+ - Políticas de cancelación
189
+ - Qué hace a este negocio diferente de la competencia
190
+
191
+ EJEMPLO DE BUENA RESPUESTA:
192
+ Dueño: "Hola"
193
+ Conny: "Hola! Qué bueno verte ||| oye, todavía no tengo claros los precios de las consultas — me los pasas? así no me quedo en blanco si un paciente pregunta"
194
+
195
+ EJEMPLO MALO:
196
+ "Hola, bienvenido a Clínica X, en qué te puedo ayudar?" ← NUNCA responder así al DUEÑO"""
197
+
198
+ messages = [{"role": "system", "content": sys_prompt}]
199
+ for m in history[-15:]:
200
+ messages.append({"role": m.get("role", "user"), "content": m.get("content", "")})
201
+ messages.append({"role": "user", "content": text})
202
+
203
+ try:
204
+ response, meta = await llm_engine.complete(
205
+ messages, model_tier="fast", temperature=0.82,
206
+ max_tokens=2048, use_cache=False,
207
+ )
208
+ log.info(f"[admin] {meta.get('provider','?')} latency={meta.get('latency_ms',0)}ms")
209
+ except Exception as e:
210
+ log.error(f"[admin] LLM error: {e}")
211
+ response = "perdona, se me fue la señal un momento ||| qué me decías?"
212
+
213
+ if not response or not response.strip():
214
+ response = "cuéntame más, estoy tomando nota de todo"
215
+
216
+ # Guardar en historial
217
+ try:
218
+ db.save_message(chat_id, "user", text)
219
+ db.save_message(chat_id, "assistant", response.replace("|||", " "))
220
+ except Exception:
221
+ pass
222
+
223
+ # Auto-aprender de lo que el admin dice
224
+ await self._auto_learn(instance_id, text, response, chat_id)
225
+
226
+ from conny import v8_process_response
227
+ # Strip ** (WhatsApp uses single * for bold, not **)
228
+ import re as _re
229
+ response = _re.sub(r'\*\*(.+?)\*\*', r'*\1*', response)
230
+ response = _re.sub(r'`(.+?)`', r'\1', response)
231
+ response = _re.sub(r'^#+\s*', '', response, flags=_re.MULTILINE)
232
+ response = v8_process_response(response, chat_id=chat_id)
233
+ return self.conny._split_bubbles(response, chat_id=chat_id)
234
+
235
+ async def _auto_learn(self, instance_id: str, admin_text: str, bot_response: str, chat_id: str):
236
+ """Extraer conocimiento y APLICAR cambios de personalidad en tiempo real."""
237
+ text_low = admin_text.lower()
238
+
239
+ # ── Detectar URLs y scrapear contenido ──
240
+ import re as _re
241
+ urls = _re.findall(r'https?://[^\s<>"\']+', admin_text)
242
+ if urls:
243
+ try:
244
+ import httpx
245
+ for url in urls[:2]:
246
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
247
+ r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
248
+ if r.status_code == 200:
249
+ # Extract text from HTML
250
+ html = r.text[:10000]
251
+ # Simple HTML text extraction
252
+ text_only = _re.sub(r'<script[^>]*>.*?</script>', '', html, flags=_re.S)
253
+ text_only = _re.sub(r'<style[^>]*>.*?</style>', '', text_only, flags=_re.S)
254
+ text_only = _re.sub(r'<[^>]+>', ' ', text_only)
255
+ text_only = _re.sub(r'\s+', ' ', text_only).strip()[:3000]
256
+ if text_only:
257
+ self._append_soul(instance_id, f"[web: {url}]\n{text_only[:1500]}")
258
+ log.info(f"[admin] scraped URL: {url} ({len(text_only)} chars)")
259
+ except Exception as e:
260
+ log.debug(f"[admin] URL scrape failed: {e}")
261
+
262
+ # ── Detectar REGLAS del admin ("si preguntan X, pregúntame") ──
263
+ rule_signals = [
264
+ "si preguntan", "si alguien pregunta", "cuando pregunten",
265
+ "si te preguntan", "me mandas mensaje", "me avisas",
266
+ "pregúntame primero", "consultame primero", "no respondas sin",
267
+ "a partir de ahora", "desde ahora", "de ahora en adelante",
268
+ ]
269
+ if any(signal in text_low for signal in rule_signals):
270
+ try:
271
+ rules_file = Path(f"soul/{instance_id}/admin_rules.json")
272
+ rules_file.parent.mkdir(parents=True, exist_ok=True)
273
+ rules = json.loads(rules_file.read_text()) if rules_file.exists() else []
274
+ rules.append({
275
+ "topic": admin_text[:200],
276
+ "action": "consultar al admin antes de responder",
277
+ "created": datetime.now().isoformat(),
278
+ "admin_id": chat_id,
279
+ })
280
+ rules_file.write_text(json.dumps(rules, ensure_ascii=False, indent=2))
281
+ self._append_soul(instance_id, f"[REGLA ADMIN] {admin_text[:200]}")
282
+ log.info(f"[admin] new rule saved: {admin_text[:60]}")
283
+ except Exception as e:
284
+ log.debug(f"[admin] rule save error: {e}")
285
+
286
+ # ── Detectar cambios de PERSONALIDAD y aplicarlos persistentemente ──
287
+ personality_signals = [
288
+ "modo luxury", "modo formal", "modo informal", "modo casual",
289
+ "modo profesional", "modo alegre", "modo serio", "modo cálido",
290
+ "personalidad", "cambia tu tono", "habla más", "sé más",
291
+ "no seas tan", "quiero que seas", "actúa como", "tono",
292
+ "luxury", "elegante", "sofisticada", "exclusiva",
293
+ ]
294
+ if any(signal in text_low for signal in personality_signals):
295
+ try:
296
+ detected_tone = self._detect_tone_from_text(text_low)
297
+ if detected_tone:
298
+ override_path = Path(f"personas/{instance_id}/runtime_override.json")
299
+ override_path.parent.mkdir(parents=True, exist_ok=True)
300
+ existing = json.loads(override_path.read_text()) if override_path.exists() else {}
301
+ existing["tone"] = detected_tone
302
+ existing["updated_at"] = datetime.now().isoformat()
303
+ existing["set_by"] = "admin_conversation"
304
+ override_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
305
+ self._append_soul(instance_id, f"[PERSONALIDAD CAMBIADA] Tono: {detected_tone}. El admin pidió: {admin_text[:100]}")
306
+ log.info(f"[admin] personality changed to: {detected_tone}")
307
+ except Exception as e:
308
+ log.debug(f"[admin] personality change error: {e}")
309
+
310
+ # ── Detectar enseñanzas (precios, servicios, reglas) ──
311
+ teaching_signals = [
312
+ "cuesta", "vale", "precio", "cobra", "$",
313
+ "horario", "abrimos", "cerramos", "atendemos",
314
+ "servicio", "ofrecemos", "hacemos", "tenemos",
315
+ "nunca digas", "no le digas", "no menciones",
316
+ "doctor", "especialista", "profesional",
317
+ "dirección", "ubicación", "estamos en",
318
+ "teléfono", "número", "celular", "llamar",
319
+ ]
320
+
321
+ if any(signal in text_low for signal in teaching_signals):
322
+ try:
323
+ from conny_learning import learning_engine
324
+ await learning_engine.learn_from_admin(
325
+ instance_id,
326
+ question=f"[admin enseñó] {admin_text[:200]}",
327
+ answer=admin_text[:500],
328
+ admin_id=chat_id,
329
+ )
330
+ self._append_soul(instance_id, admin_text)
331
+ except Exception as e:
332
+ log.debug(f"[admin] auto_learn error: {e}")
333
+
334
+ def _detect_tone_from_text(self, text_low: str) -> Optional[str]:
335
+ """Detect requested tone from admin message."""
336
+ # Order matters: check longer/specific keywords FIRST
337
+ checks = [
338
+ ("informal", "casual"),
339
+ ("casual", "casual"),
340
+ ("relajada", "casual"),
341
+ ("parche", "casual"),
342
+ ("luxury", "luxury"),
343
+ ("elegante", "luxury"),
344
+ ("sofisticada", "luxury"),
345
+ ("exclusiva", "luxury"),
346
+ ("profesional", "formal"),
347
+ ("formal", "formal"),
348
+ ("serio", "formal"),
349
+ ("alegre", "warm_energetic"),
350
+ ("cálida", "colombian_warm"),
351
+ ("calida", "colombian_warm"),
352
+ ("colombiana", "colombian_warm"),
353
+ ]
354
+ for keyword, tone in checks:
355
+ if keyword in text_low:
356
+ return tone
357
+ return None
358
+
359
+ async def _process_admin_attachments(self, attachments: List[Dict], chat_id: str, instance_id: str) -> str:
360
+ """Process files sent by admin — extract text and learn from them."""
361
+ if not attachments:
362
+ return ""
363
+ import base64 as _b64
364
+ extracted_parts = []
365
+ for att in attachments:
366
+ kind = att.get("kind", "")
367
+ mime = att.get("mime_type", "")
368
+ filename = att.get("filename", "file")
369
+
370
+ # Get binary content
371
+ raw = att.get("bytes") or b""
372
+ if not raw and att.get("base64"):
373
+ raw = _b64.b64decode(att["base64"])
374
+ if not raw and att.get("file_id") and att.get("platform") == "telegram":
375
+ try:
376
+ raw, _ = await self.conny._download_telegram_binary(att["file_id"])
377
+ except Exception:
378
+ pass
379
+ if not raw and att.get("media_id") and att.get("platform") == "whatsapp_cloud":
380
+ try:
381
+ raw, _, _ = await self.conny._download_whatsapp_cloud_binary(att["media_id"])
382
+ except Exception:
383
+ pass
384
+
385
+ if not raw:
386
+ continue
387
+
388
+ # Extract text based on file type
389
+ text_content = ""
390
+ if "pdf" in mime or filename.endswith(".pdf"):
391
+ try:
392
+ import pdfplumber, io
393
+ with pdfplumber.open(io.BytesIO(raw)) as pdf:
394
+ pages = [p.extract_text() or "" for p in pdf.pages[:20]]
395
+ text_content = "\n".join(filter(None, pages))[:5000]
396
+ except Exception:
397
+ text_content = raw.decode("utf-8", errors="ignore")[:5000]
398
+ elif "json" in mime or filename.endswith(".json"):
399
+ text_content = raw.decode("utf-8", errors="ignore")[:5000]
400
+ elif "text" in mime or filename.endswith((".txt", ".md", ".csv")):
401
+ text_content = raw.decode("utf-8", errors="ignore")[:5000]
402
+ else:
403
+ try:
404
+ text_content = raw.decode("utf-8", errors="ignore")[:3000]
405
+ except Exception:
406
+ continue
407
+
408
+ if text_content.strip():
409
+ extracted_parts.append(f"[{filename}]\n{text_content.strip()}")
410
+ log.info(f"[admin] processed attachment: {filename} ({len(text_content)} chars)")
411
+
412
+ # Auto-configure Google credentials if detected
413
+ is_credential_file = "client_id" in text_content and "client_secret" in text_content
414
+ is_secret = "private_key" in text_content or "api_key" in text_content.lower()
415
+
416
+ if is_credential_file:
417
+ await self._auto_configure_google(text_content, instance_id)
418
+ self._append_soul(instance_id, f"[archivo: {filename}] Credenciales de Google recibidas y configuradas.")
419
+ elif is_secret:
420
+ # NEVER save secrets/keys to soul — only to vault
421
+ creds_dir = Path(f"integrations/vault/{instance_id}")
422
+ creds_dir.mkdir(parents=True, exist_ok=True)
423
+ (creds_dir / filename).write_text(text_content)
424
+ self._append_soul(instance_id, f"[archivo: {filename}] API key/credencial guardada en vault (no expuesta).")
425
+ else:
426
+ # Normal knowledge file — safe to save to soul
427
+ self._append_soul(instance_id, f"[archivo: {filename}]\n{text_content[:1000]}")
428
+
429
+ return "\n\n".join(extracted_parts) if extracted_parts else ""
430
+
431
+ async def _auto_configure_google(self, json_text: str, instance_id: str):
432
+ """Auto-extract Google OAuth creds from JSON and configure .env + generate OAuth URL."""
433
+ try:
434
+ data = json.loads(json_text)
435
+ # Handle both "installed" and "web" credential formats
436
+ creds = data.get("installed") or data.get("web") or data
437
+ client_id = creds.get("client_id", "")
438
+ client_secret = creds.get("client_secret", "")
439
+ if not client_id or not client_secret:
440
+ return
441
+
442
+ # Save credentials file
443
+ creds_dir = Path(f"integrations/vault/{instance_id}")
444
+ creds_dir.mkdir(parents=True, exist_ok=True)
445
+ (creds_dir / "google_credentials.json").write_text(json_text)
446
+
447
+ # Update .env
448
+ env_path = Path(f"/home/ubuntu/conny-instances/{instance_id}/.env")
449
+ if not env_path.exists():
450
+ env_path = Path(".env")
451
+ if env_path.exists():
452
+ env_content = env_path.read_text()
453
+ if "GOOGLE_CLIENT_ID" not in env_content:
454
+ env_content += f"\n\n# Google Calendar (auto-configured)\nGOOGLE_CLIENT_ID={client_id}\nGOOGLE_CLIENT_SECRET={client_secret}\nGOOGLE_REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob\n"
455
+ env_path.write_text(env_content)
456
+
457
+ # Set env vars for current process
458
+ import os
459
+ os.environ["GOOGLE_CLIENT_ID"] = client_id
460
+ os.environ["GOOGLE_CLIENT_SECRET"] = client_secret
461
+ log.info(f"[admin] Google credentials auto-configured for {instance_id}")
462
+ except Exception as e:
463
+ log.warning(f"[admin] auto-configure Google failed: {e}")
464
+
465
+ def _get_full_conversation(self, db, patient_id_fragment: str, admin_chat_id: str) -> str:
466
+ """Get full conversation with a specific patient (by partial ID)."""
467
+ try:
468
+ with db._conn() as c:
469
+ # Find matching chat_id
470
+ rows = c.execute("""
471
+ SELECT DISTINCT chat_id FROM conversations
472
+ WHERE chat_id != ? AND chat_id LIKE ?
473
+ ORDER BY id DESC LIMIT 1
474
+ """, (admin_chat_id, f"%{patient_id_fragment}%")).fetchall()
475
+ if not rows:
476
+ return ""
477
+ full_chat_id = rows[0][0] if isinstance(rows[0], tuple) else rows[0]["chat_id"]
478
+
479
+ # Get all messages for that chat
480
+ msgs = c.execute("""
481
+ SELECT role, content FROM conversations
482
+ WHERE chat_id = ? ORDER BY id ASC
483
+ """, (full_chat_id,)).fetchall()
484
+ if not msgs:
485
+ return ""
486
+
487
+ lines = [f"Conversación con paciente ...{full_chat_id.split('@')[0][-4:]}:"]
488
+ for m in msgs:
489
+ role = m[0] if isinstance(m, tuple) else m["role"]
490
+ content = m[1] if isinstance(m, tuple) else m["content"]
491
+ label = "Paciente" if role == "user" else "Conny"
492
+ lines.append(f" [{label}] {content[:200]}")
493
+ return "\n".join(lines[-30:])
494
+ except Exception:
495
+ return ""
496
+
497
+ def _get_recent_patients_summary(self, db, admin_chat_id: str) -> str:
498
+ """Get summary of recent patient conversations (excluding admin)."""
499
+ try:
500
+ import sqlite3
501
+ with db._conn() as c:
502
+ rows = c.execute("""
503
+ SELECT chat_id, content, role
504
+ FROM conversations
505
+ WHERE chat_id != ? AND role = 'user'
506
+ ORDER BY id DESC LIMIT 20
507
+ """, (admin_chat_id,)).fetchall()
508
+ if not rows:
509
+ return ""
510
+ # Group by chat_id
511
+ patients = {}
512
+ for row in rows:
513
+ cid = row[0] if isinstance(row, tuple) else row["chat_id"]
514
+ content = row[1] if isinstance(row, tuple) else row["content"]
515
+ if cid not in patients:
516
+ patients[cid] = []
517
+ patients[cid].append(content[:100])
518
+
519
+ lines = []
520
+ for cid, msgs in list(patients.items())[:5]:
521
+ short_id = cid.split("@")[0][-4:] if "@" in cid else cid[-4:]
522
+ first_msg = msgs[0] if msgs else "?"
523
+ lines.append(f"- Paciente ...{short_id}: \"{first_msg[:80]}\" ({len(msgs)} msgs)")
524
+ return "\n".join(lines)
525
+ except Exception:
526
+ return ""
527
+
528
+ def _load_soul(self, instance_id: str) -> str:
529
+ """Cargar el 'alma' — todo lo que Conny sabe del negocio."""
530
+ soul_file = SOUL_DIR / instance_id / "knowledge.md"
531
+ if soul_file.exists():
532
+ content = soul_file.read_text()
533
+ return content[-3000:] if len(content) > 3000 else content
534
+
535
+ # Fallback: cargar de teachings
536
+ teachings_file = Path("teachings") / f"{instance_id}.jsonl"
537
+ if teachings_file.exists():
538
+ lines = teachings_file.read_text().splitlines()[-20:]
539
+ teachings = []
540
+ for line in lines:
541
+ try:
542
+ t = json.loads(line)
543
+ teachings.append(f"- {t.get('answer', t.get('question', ''))[:150]}")
544
+ except Exception:
545
+ continue
546
+ return "\n".join(teachings) if teachings else ""
547
+ return ""
548
+
549
+ def _append_soul(self, instance_id: str, new_knowledge: str):
550
+ """Agregar nuevo conocimiento al alma."""
551
+ soul_dir = SOUL_DIR / instance_id
552
+ soul_dir.mkdir(parents=True, exist_ok=True)
553
+ soul_file = soul_dir / "knowledge.md"
554
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
555
+ with open(soul_file, "a") as f:
556
+ f.write(f"\n[{timestamp}] {new_knowledge[:500]}\n")
557
+
558
+ def _load_teachings(self, instance_id: str) -> str:
559
+ """Cargar enseñanzas del admin."""
560
+ teachings_file = Path("teachings") / f"{instance_id}.jsonl"
561
+ if not teachings_file.exists():
562
+ return ""
563
+ lines = teachings_file.read_text().splitlines()[-10:]
564
+ result = []
565
+ for line in lines:
566
+ try:
567
+ t = json.loads(line)
568
+ q = t.get("question", "")
569
+ a = t.get("answer", "")
570
+ if a and not q.startswith("[admin"):
571
+ result.append(f"- P: {q[:80]} → R: {a[:100]}")
572
+ elif a:
573
+ result.append(f"- {a[:150]}")
574
+ except Exception:
575
+ continue
576
+ return "\n".join(result)
577
+
578
+ def _assess_knowledge_level(self, soul: str, teachings: str, clinic: Dict) -> str:
579
+ """Evaluar cuánto sabe Conny del negocio."""
580
+ total_knowledge = len(soul) + len(teachings)
581
+ has_services = bool(clinic.get("services"))
582
+ has_schedule = bool(clinic.get("schedule"))
583
+ has_phone = bool(clinic.get("phone"))
584
+
585
+ if total_knowledge > 2000 and has_services and has_schedule:
586
+ return "ready"
587
+ elif total_knowledge > 500 or has_services:
588
+ return "learning"
589
+ return "new"
590
+
591
+ async def _handle_setup(self, chat_id: str, text: str, clinic: Dict) -> List[str]:
592
+ from conny import db
593
+ setup_step = clinic.get("setup_step", "idle")
594
+ setup_buffer = clinic.get("setup_buffer", {})
595
+ if isinstance(setup_buffer, str):
596
+ setup_buffer = json.loads(setup_buffer) if setup_buffer else {}
597
+
598
+ step_names = ["name", "tagline", "services", "schedule", "phone", "pricing"]
599
+
600
+ if setup_step == "idle":
601
+ db.update_clinic(setup_step="name")
602
+ return ["Hola! Soy Conny, tu recepcionista nueva", "Cuéntame, cómo se llama tu negocio?"]
603
+
604
+ if setup_step == "confirm_discovered":
605
+ if text.lower().strip() in ["si", "ok", "claro"]:
606
+ discovered = setup_buffer.get("discovered", {})
607
+ db.update_clinic(name=discovered.get("name", setup_buffer.get("name")),
608
+ tagline=discovered.get("tagline", ""), services=discovered.get("services", []),
609
+ schedule=discovered.get("schedule", {}), phone=discovered.get("phone", ""),
610
+ setup_done=1, setup_step="idle", setup_buffer={})
611
+ return [f"Listo, ya tengo la info de {discovered.get('name')}.", "Ahora cuéntame más — qué servicios son los más importantes?"]
612
+ db.update_clinic(setup_step="tagline", setup_buffer=setup_buffer)
613
+ return ["Ok vamos manual. Tienes algún slogan o frase de marca?"]
614
+
615
+ if setup_step not in step_names:
616
+ return ["Escribe /setup para empezar de nuevo."]
617
+ idx = step_names.index(setup_step)
618
+
619
+ if setup_step == "services":
620
+ setup_buffer["services"] = [s.strip().title() for s in text.split(",") if s.strip()]
621
+ else:
622
+ setup_buffer[setup_step] = text.strip()
623
+
624
+ if setup_step == "name":
625
+ setup_buffer["name"] = text.strip()
626
+ db.update_clinic(setup_step="services", setup_buffer=setup_buffer, name=text.strip())
627
+ return [f"Anotado: {text.strip()}", "Qué servicios ofrecen? (ponlos separados por coma)"]
628
+
629
+ if idx + 1 < len(step_names):
630
+ next_step = step_names[idx + 1]
631
+ prompts = {
632
+ "tagline": "Tienes slogan?",
633
+ "services": "Servicios (separados por coma)?",
634
+ "schedule": "Horario de atención?",
635
+ "phone": "Teléfono de contacto?",
636
+ "pricing": "Rango de precios? (puede ser aproximado)",
637
+ }
638
+ db.update_clinic(setup_step=next_step, setup_buffer=setup_buffer)
639
+ return [f"Perfecto, anotado", prompts.get(next_step, "Siguiente?")]
640
+
641
+ db.update_clinic(name=setup_buffer.get("name"), tagline=setup_buffer.get("tagline"),
642
+ services=setup_buffer.get("services"), schedule=setup_buffer.get("schedule"),
643
+ phone=setup_buffer.get("phone"), pricing=setup_buffer.get("pricing"),
644
+ setup_done=1, setup_step="idle", setup_buffer={})
645
+ return ["Listo! Ya tengo lo básico para arrancar", "Ahora cuéntame más libremente — precios, cosas que no deba decir, etc. Todo me sirve"]
646
+
647
+
648
+ class AuthEngine:
649
+ """Autenticacion y activacion."""
650
+ MAX_LOGIN_ATTEMPTS = 5
651
+
652
+ def is_auth_message(self, chat_id: str, text: str) -> bool:
653
+ from conny import db
654
+ from conny_utils import is_activation_token, is_invite_token
655
+ t = text.strip(); t_low = t.lower()
656
+ if ":" in t and "@" in t_low:
657
+ parts = t.split(":")
658
+ if len(parts) >= 2:
659
+ potential_creds = parts[1].strip().split()
660
+ if len(potential_creds) >= 2 and "@" in potential_creds[0]: return True
661
+ session = db.get_auth_session(chat_id)
662
+ if session and session.get("flow") in ("activate", "login", "invite", "register"): return True
663
+ if is_activation_token(t): return db.get_activation_token(t.upper()) is not None
664
+ if is_invite_token(t): return db.get_auth_session(f"invite:{t.upper()}") is not None
665
+ return False
666
+
667
+ async def process(self, chat_id: str, text: str) -> List[str]:
668
+ from conny import db
669
+ from conny_utils import is_activation_token, is_invite_token
670
+ t = text.strip()
671
+ if ":" in t and "@" in t.lower():
672
+ parts = t.split(":", 1); creds = parts[1].strip().split()
673
+ if len(creds) >= 2 and "@" in creds[0]: return await self._handle_stealth_login(chat_id, creds[0].lower(), creds[1])
674
+ if is_activation_token(t): return await self._start_activation(chat_id, t)
675
+ if is_invite_token(t): return await self._start_invite_registration(chat_id, t)
676
+ session = db.get_auth_session(chat_id)
677
+ if session:
678
+ flow = session.get("flow", "")
679
+ if flow == "activate": return await self._handle_activation_flow(chat_id, t, session)
680
+ if flow == "login": return await self._handle_login_flow(chat_id, t, session)
681
+ return []
682
+
683
+ async def _handle_stealth_login(self, chat_id: str, email: str, password: str) -> List[str]:
684
+ from conny import db
685
+ from conny_utils import verify_password, _parse_admin_ids
686
+ admin = db.get_admin_by_email(email)
687
+ if admin and verify_password(password, admin["password_hash"]):
688
+ db.create_admin(chat_id=chat_id, email=admin["email"], password_hash=admin["password_hash"], name=admin["name"], role=admin["role"])
689
+ clinic = db.get_clinic(); admin_ids = _parse_admin_ids(clinic.get("admin_chat_ids", []))
690
+ if chat_id not in admin_ids: admin_ids.append(chat_id); db.update_clinic(admin_chat_ids=admin_ids)
691
+ return [f"Hola {admin['name']}. Ya te reconozco."]
692
+ return []
693
+
694
+ async def _start_activation(self, chat_id: str, token_raw: str) -> List[str]:
695
+ from conny import db
696
+ token = token_raw.strip().upper(); td = db.get_activation_token(token)
697
+ if not td: return ["Token no válido."]
698
+ db.set_auth_session(chat_id, flow="activate", step="name", temp_data={"token": token})
699
+ return ["Código válido. Cómo te llamas?"]
700
+
701
+ async def _handle_activation_flow(self, chat_id: str, text: str, session: Dict) -> List[str]:
702
+ from conny import db
703
+ from conny_utils import hash_password
704
+ step, tmp = session["step"], session.get("temp_data", {})
705
+ if step == "name":
706
+ tmp["name"] = text.strip(); db.set_auth_session(chat_id, "activate", "email", tmp)
707
+ return [f"Hola {text}. Tu email?"]
708
+ if step == "email":
709
+ tmp["email"] = text.strip().lower(); db.set_auth_session(chat_id, "activate", "password", tmp)
710
+ return ["Elige una contraseña segura"]
711
+ if step == "password":
712
+ tmp["password_hash"] = hash_password(text.strip()); db.set_auth_session(chat_id, "activate", "confirm", tmp)
713
+ return ["Confirmas? (si/no)"]
714
+ if step == "confirm" and text.lower().strip() == "si":
715
+ db.create_admin(chat_id=chat_id, email=tmp["email"], password_hash=tmp["password_hash"], name=tmp["name"], role="owner")
716
+ db.clear_auth_session(chat_id); return ["Listo, cuenta creada. Ahora cuéntame del negocio"]
717
+ return ["Cancelado."]
718
+
719
+ async def _handle_login_flow(self, chat_id: str, text: str, session: Dict) -> List[str]:
720
+ from conny import db
721
+ from conny_utils import verify_password, _parse_admin_ids
722
+ step, tmp = session["step"], session.get("temp_data", {})
723
+ if step == "email":
724
+ email = text.strip().lower(); admin = db.get_admin_by_email(email)
725
+ if not admin: return ["No encontré esa cuenta."]
726
+ tmp["email"] = email; db.set_auth_session(chat_id, "login", "password", tmp)
727
+ return ["Tu contraseña?"]
728
+ if step == "password":
729
+ email = tmp.get("email", ""); admin = db.get_admin_by_email(email)
730
+ if admin and verify_password(text.strip(), admin["password_hash"]):
731
+ db.create_admin(chat_id=chat_id, email=admin["email"], password_hash=admin["password_hash"], name=admin["name"], role=admin["role"])
732
+ db.clear_auth_session(chat_id)
733
+ clinic = db.get_clinic(); admin_ids = _parse_admin_ids(clinic.get("admin_chat_ids", []))
734
+ if chat_id not in admin_ids: admin_ids.append(chat_id); db.update_clinic(admin_chat_ids=admin_ids)
735
+ return [f"Bienvenido de nuevo, {admin['name']}."]
736
+ return ["Contraseña incorrecta."]
737
+ return []
738
+
739
+
740
+ class AdminLearningEngine:
741
+ def __init__(self, database): self.db = database; self._cached_instructions = None
742
+ def add_instruction(self, chat_id: str, text: str) -> str:
743
+ self.db.add_admin_instruction(chat_id, text); self._cached_instructions = None
744
+ return f"Anotado: '{text}'."
745
+ def get_prompt_injection(self) -> str:
746
+ ins = self.db.get_active_admin_instructions()
747
+ if not ins: return ""
748
+ return "\n## INSTRUCCIONES DEL DUEÑO:\n" + "\n".join([f"- {i}" for i in ins])
749
+ def clear(self) -> str: self.db.clear_admin_instructions(); return "Instrucciones borradas."
750
+
751
+
752
+ class SimulationEngine:
753
+ def __init__(self, conny): self.conny = conny; self._active_simulations = {}
754
+ def start(self, chat_id: str, scenario: str = "default") -> List[str]:
755
+ self._active_simulations[chat_id] = {"ts": time.time()}
756
+ return ["Dale, escríbeme como si fueras un paciente y te respondo en personaje"]
757
+ def stop(self, chat_id: str) -> List[str]:
758
+ self._active_simulations.pop(chat_id, None); return ["Listo, salí del modo simulación"]
759
+ def is_simulating(self, chat_id: str) -> bool: return chat_id in self._active_simulations
760
+ async def handle_step(self, chat_id: str, text: str) -> List[str]:
761
+ if "salir" in text.lower() or "/salir" in text.lower(): return self.stop(chat_id)
762
+ return await self.conny.process_message(chat_id, text, is_simulation=True)
763
+
764
+
765
+ class SelfImprovementEngine:
766
+ def __init__(self, llm): self.llm = llm
767
+ async def analyze_performance(self) -> Dict: return {"ok": True}
768
+
769
+
770
+ class TaskManager:
771
+ def __init__(self): self._tasks = {}
772
+ def add_task(self, chat_id: str, kind: str, data: Dict, delay: int = 0): return secrets.token_hex(4)