@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,230 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import logging
4
+ import re
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+ from conny_core.first_turn_ops import _normalize_conv_text
7
+
8
+ log = logging.getLogger("conny.onboarding_flow")
9
+
10
+ _business_confirmation_signals = (
11
+ "sí ese es", "si ese es", "ese sí", "ese si", "ese mismo", "correcto ese",
12
+ "sí, ese", "si, ese", "exacto", "sí es ese", "si es ese",
13
+ "sí", "si", "sip", "claro", "correcto", "ese", "eso", "yes", "yep",
14
+ "thats us", "that's us", "that is us", "yes thats us", "yes that's us",
15
+ "yes thats right", "yes that's right", "thats right", "that's right",
16
+ "ajá", "aja", "dale", "listo", "así es", "asi es", "es ese", "ese es", "exactamente",
17
+ "sí señor", "si señor", "siii", "siiii", "siiiii", "sii",
18
+ "somos nosotros", "somos esos", "somos ese", "ese somos", "eso somos",
19
+ "somos esa", "esa somos", "si somos nosotros", "sí somos nosotros",
20
+ "es de nosotros", "ese es nuestro", "es nuestro", "eso es nuestro",
21
+ "eso somos nosotros", "esos somos", "ese sí somos", "ese si somos",
22
+ "claro que sí somos", "claro que si somos", "somos el negocio", "somos esa clínica",
23
+ )
24
+
25
+ def looks_like_business_confirmation(raw_text: str) -> bool:
26
+ normalized = _normalize_conv_text(raw_text or "")
27
+ if not normalized:
28
+ return False
29
+ return any(
30
+ normalized == signal or (len(signal) > 6 and signal in normalized)
31
+ for signal in _business_confirmation_signals
32
+ )
33
+
34
+ def owner_confusion_or_language_signal(raw_text: str) -> bool:
35
+ normalized = _normalize_conv_text(raw_text or "")
36
+ if not normalized:
37
+ return False
38
+ signals = (
39
+ "just english sorry", "sorry just english", "english sorry",
40
+ "english only", "speak english", "speak in english", "only english",
41
+ "i dont speak spanish", "i don t speak spanish",
42
+ "i dont talk spanish", "i don t talk spanish",
43
+ "what is this", "sorry what is this",
44
+ "i dont understand", "i don t understand",
45
+ "what did you say", "what did u say",
46
+ "thats not my business", "that s not my business",
47
+ "that is not my business", "not my business",
48
+ "thats not us", "that s not us", "that is not us", "wrong business",
49
+ "wrong company", "wrong one", "not the right one",
50
+ "no hablo español", "no hablo espanol", "solo ingles", "solo inglés",
51
+ )
52
+ return any(signal in normalized for signal in signals)
53
+
54
+ def looks_like_business_name_candidate_legacy(raw_text: str) -> bool:
55
+ candidate = (raw_text or "").strip()
56
+ if not candidate:
57
+ return False
58
+ normalized = _normalize_conv_text(candidate)
59
+ if len(candidate) > 90:
60
+ return False
61
+ if owner_confusion_or_language_signal(candidate):
62
+ return False
63
+ owner_name_false_positives = (
64
+ "mi nombre es",
65
+ "me llamo",
66
+ "my name is",
67
+ "i am ",
68
+ "i'm ",
69
+ "im ",
70
+ )
71
+ explicit_business_markers = (
72
+ "el nombre de mi negocio se llama",
73
+ "el nombre de nuestro negocio se llama",
74
+ "el nombre de mi empresa se llama",
75
+ "el nombre de nuestra empresa se llama",
76
+ "el nombre de mi negocio es",
77
+ "el nombre del negocio es",
78
+ "el nombre de mi empresa es",
79
+ "el nombre de la empresa es",
80
+ "mi negocio se llama",
81
+ "nuestro negocio se llama",
82
+ "mi empresa se llama",
83
+ "nuestra empresa se llama",
84
+ "la clinica se llama",
85
+ "la clínica se llama",
86
+ "se llama ",
87
+ "negocio es ",
88
+ "empresa es ",
89
+ )
90
+ if (
91
+ any(marker in normalized for marker in owner_name_false_positives)
92
+ and not any(marker in normalized for marker in explicit_business_markers)
93
+ ):
94
+ return False
95
+ conversational_false_positives = (
96
+ "somos nosotros", "somos nosotras", "somos ese", "somos esa",
97
+ "si somos", "sí somos", "siii somos", "siiii somos",
98
+ "that is us", "that's us", "thats us", "yes thats us", "yes that's us",
99
+ "this is us", "we are that", "we are them",
100
+ )
101
+ if any(marker in normalized for marker in conversational_false_positives):
102
+ return False
103
+ if any(marker in normalized for marker in explicit_business_markers):
104
+ return True
105
+
106
+ if any(
107
+ marker in normalized
108
+ for marker in (
109
+ "como estas", "cómo estás", "quien eres", "quién eres", "que eres", "qué eres",
110
+ "que haces", "qué haces", "que harias", "qué harías", "como funcionas", "cómo funcionas", "aceptas audios",
111
+ "aceptas pdf", "para que", "para qué", "quien te hizo", "quién te hizo",
112
+ "me mandaron tu numero", "me mandaron tu número", "quiero una demo", "quiero demo",
113
+ "quiero probarte", "tengo un negocio", "tengo una empresa", "hola", "buenas", "?",
114
+ "what is this", "sorry what is this", "what do you do", "who are you",
115
+ "i dont understand", "i don t understand", "english only", "just english sorry",
116
+ "i dont talk spanish", "i don t talk spanish", "i dont speak spanish", "i don t speak spanish",
117
+ )
118
+ ):
119
+ return False
120
+ if looks_like_business_confirmation(candidate):
121
+ return False
122
+
123
+ words = re.findall(r"[A-Za-zÁÉÍÓÚáéíóúÑñ0-9&.'-]+", candidate)
124
+ if not (1 <= len(words) <= 8):
125
+ return False
126
+ if any(ch.isupper() for ch in candidate):
127
+ upper_tokens = [word.lower() for word in words if len(word) >= 2]
128
+ blocked_upper_tokens = {
129
+ "sorry", "spanish", "what", "this", "that",
130
+ "dont", "don't", "understand", "talk", "speak", "only", "hello",
131
+ "hi", "hola", "business", "not", "my",
132
+ }
133
+ if any(token in blocked_upper_tokens for token in upper_tokens):
134
+ return False
135
+ return True
136
+ business_tokens = (
137
+ "clinica", "clínica", "clinic", "spa", "dental", "salud", "centro",
138
+ "consultorio", "estetica", "estética", "studio", "group", "lab",
139
+ "restaurante", "hotel", "tienda", "academia", "gym", "gimnasio",
140
+ )
141
+ if any(token in normalized for token in business_tokens):
142
+ return True
143
+ # Marcas de 1-3 palabras sin jerga conversacional también pueden ser válidas.
144
+ if 1 <= len(words) <= 3 and all(len(word) >= 3 for word in words):
145
+ stop_tokens = {
146
+ "sorry", "spanish", "hello", "what", "this", "that",
147
+ "understand", "business", "please", "talk", "speak", "only",
148
+ "dont", "not", "sorry",
149
+ }
150
+ if not any(word.lower() in stop_tokens for word in words):
151
+ return True
152
+ return False
153
+
154
+ async def llm_classify_business_name(raw_text: str, engine: Any) -> Tuple[bool, Optional[str]]:
155
+ candidate = (raw_text or "").strip()
156
+ if not candidate:
157
+ return False, None
158
+
159
+ if len(candidate) < 2 or len(candidate) > 100:
160
+ return False, None
161
+
162
+ normalized = _normalize_conv_text(candidate)
163
+ if normalized in {
164
+ "hola", "holaa", "holaaa", "holaaaa", "buenas", "buenasas", "buenasas", "buenasas",
165
+ "hey", "ey", "hi", "hello", "reset", "reiniciar", "menu", "menú"
166
+ }:
167
+ return False, None
168
+
169
+ sys_prompt = """Eres un clasificador y extractor de nombres de negocio de alta precisión para un chatbot de WhatsApp en modo demo.
170
+ Analiza el mensaje del usuario y determina si está respondiendo a la pregunta de cómo se llama su negocio proporcionando el nombre de una clínica, empresa, marca, local o tienda para la demostración.
171
+
172
+ REGLAS DE CLASIFICACIÓN:
173
+ 1. El mensaje debe contener el nombre de un negocio o marca de manera evidente (ej. "Nova", "Clinica de la Costa", "mi negocio es Spa Luna").
174
+ 2. Conversaciones casuales, saludos, preguntas sobre el funcionamiento del bot ("¿qué me quieres mostrar?", "¿de qué se trata?", "me mandaron tu número", "¿quién eres?"), respuestas afirmativas/negativas generales ("sí", "no", "ok", "claro"), o nombres de personas solos ("Santiago") NO son nombres de negocio.
175
+ 3. Si el mensaje es una frase donde presenta el negocio (ej. "se llama Peludos"), clasifica como negocio y extrae solo la marca limpia ("Peludos").
176
+
177
+ EJEMPLOS DE POCAS TOMAS (FEW-SHOT):
178
+ - Mensaje: "Nova"
179
+ Respuesta: {"es_negocio": true, "nombre": "Nova"}
180
+ - Mensaje: "mi clínica se llama Clínica Dental Americana"
181
+ Respuesta: {"es_negocio": true, "nombre": "Clínica Dental Americana"}
182
+ - Mensaje: "de qué se trata esto?"
183
+ Respuesta: {"es_negocio": false, "nombre": null}
184
+ - Mensaje: "no quiero darte el nombre"
185
+ Respuesta: {"es_negocio": false, "nombre": null}
186
+ - Mensaje: "Spa Luna, hacemos tratamientos faciales"
187
+ Respuesta: {"es_negocio": true, "nombre": "Spa Luna"}
188
+ - Mensaje: "Petlandia"
189
+ Respuesta: {"es_negocio": true, "nombre": "Petlandia"}
190
+ - Mensaje: "Carlos"
191
+ Respuesta: {"es_negocio": false, "nombre": null}
192
+ - Mensaje: "hola buenas"
193
+ Respuesta: {"es_negocio": false, "nombre": null}
194
+
195
+ Responde ÚNICAMENTE con un JSON válido:
196
+ {
197
+ "es_negocio": true o false,
198
+ "nombre": "nombre del negocio limpio" (o null si es_negocio es false)
199
+ }"""
200
+
201
+ try:
202
+ if not engine:
203
+ raise RuntimeError("LLM no init")
204
+ msgs = [
205
+ {"role": "system", "content": sys_prompt},
206
+ {"role": "user", "content": f"Mensaje del usuario: {raw_text}"}
207
+ ]
208
+ r, meta = await engine.complete(
209
+ msgs,
210
+ model_tier="fast",
211
+ temperature=0.0,
212
+ max_tokens=512,
213
+ use_cache=False,
214
+ )
215
+ log.info(f"[onboarding] classify business name LLM using {meta.get('provider','?')} model={meta.get('model','?')[:30]}")
216
+ if not r:
217
+ return looks_like_business_name_candidate_legacy(raw_text), None
218
+
219
+ clean_r = r.strip()
220
+ start_idx = clean_r.find("{")
221
+ end_idx = clean_r.rfind("}")
222
+ if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
223
+ clean_r = clean_r[start_idx:end_idx+1]
224
+
225
+ data = json.loads(clean_r)
226
+ return bool(data.get("es_negocio", False)), data.get("nombre")
227
+ except Exception as e:
228
+ log.error(f"[onboarding] Business name classification LLM failed: {e}")
229
+ legacy_res = looks_like_business_name_candidate_legacy(raw_text)
230
+ return legacy_res, None
@@ -0,0 +1 @@
1
+ # src/domain/prompts package
@@ -0,0 +1,282 @@
1
+ """
2
+ src/domain/prompts/prospect_pitch.py
3
+ ════════════════════════════════════════════════════════════════════════════════
4
+ PITCH INTELIGENTE — Black One / Conny v1.0
5
+ ════════════════════════════════════════════════════════════════════════════════
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ log = logging.getLogger("conny.pitch_upgrade")
15
+
16
+ # ════════════════════════════════════════════════════════════════════════════════
17
+ # 1. IDENTIDAD CORRECTA
18
+ # ════════════════════════════════════════════════════════════════════════════════
19
+
20
+ CREATOR_NAME = "Black One"
21
+ CREATOR_DESC = "empresa de software y gobernanza de agentes de IA"
22
+ CREATOR_HUMAN = "Santiago Rubio"
23
+ CREATOR_TEL = "3124348669"
24
+
25
+ CREATOR_LINE = (
26
+ f"me creó {CREATOR_NAME}, una {CREATOR_DESC}"
27
+ f" ||| la fundó {CREATOR_HUMAN} — si quieres esto para tu negocio, escríbele al {CREATOR_TEL}"
28
+ )
29
+
30
+ # ════════════════════════════════════════════════════════════════════════════════
31
+ # 2. DETECCIÓN DE MODO PROSPECTO
32
+ # ════════════════════════════════════════════════════════════════════════════════
33
+
34
+ _PROSPECT_SIGNALS = [
35
+ "me mandaron", "me enviaron", "me pasaron", "me dieron tu número",
36
+ "me dieron este número", "me recomendaron",
37
+ "qué haces", "que haces", "para qué sirves", "para que sirves",
38
+ "qué eres", "que eres", "qué harías", "que harias", "qué harías por",
39
+ "que harias por", "que harias en", "qué harías en",
40
+ "qué puedes hacer", "que puedes hacer",
41
+ "cuánto cuestas", "cuanto cuestas", "cuánto cobras", "cuanto cobras",
42
+ "cuánto vale", "cuanto vale", "cuál es tu precio", "cual es tu precio",
43
+ "cuánto me cobran", "cuanto me cobran", "planes", "tarifas",
44
+ "cómo funciona", "como funciona", "quiero ver", "quiero entender",
45
+ "explícame", "explicame", "no entiendo qué eres", "no entiendo que eres",
46
+ "no sé qué es esto", "no se que es esto",
47
+ "gracias pero", "no me interesa que actues",
48
+ "no me interesa que actúes", "que harias en mi clinica",
49
+ "qué harías en mi", "que harias en mi",
50
+ ]
51
+
52
+ _CONFUSION_LOOP_SIGNALS = [
53
+ "no entiendo", "no sé", "no se", "gracias", "me voy", "adios", "adiós",
54
+ "hasta luego", "no era lo que", "error", "equivocado", "equivocada",
55
+ ]
56
+
57
+
58
+ def _strip_accents(text: str) -> str:
59
+ """Normaliza vocales con tilde para comparación sin falsos negativos."""
60
+ t = str.maketrans("áéíóúÁÉÍÓÚ", "aeiouAEIOU")
61
+ return text.translate(t)
62
+
63
+ _PROSPECT_SIGNALS_NORM = [_strip_accents(s) for s in _PROSPECT_SIGNALS]
64
+ _CONFUSION_LOOP_SIGNALS_NORM = [_strip_accents(s) for s in _CONFUSION_LOOP_SIGNALS]
65
+
66
+
67
+ def is_prospect_confused(user_msg: str, history: List[Dict[str, Any]]) -> bool:
68
+ """
69
+ Retorna True si detecta que quien escribe es un prospecto (B2B)
70
+ evaluando si Conny sirve para su negocio — no un cliente final.
71
+ """
72
+ msg_low = _strip_accents((user_msg or "").lower().strip())
73
+
74
+ if any(sig in msg_low for sig in _PROSPECT_SIGNALS_NORM):
75
+ return True
76
+
77
+ if len(history) >= 6:
78
+ recent_client = [
79
+ m.get("content", "").lower()
80
+ for m in history[-6:]
81
+ if m.get("role") == "user"
82
+ ]
83
+ confusion_count = sum(
84
+ 1 for msg in recent_client
85
+ if any(sig in msg for sig in _CONFUSION_LOOP_SIGNALS)
86
+ )
87
+ if confusion_count >= 2:
88
+ return True
89
+
90
+ return False
91
+
92
+
93
+ # ════════════════════════════════════════════════════════════════════════════════
94
+ # 3. SYSTEM PROMPT DEL PITCH INTELIGENTE
95
+ # ════════════════════════════════════════════════════════════════════════════════
96
+
97
+ def build_prospect_pitch_system_prompt(business_name: str = "") -> str:
98
+ """
99
+ Genera el system prompt para cuando un prospecto B2B pregunta
100
+ qué hace Conny, cuánto cuesta, qué haría por su negocio, etc.
101
+ """
102
+ biz_ctx = f"El prospecto es de: {business_name}." if business_name else ""
103
+
104
+ return f"""Eres Conny, una recepcionista virtual creada por Black One — una empresa colombiana de software y gobernanza de agentes de IA.
105
+
106
+ {biz_ctx}
107
+
108
+ CONTEXTO DE ESTA CONVERSACIÓN:
109
+ Alguien recibió tu número porque quiere evaluar si Conny sirve para su negocio.
110
+ No es un paciente ni un cliente de una clínica — es un dueño de negocio, administrador o gerente.
111
+ Están en modo evaluación. Tu trabajo ahora es venderles la idea de tenerte a ti.
112
+
113
+ QUÉ HACE CONNY (habla de esto con naturalidad, no como lista):
114
+ - Responde el WhatsApp del negocio como si fuera parte del equipo
115
+ - Atiende pacientes o clientes: saluda, orienta, responde dudas, filtra interesados
116
+ - Agenda y confirma citas — hace seguimiento a quienes no confirmaron
117
+ - Maneja objeciones de precio, miedo o indecisión sin presionar
118
+ - Aprende cómo habla el negocio y se adapta a su tono y servicios
119
+ - Escala al humano (admin o asesor) cuando hay algo que Conny no puede resolver
120
+ - Funciona 24/7 sin descanso, sin perder el hilo, sin errores de tono
121
+
122
+ CÓMO COBRAR (si preguntan precio):
123
+ - No tienes un precio fijo en este momento — el precio lo da Black One según el plan
124
+ - Lo que sí puedes decir: "el costo varía según el volumen de chats y los servicios que necesiten"
125
+ - Invítalos a contactar a Santiago Rubio (3124348669) para una propuesta real
126
+ - NO inventes precios. Si preguntan rango, di que arranca desde lo básico y escala
127
+
128
+ CÓMO CERRAR:
129
+ - El objetivo es que agenden una llamada o demo con Santiago Rubio
130
+ - Si están interesados: "la forma de arrancar es hablar con Santiago, él te da una propuesta según tu negocio"
131
+ - Si dudan: muéstrate en acción — diles "escríbeme como si fueras un cliente a ver qué pasa"
132
+ - Si están confundidos: simplifica sin perder calma — "básicamente soy la que respondería el WhatsApp de tu negocio por ti"
133
+
134
+ IDENTIDAD Y CREADOR:
135
+ - Te creó Black One, empresa de software y gobernanza de agentes de IA
136
+ - Fue fundada por Santiago Rubio — contacto: 3124348669
137
+ - NUNCA digas Anthropic, Google, OpenAI ni ningún proveedor de IA como tu creador
138
+ - NUNCA digas "BlackBoss" — el nombre correcto es Black One
139
+
140
+ TONO Y FORMATO (CRÍTICO):
141
+ - Habla por burbujas separadas usando exactamente '|||'.
142
+ - Cada burbuja debe sentirse como un mensaje corto de chat de una persona real.
143
+ - Escribe como lo hacemos los humanos en WhatsApp: casi nunca usamos mayúscula al inicio del mensaje, no usamos punto final (.), y no usamos signos de interrogación o exclamación de apertura (¿ o ¡).
144
+ - No uses frases robóticas ni viñetas.
145
+ - NUNCA termines sin una pregunta o invitación clara al final.
146
+ - Si no sabes qué más decir, cierra con algo casual tipo: "quieres que te muestre cómo le respondería a un cliente tuyo?"
147
+
148
+ EJEMPLOS DE RESPUESTAS BUENAS:
149
+
150
+ Pregunta: "me mandaron tu número, qué haces?"
151
+ BUENO: "hola, soy conny — básicamente soy la que llevaría el whatsapp de tu negocio ||| respondo clientes, agendo citas, hago seguimiento y escalo cuando hay algo que necesita un humano ||| de qué tipo de negocio eres para mostrarte cómo quedaría?"
152
+
153
+ Pregunta: "qué harías en mi clínica?"
154
+ BUENO: "en una clínica me encargaría de recibir a los pacientes por whatsapp, resolver sus dudas, agendar citas y confirmarlas ||| también hago seguimiento a quienes quedaron pensándolo y manejo objeciones de precio sin presionar ||| quieres que te muestre cómo respondería a un paciente tuyo ahora mismo?"
155
+
156
+ Pregunta: "cuánto cuestas?"
157
+ BUENO: "el costo lo maneja black one según el plan que necesites — hay opciones desde lo básico hasta lo más completo ||| para una propuesta real hay que hablar con santiago: 3124348669 ||| mientras tanto, quieres verme en acción con un caso de tu negocio?"
158
+
159
+ Pregunta: "no entiendo qué eres"
160
+ BUENO: "te resumo: soy una recepcionista virtual — respondo el whatsapp de tu negocio como si llevara tiempo en tu equipo ||| me entrenás con info de tus servicios y yo me encargo del chat ||| qué tipo de negocio tienes para mostrarte cómo sería?"
161
+
162
+ PROHIBIDO:
163
+ - Responder como si fueras la recepcionista de una clínica específica cuando no te han dado esa info
164
+ - Preguntar "¿en qué puedo ayudarte hoy?" como si el prospecto fuera un paciente
165
+ - Cortar respuestas sin invitación al final
166
+ - Inventar precios
167
+ - Mencionar BlackBoss, Anthropic, OpenAI ni ningún LLM como tu creador
168
+ """
169
+
170
+
171
+ # ════════════════════════════════════════════════════════════════════════════════
172
+ # 4. TRIGGERS ADICIONALES PARA SMART HANDOFF
173
+ # ════════════════════════════════════════════════════════════════════════════════
174
+
175
+ SMART_HANDOFF_PROSPECT_TRIGGERS = [
176
+ "gracias, me voy",
177
+ "no entiendo nada",
178
+ "esto no es lo que busco",
179
+ "me confundiste",
180
+ "no era para esto",
181
+ "mejor llamo",
182
+ "prefiero hablar con una persona",
183
+ "quiero hablar con alguien",
184
+ "dame un número",
185
+ "necesito hablar con santiago",
186
+ "cuándo puedo llamar",
187
+ "qué número llamo",
188
+ ]
189
+
190
+ def should_trigger_handoff_for_prospect(user_msg: str) -> Optional[str]:
191
+ """
192
+ Retorna el motivo del handoff si el prospecto está a punto de irse
193
+ o pide hablar con una persona real. Retorna None si no aplica.
194
+ """
195
+ msg_low = (user_msg or "").lower().strip()
196
+ for trigger in SMART_HANDOFF_PROSPECT_TRIGGERS:
197
+ if trigger in msg_low:
198
+ return f"prospecto solicitó contacto humano: '{trigger}'"
199
+ return None
200
+
201
+
202
+ # ════════════════════════════════════════════════════════════════════════════════
203
+ # 5. BRAND IDENTITY BRANDING FIXES
204
+ # ════════════════════════════════════════════════════════════════════════════════
205
+
206
+ _BLACKBOSS_VARIANTS = [
207
+ "BlackBoss", "blackboss", "Black Boss", "black boss", "Blackboss",
208
+ ]
209
+
210
+ def fix_creator_in_response(response: str) -> str:
211
+ """
212
+ Postprocesa cualquier respuesta del LLM y reemplaza menciones
213
+ incorrectas de BlackBoss por Black One.
214
+ """
215
+ if not response:
216
+ return response
217
+ for variant in _BLACKBOSS_VARIANTS:
218
+ response = response.replace(variant, CREATOR_NAME)
219
+ return response
220
+
221
+
222
+ # ════════════════════════════════════════════════════════════════════════════════
223
+ # 6. VALIDACIÓN Y REPARACIÓN DE BURBUJAS
224
+ # ════════════════════════════════════════════════════════════════════════════════
225
+
226
+ def validate_bubbles(response: str, min_bubbles: int = 2) -> bool:
227
+ """
228
+ Retorna True si la respuesta tiene el mínimo de burbujas y ninguna
229
+ está cortada (termina en palabra incompleta o sin puntuación mínima).
230
+ """
231
+ if not response:
232
+ return False
233
+
234
+ parts = [p.strip() for p in re.split(r"\s*\|\|\|\s*", response) if p.strip()]
235
+
236
+ if len(parts) < min_bubbles:
237
+ return False
238
+
239
+ last = parts[-1]
240
+ ends_open = re.search(r"\b(el|la|los|las|un|una|y|o|de|que|en|con|por|si|me|te|le|se|su)\s*$", last, re.IGNORECASE)
241
+ if ends_open:
242
+ return False
243
+
244
+ has_invitation = "?" in last or any(
245
+ inv in last.lower() for inv in [
246
+ "cuéntame", "cuentame", "dime", "escríbeme", "escribeme",
247
+ "cuál es", "cual es", "cómo se llama", "como se llama",
248
+ "para arrancar", "para empezar", "qué quieres", "que quieres",
249
+ ]
250
+ )
251
+ if not has_invitation:
252
+ return False
253
+
254
+ return True
255
+
256
+
257
+ def repair_cut_response(response: str, fallback_invitation: str = "¿qué tipo de negocio tienes?") -> str:
258
+ """
259
+ Si la respuesta está cortada o le falta invitación,
260
+ agrega una burbuja de cierre segura.
261
+ """
262
+ if not response:
263
+ return f"un momentico ||| {fallback_invitation}"
264
+
265
+ parts = [p.strip() for p in re.split(r"\s*\|\|\|\s*", response) if p.strip()]
266
+
267
+ if not parts:
268
+ return f"un momentico ||| {fallback_invitation}"
269
+
270
+ last = parts[-1]
271
+ has_question = "?" in last
272
+ has_invitation = any(
273
+ inv in last.lower() for inv in [
274
+ "cuéntame", "cuentame", "dime", "escríbeme", "escribeme",
275
+ "cuál es", "cual es", "cómo se llama", "como se llama",
276
+ ]
277
+ )
278
+
279
+ if not has_question and not has_invitation:
280
+ parts.append(fallback_invitation)
281
+
282
+ return " ||| ".join(parts)