@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,636 @@
1
+ """
2
+ conny_send_guard.py
3
+ ════════════════════════════════════════════════════════════════════════════════
4
+ GUARDIA DEL PIPELINE DE ENVÍO — v1.0
5
+ ════════════════════════════════════════════════════════════════════════════════
6
+
7
+ SOLUCIONA:
8
+ 1. Respuestas cortadas ("Listo, ahora soy más", "Entendido, vol...")
9
+ → Detecta el corte y repara antes de enviar al cliente
10
+ 2. Robot phrases que borran la última burbuja dejando la respuesta incompleta
11
+ → Agrega burbuja de cierre segura cuando la respuesta queda sin invitación
12
+ 3. Smart Handoff subutilizado en demo
13
+ → Intercepta señales de confusión en el dueño/prospecto ANTES del LLM
14
+ 4. "Cambia personalidad a X" sin reconocimiento explícito
15
+ → Genera confirmación + continuación en vez de respuesta genérica
16
+
17
+ CÓMO USAR en conny.py:
18
+
19
+ # Al inicio con los demás imports opcionales:
20
+ try:
21
+ from src.domain.send_guard import (
22
+ SendGuard,
23
+ guard_response,
24
+ patch_demo_send,
25
+ DEMO_PERSONALITY_COMMANDS,
26
+ )
27
+ _SEND_GUARD = True
28
+ except ImportError:
29
+ _SEND_GUARD = False
30
+
31
+ # En _handle_demo_message, justo ANTES de "return _send(r)":
32
+ if _SEND_GUARD:
33
+ r = guard_response(r, context="demo")
34
+
35
+ # Para parchear la función _send completa del demo (más robusto):
36
+ # Llamar una vez después de definir _send, dentro de _handle_demo_message:
37
+ if _SEND_GUARD:
38
+ _send = patch_demo_send(_send, business_name=business_name)
39
+
40
+ ════════════════════════════════════════════════════════════════════════════════
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ import re
47
+ from typing import Any, Callable, Dict, List, Optional, Tuple
48
+
49
+ log = logging.getLogger("conny.send_guard")
50
+
51
+
52
+ def _normalize_conv_text(text: str) -> str:
53
+ text = (text or "").lower()
54
+ replacements = str.maketrans({
55
+ "á": "a", "é": "e", "í": "i", "ó": "o", "ú": "u", "ü": "u", "ñ": "n",
56
+ })
57
+ text = text.translate(replacements)
58
+ text = re.sub(r"[^a-z0-9@\+\s]", " ", text)
59
+ return re.sub(r"\s+", " ", text).strip()
60
+
61
+
62
+ # ════════════════════════════════════════════════════════════════════════════════
63
+ # 1. DETECTOR DE CORTES
64
+ # Identifica respuestas que el pipeline cortó o dejó incompletas.
65
+ # ════════════════════════════════════════════════════════════════════════════════
66
+
67
+ # Palabras que aparecen al FINAL de la respuesta y claramente son incompletas
68
+ _DANGLING_WORDS = {
69
+ # Artículos + preposiciones como última palabra → se cortó antes de terminar
70
+ "el", "la", "los", "las", "un", "una", "de", "del", "en", "con",
71
+ "por", "para", "que", "y", "o", "si", "me", "te", "le", "se", "su",
72
+ "al", "a",
73
+ # Comienzos de palabra que nunca terminan solos
74
+ "vol", # → volvemos, volveré
75
+ "per", # → pero, permíteme
76
+ "más", # puede ser válido, pero raro al final sin contexto
77
+ "tam", # → también
78
+ "sol", # → solo, solución
79
+ "sig", # → siguiente, sigue
80
+ }
81
+
82
+ # Patrones de respuesta claramente incompleta
83
+ _CUT_PATTERNS = [
84
+ # Termina en preposición o conector
85
+ r'\b(el|la|los|las|un|una|de|del|en|con|por|para|que|y|o|si)\s*$',
86
+ # Termina en comienzo de palabra (3-4 chars, no es una palabra real completa)
87
+ r'\b(vol|per|tam|sol|sig|par|ten|man|pro)\s*$',
88
+ # Termina con coma o punto y coma (siempre cortado)
89
+ r'[,;]\s*$',
90
+ # Última burbuja es un solo token corto sin puntuación ni pregunta
91
+ r'^\s*\w{1,4}\s*$',
92
+ ]
93
+
94
+
95
+ def _split_response_bubbles(response: str) -> List[str]:
96
+ return [p.strip() for p in re.split(r'\s*\|\|\|\s*', response) if p.strip()]
97
+
98
+
99
+ def _extract_last_token(text: str) -> str:
100
+ stripped = (text or "").rstrip().rstrip(".,!?;:")
101
+ match = re.search(r'\b(\w+)\s*$', stripped.lower())
102
+ return match.group(1) if match else ""
103
+
104
+
105
+ def _has_dangling_last_token(text: str) -> Tuple[bool, str]:
106
+ token = _extract_last_token(text)
107
+ if not token or token not in _DANGLING_WORDS:
108
+ return False, ""
109
+ return True, token
110
+
111
+
112
+ def is_cut_response(response: str) -> Tuple[bool, str]:
113
+ """
114
+ Detecta si una respuesta está cortada.
115
+ Retorna (es_cortado, razón).
116
+ """
117
+ if not response:
118
+ return True, "respuesta vacía"
119
+
120
+ # Tomar la última burbuja
121
+ parts = _split_response_bubbles(response)
122
+ if not parts:
123
+ return True, "sin burbujas válidas"
124
+
125
+ last = parts[-1]
126
+ last_lower = last.lower()
127
+ last_norm = _normalize_conv_text(last)
128
+
129
+ _direct_complete_prefixes = (
130
+ "te llamas",
131
+ "tu nombre es",
132
+ "your name is",
133
+ "you are",
134
+ "ya tengo",
135
+ "i ve got",
136
+ "ive got",
137
+ )
138
+ if any(last_norm.startswith(prefix) for prefix in _direct_complete_prefixes):
139
+ return False, ""
140
+
141
+ # Verificar si termina en palabra colgante
142
+ has_dangling_word, dangling_word = _has_dangling_last_token(last)
143
+ if has_dangling_word:
144
+ return True, f"termina en palabra incompleta: '{dangling_word}'"
145
+
146
+ # Verificar patrones de corte
147
+ for pattern in _CUT_PATTERNS:
148
+ if re.search(pattern, last_lower):
149
+ return True, f"patrón de corte detectado: {pattern[:40]}"
150
+
151
+ # Respuesta completa que no tiene invitación ni pregunta al final
152
+ # (no es un error crítico, pero sí una señal de que el robot phrase filter borró algo)
153
+ has_question = '?' in last
154
+ has_invitation = any(inv in last_lower for inv in [
155
+ "cuéntame", "cuentame", "dime", "escríbeme", "escribeme",
156
+ "cuál es", "cual es", "cómo se llama", "como se llama",
157
+ "escríbeme", "probame", "pruébame", "qué quieres", "que quieres",
158
+ "seguimos", "dale", "listo",
159
+ ])
160
+ has_terminal_punctuation = last.rstrip().endswith((".", "!", "?", "…"))
161
+
162
+ # Removido para evitar conflicto con la regla de estilo "sin punto al final" y confiar en el LLM
163
+ return False, ""
164
+
165
+
166
+ # ════════════════════════════════════════════════════════════════════════════════
167
+ # 2. REPARADOR DE RESPUESTAS CORTADAS
168
+ # Agrega burbujas de cierre seguras sin inventar contenido.
169
+ # ════════════════════════════════════════════════════════════════════════════════
170
+
171
+ _SAFE_CLOSINGS_DEMO = [
172
+ "cuéntame cómo quieres probarlo",
173
+ "si quieres, seguimos desde ahí",
174
+ "qué quieres revisar primero",
175
+ "escríbeme como si fueras un cliente y sigo",
176
+ "si quieres, te muestro cómo sonaría en el chat",
177
+ ]
178
+
179
+ _SAFE_CLOSINGS_PATIENT = [
180
+ "si quieres, sigo desde ahí",
181
+ "cuéntame qué quieres revisar",
182
+ "qué te gustaría ver primero",
183
+ "si quieres, te ubico por ahí",
184
+ ]
185
+
186
+ import random as _random
187
+
188
+
189
+ _SEVERE_FRAGMENT_PATTERNS = (
190
+ r"^hola!?(\s+soy)?$",
191
+ r"^hola!?(\s+sea)?$",
192
+ r"^soy$",
193
+ r"^soy\s+\w+$",
194
+ r"^ok$",
195
+ r"^claro$",
196
+ )
197
+
198
+
199
+ def _is_severe_fragment(text: str) -> bool:
200
+ current = (text or "").strip()
201
+ if not current:
202
+ return True
203
+ normalized = _normalize_conv_text(current)
204
+ if not normalized:
205
+ return True
206
+ if len(normalized.split()) <= 4:
207
+ for pattern in _SEVERE_FRAGMENT_PATTERNS:
208
+ if re.match(pattern, normalized):
209
+ return True
210
+ return False
211
+
212
+
213
+ def _severe_fragment_rescue(context: str = "demo", business_name: str = "") -> str:
214
+ if context == "demo":
215
+ if business_name:
216
+ return (
217
+ f"ya me ubiqué con {business_name}"
218
+ " ||| escríbeme como si fueras un cliente y te respondo"
219
+ )
220
+ return (
221
+ "te lo resumo rápido"
222
+ " ||| dime el nombre de tu negocio y te muestro cómo funcionaría"
223
+ )
224
+ return "te sigo por aquí ||| cuéntame qué necesitas"
225
+
226
+ def repair_response(
227
+ response: str,
228
+ context: str = "demo",
229
+ business_name: str = "",
230
+ ) -> str:
231
+ """
232
+ Repara una respuesta cortada agregando una burbuja de cierre segura.
233
+
234
+ Args:
235
+ response: La respuesta potencialmente cortada.
236
+ context: "demo" (con dueño de negocio) o "patient" (con cliente final).
237
+ business_name: Nombre del negocio si está disponible.
238
+
239
+ Returns:
240
+ Respuesta reparada lista para enviar.
241
+ """
242
+ if not response or not response.strip():
243
+ if context == "demo":
244
+ return f"un momentico ||| ¿cómo se llama tu negocio?"
245
+ return "un momentico ||| cuéntame"
246
+
247
+ parts = _split_response_bubbles(response)
248
+
249
+ # Limpiar la última burbuja si está claramente cortada
250
+ if parts:
251
+ last = parts[-1]
252
+ has_dangling_word, _ = _has_dangling_last_token(last)
253
+ if has_dangling_word:
254
+ parts = parts[:-1]
255
+ log.info(f"[send_guard] burbuja cortada removida: '{last[:40]}'")
256
+
257
+ # Si no quedaron burbujas, recuperar con fallback
258
+ if not parts:
259
+ if context == "demo":
260
+ return f"cuéntame, ¿cómo se llama tu negocio?"
261
+ return "cuéntame"
262
+
263
+ # Si ya quedó una respuesta utilizable, no le inyectamos copy genérico.
264
+ # Solo cerramos cuando el texto quedó claramente cojo y demasiado corto.
265
+ last = parts[-1]
266
+ has_close = '?' in last or any(
267
+ inv in last.lower() for inv in [
268
+ "cuéntame", "cuentame", "dime", "escríbeme", "escribeme",
269
+ "dale", "listo", "cuál es", "cual es", "seguimos",
270
+ ]
271
+ )
272
+
273
+ looks_too_thin = len(last.strip()) < 18 or len(parts) == 0
274
+
275
+ if _is_severe_fragment(last):
276
+ if len(parts) > 1:
277
+ parts = parts[:-1]
278
+ log.info(f"[send_guard] burbuja final corta removida pero se conservan las previas ({len(parts)})")
279
+ # Verificar si la nueva burbuja de cierre necesita un cierre seguro
280
+ last = parts[-1]
281
+ has_close = '?' in last or any(
282
+ inv in last.lower() for inv in [
283
+ "cuéntame", "cuentame", "dime", "escríbeme", "escribeme",
284
+ "dale", "listo", "cuál es", "cual es", "seguimos",
285
+ ]
286
+ )
287
+ looks_too_thin = len(last.strip()) < 18 or len(parts) == 0
288
+ if not has_close and looks_too_thin:
289
+ if context == "demo":
290
+ closing = _random.choice(_SAFE_CLOSINGS_DEMO)
291
+ else:
292
+ closing = _random.choice(_SAFE_CLOSINGS_PATIENT)
293
+ parts.append(closing)
294
+ return " ||| ".join(parts)
295
+ else:
296
+ log.info(f"[send_guard] fragmento severo en respuesta única → se conserva intacto para confiar en el LLM: '{response}'")
297
+ return response
298
+
299
+ if not has_close and looks_too_thin:
300
+ if context == "demo":
301
+ closing = _random.choice(_SAFE_CLOSINGS_DEMO)
302
+ else:
303
+ closing = _random.choice(_SAFE_CLOSINGS_PATIENT)
304
+ parts.append(closing)
305
+ log.info(f"[send_guard] invitación agregada: '{closing}'")
306
+
307
+ return " ||| ".join(parts)
308
+
309
+
310
+ def guard_response(
311
+ response: str,
312
+ context: str = "demo",
313
+ business_name: str = "",
314
+ ) -> str:
315
+ """
316
+ Punto de entrada principal del guard.
317
+ Verifica si la respuesta está cortada y la repara si es necesario.
318
+
319
+ Usar antes de llamar a _send():
320
+ r = guard_response(r, context="demo", business_name=business_name)
321
+ return _send(r)
322
+ """
323
+ cut, reason = is_cut_response(response)
324
+ if cut:
325
+ log.warning(f"[send_guard] respuesta cortada ({reason}): '{(response or '')[:60]}'")
326
+ repaired = repair_response(response, context=context, business_name=business_name)
327
+ log.info(f"[send_guard] reparada → '{repaired[:80]}'")
328
+ return repaired
329
+ return response
330
+
331
+
332
+ def check_message(
333
+ message: str,
334
+ context: str = "patient",
335
+ business_name: str = "",
336
+ ) -> str:
337
+ """
338
+ Wrapper liviano para validaciones e integraciones externas.
339
+ Retorna el mensaje limpio y falla si quedó vacío.
340
+ """
341
+ cleaned = guard_response(message, context=context, business_name=business_name).strip()
342
+ if not cleaned:
343
+ raise ValueError("mensaje vacío después del guard")
344
+ return cleaned
345
+
346
+
347
+ # ════════════════════════════════════════════════════════════════════════════════
348
+ # 3. DETECTOR DE CAMBIOS DE PERSONALIDAD EN TEXTO LIBRE
349
+ # "cambia personalidad a amigable" → respuesta explícita y apropiada
350
+ # ════════════════════════════════════════════════════════════════════════════════
351
+
352
+ DEMO_PERSONALITY_COMMANDS = {
353
+ "amigable": "listo, modo amigable ||| escríbeme como si fueras un cliente y lo notas",
354
+ "formal": "listo, activé modo formal ||| cuéntame qué quieres revisar",
355
+ "luxury": "listo, modo premium activado ||| en qué le puedo asistir",
356
+ "directa": "listo, al grano ||| qué necesitas",
357
+ "energica": "listo, energía al máximo ||| qué andas buscando",
358
+ "empatica": "listo, modo escucha ||| cuéntame",
359
+ "experta": "listo, modo técnico ||| en qué le puedo ayudar",
360
+ "juvenil": "dale, modo casual ||| qué buscas",
361
+ "profesional": "listo, modo profesional ||| cuéntame qué quieres revisar",
362
+ }
363
+
364
+ _PERSONALITY_CHANGE_SIGNALS = [
365
+ r"cambia(?:r)?\s+(?:la\s+)?personalidad\s+(?:a\s+)?(\w+)",
366
+ r"mode\s+(\w+)",
367
+ r"activa(?:r)?\s+(?:modo\s+)?(\w+)",
368
+ r"pon(?:(?:me|te|lo)\s+)?(?:en\s+)?modo\s+(\w+)",
369
+ r"sé\s+más\s+(\w+)",
370
+ r"se\s+mas\s+(\w+)",
371
+ r"quiero\s+(?:que\s+seas\s+más\s+)?(\w+)",
372
+ ]
373
+
374
+
375
+ def detect_personality_change(user_msg: str) -> Optional[str]:
376
+ """
377
+ Detecta si el usuario está pidiendo cambiar la personalidad de Conny.
378
+ Retorna el nombre del arquetipo si se detecta, None si no.
379
+ """
380
+ msg_low = (user_msg or "").lower().strip()
381
+
382
+ for pattern in _PERSONALITY_CHANGE_SIGNALS:
383
+ m = re.search(pattern, msg_low)
384
+ if m:
385
+ requested = m.group(1).strip()
386
+ # Buscar coincidencia exacta o parcial con arquetipos conocidos
387
+ if requested in DEMO_PERSONALITY_COMMANDS:
388
+ return requested
389
+ # Fuzzy match básico
390
+ for arch in DEMO_PERSONALITY_COMMANDS:
391
+ if requested in arch or arch in requested:
392
+ return arch
393
+ return None
394
+
395
+
396
+ def get_personality_change_response(archetype: str, business_name: str = "") -> str:
397
+ """
398
+ Retorna la respuesta de confirmación del cambio de personalidad.
399
+ """
400
+ base = DEMO_PERSONALITY_COMMANDS.get(archetype, f"listo, modo {archetype} activado")
401
+ return base
402
+
403
+
404
+ # ════════════════════════════════════════════════════════════════════════════════
405
+ # 4. WRAPPER DEL DEMO _send
406
+ # Parchea _send en el contexto del demo para interceptar respuestas antes
407
+ # de que lleguen al cliente.
408
+ # ════════════════════════════════════════════════════════════════════════════════
409
+
410
+ def patch_demo_send(
411
+ original_send: Callable[[str], Any],
412
+ business_name: str = "",
413
+ context: str = "demo",
414
+ ) -> Callable[[str], Any]:
415
+ """
416
+ Retorna un wrapper de _send que aplica:
417
+ 1. guard_response (fix de cortes)
418
+ 2. fix_creator_in_response (Black One, no BlackBoss)
419
+
420
+ Usar en _handle_demo_message así:
421
+ def _send(r): ... # definición original
422
+
423
+ # Aplicar el guard
424
+ if _SEND_GUARD:
425
+ _send = patch_demo_send(_send, business_name=business_name)
426
+
427
+ # Ahora todos los return _send(r) pasan por el guard automáticamente
428
+ """
429
+ try:
430
+ from src.domain.prompts.prospect_pitch import fix_creator_in_response
431
+ _has_pitch_upgrade = True
432
+ except ImportError:
433
+ _has_pitch_upgrade = False
434
+ def fix_creator_in_response(r): return r # type: ignore
435
+
436
+ def guarded_send(r: str) -> Any:
437
+ # 1. Fix Black One / BlackBoss
438
+ if _has_pitch_upgrade:
439
+ r = fix_creator_in_response(r)
440
+
441
+ # 2. Detect and repair cuts
442
+ r = guard_response(r, context=context, business_name=business_name)
443
+
444
+ return original_send(r)
445
+
446
+ return guarded_send
447
+
448
+
449
+ # ════════════════════════════════════════════════════════════════════════════════
450
+ # 5. SMART HANDOFF PROACTIVO — señales en el flujo demo
451
+ # Para usar ANTES de llamar al LLM — si detecta que el prospecto necesita
452
+ # un humano, no gasta tokens en generar una respuesta que no sirve.
453
+ # ════════════════════════════════════════════════════════════════════════════════
454
+
455
+ _IMMEDIATE_HANDOFF_SIGNALS = [
456
+ # El prospecto quiere hablar con Santiago / con un humano directamente
457
+ "hablar con santiago", "hablar con alguien", "necesito hablar con",
458
+ "dame el número", "dame un número", "cuál es el número",
459
+ "quiero llamar", "me pueden llamar", "puedo llamar",
460
+ "me pueden contactar", "pueden contactarme",
461
+ "quiero una reunión", "quiero una llamada", "agendar una llamada",
462
+ "quiero contratar", "cómo contrato", "como contrato",
463
+ "quiero empezar", "cómo empiezo", "como empiezo",
464
+ "cuándo empezamos", "cuando empezamos",
465
+ # Despedida con interés (se va pero quiere seguimiento)
466
+ "gracias me comunico", "gracias los llamo", "gracias les escribo",
467
+ "gracias más tarde", "gracias después", "gracias luego",
468
+ ]
469
+
470
+ _COOLDOWN_HANDOFF_SIGNALS = [
471
+ # Se va frustrado — handoff para salvar la conversación
472
+ "gracias me voy", "hasta luego", "adiós", "adios", "chao", "bye",
473
+ "no era lo que buscaba", "no me interesa", "gracias no",
474
+ "me equivoqué", "me equivoque", "número equivocado",
475
+ ]
476
+
477
+
478
+ def check_proactive_handoff(user_msg: str, history: List[Dict[str, Any]]) -> Optional[Dict[str, str]]:
479
+ """
480
+ Verifica si se debe escalar a humano ANTES de llamar al LLM.
481
+
482
+ Retorna un dict con:
483
+ {"reason": "...", "urgency": "high" | "medium", "suggested_reply": "..."}
484
+ O None si no aplica.
485
+
486
+ Usar en _handle_demo_message:
487
+ handoff_check = check_proactive_handoff(text, history)
488
+ if handoff_check and _SMART_HANDOFF and handoff_manager:
489
+ return await handoff_manager.trigger_handoff(...)
490
+ """
491
+ msg_low = (user_msg or "").lower().strip()
492
+
493
+ for signal in _IMMEDIATE_HANDOFF_SIGNALS:
494
+ if signal in msg_low:
495
+ return {
496
+ "reason": f"prospecto solicita contacto directo: '{signal}'",
497
+ "urgency": "high",
498
+ "suggested_reply": (
499
+ "claro, te paso con Santiago directamente ||| "
500
+ "su contacto es 3124348669 — él te da la propuesta según tu negocio"
501
+ ),
502
+ }
503
+
504
+ for signal in _COOLDOWN_HANDOFF_SIGNALS:
505
+ if signal in msg_low:
506
+ # Solo escalar si hay historial (no en primera interacción)
507
+ if len(history) >= 4:
508
+ return {
509
+ "reason": f"prospecto se va: '{signal}'",
510
+ "urgency": "medium",
511
+ "suggested_reply": (
512
+ "entendido, sin problema ||| "
513
+ "si en algún momento quieres verme en acción, "
514
+ "el contacto de Black One es 3124348669"
515
+ ),
516
+ }
517
+
518
+ return None
519
+
520
+
521
+ # ════════════════════════════════════════════════════════════════════════════════
522
+ # 6. CLASE PRINCIPAL — SendGuard
523
+ # Encapsula todo el pipeline de guardería en un objeto reutilizable.
524
+ # ════════════════════════════════════════════════════════════════════════════════
525
+
526
+ class SendGuard:
527
+ """
528
+ Guardia completa del pipeline de envío.
529
+
530
+ Uso típico:
531
+ guard = SendGuard(context="demo", business_name=business_name)
532
+
533
+ # Antes de llamar al LLM:
534
+ handoff = guard.check_handoff(text, history)
535
+ if handoff:
536
+ # ... triggear smart handoff
537
+ pass
538
+
539
+ # También detectar cambio de personalidad:
540
+ arch = guard.detect_personality_change(text)
541
+ if arch:
542
+ response = guard.personality_response(arch)
543
+ return _send(response)
544
+
545
+ # Después de generar respuesta LLM:
546
+ clean_response = guard.clean(llm_response)
547
+ return _send(clean_response)
548
+ """
549
+
550
+ def __init__(self, context: str = "demo", business_name: str = ""):
551
+ self.context = context
552
+ self.business_name = business_name
553
+ self._pitch_fix_available = False
554
+ try:
555
+ from src.domain.prompts.prospect_pitch import fix_creator_in_response
556
+ self._fix_creator = fix_creator_in_response
557
+ self._pitch_fix_available = True
558
+ except ImportError:
559
+ self._fix_creator = lambda r: r
560
+
561
+ def check_handoff(
562
+ self, user_msg: str, history: List[Dict[str, Any]]
563
+ ) -> Optional[Dict[str, str]]:
564
+ """Verifica si se debe escalar antes del LLM."""
565
+ return check_proactive_handoff(user_msg, history)
566
+
567
+ def detect_personality_change(self, user_msg: str) -> Optional[str]:
568
+ """Detecta si el usuario pide cambiar la personalidad."""
569
+ return detect_personality_change(user_msg)
570
+
571
+ def personality_response(self, archetype: str) -> str:
572
+ """Retorna la respuesta de confirmación del cambio de personalidad."""
573
+ return get_personality_change_response(archetype, self.business_name)
574
+
575
+ def clean(self, response: str) -> str:
576
+ """Limpia y repara una respuesta antes de enviarla."""
577
+ r = self._fix_creator(response)
578
+ r = guard_response(r, context=self.context, business_name=self.business_name)
579
+ return r
580
+
581
+ def wrap_send(self, send_fn: Callable) -> Callable:
582
+ """Retorna send_fn envuelta con el guard."""
583
+ return patch_demo_send(send_fn, business_name=self.business_name, context=self.context)
584
+
585
+
586
+ # ════════════════════════════════════════════════════════════════════════════════
587
+ # 7. INTEGRACIÓN COMPLETA — snippet listo para pegar
588
+ # ════════════════════════════════════════════════════════════════════════════════
589
+
590
+ INTEGRATION_SNIPPET = '''
591
+ # ═══════════════════════════════════════════════════════════════════════
592
+ # CONNY_SEND_GUARD — Pegar al inicio de _handle_demo_message
593
+ # ═══════════════════════════════════════════════════════════════════════
594
+ try:
595
+ from src.domain.send_guard import SendGuard
596
+ from src.domain.prompts.prospect_pitch import is_prospect_confused, build_prospect_pitch_system_prompt
597
+ _guard = SendGuard(context="demo", business_name=business_name)
598
+ _GUARD_ACTIVE = True
599
+ except ImportError:
600
+ _GUARD_ACTIVE = False
601
+ _guard = None
602
+
603
+ # ── 1. Antes del bloque de comandos: check handoff proactivo ─────────
604
+ if _GUARD_ACTIVE and _guard:
605
+ _handoff_check = _guard.check_handoff(text, history)
606
+ if _handoff_check and _SMART_HANDOFF and handoff_manager:
607
+ _save("user", text)
608
+ _save("assistant", _handoff_check["suggested_reply"])
609
+ return _send(_handoff_check["suggested_reply"])
610
+
611
+ # ── 2. Cambio de personalidad en texto libre (antes del LLM) ────────
612
+ if _GUARD_ACTIVE and _guard:
613
+ _arch = _guard.detect_personality_change(text)
614
+ if _arch:
615
+ _pers_resp = _guard.personality_response(_arch)
616
+ _save("user", text)
617
+ return _send(_pers_resp)
618
+
619
+ # ── 3. Pitch inteligente si es prospecto confundido ──────────────────
620
+ if _GUARD_ACTIVE:
621
+ try:
622
+ if is_prospect_confused(text, history):
623
+ system_prompt = build_prospect_pitch_system_prompt(business_name)
624
+ # system_prompt listo para usar en lugar del genérico
625
+ except Exception:
626
+ pass
627
+
628
+ # ── 4. Después de definir _send, envolver con el guard ───────────────
629
+ if _GUARD_ACTIVE and _guard:
630
+ _send = _guard.wrap_send(_send)
631
+
632
+ # ═══════════════════════════════════════════════════════════════════════
633
+ # FIN DE LA INTEGRACIÓN — el resto del código de _handle_demo_message
634
+ # queda igual. Todo return _send(r) pasa ahora por el guard.
635
+ # ═══════════════════════════════════════════════════════════════════════
636
+ '''