@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
package/conny-chat.py ADDED
@@ -0,0 +1,579 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ conny-chat — Interfaz de lenguaje natural para administrar Conny.
4
+
5
+ Uso:
6
+ python3 conny-chat.py → conecta a instancia base (puerto 8001)
7
+ python3 conny-chat.py clinica-bella → conecta a instancia específica
8
+ python3 conny-chat.py --port 8005 → puerto específico
9
+
10
+ Ejemplo de conversación:
11
+ Tu: "Conny tenemos nuevo cliente, la clínica se llama Estética Sofía,
12
+ búscala en Google. El admin se llama Carolina, su número es 3124567890.
13
+ Dame el token y me lo envío."
14
+ Conny: "Listo Santiago. Busqué Estética Sofía...
15
+ [resumen de lo encontrado]
16
+ Token de activación: ACTV-SOFIA-2025-XXXXXX
17
+ Envíaselo a Carolina al 3124567890. Expira en 72h."
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import os
23
+ import re
24
+ import sys
25
+ import time
26
+ import readline # historial de comandos con flecha arriba
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ import httpx
31
+
32
+ # ── Colores ANSI ───────────────────────────────────────────────────────────────
33
+ R = "\033[0;31m" # rojo
34
+ G = "\033[0;32m" # verde
35
+ Y = "\033[1;33m" # amarillo
36
+ B = "\033[0;34m" # azul
37
+ P = "\033[38;2;139;92;246m" # morado
38
+ ROSA = "\033[38;2;236;72;153m" # rosa
39
+ C = "\033[0;36m" # cyan
40
+ W = "\033[1;37m" # blanco brillante
41
+ DIM= "\033[2m" # dimmed
42
+ NC = "\033[0m" # reset
43
+
44
+ def clear_line():
45
+ sys.stdout.write("\033[2K\033[1G")
46
+ sys.stdout.flush()
47
+
48
+ def print_typing(name: str = "Conny"):
49
+ """Muestra animación de 'escribiendo...'"""
50
+ chars = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]
51
+ for i in range(12):
52
+ clear_line()
53
+ sys.stdout.write(f" {P}{name}{NC} {DIM}{chars[i % len(chars)]} escribiendo...{NC}")
54
+ sys.stdout.flush()
55
+ time.sleep(0.08)
56
+ clear_line()
57
+
58
+ # ── Config ─────────────────────────────────────────────────────────────────────
59
+ INSTANCES_DIR = "/home/ubuntu/conny-instances"
60
+ TEMPLATE_DIR = "/home/ubuntu/conny"
61
+ SERP_API_KEY = "" # se lee del .env
62
+
63
+
64
+ def load_env(env_path: str) -> dict:
65
+ """Lee un archivo .env y retorna dict."""
66
+ env = {}
67
+ try:
68
+ with open(env_path) as f:
69
+ for line in f:
70
+ line = line.strip()
71
+ if line and not line.startswith('#') and '=' in line:
72
+ k, _, v = line.partition('=')
73
+ env[k.strip()] = v.strip().strip('"').strip("'")
74
+ except Exception:
75
+ pass
76
+ return env
77
+
78
+
79
+ def resolve_instance(arg: Optional[str]) -> dict:
80
+ """
81
+ Dado un nombre de instancia (o None para la base), retorna su config.
82
+ Retorna: {port, base_url, master_key, name, env_path}
83
+ """
84
+ if not arg:
85
+ env = load_env(f"{TEMPLATE_DIR}/.env")
86
+ return {
87
+ "name": "base",
88
+ "port": int(env.get("PORT", 8001)),
89
+ "base_url": f"http://localhost:{env.get('PORT', 8001)}",
90
+ "master_key": env.get("MASTER_API_KEY", ""),
91
+ "env_path": f"{TEMPLATE_DIR}/.env",
92
+ "env": env,
93
+ }
94
+
95
+ # Slugify
96
+ slug = arg.lower().strip()
97
+ inst_dir = f"{INSTANCES_DIR}/{slug}"
98
+ env_path = f"{inst_dir}/.env"
99
+
100
+ if not os.path.exists(env_path):
101
+ # Buscar por aproximación
102
+ for d in os.listdir(INSTANCES_DIR) if os.path.exists(INSTANCES_DIR) else []:
103
+ if slug in d:
104
+ inst_dir = f"{INSTANCES_DIR}/{d}"
105
+ env_path = f"{inst_dir}/.env"
106
+ slug = d
107
+ break
108
+ else:
109
+ return None
110
+
111
+ env = load_env(env_path)
112
+ port = int(env.get("PORT", 8002))
113
+ return {
114
+ "name": slug,
115
+ "port": port,
116
+ "base_url": f"http://localhost:{port}",
117
+ "master_key": env.get("MASTER_API_KEY", ""),
118
+ "env_path": env_path,
119
+ "env": env,
120
+ }
121
+
122
+
123
+ # ── API Client ─────────────────────────────────────────────────────────────────
124
+
125
+ async def api_get(base_url: str, path: str, master_key: str = "") -> dict:
126
+ headers = {}
127
+ if master_key:
128
+ headers["X-Master-Key"] = master_key
129
+ async with httpx.AsyncClient(timeout=15.0) as c:
130
+ r = await c.get(f"{base_url}{path}", headers=headers)
131
+ r.raise_for_status()
132
+ return r.json()
133
+
134
+ async def api_post(base_url: str, path: str, data: dict, master_key: str = "") -> dict:
135
+ headers = {"Content-Type": "application/json"}
136
+ if master_key:
137
+ headers["X-Master-Key"] = master_key
138
+ async with httpx.AsyncClient(timeout=30.0) as c:
139
+ r = await c.post(f"{base_url}{path}", json=data, headers=headers)
140
+ r.raise_for_status()
141
+ return r.json()
142
+
143
+ async def get_health(base_url: str) -> dict:
144
+ try:
145
+ return await api_get(base_url, "/health")
146
+ except Exception:
147
+ return {"status": "offline"}
148
+
149
+ async def search_clinic_web(clinic_name: str, serp_key: str, city: str = "Medellin") -> str:
150
+ """Busca la clínica en Google vía SerpAPI."""
151
+ if not serp_key:
152
+ return ""
153
+ try:
154
+ async with httpx.AsyncClient(timeout=12.0) as c:
155
+ r = await c.get("https://serpapi.com/search", params={
156
+ "engine": "google",
157
+ "q": f"{clinic_name} {city} clinica estetica servicios precios horario",
158
+ "api_key": serp_key,
159
+ "hl": "es", "gl": "co", "num": 5
160
+ })
161
+ r.raise_for_status()
162
+ data = r.json()
163
+
164
+ parts = []
165
+ ab = data.get("answer_box", {})
166
+ if ab.get("snippet"):
167
+ parts.append(ab["snippet"][:400])
168
+ for res in data.get("organic_results", [])[:4]:
169
+ if res.get("snippet"):
170
+ parts.append(f"{res.get('title','')}: {res['snippet'][:200]}")
171
+ return "\n".join(parts)[:1000]
172
+ except Exception as e:
173
+ return f"[búsqueda fallida: {e}]"
174
+
175
+
176
+ async def create_activation_token(base_url: str, master_key: str, clinic_label: str) -> dict:
177
+ """Crea un token de activación para una nueva clínica."""
178
+ return await api_post(base_url, "/api/tokens/create",
179
+ {"clinic_label": clinic_label}, master_key)
180
+
181
+ async def get_recent_chats(base_url: str, master_key: str) -> list:
182
+ try:
183
+ r = await api_get(base_url, "/conversations/patients?limit=5", master_key)
184
+ return r.get("conversations", [])
185
+ except Exception:
186
+ return []
187
+
188
+ async def get_stats(base_url: str, master_key: str) -> dict:
189
+ try:
190
+ h = await api_get(base_url, "/health")
191
+ a = await api_get(base_url, "/analytics/summary?days=7", master_key)
192
+ return {**h, **a}
193
+ except Exception as e:
194
+ return {"error": str(e)}
195
+
196
+
197
+ # ── LLM Brain para el CLI ──────────────────────────────────────────────────────
198
+
199
+ async def llm_interpret(
200
+ user_input: str,
201
+ history: list,
202
+ instance: dict,
203
+ health: dict,
204
+ serp_key: str,
205
+ ) -> str:
206
+ """
207
+ Usa el LLM de la instancia activa para interpretar el mensaje del usuario
208
+ y decidir qué acciones tomar. Retorna la respuesta final.
209
+ """
210
+
211
+ # Intentar todos los LLMs disponibles
212
+ env = instance.get("env", {})
213
+
214
+ providers = []
215
+ if env.get("GROQ_API_KEY"):
216
+ providers.append(("groq", "https://api.groq.com/openai/v1", env["GROQ_API_KEY"], "llama-3.3-70b-versatile"))
217
+ for k in ["GEMINI_API_KEY", "GEMINI_API_KEY_2", "GEMINI_API_KEY_3"]:
218
+ if env.get(k):
219
+ providers.append(("gemini", "native", env[k], "gemini-2.0-flash"))
220
+ if env.get("OPENROUTER_API_KEY"):
221
+ providers.append(("openrouter", "https://openrouter.ai/api/v1", env["OPENROUTER_API_KEY"], "google/gemini-2.0-flash-001"))
222
+ if env.get("OPENAI_API_KEY"):
223
+ providers.append(("openai", "https://api.openai.com/v1", env["OPENAI_API_KEY"], "gpt-4o-mini"))
224
+
225
+ if not providers:
226
+ return "No hay LLMs configurados en el .env. Revisa las claves de API."
227
+
228
+ # Contexto de la instancia
229
+ clinic_name = health.get("clinic", "desconocida")
230
+ status = health.get("status", "offline")
231
+
232
+ hist_text = ""
233
+ for h in history[-6:]:
234
+ role = "Santiago" if h["role"] == "user" else "Conny"
235
+ hist_text += f"{role}: {h['content']}\n"
236
+
237
+ system = f"""Eres Conny, asistente virtual de administración de clínicas estéticas.
238
+ Santiago (el dueño del sistema) te está hablando desde la terminal de su servidor.
239
+
240
+ INSTANCIA ACTIVA:
241
+ - Nombre: {instance['name']}
242
+ - Puerto: {instance['port']}
243
+ - Clínica: {clinic_name}
244
+ - Estado: {status}
245
+ - Master Key: {instance['master_key'][:20]}...
246
+
247
+ ACCIONES QUE PUEDES HACER (cuando las detectes en el mensaje):
248
+ 1. Crear token de activación para nueva clínica
249
+ 2. Buscar clínica en Google
250
+ 3. Ver conversaciones recientes de pacientes
251
+ 4. Ver estadísticas
252
+ 5. Dar información sobre configuración
253
+ 6. Responder preguntas sobre el sistema
254
+
255
+ FORMATO DE RESPUESTA:
256
+ Responde en español natural, directo, como una asistente que conoce el sistema.
257
+ Si necesitas ejecutar una acción, incluye al FINAL del mensaje UNA línea con el JSON:
258
+ ACTION:{{"type": "create_token", "clinic_label": "...", "admin_name": "...", "admin_phone": "..."}}
259
+ ACTION:{{"type": "search_clinic", "clinic_name": "...", "city": "Medellin"}}
260
+ ACTION:{{"type": "show_chats"}}
261
+ ACTION:{{"type": "show_stats"}}
262
+
263
+ Si no hay acción, no incluyas ninguna línea ACTION.
264
+
265
+ REGLAS:
266
+ - Si te piden crear un nuevo cliente/clínica: extrae el nombre de la clínica y crea el token
267
+ - Si mencionan buscar en Google: extrae el nombre y busca
268
+ - Si preguntan por mensajes/pacientes: muestra los chats recientes
269
+ - Respuestas cortas y directas — máximo 4 oraciones antes de la acción
270
+
271
+ CONVERSACIÓN RECIENTE:
272
+ {hist_text or "(inicio)"}"""
273
+
274
+ messages = [
275
+ {"role": "system", "content": system},
276
+ {"role": "user", "content": user_input}
277
+ ]
278
+
279
+ for provider_name, base_url, api_key, model in providers:
280
+ try:
281
+ if provider_name == "gemini":
282
+ # API nativa de Gemini
283
+ system_parts = [m for m in messages if m["role"] == "system"]
284
+ contents = []
285
+ for m in messages:
286
+ if m["role"] == "user":
287
+ contents.append({"role": "user", "parts": [{"text": m["content"]}]})
288
+ elif m["role"] == "assistant":
289
+ contents.append({"role": "model", "parts": [{"text": m["content"]}]})
290
+ payload = {
291
+ "contents": contents,
292
+ "generationConfig": {"temperature": 0.4, "maxOutputTokens": 600}
293
+ }
294
+ if system_parts:
295
+ payload["systemInstruction"] = {"parts": [{"text": system_parts[0]["content"]}]}
296
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
297
+ async with httpx.AsyncClient(timeout=20.0) as c:
298
+ r = await c.post(url, json=payload)
299
+ r.raise_for_status()
300
+ data = r.json()
301
+ return data["candidates"][0]["content"]["parts"][0]["text"].strip()
302
+ else:
303
+ # OpenAI-compatible
304
+ async with httpx.AsyncClient(timeout=20.0) as c:
305
+ r = await c.post(
306
+ f"{base_url}/chat/completions",
307
+ headers={
308
+ "Authorization": f"Bearer {api_key}",
309
+ "Content-Type": "application/json",
310
+ "HTTP-Referer": "https://conny.ai",
311
+ },
312
+ json={
313
+ "model": model,
314
+ "messages": messages,
315
+ "temperature": 0.4,
316
+ "max_tokens": 600
317
+ }
318
+ )
319
+ r.raise_for_status()
320
+ data = r.json()
321
+ return data["choices"][0]["message"]["content"].strip()
322
+
323
+ except Exception as e:
324
+ continue # siguiente proveedor
325
+
326
+ return "No pude conectarme al LLM. Revisa las claves de API en el .env."
327
+
328
+
329
+ def extract_action(response: str) -> Optional[dict]:
330
+ """Extrae el JSON de acción del final de la respuesta."""
331
+ m = re.search(r'ACTION:\s*(\{[^\n]+\})', response)
332
+ if m:
333
+ try:
334
+ return json.loads(m.group(1))
335
+ except Exception:
336
+ pass
337
+ return None
338
+
339
+
340
+ def clean_response(response: str) -> str:
341
+ """Quita la línea ACTION del texto mostrado al usuario."""
342
+ return re.sub(r'\nACTION:\s*\{[^\n]+\}', '', response).strip()
343
+
344
+
345
+ # ── Ejecutor de acciones ───────────────────────────────────────────────────────
346
+
347
+ async def execute_action(action: dict, instance: dict, serp_key: str) -> str:
348
+ """Ejecuta la acción y retorna texto adicional para mostrar."""
349
+ base_url = instance["base_url"]
350
+ master_key = instance["master_key"]
351
+ action_type = action.get("type", "")
352
+
353
+ if action_type == "create_token":
354
+ clinic_label = action.get("clinic_label", "Nueva Clínica")
355
+ admin_name = action.get("admin_name", "")
356
+ admin_phone = action.get("admin_phone", "")
357
+
358
+ try:
359
+ result = await create_activation_token(base_url, master_key, clinic_label)
360
+ token = result.get("token", "")
361
+ expires = result.get("expires_at", "")[:16] if result.get("expires_at") else ""
362
+
363
+ lines = [
364
+ f"\n {G}Token creado:{NC}",
365
+ f" {W}{token}{NC}",
366
+ ]
367
+ if expires:
368
+ lines.append(f" {DIM}Expira: {expires}{NC}")
369
+ if admin_name and admin_phone:
370
+ lines.append(f"\n {Y}Envíaselo a {admin_name} al {admin_phone}:{NC}")
371
+ lines.append(f" Mensaje sugerido:")
372
+ lines.append(f" {DIM}\"Hola {admin_name}! Para activar tu asistente virtual")
373
+ lines.append(f" envía este código al bot de Telegram: {token}\"{NC}")
374
+ lines.append(f"\n {DIM}Instrucciones de activación:{NC}")
375
+ lines.append(f" {DIM}1. El admin escribe el token al bot{NC}")
376
+ lines.append(f" {DIM}2. El bot le pide nombre, correo y contraseña{NC}")
377
+ lines.append(f" {DIM}3. Queda activado como owner de la clínica{NC}")
378
+ lines.append(f" {DIM}4. Puede invitar a su equipo con /addadmin{NC}")
379
+ return "\n".join(lines)
380
+
381
+ except Exception as e:
382
+ return f"\n {R}Error creando token: {e}{NC}"
383
+
384
+ elif action_type == "search_clinic":
385
+ clinic_name = action.get("clinic_name", "")
386
+ city = action.get("city", "Medellin")
387
+
388
+ if not clinic_name:
389
+ return f"\n {Y}No especificaste el nombre de la clínica a buscar.{NC}"
390
+
391
+ sys.stdout.write(f"\n {DIM}Buscando '{clinic_name}' en Google...{NC}")
392
+ sys.stdout.flush()
393
+ result = await search_clinic_web(clinic_name, serp_key, city)
394
+ clear_line()
395
+
396
+ if not result:
397
+ return f"\n {Y}No encontré información de '{clinic_name}' en Google.{NC}"
398
+
399
+ lines = [f"\n {C}Lo que encontré de {clinic_name}:{NC}", ""]
400
+ for line in result.split("\n")[:8]:
401
+ if line.strip():
402
+ lines.append(f" {line[:100]}")
403
+ return "\n".join(lines)
404
+
405
+ elif action_type == "show_chats":
406
+ try:
407
+ chats = await get_recent_chats(base_url, master_key)
408
+ if not chats:
409
+ return f"\n {DIM}No hay conversaciones de pacientes todavía.{NC}"
410
+
411
+ lines = [f"\n {C}Últimas conversaciones:{NC}", ""]
412
+ for c in chats[:5]:
413
+ name = c.get("name") or "Desconocido"
414
+ cid = c.get("chat_id", "")[-6:]
415
+ n = c.get("message_count", 0)
416
+ last = (c.get("last_user_msg") or "")[:50]
417
+ lines.append(f" • {W}{name}{NC} (...{cid}) — {n} msgs")
418
+ if last:
419
+ lines.append(f" {DIM}\"{last}\"{NC}")
420
+ return "\n".join(lines)
421
+ except Exception as e:
422
+ return f"\n {R}Error: {e}{NC}"
423
+
424
+ elif action_type == "show_stats":
425
+ try:
426
+ stats = await get_stats(base_url, master_key)
427
+ lines = [f"\n {C}Estadísticas (últimos 7 días):{NC}", ""]
428
+ if "total_conversations" in stats:
429
+ lines.append(f" Conversaciones: {stats['total_conversations']}")
430
+ if "total_appointments" in stats:
431
+ lines.append(f" Citas agendadas: {stats['total_appointments']}")
432
+ if "conversion_rate" in stats:
433
+ lines.append(f" Conversión: {stats['conversion_rate']}%")
434
+ if "clinic" in stats:
435
+ lines.append(f" Clínica: {stats['clinic']}")
436
+ return "\n".join(lines)
437
+ except Exception as e:
438
+ return f"\n {R}Error: {e}{NC}"
439
+
440
+ return ""
441
+
442
+
443
+ # ── REPL Principal ─────────────────────────────────────────────────────────────
444
+
445
+ async def repl(instance: dict):
446
+ """Loop principal de chat."""
447
+ base_url = instance["base_url"]
448
+ master_key = instance["master_key"]
449
+ name = instance["name"]
450
+ env = instance.get("env", {})
451
+ serp_key = env.get("SERP_API_KEY", "")
452
+
453
+ # Verificar conexión
454
+ health = await get_health(base_url)
455
+ status = health.get("status", "offline")
456
+ clinic = health.get("clinic", "sin configurar")
457
+
458
+ # Header
459
+ print(f"\n{P}{'═'*54}{NC}")
460
+ print(f"{ROSA} CONNY CHAT{NC} — {W}{name}{NC}")
461
+ print(f" Puerto: {C}{instance['port']}{NC} Clínica: {W}{clinic}{NC}")
462
+ status_color = G if status == "online" else R
463
+ print(f" Estado: {status_color}{status}{NC}")
464
+ print(f"{P}{'═'*54}{NC}")
465
+ print(f"\n {DIM}Escríbeme en lenguaje natural.")
466
+ print(f" Ej: 'nuevo cliente, clínica La Bella, admin Juan 3124567890'")
467
+ print(f" Comandos rápidos: /chats /stats /exit{NC}\n")
468
+
469
+ history = []
470
+ readline.set_history_length(100)
471
+
472
+ while True:
473
+ try:
474
+ user_input = input(f"{W} Tú:{NC} ").strip()
475
+ except (EOFError, KeyboardInterrupt):
476
+ print(f"\n\n {DIM}Hasta luego.{NC}\n")
477
+ break
478
+
479
+ if not user_input:
480
+ continue
481
+
482
+ # Comandos rápidos
483
+ if user_input.lower() in ("/exit", "/quit", "salir", "exit", "quit"):
484
+ print(f"\n {DIM}Hasta luego.{NC}\n")
485
+ break
486
+
487
+ if user_input.lower() in ("/chats", "chats"):
488
+ user_input = "muéstrame las conversaciones recientes de pacientes"
489
+ elif user_input.lower() in ("/stats", "stats", "estadísticas"):
490
+ user_input = "dame las estadísticas"
491
+ elif user_input.lower() in ("/help", "help", "ayuda"):
492
+ print(f"""
493
+ {C}Lo que puedo hacer:{NC}
494
+ • Crear token para nuevo cliente:
495
+ "nuevo cliente, la clínica se llama X, el admin es Juan, número 3124567890"
496
+ • Buscar clínica en Google:
497
+ "busca la clínica X en Google"
498
+ • Ver conversaciones:
499
+ "quién me ha escrito?" o /chats
500
+ • Ver estadísticas:
501
+ "cómo vamos?" o /stats
502
+ • Cualquier pregunta sobre el sistema
503
+ """)
504
+ continue
505
+
506
+ # Procesar con LLM
507
+ print()
508
+ print_typing("Conny")
509
+
510
+ response = await llm_interpret(user_input, history, instance, health, serp_key)
511
+
512
+ # Extraer y ejecutar acción si hay
513
+ action = extract_action(response)
514
+ reply = clean_response(response)
515
+
516
+ # Mostrar respuesta
517
+ print(f"{P} Conny:{NC} {reply}")
518
+
519
+ # Ejecutar acción y mostrar resultado
520
+ if action:
521
+ action_output = await execute_action(action, instance, serp_key)
522
+ if action_output:
523
+ print(action_output)
524
+
525
+ print()
526
+
527
+ # Guardar en historial
528
+ history.append({"role": "user", "content": user_input})
529
+ history.append({"role": "assistant", "content": reply})
530
+ if len(history) > 20:
531
+ history = history[-20:]
532
+
533
+
534
+ # ── Entry point ────────────────────────────────────────────────────────────────
535
+
536
+ def main():
537
+ # Parsear argumentos
538
+ instance_name = None
539
+ port_override = None
540
+
541
+ args = sys.argv[1:]
542
+ i = 0
543
+ while i < len(args):
544
+ if args[i] == "--port" and i + 1 < len(args):
545
+ port_override = int(args[i + 1])
546
+ i += 2
547
+ elif not args[i].startswith("--"):
548
+ instance_name = args[i]
549
+ i += 1
550
+ else:
551
+ i += 1
552
+
553
+ # Resolver instancia
554
+ instance = resolve_instance(instance_name)
555
+
556
+ if instance is None:
557
+ print(f"\n{R} Instancia '{instance_name}' no encontrada.{NC}")
558
+ if os.path.exists(INSTANCES_DIR):
559
+ available = os.listdir(INSTANCES_DIR)
560
+ if available:
561
+ print(f" Instancias disponibles: {', '.join(available)}")
562
+ print(f" Crea una con: ./conny-cli nuevo-cliente\n")
563
+ sys.exit(1)
564
+
565
+ if port_override:
566
+ instance["port"] = port_override
567
+ instance["base_url"] = f"http://localhost:{port_override}"
568
+
569
+ try:
570
+ asyncio.run(repl(instance))
571
+ except (KeyboardInterrupt, asyncio.CancelledError):
572
+ pass
573
+
574
+ if __name__ == "__main__":
575
+ main()
576
+
577
+ # Alias directo: también se puede llamar como script standalone
578
+ # python3 conny-chat.py → instancia base
579
+ # python3 conny-chat.py nombre → instancia específica