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