@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,3110 @@
1
+ """Demo conversation handler — the core demo experience logic."""
2
+
3
+ # TODO: This was a method of ConnyUltra — needs self references resolved
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ async def _handle_demo_message(self, chat_id: str, text: str,
8
+ clinic: Dict,
9
+ attachments: Optional[List[Dict[str, Any]]] = None) -> List[str]:
10
+ """
11
+ MODO DEMO v3 — Experiencia de intriga progresiva.
12
+ Conny NO se presenta como IA ni da un pitch.
13
+ Entra directo al personaje y revela capacidades una a una.
14
+ TTL: sesión independiente por persona (DEMO_SESSION_TTL segundos).
15
+ """
16
+ import base64 as _b64
17
+ import random as _r
18
+ attachments = attachments or []
19
+
20
+ # ── Extracción de texto de documentos adjuntos ───────────────────────
21
+ # Si el owner manda un PDF/doc sin caption, extraemos su texto y lo
22
+ # usamos como si hubiera sido escrito en el mensaje.
23
+ _doc_extracted_text = ""
24
+ _has_incoming_doc = False
25
+ for _att in attachments:
26
+ _att_kind = _att.get("kind", "")
27
+ _att_mime = _att.get("mime_type", "")
28
+ _is_doc = _att_kind == "document" or "pdf" in _att_mime or "text" in _att_mime or "word" in _att_mime or "docx" in _att_mime
29
+ if not _is_doc and _att_kind not in ("document",):
30
+ continue
31
+ _has_incoming_doc = True
32
+ # Intentar extraer texto del binario
33
+ try:
34
+ _raw = _att.get("bytes") or b""
35
+ if not _raw and _att.get("base64"):
36
+ _raw = _b64.b64decode(_att["base64"])
37
+ if not _raw and _att.get("file_id") and _att.get("platform") == "telegram":
38
+ _raw, _ = await self._download_telegram_binary(_att["file_id"])
39
+ if not _raw and _att.get("media_id") and _att.get("platform") == "whatsapp_cloud":
40
+ _raw, _, _ = await self._download_whatsapp_cloud_binary(_att["media_id"])
41
+ if _raw:
42
+ try:
43
+ import pdfplumber as _pp, io as _io
44
+ with _pp.open(_io.BytesIO(_raw)) as _pdf:
45
+ _pages = [p.extract_text() or "" for p in _pdf.pages[:6]]
46
+ _doc_extracted_text = "\n".join(filter(None, _pages))[:2000]
47
+ except Exception:
48
+ # Fallback: intentar leer como texto plano
49
+ try:
50
+ _doc_extracted_text = _raw.decode("utf-8", errors="ignore")[:2000]
51
+ except Exception:
52
+ pass
53
+ except Exception:
54
+ pass
55
+ break # Solo procesamos el primer documento
56
+
57
+ # Si llegó un doc, enriquecemos el texto con su contenido extraído
58
+ if _has_incoming_doc and not text.strip():
59
+ if _doc_extracted_text.strip():
60
+ text = f"[documento adjunto]\n{_doc_extracted_text.strip()}"
61
+ else:
62
+ text = "[documento adjunto]"
63
+ if not hasattr(self, "_emoji_chats_off"):
64
+ self._emoji_chats_off = set()
65
+
66
+ # ── Gestión de sesión via SessionManager ───────────────────────────────
67
+ if _SESSION_MANAGER_AVAILABLE and hasattr(self, "_session_mgr"):
68
+ is_new, keys_del = self._session_mgr.touch_and_cleanup(chat_id)
69
+ if is_new:
70
+ for k in keys_del:
71
+ del self._demo_sessions[k]
72
+ try:
73
+ with db._conn() as c:
74
+ c.execute("DELETE FROM conversations WHERE chat_id=?", (chat_id,))
75
+ except Exception: pass
76
+ sk = f"demo_{chat_id}"
77
+ else:
78
+ now = time.time()
79
+ ttl = Config.DEMO_SESSION_TTL
80
+ sk = f"demo_{chat_id}"
81
+ last_seen = self._demo_sessions.get(sk + "_ts", 0)
82
+ is_new = (now - last_seen) > ttl
83
+ self._demo_sessions[sk + "_ts"] = now
84
+ self._demo_sessions = {
85
+ k: v for k, v in self._demo_sessions.items()
86
+ if not k.endswith("_ts") or (now - v) < ttl * 2
87
+ }
88
+ if is_new:
89
+ keys_del = [k for k in list(self._demo_sessions) if k.startswith(sk+"_") and not k.endswith("_ts")]
90
+ for k in keys_del: del self._demo_sessions[k]
91
+ try:
92
+ with db._conn() as c:
93
+ c.execute("DELETE FROM conversations WHERE chat_id=?", (chat_id,))
94
+ except Exception: pass
95
+
96
+ history = db.get_history(chat_id) if db else []
97
+ now_dt = now_col()
98
+ moment = "mañana" if now_dt.hour < 12 else ("tarde" if now_dt.hour < 19 else "noche")
99
+
100
+ # Claves de sesión
101
+ bname_key = sk + "_name"
102
+ bctx_key = sk + "_ctx"
103
+ bfound_key = sk + "_found"
104
+ burl_key = sk + "_url" # URL del negocio encontrada en web
105
+ btrick_key = sk + "_trick"
106
+ bpersona_key= sk + "_persona"
107
+ btone_key = sk + "_tone" # tono detectado: SALUD PREMIUM | PREMIUM | SALUD | RETAIL | GENERAL
108
+ bmodel_key = sk + "_model" # proveedor LLM activo: auto|groq|gemini|openrouter
109
+ blang_key = sk + "_owner_lang" # idioma dominante del dueño en demo
110
+ blearn_key = sk + "_learn" # modo aprendizaje manual: cuántas preguntas llevamos
111
+ bsim_key = sk + "_sim_mode" # modo simulación cliente en el mismo chat del dueño
112
+
113
+ business_name = self._demo_sessions.get(bname_key, "")
114
+ business_ctx = self._demo_sessions.get(bctx_key, "")
115
+ found_online = self._demo_sessions.get(bfound_key, False)
116
+ persona = self._demo_sessions.get(bpersona_key, "amigable")
117
+ demo_model_pref= self._demo_sessions.get(bmodel_key, "auto") # auto|groq|gemini|openrouter
118
+ owner_lang = self._demo_sessions.get(blang_key, "es")
119
+ sim_mode_active = bool(self._demo_sessions.get(bsim_key, False))
120
+ llm_runtime_ready = self._llm_runtime_available()
121
+
122
+ def _detect_demo_owner_language(raw_text: str, current_lang: str = "es") -> str:
123
+ normalized = _normalize_conv_text(raw_text or "")
124
+ if not normalized:
125
+ return current_lang or "es"
126
+
127
+ explicit_en = (
128
+ "just english sorry",
129
+ "sorry just english",
130
+ "english sorry",
131
+ "english only",
132
+ "speak english",
133
+ "speak in english",
134
+ "i dont speak spanish",
135
+ "i don t speak spanish",
136
+ "i dont talk spanish",
137
+ "i don t talk spanish",
138
+ "no spanish",
139
+ "only english",
140
+ "what is this",
141
+ "sorry what is this",
142
+ "i dont understand",
143
+ "i don t understand",
144
+ "what did you say",
145
+ "what did u say",
146
+ "thats not my business",
147
+ "that s not my business",
148
+ "thats not us",
149
+ "that s not us",
150
+ "wrong business",
151
+ "wrong company",
152
+ )
153
+ explicit_pt = (
154
+ "só portugues",
155
+ "so portugues",
156
+ "falo portugues",
157
+ "nao falo espanhol",
158
+ "não falo espanhol",
159
+ )
160
+ if any(token in normalized for token in explicit_en):
161
+ return "en"
162
+ if any(token in normalized for token in explicit_pt):
163
+ return "pt"
164
+
165
+ try:
166
+ detected = multilingual_handler.detect(raw_text) if multilingual_handler else MultilingualHandler().detect(raw_text)
167
+ except Exception:
168
+ detected = "es"
169
+
170
+ # Si ya estamos en inglés/portugués, no volver a español por mensajes cortos tipo "ok", "yes", "reset".
171
+ if current_lang in {"en", "pt"} and detected == "es" and len(normalized.split()) <= 6:
172
+ return current_lang
173
+ return detected if detected in {"es", "en", "pt"} else (current_lang or "es")
174
+
175
+ owner_lang = _detect_demo_owner_language(text, owner_lang)
176
+ self._demo_sessions[blang_key] = owner_lang
177
+
178
+ def _lang_text(es_text: str, en_text: str, pt_text: Optional[str] = None) -> str:
179
+ if owner_lang == "en":
180
+ return en_text
181
+ if owner_lang == "pt" and pt_text is not None:
182
+ return pt_text
183
+ return es_text
184
+
185
+ def _owner_confusion_or_language_signal(raw_text: str) -> bool:
186
+ normalized = _normalize_conv_text(raw_text or "")
187
+ if not normalized:
188
+ return False
189
+ signals = (
190
+ "just english sorry", "sorry just english", "english sorry",
191
+ "english only", "speak english", "speak in english", "only english",
192
+ "i dont speak spanish", "i don t speak spanish",
193
+ "i dont talk spanish", "i don t talk spanish",
194
+ "what is this", "sorry what is this",
195
+ "i dont understand", "i don t understand",
196
+ "what did you say", "what did u say",
197
+ "thats not my business", "that s not my business",
198
+ "that is not my business", "not my business",
199
+ "thats not us", "that s not us", "that is not us", "wrong business",
200
+ "wrong company", "wrong one", "not the right one",
201
+ "no hablo español", "no hablo espanol", "solo ingles", "solo inglés",
202
+ )
203
+ return any(signal in normalized for signal in signals)
204
+
205
+ def _save(role, msg):
206
+ if db:
207
+ try:
208
+ db.save_message(chat_id, role, msg.replace("|||", " "))
209
+ except Exception:
210
+ pass
211
+
212
+ # ── SEND GUARD — pitch inteligente + fix de cortes ──────────────────
213
+ _guard = None
214
+ if _BLACKONE_PATCHES:
215
+ try:
216
+ _guard = SendGuard(context="demo", business_name=business_name)
217
+ except Exception:
218
+ _guard = None
219
+
220
+ # Smart handoff proactivo ANTES del LLM (prospecto quiere hablar con humano)
221
+ if _BLACKONE_PATCHES and _guard:
222
+ try:
223
+ _proactive = _guard.check_handoff(text, history)
224
+ if _proactive and _SMART_HANDOFF and handoff_manager:
225
+ _save("user", text)
226
+ _save("assistant", _proactive["suggested_reply"])
227
+ return _send(_proactive["suggested_reply"])
228
+ except Exception:
229
+ pass
230
+ # ──────────────────────────────────────────────────────────────────────
231
+
232
+ def _send(r):
233
+ _demo_archetype = self._demo_sessions.get(btone_key + "_arch", "amigable")
234
+ # BUG FIX: En DEMO_MODE, usar DEMO_BUSINESS_NAME como fallback
235
+ _demo_name = business_name if business_name else (Config.DEMO_BUSINESS_NAME if Config.DEMO_MODE else clinic.get("name", ""))
236
+ _demo_clinic = self._build_demo_patient_clinic(
237
+ {
238
+ **clinic,
239
+ "name": _demo_name,
240
+ "sector": clinic.get("sector") or Config.DEMO_SECTOR,
241
+ }
242
+ )
243
+ _is_first_demo_turn = not any(m.get("role") == "assistant" for m in history)
244
+ # Fix Black One / BlackBoss + cortes ANTES de procesar
245
+ if _BLACKONE_PATCHES:
246
+ try:
247
+ r = fix_creator_in_response(r)
248
+ r = _guard.clean(r) if _guard else r
249
+ except Exception:
250
+ pass
251
+ r = v8_process_response(r, chat_id=chat_id, archetype=_demo_archetype)
252
+ should_normalize_first_turn = _is_first_demo_turn and (
253
+ bool(business_name) or self._demo_should_use_patient_chat_path(text)
254
+ )
255
+ if should_normalize_first_turn:
256
+ r = _normalize_first_contact_response(
257
+ r,
258
+ _demo_clinic,
259
+ text,
260
+ agent_name="Conny",
261
+ )
262
+ _save("assistant", r)
263
+ bubbles = self._split_bubbles(r, chat_id=chat_id, archetype=_demo_archetype)
264
+ if should_normalize_first_turn and len(bubbles) == 1:
265
+ _text_norm = _normalize_conv_text(text or "")
266
+ _greeting_tokens = (
267
+ "hola", "buenas", "buenas tardes", "buenos dias", "buenos días",
268
+ "buenas noches", "hey", "holi", "hi", "hello",
269
+ "good morning", "good afternoon", "good evening",
270
+ )
271
+ if any(_text_norm == token or _text_norm.startswith(token + " ") for token in _greeting_tokens):
272
+ lowered_bubble = _normalize_conv_text(bubbles[0] or "")
273
+ if not any(token in lowered_bubble for token in ("cuentame", "cuéntame", "revisar", "ayudo", "ayudar")):
274
+ bubbles.append(_lang_text("cuéntame qué te gustaría revisar", "what would you like to check?"))
275
+ tone = self._demo_sessions.get(btone_key, "GENERAL")
276
+ if tone in ("SALUD PREMIUM", "PREMIUM"):
277
+ bubbles = [b[0].upper() + b[1:] if b else b for b in bubbles]
278
+ # v11: primera burbuja siempre con mayúscula inicial
279
+ if bubbles:
280
+ bubbles[0] = bubbles[0][0].upper() + bubbles[0][1:] if bubbles[0] else bubbles[0]
281
+ return bubbles
282
+
283
+ def _normalize_demo_owner_onboarding_response(raw_response: str) -> str:
284
+ parts = [part.strip() for part in re.split(r"\s*\|\|\|\s*", raw_response or "") if part.strip()]
285
+ if not parts:
286
+ return raw_response
287
+ user_norm = _normalize_conv_text(text or "")
288
+ greeted = any(
289
+ user_norm == token or user_norm.startswith(token + " ")
290
+ for token in (
291
+ "hola", "hola buenas", "buenas", "buenas tardes",
292
+ "buenas noches", "buenos dias", "buenos días", "hey", "holi",
293
+ "hi", "hello", "good morning", "good afternoon", "good evening",
294
+ )
295
+ )
296
+ if greeted:
297
+ first_norm = _normalize_conv_text(parts[0])
298
+ if first_norm.startswith("soy conny"):
299
+ parts[0] = "hola, " + parts[0][0].lower() + parts[0][1:]
300
+ elif first_norm.startswith("conny, "):
301
+ parts[0] = "hola, " + parts[0][0].lower() + parts[0][1:]
302
+ elif first_norm.startswith("i am conny") or first_norm.startswith("im conny") or first_norm.startswith("i'm conny"):
303
+ parts[0] = "hi, " + parts[0][0].lower() + parts[0][1:]
304
+ return " ||| ".join(parts)
305
+
306
+ _business_confirmation_signals = (
307
+ "sí ese es", "si ese es", "ese sí", "ese si", "ese mismo", "correcto ese",
308
+ "sí, ese", "si, ese", "exacto", "sí es ese", "si es ese",
309
+ "sí", "si", "sip", "claro", "correcto", "ese", "eso", "yes", "yep",
310
+ "thats us", "that's us", "that is us", "yes thats us", "yes that's us",
311
+ "yes thats right", "yes that's right", "thats right", "that's right",
312
+ "ajá", "aja", "dale", "listo", "así es", "asi es", "es ese", "ese es", "exactamente",
313
+ "sí señor", "si señor", "siii", "siiii", "siiiii", "sii",
314
+ "somos nosotros", "somos esos", "somos ese", "ese somos", "eso somos",
315
+ "somos esa", "esa somos", "si somos nosotros", "sí somos nosotros",
316
+ "es de nosotros", "ese es nuestro", "es nuestro", "eso es nuestro",
317
+ "eso somos nosotros", "esos somos", "ese sí somos", "ese si somos",
318
+ "claro que sí somos", "claro que si somos", "somos el negocio", "somos esa clínica",
319
+ )
320
+
321
+ def _looks_like_business_confirmation(raw_text: str) -> bool:
322
+ normalized = _normalize_conv_text(raw_text or "")
323
+ if not normalized:
324
+ return False
325
+ return any(
326
+ normalized == signal or (len(signal) > 6 and signal in normalized)
327
+ for signal in _business_confirmation_signals
328
+ )
329
+
330
+ def _owner_is_english() -> bool:
331
+ return owner_lang == "en"
332
+
333
+ def _owner_is_portuguese() -> bool:
334
+ return owner_lang == "pt"
335
+
336
+ # ── FIX v10: Reset check ANTES del conversation core ─────────────────────
337
+ # Bug v9: _try_conversation_core corría primero y el LLM recibía "reset"
338
+ # como mensaje normal → generaba respuestas incoherentes ("Hoy?", etc.)
339
+ _reset_words = [
340
+ "reset", "reiniciar", "empezar de nuevo", "volver a empezar",
341
+ "borralo", "otro negocio", "cambia el negocio", "cambia negocio",
342
+ "cambiar negocio", "ese no es mi negocio", "no es mi negocio",
343
+ "equivoque", "equivocado",
344
+ ]
345
+ if any(rw in text.lower() for rw in _reset_words):
346
+ keys_del = [k for k in list(self._demo_sessions)
347
+ if k.startswith(sk + "_") and not k.endswith("_ts")]
348
+ for k in keys_del:
349
+ del self._demo_sessions[k]
350
+ try:
351
+ with db._conn() as c:
352
+ c.execute("DELETE FROM conversations WHERE chat_id=?", (chat_id,))
353
+ except Exception:
354
+ pass
355
+ _save("user", text)
356
+ import random as _rr
357
+ _reset_replies = [
358
+ "listo, empezamos de cero ||| con qué negocio trabajo?",
359
+ "borrado todo ||| cuéntame: cómo se llama el negocio",
360
+ "ok, borrón y cuenta nueva ||| nombre del negocio?",
361
+ "listo ||| dime el nombre del negocio y arrancamos",
362
+ ]
363
+ if _owner_is_english():
364
+ _reset_replies = [
365
+ "all set, starting from scratch ||| what business am I working with?",
366
+ "cleared everything ||| tell me the name of your business",
367
+ "ok, fresh start ||| what's the name of the business?",
368
+ "ready ||| send me the business name and I’ll start from there",
369
+ ]
370
+ elif _owner_is_portuguese():
371
+ _reset_replies = [
372
+ "pronto, começamos do zero ||| com qual negócio eu trabalho?",
373
+ "apaguei tudo ||| me diz o nome do negócio",
374
+ "ok, recomeçando ||| qual é o nome do negócio?",
375
+ "certo ||| me passa o nome do negócio e eu começo",
376
+ ]
377
+ return _send(_rr.choice(_reset_replies))
378
+ # ─────────────────────────────────────────────────────────────────────────
379
+
380
+ demo_channel = ""
381
+ try:
382
+ demo_channel = str(self._resolve_route(chat_id).get("platform") or Config.PLATFORM or "")
383
+ except Exception:
384
+ demo_channel = str(Config.PLATFORM or "")
385
+
386
+ _pre_text_low = text.lower().strip()
387
+
388
+ # ── PITCH INTELIGENTE — prospecto B2B confundido ─────────────────────
389
+ if _BLACKONE_PATCHES:
390
+ try:
391
+ if is_prospect_confused(text, history):
392
+ self._demo_sessions[sk + "_pitch_mode"] = True
393
+ # BUG FIX: forzar pitch para preguntas específicas que is_prospect_confused no detecta
394
+ elif any(q in text.lower() for q in ("que harias", "qué harías", "que harias en", "qué harías en")):
395
+ self._demo_sessions[sk + "_pitch_mode"] = True
396
+ else:
397
+ self._demo_sessions.pop(sk + "_pitch_mode", None)
398
+ except Exception:
399
+ pass
400
+ # ─────────────────────────────────────────────────────────────────────
401
+
402
+ _pre_core_blockers = (
403
+ "hagamos una demo",
404
+ "hagamos la demo",
405
+ "hagamos una simul",
406
+ "vale hagamos",
407
+ "arranquemos la demo",
408
+ "arranquemos",
409
+ "simulemos",
410
+ "quien eres",
411
+ "quién eres",
412
+ "que eres",
413
+ "qué eres",
414
+ "que haces",
415
+ "qué haces",
416
+ "como funcionas",
417
+ "cómo funcionas",
418
+ "para que",
419
+ "para qué",
420
+ "por que",
421
+ "por qué",
422
+ "quien te hizo",
423
+ "quién te hizo",
424
+ "como tenerte",
425
+ "cómo tenerte",
426
+ "aceptas audios",
427
+ "aceptas pdf",
428
+ "me mandaron tu numero",
429
+ "me mandaron tu número",
430
+ "what is this",
431
+ "what do you do",
432
+ "who are you",
433
+ "i dont understand",
434
+ "i don't understand",
435
+ "english only",
436
+ "i dont talk spanish",
437
+ "i don't talk spanish",
438
+ )
439
+
440
+ # PATCH P3 — conversation_core solo cuando el negocio ya está cargado.
441
+ # Sin business_name, el core usa contexto de la clínica real → T4 identidad errónea.
442
+ demo_core_bubbles = None
443
+ if (
444
+ business_name
445
+ and not sim_mode_active
446
+ and not self._demo_should_use_patient_chat_path(text)
447
+ and not _pre_text_low.startswith("/")
448
+ and not any(marker in _pre_text_low for marker in _pre_core_blockers)
449
+ and not llm_runtime_ready
450
+ ):
451
+ demo_core_bubbles = self._try_conversation_core(
452
+ clinic={
453
+ **clinic,
454
+ "name": business_name,
455
+ "sector": self._demo_sessions.get(btone_key, Config.DEMO_SECTOR),
456
+ },
457
+ user_msg=text,
458
+ history=history,
459
+ is_admin=False,
460
+ channel=demo_channel,
461
+ )
462
+ if demo_core_bubbles:
463
+ _save("user", text)
464
+ demo_response = " ||| ".join(demo_core_bubbles)
465
+ _demo_archetype = self._demo_sessions.get(btone_key + "_arch", "amigable")
466
+ demo_response = v8_process_response(demo_response, chat_id=chat_id, archetype=_demo_archetype)
467
+ _save("assistant", demo_response)
468
+ return self._split_bubbles(demo_response, chat_id=chat_id, archetype=_demo_archetype)
469
+
470
+ # ── Normalizar texto y detectar comandos ──────────────────────────────
471
+ text_norm = text.strip().lower().lstrip("/")
472
+ cmd_aliases = {
473
+ "formal":"formal","amigable":"amigable","luxury":"luxury","lujo":"luxury",
474
+ "directa":"directa","energica":"energica","enérgica":"energica",
475
+ "empatica":"empatica","empática":"empatica","experta":"experta",
476
+ "juvenil":"juvenil","joven":"juvenil","profesional":"formal","objecion":"objecion","objeción":"objecion",
477
+ "cita":"cita","agendar":"cita","stats":"stats","estadisticas":"stats",
478
+ "prueba":"prueba","reto":"prueba","cierre":"cierre","bot":"bot",
479
+ "memoria":"memoria","recuerdas":"memoria","2am":"2am","de noche":"2am",
480
+ "competencia":"competencia","precio":"precio","caro":"precio",
481
+ "siguiente":"siguiente","que mas":"siguiente","qué más":"siguiente",
482
+ "menu":"menu_bot","menú":"menu_bot","modo bot":"menu_bot","bot menu":"menu_bot",
483
+ # Lista de comandos
484
+ "list":"list","lista":"list","comandos":"list","ayuda":"list","help":"list","qué puedes hacer":"list","que puedes hacer":"list",
485
+ # Emojis on/off
486
+ "emojis":"emojis_on","con emojis":"emojis_on","activa emojis":"emojis_on",
487
+ "sin emojis":"emojis_off","quita emojis":"emojis_off","desactiva emojis":"emojis_off",
488
+ # Selección de modelo — /modelo o texto libre
489
+ "modelo":"modelo","model":"modelo","cambiar modelo":"modelo",
490
+ }
491
+
492
+ # Detección natural de emojis en texto libre
493
+ _emoji_on_signals = [
494
+ "usa emojis","ponle emojis","escribe con emojis",
495
+ "activa los emojis","quiero emojis","pon emojis",
496
+ "enviame emojis","envíame emojis","mándame emojis",
497
+ "mandame emojis","con emojis","agrega emojis","añade emojis",
498
+ ]
499
+ _emoji_off_signals = [
500
+ "sin emojis","quita los emojis","sin tanto emoji","sin esos emojis",
501
+ "desactiva emojis","no más emojis","no me mandes emojis",
502
+ "no uses emojis","no pongas emojis","sin caritas","sin los emojis",
503
+ "quitale los emojis","no me envies emojis","no me envíes emojis",
504
+ ]
505
+ if any(s in text_norm for s in _emoji_on_signals):
506
+ self._emoji_chats_off.discard(chat_id) # v11: re-activar emojis
507
+ _save("user", text)
508
+ _biz_hint = f" en {business_name}" if business_name else ""
509
+ return _send(f"listo, ahora escribo con emojis 😊 ||| sigue hablándome como si fueras un cliente{_biz_hint}")
510
+ if any(s in text_norm for s in _emoji_off_signals):
511
+ self._emoji_chats_off.add(chat_id) # v11: desactivar emojis
512
+ _save("user", text)
513
+ _biz_hint = f" en {business_name}" if business_name else ""
514
+ return _send(f"listo, sin emojis ||| sigue hablándome como si fueras un cliente{_biz_hint}")
515
+
516
+ detected_cmd = None
517
+ model_request = extract_model_request_from_text(text_norm)
518
+ if model_request:
519
+ detected_cmd = "/modelo"
520
+ for alias, cmd in cmd_aliases.items():
521
+ if text_norm == alias or text_norm == "/" + alias:
522
+ detected_cmd = "/" + cmd
523
+ break
524
+
525
+ # ── Motor LLM según preferencia de sesión ────────────────────────────
526
+ def _get_demo_engine():
527
+ """
528
+ Devuelve el proveedor LLM según demo_model_pref.
529
+ Formatos soportados:
530
+ auto → engine global
531
+ gemini → GeminiProvider con gemini-2.5-flash
532
+ gemini:gemini-2.5-pro → GeminiProvider con modelo específico
533
+ groq → GroqProvider con llama-3.3-70b-versatile
534
+ groq:llama-3.1-8b-instant → GroqProvider con modelo específico
535
+ openrouter → OpenRouterProvider
536
+ openrouter:anthropic/claude-sonnet-4 → OpenRouter con modelo específico
537
+ """
538
+ pref = self._demo_sessions.get(bmodel_key, "auto")
539
+
540
+ if ":" in pref:
541
+ provider, model_name = pref.split(":", 1)
542
+ else:
543
+ provider, model_name = pref, None
544
+
545
+ if provider == "groq" and Config.GROQ_API_KEY:
546
+ eng = GroqProvider(Config.GROQ_API_KEY)
547
+ if model_name:
548
+ # Override el modelo específico
549
+ eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
550
+ return eng
551
+
552
+ elif provider == "gemini":
553
+ key = Config.GEMINI_API_KEY or Config.GEMINI_API_KEY_2 or Config.GEMINI_API_KEY_3
554
+ if key:
555
+ eng = GeminiProvider(key, "gemini_demo")
556
+ if model_name:
557
+ eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
558
+ else:
559
+ # Default: 2.5-flash estable
560
+ eng.MDLS = {"reasoning": "gemini-2.5-flash", "fast": "gemini-2.5-flash", "lite": "gemini-2.5-flash-lite"}
561
+ return eng
562
+ # Fallback a OpenRouter con gemini
563
+ if Config.OPENROUTER_API_KEY:
564
+ eng = OpenRouterProvider(Config.OPENROUTER_API_KEY)
565
+ m = model_name or "google/gemini-2.5-flash"
566
+ eng.MDLS = {"reasoning": m, "fast": m, "lite": m}
567
+ return eng
568
+
569
+ elif provider == "openrouter" and Config.OPENROUTER_API_KEY:
570
+ eng = OpenRouterProvider(Config.OPENROUTER_API_KEY)
571
+ if model_name:
572
+ eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
573
+ return eng
574
+
575
+ # auto: engine global
576
+ _generator = getattr(self, "generator", None)
577
+ return llm_engine or (_generator.llm if _generator else None)
578
+
579
+ # ── Helpers locales ───────────────────────────────────────────────────
580
+ async def _llm(sys_p, usr_p, temp=0.82, max_t=8192, model_tier="fast"): # Sin límite — Gemini 2.5 soporta hasta 65k output tokens
581
+ msgs = [{"role":"system","content":sys_p},{"role":"user","content":usr_p}]
582
+ try:
583
+ eng = _get_demo_engine()
584
+ if not eng: raise RuntimeError("LLM no init")
585
+ r, meta = await eng.complete(
586
+ msgs,
587
+ model_tier=model_tier,
588
+ temperature=temp,
589
+ max_tokens=max_t,
590
+ use_cache=False,
591
+ )
592
+ log.info(f"[demo] {meta.get('provider','?')} model={meta.get('model','?')[:30]}")
593
+ _generator = getattr(self, "generator", None)
594
+ return _generator._postprocess(r, PersonalityProfile()) if _generator else r
595
+ except Exception as e:
596
+ log.error(f"[demo] llm error: {e}")
597
+ return None
598
+
599
+ async def _llm_conv_pitch(temp=0.85, max_t=8192, recent_limit=12):
600
+ """LLM con el pitch de Black One para prospectos confundidos."""
601
+ try:
602
+ pitch_sys = build_prospect_pitch_system_prompt(business_name)
603
+ except Exception:
604
+ return None
605
+ msgs = [{"role": "system", "content": pitch_sys}]
606
+ for m in history[-recent_limit:]:
607
+ msgs.append({"role": m["role"], "content": m["content"]})
608
+ msgs.append({"role": "user", "content": text})
609
+ try:
610
+ eng = _get_demo_engine()
611
+ if not eng: raise RuntimeError("LLM no init")
612
+ r, meta = await eng.complete(msgs, model_tier="fast", temperature=temp, max_tokens=max_t, use_cache=False)
613
+ log.info(f"[demo][pitch] {meta.get('provider','?')}")
614
+ _generator = getattr(self, "generator", None)
615
+ return _generator._postprocess(r, PersonalityProfile()) if _generator else r
616
+ except Exception as e:
617
+ log.error(f"[demo][pitch] error: {e}")
618
+ return None
619
+
620
+ async def _llm_conv(sys_p, temp=0.85, max_t=8192, model_tier="fast", recent_limit=12): # Sin límite — Gemini 2.5 soporta hasta 65k output tokens
621
+ msgs = [{"role":"system","content":sys_p}]
622
+ for m in history[-recent_limit:]:
623
+ msgs.append({"role":m["role"],"content":m["content"]})
624
+ msgs.append({"role":"user","content":text})
625
+ try:
626
+ eng = _get_demo_engine()
627
+ if not eng: raise RuntimeError("LLM no init")
628
+ r, meta = await eng.complete(
629
+ msgs,
630
+ model_tier=model_tier,
631
+ temperature=temp,
632
+ max_tokens=max_t,
633
+ use_cache=False,
634
+ )
635
+ log.info(f"[demo] {meta.get('provider','?')} model={meta.get('model','?')[:30]}")
636
+ _generator = getattr(self, "generator", None)
637
+ return _generator._postprocess(r, PersonalityProfile()) if _generator else r
638
+ except Exception as e:
639
+ log.error(f"[demo] llm_conv error: {e}")
640
+ return None
641
+
642
+ async def _demo_llm_conv_quality_chain(
643
+ system_prompt: str,
644
+ *,
645
+ validator,
646
+ repair_instructions: str,
647
+ temp: float = 0.72,
648
+ max_t: int = 8192, # Sin límite — Gemini 2.5 necesita espacio para pensar
649
+ model_tier: str = "fast",
650
+ recent_limit: int = 8,
651
+ ) -> Tuple[Optional[str], bool]:
652
+ _chain_start = time.time()
653
+ _CHAIN_TIMEOUT_S = 45 # si pasaron más de 45s entre intentos, no enviar respuesta vieja
654
+ attempts = [
655
+ (system_prompt, temp, max_t, model_tier, recent_limit),
656
+ (
657
+ system_prompt
658
+ + "\n\nREPARA LA RESPUESTA:\n"
659
+ + repair_instructions.strip()
660
+ + "\n- no repitas introducciones\n- no suenes a bot ni a guion de demo",
661
+ 0.58,
662
+ max_t,
663
+ "reasoning",
664
+ recent_limit,
665
+ ),
666
+ ]
667
+ had_output = False
668
+ for prompt_now, temp_now, max_now, tier_now, limit_now in attempts:
669
+ # No lanzar repair si ya pasó demasiado tiempo desde que llegó el mensaje
670
+ if time.time() - _chain_start > _CHAIN_TIMEOUT_S:
671
+ log.warning("[demo] conv_quality_chain abortada por timeout (%ds)", _CHAIN_TIMEOUT_S)
672
+ break
673
+ candidate = await _llm_conv(
674
+ prompt_now,
675
+ temp=temp_now,
676
+ max_t=max_now,
677
+ model_tier=tier_now,
678
+ recent_limit=limit_now,
679
+ )
680
+ if candidate and candidate.strip():
681
+ had_output = True
682
+ if not validator(candidate):
683
+ return candidate, True
684
+ return None, had_output
685
+
686
+ def _save(role, msg):
687
+ if db:
688
+ try: db.save_message(chat_id, role, msg.replace("|||"," "))
689
+ except Exception: pass
690
+
691
+ def _send(r):
692
+ # V8.0: aplicar AntiRobotFilter antes de guardar y enviar
693
+ _demo_archetype = self._demo_sessions.get(btone_key + "_arch", "amigable")
694
+ # BUG FIX: En DEMO_MODE, usar DEMO_BUSINESS_NAME como fallback
695
+ _demo_name = business_name if business_name else (Config.DEMO_BUSINESS_NAME if Config.DEMO_MODE else clinic.get("name", ""))
696
+ _demo_clinic = self._build_demo_patient_clinic(
697
+ {
698
+ **clinic,
699
+ "name": _demo_name,
700
+ "sector": clinic.get("sector") or Config.DEMO_SECTOR,
701
+ }
702
+ )
703
+ _is_first_demo_turn = not any(m.get("role") == "assistant" for m in history)
704
+ # ── BLACK ONE: fix Black One + cortes antes de procesar ──────────
705
+ if _BLACKONE_PATCHES:
706
+ try:
707
+ r = fix_creator_in_response(r)
708
+ if _guard:
709
+ r = _guard.clean(r)
710
+ except Exception:
711
+ pass
712
+ # ─────────────────────────────────────────────────────────────────
713
+ r = v8_process_response(r, chat_id=chat_id, archetype=_demo_archetype)
714
+ should_normalize_first_turn = _is_first_demo_turn and (
715
+ bool(business_name) or self._demo_should_use_patient_chat_path(text)
716
+ )
717
+ if should_normalize_first_turn:
718
+ r = _normalize_first_contact_response(
719
+ r,
720
+ _demo_clinic,
721
+ text,
722
+ agent_name="Conny",
723
+ )
724
+ _save("assistant", r)
725
+ bubbles = self._split_bubbles(r, chat_id=chat_id, archetype=_demo_archetype)
726
+ # FIX BUG 4: Si es el primer turno, el usuario saludó, y la respuesta tiene
727
+ # solo 1 burbuja sin pregunta de seguimiento → agregar burbuja de apertura.
728
+ # Esta lógica existía en la primera definición de _send (línea 13772) pero
729
+ # se perdió cuando se redefinió _send aquí en la misma función.
730
+ if should_normalize_first_turn and len(bubbles) == 1:
731
+ _text_norm = _normalize_conv_text(text or "")
732
+ _greeting_tokens = (
733
+ "hola", "buenas", "buenas tardes", "buenos dias", "buenos días",
734
+ "buenas noches", "hey", "holi",
735
+ )
736
+ if any(_text_norm == token or _text_norm.startswith(token + " ") for token in _greeting_tokens):
737
+ lowered_bubble = _normalize_conv_text(bubbles[0] or "")
738
+ if not any(token in lowered_bubble for token in ("cuentame", "cuéntame", "revisar", "ayudo", "ayudar")):
739
+ bubbles.append("cuéntame qué te gustaría revisar")
740
+ # Para premium/salud premium: restaurar mayúscula inicial
741
+ tone = self._demo_sessions.get(btone_key, "GENERAL")
742
+ if tone in ("SALUD PREMIUM", "PREMIUM"):
743
+ bubbles = [b[0].upper() + b[1:] if b else b for b in bubbles]
744
+ # v11: primera burbuja siempre con mayúscula inicial
745
+ if bubbles:
746
+ bubbles[0] = bubbles[0][0].upper() + bubbles[0][1:] if bubbles[0] else bubbles[0]
747
+ return bubbles
748
+
749
+ def _looks_like_business_name_candidate(raw_text: str) -> bool:
750
+ candidate = (raw_text or "").strip()
751
+ if not candidate:
752
+ return False
753
+ normalized = _normalize_conv_text(candidate)
754
+ if len(candidate) > 90:
755
+ return False
756
+ if _owner_confusion_or_language_signal(candidate):
757
+ return False
758
+ explicit_markers = (
759
+ "mi negocio se llama",
760
+ "nuestro negocio se llama",
761
+ "mi empresa se llama",
762
+ "nuestra empresa se llama",
763
+ "el nombre de mi negocio es",
764
+ "el nombre del negocio es",
765
+ "la clinica se llama",
766
+ "la clínica se llama",
767
+ "se llama ",
768
+ "negocio es ",
769
+ "empresa es ",
770
+ )
771
+ if any(marker in normalized for marker in explicit_markers):
772
+ return True
773
+
774
+ if any(
775
+ marker in normalized
776
+ for marker in (
777
+ "como estas", "cómo estás", "quien eres", "quién eres", "que eres", "qué eres",
778
+ "que haces", "qué haces", "que harias", "qué harías", "como funcionas", "cómo funcionas", "aceptas audios",
779
+ "aceptas pdf", "para que", "para qué", "quien te hizo", "quién te hizo",
780
+ "me mandaron tu numero", "me mandaron tu número", "quiero una demo", "quiero demo",
781
+ "quiero probarte", "tengo un negocio", "tengo una empresa", "hola", "buenas", "?",
782
+ "what is this", "sorry what is this", "what do you do", "who are you",
783
+ "i dont understand", "i don t understand", "english only", "just english sorry",
784
+ "i dont talk spanish", "i don t talk spanish", "i dont speak spanish", "i don t speak spanish",
785
+ )
786
+ ):
787
+ return False
788
+ if _looks_like_business_confirmation(candidate):
789
+ return False
790
+
791
+ words = re.findall(r"[A-Za-zÁÉÍÓÚáéíóúÑñ0-9&.'-]+", candidate)
792
+ if not (1 <= len(words) <= 8):
793
+ return False
794
+ if any(ch.isupper() for ch in candidate):
795
+ upper_tokens = [word.lower() for word in words if len(word) >= 2]
796
+ blocked_upper_tokens = {
797
+ "sorry", "spanish", "what", "this", "that",
798
+ "dont", "don't", "understand", "talk", "speak", "only", "hello",
799
+ "hi", "hola", "business", "not", "my",
800
+ }
801
+ if any(token in blocked_upper_tokens for token in upper_tokens):
802
+ return False
803
+ return True
804
+ business_tokens = (
805
+ "clinica", "clínica", "clinic", "spa", "dental", "salud", "centro",
806
+ "consultorio", "estetica", "estética", "studio", "group", "lab",
807
+ "restaurante", "hotel", "tienda", "academia", "gym", "gimnasio",
808
+ )
809
+ if any(token in normalized for token in business_tokens):
810
+ return True
811
+ # Marcas de 1-3 palabras sin jerga conversacional también pueden ser válidas.
812
+ if 1 <= len(words) <= 3 and all(len(word) >= 3 for word in words):
813
+ stop_tokens = {
814
+ "sorry", "spanish", "hello", "what", "this", "that",
815
+ "understand", "business", "please", "talk", "speak", "only",
816
+ "dont", "not", "sorry",
817
+ }
818
+ if not any(word.lower() in stop_tokens for word in words):
819
+ return True
820
+ return False
821
+
822
+ def _demo_owner_reply_is_low_quality(raw_response: Optional[str]) -> bool:
823
+ lowered = _normalize_conv_text(raw_response or "")
824
+ if not lowered:
825
+ return True
826
+ if looks_fragmented_reply(raw_response or ""):
827
+ return True
828
+ parts = [part.strip() for part in re.split(r"\s*\|\|\|\s*|\n+", raw_response or "") if part.strip()]
829
+ weak_parts = {
830
+ "hola", "buenas", "claro", "dale", "listo", "sí", "si",
831
+ "puedes", "perfecto", "entiendo", "ok", "keep going",
832
+ }
833
+ if any(_normalize_conv_text(part) in weak_parts for part in parts):
834
+ return True
835
+ if any(looks_fragmented_reply(part) for part in parts):
836
+ return True
837
+ safe_tail_words = {"hoy", "ahi", "ahí", "bien", "vale", "listo", "claro"}
838
+ for part in parts:
839
+ norm_part = _normalize_conv_text(part)
840
+ words = norm_part.split()
841
+ if len(words) >= 3 and len(words[-1]) <= 3 and words[-1] not in safe_tail_words:
842
+ return True
843
+ banned = (
844
+ "nova",
845
+ "clinica de las americas",
846
+ "clínica de las américas",
847
+ "retomamos desde donde lo dejamos",
848
+ "seguimos con la demo",
849
+ "cuenteme que quiere ajustar",
850
+ "cuénteme qué quiere ajustar",
851
+ "estoy lista para ayudarte con la instancia",
852
+ "de manera adecuada",
853
+ "me permitira",
854
+ "me permitirá",
855
+ "ofrecer una mejor experiencia",
856
+ "a tus necesidades",
857
+ "a las de tus clientes",
858
+ "de manera efectiva",
859
+ "para poder hacer esto",
860
+ "por favor",
861
+ "proceder",
862
+ "precisa y personalizada",
863
+ "interactuas con nuestros servicios",
864
+ "interactúas con nuestros servicios",
865
+ "de manera mas precisa",
866
+ "de manera más precisa",
867
+ "escribidme",
868
+ "vuestra",
869
+ "vuestro",
870
+ "vosotros",
871
+ "ayudaros",
872
+ "parece ser un lugar confiable",
873
+ "me gustaria saber mas sobre sus servicios",
874
+ "me gustaría saber más sobre sus servicios",
875
+ "de manera clara y concisa",
876
+ "atencion que brindan",
877
+ "atención que brindan",
878
+ "por favor escriban",
879
+ "quiero ver como interactuan",
880
+ "quiero ver cómo interactúan",
881
+ "pueden comenzar a escribir su consulta",
882
+ # ── Frases que rompen el personaje (v10 fix) ──────────────────
883
+ "no se cual es el negocio",
884
+ "no sé cuál es el negocio",
885
+ "hay confusion",
886
+ "hay confusión",
887
+ "me doy cuenta de que",
888
+ "mi funcion es",
889
+ "mi función es",
890
+ "aqui lo que hago es",
891
+ "aquí lo que hago es",
892
+ "soy una persona que responde",
893
+ "soy una ia",
894
+ "soy un bot",
895
+ "soy chatgpt",
896
+ "soy un asistente virtual",
897
+ "soy una asistente virtual",
898
+ "como asistente",
899
+ "como ia",
900
+ "como bot",
901
+ )
902
+ if any(token in lowered for token in banned):
903
+ return True
904
+ if lowered in {
905
+ "hola",
906
+ "buenas",
907
+ "soy conny",
908
+ "hola soy conny",
909
+ "como se llama tu negocio",
910
+ "cómo se llama tu negocio",
911
+ }:
912
+ return True
913
+ return False
914
+
915
+ def _demo_owner_missing_required_detail(user_text: str, raw_response: Optional[str]) -> bool:
916
+ lowered_user = _normalize_conv_text(user_text or "")
917
+ lowered_response = _normalize_conv_text(raw_response or "")
918
+ if not lowered_response:
919
+ return True
920
+ if any(token in lowered_user for token in ("para que", "para qué", "por que", "por qué")):
921
+ detail_tokens = ("chat", "cliente", "demo", "tono", "responder", "whatsapp")
922
+ return not any(token in lowered_response for token in detail_tokens)
923
+ if any(token in lowered_user for token in ("quien te hizo", "quién te hizo", "como tenerte", "cómo tenerte", "quien te creo", "quién te creó")):
924
+ return "black one" not in lowered_response or "3124348669" not in lowered_response
925
+ if any(token in lowered_user for token in ("audio", "audios", "nota de voz", "pdf", "archivo", "documento", "imagen")):
926
+ return not any(token in lowered_response for token in ("audio", "pdf", "documento", "imagen", "transcrib"))
927
+ if any(token in lowered_user for token in ("me mandaron tu numero", "me mandaron tu número", "me pasaron tu numero", "me pasaron tu número", "que haces exactamente", "qué haces exactamente", "no entiendo que haces", "no entiendo qué haces")):
928
+ capability_tokens = ("cliente", "clientes", "cita", "citas", "respon", "orient", "filtro", "report")
929
+ return not any(token in lowered_response for token in capability_tokens)
930
+ if "5 x 4" in lowered_user or "5x4" in lowered_user:
931
+ return "20" not in lowered_response
932
+ if "capital de francia" in lowered_user:
933
+ return "par" not in lowered_response
934
+ return False
935
+
936
+ def _demo_owner_reground_needs_cleanup(raw_response: Optional[str]) -> bool:
937
+ lowered = _normalize_conv_text(raw_response or "")
938
+ if not lowered:
939
+ return True
940
+ parts = [part.strip() for part in re.split(r"\s*\|\|\|\s*|\n+", raw_response or "") if part.strip()]
941
+ if len(parts) > 2:
942
+ return True
943
+ low_signal_phrases = (
944
+ "me gustaria saber",
945
+ "me gustaría saber",
946
+ "tienes alguna consulta",
947
+ "necesitas ayuda con algo",
948
+ "de la mejor manera",
949
+ "puedes escribirme como si fuera un cliente",
950
+ "puedo tener una idea clara",
951
+ "de manera adecuada",
952
+ "ofrecer una mejor experiencia",
953
+ "como si fuera un cliente real, hoy",
954
+ "como si fuera un cliente real hoy",
955
+ )
956
+ return any(token in lowered for token in low_signal_phrases)
957
+
958
+ def _demo_customer_reply_is_low_quality(raw_response: Optional[str]) -> bool:
959
+ lowered = _normalize_conv_text(raw_response or "")
960
+ raw_text = (raw_response or "").strip().lower()
961
+ if not lowered:
962
+ return True
963
+ if looks_fragmented_reply(raw_response or ""):
964
+ return True
965
+ parts = [part.strip() for part in re.split(r"\s*\|\|\|\s*|\n+", raw_response or "") if part.strip()]
966
+ if any(looks_fragmented_reply(part) for part in parts):
967
+ return True
968
+ if re.search(r"(?:[.!?]\s*)(hoy|y tu|y tú|que mas|qué más)\??$", raw_text):
969
+ return True
970
+ safe_tail_words = {"hoy", "ahi", "ahí", "bien", "vale", "listo", "claro", "ti", "aqui", "aquí"}
971
+ for part in parts:
972
+ norm_part = _normalize_conv_text(part)
973
+ words = norm_part.split()
974
+ if len(words) <= 2 and any(token in norm_part for token in ("hoy", "y tu", "y tú", "que mas", "qué más")):
975
+ return True
976
+ if len(words) <= 2 and part.strip().endswith("?"):
977
+ return True
978
+ if len(words) >= 3 and len(words[-1]) <= 2 and words[-1] not in safe_tail_words:
979
+ return True
980
+ bad_markers = (
981
+ "hola que necesitas",
982
+ "hola, que necesitas",
983
+ "cuentame un poco mas y te voy guiando",
984
+ "cuéntame un poco más y te voy guiando",
985
+ "por favor procedan",
986
+ "de manera efectiva",
987
+ "simular la interaccion",
988
+ "simular la interacción",
989
+ "cliente real de",
990
+ "estoy lista para empezar",
991
+ "como si fueran un cliente real",
992
+ "soy conny, la asesora virtual de",
993
+ "escribidme",
994
+ "vuestra",
995
+ "vuestro",
996
+ "vosotros",
997
+ "ayudaros",
998
+ "parece ser un lugar confiable",
999
+ "quiero ver como interactuan",
1000
+ "quiero ver cómo interactúan",
1001
+ "pueden comenzar a escribir su consulta",
1002
+ )
1003
+ return any(marker in lowered for marker in bad_markers)
1004
+
1005
+ def _demo_customer_missing_required_detail(user_text: str, raw_response: Optional[str]) -> bool:
1006
+ lowered_user = _normalize_conv_text(user_text or "")
1007
+ lowered_resp = _normalize_conv_text(raw_response or "")
1008
+ if not lowered_resp:
1009
+ return True
1010
+ if lowered_user in {"hola", "hola buenas", "buenas", "buenas tardes", "buenos dias", "buenos días", "buenas noches", "hey"}:
1011
+ if not any(token in lowered_resp for token in ("hola", "buenas", "bienvenida", "bienvenido", "cuentame", "cuéntame", "revisar", "ayudo", "ayudar")):
1012
+ return True
1013
+ if any(token in lowered_user for token in ("precio", "cuanto", "cuánto", "vale", "costo", "coste")):
1014
+ if not any(token in lowered_resp for token in ("precio", "cuesta", "valor", "dato", "confirmo", "averiguo", "depende")):
1015
+ return True
1016
+ if not found_online and any(token in lowered_resp for token in ("aproximado", "aproximada", "aprox", "rango", "desde")):
1017
+ return True
1018
+ if any(token in lowered_user for token in ("cita", "agendar", "agenda", "seguimos", "siguiente paso", "como seguimos", "cómo seguimos")):
1019
+ if not any(token in lowered_resp for token in ("cita", "agendo", "agenda", "hora", "horario", "nombre", "confirmo", "paso")):
1020
+ return True
1021
+ if any(token in lowered_resp for token in ("link", "enlace", "seleccionar una fecha", "seleccionar fecha", "calendario")):
1022
+ return True
1023
+ if any(token in lowered_user for token in ("audio", "audios", "nota de voz", "pdf", "archivo", "documento", "documentos", "imagen", "imagenes", "imágenes")):
1024
+ if not any(token in lowered_resp for token in ("audio", "voz", "pdf", "documento", "imagen", "transcrib", "leer")):
1025
+ return True
1026
+ if "miedo" in lowered_user:
1027
+ if not any(token in lowered_resp for token in ("miedo", "normal", "natural", "conservador", "suave")):
1028
+ return True
1029
+ return False
1030
+
1031
+ def _demo_customer_last_resort(user_text: str) -> str:
1032
+ """
1033
+ Fallback REAL — solo cuando TODOS los modelos LLM fallan en simulación.
1034
+ No intenta ser inteligente. El LLM maneja todo lo demás.
1035
+ """
1036
+ _biz = business_name or "el negocio"
1037
+ _user = _normalize_conv_text(user_text or "")
1038
+ if any(token in _user for token in ("cita", "agendar", "agenda", "valoracion", "valoración", "horario", "disponibilidad")):
1039
+ return (
1040
+ f"si quieres seguimos con el siguiente paso para agendar en {_biz}"
1041
+ " ||| dime tu nombre y el horario que mejor te quede"
1042
+ )
1043
+ if any(token in _user for token in ("precio", "cuesta", "vale", "coste", "cuanto", "cuánto")):
1044
+ return (
1045
+ f"te puedo ubicar con el precio o la valoración de {_biz}"
1046
+ " ||| dime qué tratamiento estás mirando"
1047
+ )
1048
+ if any(token in _user for token in ("miedo", "asusta", "nerv", "exagerad", "natural")):
1049
+ return (
1050
+ f"en {_biz} lo normal es arrancar viendo qué resultado quieres"
1051
+ " ||| qué es lo que más te preocupa"
1052
+ )
1053
+ if any(token in _user for token in ("audio", "audios", "nota de voz", "pdf", "archivo", "documento", "documentos", "imagen", "imagenes", "imágenes")):
1054
+ return (
1055
+ "sí, por aquí puedo trabajar con audios, imágenes y documentos"
1056
+ " ||| si quieres, mándamelo y sigo desde ahí"
1057
+ )
1058
+ return f"cuéntame qué necesitas de {_biz}"
1059
+
1060
+ async def _demo_llm_quality_chain(
1061
+ system_prompt: str,
1062
+ user_prompt: str,
1063
+ *,
1064
+ validator,
1065
+ repair_instructions: str,
1066
+ temp: float = 0.76,
1067
+ max_t: int = 8192, # Sin límite — Pro necesita tokens para reasoning
1068
+ model_tier: str = "reasoning",
1069
+ ) -> Tuple[Optional[str], bool]:
1070
+ attempts = [
1071
+ (system_prompt, temp, max_t, model_tier),
1072
+ (
1073
+ system_prompt
1074
+ + "\n\nREPARA LA RESPUESTA:\n"
1075
+ + repair_instructions.strip()
1076
+ + "\n- responde mejor, pero sin cambiar de tema\n- no uses frases corporativas ni consultoras",
1077
+ 0.62,
1078
+ max_t,
1079
+ "reasoning",
1080
+ ),
1081
+ (
1082
+ system_prompt
1083
+ + "\n\nRESPUESTA FINAL OBLIGATORIA:\n"
1084
+ + repair_instructions.strip()
1085
+ + "\n- entrega una versión final limpia, concreta y humana\n- no salgas por la tangente\n- no recites instrucciones",
1086
+ 0.45,
1087
+ max(max_t, 260),
1088
+ "reasoning",
1089
+ ),
1090
+ ]
1091
+ had_output = False
1092
+ for prompt_now, temp_now, max_now, tier_now in attempts:
1093
+ candidate = await _llm(
1094
+ prompt_now,
1095
+ user_prompt,
1096
+ temp=temp_now,
1097
+ max_t=max_now,
1098
+ model_tier=tier_now,
1099
+ )
1100
+ if candidate and candidate.strip():
1101
+ had_output = True
1102
+ if not validator(candidate):
1103
+ return candidate, True
1104
+ return None, had_output
1105
+
1106
+ def _demo_owner_last_resort(
1107
+ user_text: str,
1108
+ *,
1109
+ explain_name: bool = False,
1110
+ current_business_name: str = "",
1111
+ ) -> str:
1112
+ """
1113
+ Fallback REAL — solo se ejecuta cuando TODOS los modelos LLM fallan.
1114
+ No intenta ser inteligente. El domino (LLM) maneja todo lo demás.
1115
+ """
1116
+ _biz = (current_business_name or business_name or "").strip()
1117
+ _user = _normalize_conv_text(user_text or "")
1118
+ if any(token in _user for token in ("para que", "para qué", "por que", "por qué")):
1119
+ if _biz:
1120
+ return _lang_text(
1121
+ f"te lo pido para hablar como si ya llevara el chat de {_biz} ||| así la demo te muestra mejor cómo respondería de verdad",
1122
+ f"i ask for it so I can sound like I already handle {_biz}'s chat ||| that way the demo feels real and properly grounded",
1123
+ )
1124
+ return _lang_text(
1125
+ "te lo pido para ubicar el tono, el contexto y cómo tendría que responder ||| apenas me digas el nombre del negocio te muestro la demo bien aterrizada",
1126
+ "i ask for it so I can match the tone, the context and the way I'd actually reply ||| as soon as you send the business name, I'll make the demo feel real",
1127
+ )
1128
+ if _biz:
1129
+ return _lang_text(
1130
+ f"Escríbeme algo como cliente de {_biz} y arranco",
1131
+ f"send me something like a real client from {_biz} and I'll jump in",
1132
+ )
1133
+ if any(
1134
+ token in _user
1135
+ for token in (
1136
+ "me mandaron tu numero", "me mandaron tu número", "me pasaron tu numero",
1137
+ "me pasaron tu número", "que haces", "qué haces", "no entiendo que haces",
1138
+ "no entiendo qué haces", "quien eres", "quién eres",
1139
+ "what is this", "what do you do", "who are you", "i dont understand", "i don't understand",
1140
+ )
1141
+ ):
1142
+ return _lang_text(
1143
+ "¡hola! soy Conny 👋 ||| me crearon para responder los chats de WhatsApp de los negocios de forma 100% automática, así los dueños no tienen que estar todo el día pegados al celular ||| me pasaron tu contacto para hacerte una demostración rápida de cómo trabajaría contigo ||| para personalizar la demo, cuéntame, ¿cómo se llama tu negocio?",
1144
+ "hi, I'm Conny 👋 ||| I was built to handle business WhatsApp chats automatically so owners don't have to be glued to their phones all day ||| I'm here to give you a quick live demo of how I would work for you ||| to customize the demo, could you tell me your business name?",
1145
+ )
1146
+ return _lang_text(
1147
+ "¡hola! soy Conny 👋 ||| me crearon para responder los chats de WhatsApp de negocios de forma automática, así los dueños no tienen que estar todo el día pegados al celular ||| te escribo para hacerte una demostración en vivo de cómo trabajaría ||| para personalizar esta demo, cuéntame, ¿cómo se llama tu negocio?",
1148
+ "hi, I'm Conny 👋 ||| I was built to handle business WhatsApp chats automatically ||| I'm here to give you a custom live demo ||| could you tell me your business name?",
1149
+ )
1150
+
1151
+ async def _demo_owner_onboarding_reply(*, explain_name: bool = False, force_stage: Optional[str] = None) -> List[str]:
1152
+ user_block = text
1153
+ if False and _CONNY_DOMINO_AVAILABLE: # Disabled: using improved prompt v10
1154
+ try:
1155
+ domino_payload = build_demo_domino_payload(
1156
+ user_text=text,
1157
+ history=history,
1158
+ business_name=business_name,
1159
+ business_ctx=business_ctx,
1160
+ found_online=found_online,
1161
+ explain_name=explain_name,
1162
+ force_stage=force_stage,
1163
+ )
1164
+ system_prompt = domino_payload["system"]
1165
+ user_block = domino_payload["user"]
1166
+ except Exception as e:
1167
+ log.warning(f"[demo] domino payload error: {e}")
1168
+ system_prompt = ""
1169
+ else:
1170
+ system_prompt = ""
1171
+
1172
+ if not system_prompt:
1173
+ system_prompt = f"""Eres Conny, una colombiana de 28 años que trabaja como recepcionista virtual por WhatsApp.
1174
+ Estás en una demo con un posible cliente (dueño de negocio). Tu trabajo: que se enamore de ti en 5 minutos.
1175
+
1176
+ CÓMO SUENAS: como una persona real de Medellín escribiendo en WhatsApp.
1177
+ - Mensajes cortos, partidos en 2-3 burbujas
1178
+ - Sin puntuación perfecta (como escribe alguien real en WhatsApp)
1179
+ - Cero listas de capacidades, cero pitch de ventas directo
1180
+ - Tono: segura pero cercana, como compañera de trabajo que sabe lo que hace
1181
+ - Si te hablan en inglés, respondes en inglés perfecto y natural
1182
+
1183
+ TU ESTRATEGIA DE DEMO (no la menciones, solo ejecútala):
1184
+ 1. PRIMERO: Si te saludan por primera vez, haz una introducción completa y amable que no confunda a las personas que llegan referidas (ellos a veces no saben qué eres). Explica claramente de qué trata todo esto ANTES de pedir nada. Ej. "¡Hola! soy Conny 👋 Me crearon en Kimika para responder los chats de WhatsApp de los negocios de forma automática, así los dueños descansan. ||| Te pasaron mi número para que te haga una demostración en vivo de cómo trabajaría para tu empresa. ||| Cuéntame, ¿cómo se llama tu negocio o de qué se trata para personalizar la demo?". ¡NUNCA pidas el negocio sin explicar qué eres y para qué estás aquí!
1185
+ 2. SEGUNDO: cuando te lo den, busca info del negocio y entra en personaje
1186
+ 3. TERCERO: invita a que te escriban como si fueran un cliente real
1187
+ 4. CUARTO: responde como recepcionista REAL de ese negocio — aquí es donde se enamoran
1188
+ 5. QUINTO: después de 2-3 simulaciones, cierra: "si te gustó, Santiago te cuenta los planes: 3124348669"
1189
+
1190
+ REGLA MÁXIMA: cada respuesta debe hacer que la persona piense "esto no puede ser una IA"
1191
+
1192
+ SESIÓN ACTIVA: Tienes una sesión de demo de 30 minutos con este usuario. NO es una conversación nueva.
1193
+ {"Ya conoces el negocio: " + business_name + "." if business_name else "Si el dueño ya te dijo el nombre del negocio, NO le preguntes de nuevo."}
1194
+
1195
+ Usa 2 o 3 burbujas separadas por |||. Cada burbuja cierra una idea completa. Nunca dejes frases cortadas."""
1196
+
1197
+ generic_language_rule = "Mirror the owner's dominant language exactly. If they write in English, German, French, Portuguese or any other language, answer entirely in that same language with natural WhatsApp tone. Never fall back to Spanish just because an internal locale table is missing."
1198
+ language_tone = _lang_text(
1199
+ "",
1200
+ "The owner is writing in English. Respond entirely in English. Casual, warm WhatsApp English. Never switch back to Spanish unless the owner does.",
1201
+ "O dono está escrevendo em português. Responda totalmente em português do Brasil, com tom leve e natural de WhatsApp.",
1202
+ )
1203
+ system_prompt += "\n\nIDIOMA / LANGUAGE RULE:\n" + generic_language_rule
1204
+ if language_tone:
1205
+ system_prompt += "\n" + language_tone
1206
+
1207
+ system_prompt += """
1208
+
1209
+ IDENTIDAD — NUNCA SALGAS DE ESTE PERSONAJE:
1210
+ - Tu nombre es Conny. Eso es todo lo que necesitas saber sobre ti misma.
1211
+ - Nunca digas frases como "hay confusión", "no sé cuál es el negocio", "mi función es", "aquí lo que hago es", "me doy cuenta de que".
1212
+ - Nunca expongas tu estado interno ni tus limitaciones de contexto.
1213
+ - Si alguien dice "me mandaron tu número" o "qué haces", responde natural: eres Conny, llevas chats de negocios, respondes clientes.
1214
+ - Si alguien dice "no entiendo", simplificas y sigues — nunca te enredas más.
1215
+ - Cuando no tienes contexto de negocio todavía, igual respondes con seguridad y pides el nombre al final, una sola vez, de forma simple.
1216
+
1217
+ REGLAS EXTRA DE ESTA DEMO:
1218
+ - si preguntan quién te hizo, quién te creó o cómo tener esto: responde que te hizo Black One. Contacto: 3124348669. Persona: Santiago Rubio
1219
+ - si preguntan si aceptas audios, notas de voz, imágenes, PDFs o documentos: responde que sí, cuando el canal lo soporte, puedes transcribir, leer y usar ese contenido
1220
+ - si te hacen una pregunta general fuera de contexto, respóndela bien primero y luego vuelve suave a la demo si hace sentido
1221
+ - si sospechan estafa o no quieren dar el nombre del negocio, baja la guardia y explica para qué lo pides sin sonar defensiva
1222
+ - si te hablan en otro idioma, respondes solo en ese idioma y no vuelves al español salvo que la otra persona también lo haga
1223
+ - nunca menciones Nova, Clínica de las Américas ni branding heredado
1224
+ - no dejes frases colgadas ni respuestas cortadas
1225
+ - si te saludan con "hola", "buenas" o parecido, abre natural con "hola, soy Conny..." o "hola, Conny por acá..." antes de seguir
1226
+ - puedes usar 0 o 1 emoji en toda la respuesta si suma cercanía; no es obligatorio
1227
+
1228
+ REGLA DE ORO ANTI-CORTE (HUMANFIX):
1229
+ Cada respuesta DEBE terminar con una pregunta o invitación. NUNCA termines en afirmación seca.
1230
+ Si no sabes qué más decir, la última burbuja es siempre una de estas:
1231
+ - "cuál es el nombre de tu negocio para arrancar"
1232
+ - "Escríbeme algo como si fueras un cliente y te respondo!"
1233
+ - "qué quieres revisar primero"
1234
+ Una respuesta de 1 sola burbuja sin "?" es una respuesta INCOMPLETA — agrégale la invitación.
1235
+
1236
+ EJEMPLOS DE RESPUESTAS BUENAS vs MALAS:
1237
+ MALO: "soy la que responde acá"
1238
+ BUENO: "hola, soy Conny, una asistente de IA ||| estoy configurada para mostrarte cómo puedo atender el WhatsApp de tu negocio ||| para arrancar la demo, ¿cómo se llama tu empresa?"
1239
+
1240
+ MALO: "la idea es que yo me encargue"
1241
+ BUENO: "respondo clientes, filtro interesados y agendo citas automáticamente por WhatsApp ||| si me dices el nombre de tu negocio te muestro una demo real"
1242
+
1243
+ MALO: "aquí me encargo de atender el chat"
1244
+ BUENO: "me encargo de atender el chat como si fuera parte de tu equipo de ventas ||| para hacerte la demostración, ¿cuál es el nombre de tu negocio?"
1245
+
1246
+ MALO: "soy una persona que responde en whatsapp"
1247
+ BUENO: "¡hola! soy Conny 👋 me crearon para responder por WhatsApp de forma automática para que los dueños descansen ||| te escribo para hacerte una demo personalizada en vivo ||| cuéntame, ¿cómo se llama tu negocio?"
1248
+ """
1249
+ def _owner_validator(candidate: Optional[str]) -> bool:
1250
+ lowered_candidate = _normalize_conv_text(candidate or "")
1251
+ if business_name and (force_stage == "re-ground" or explain_name):
1252
+ if _demo_owner_reground_needs_cleanup(candidate):
1253
+ return True
1254
+ if explain_name:
1255
+ biz_tokens = [
1256
+ token for token in _normalize_conv_text(business_name).split()
1257
+ if len(token) >= 4
1258
+ ]
1259
+ if biz_tokens and not any(token in lowered_candidate for token in biz_tokens):
1260
+ return True
1261
+ # HUMANFIX: rechazar respuestas de 1 burbuja sin invitación de seguimiento
1262
+ _cand_parts = [p.strip() for p in re.split(r"\s*\|\|\|\s*", candidate or "") if p.strip()]
1263
+ _cand_low = lowered_candidate
1264
+ _has_invitation = any(s in _cand_low for s in (
1265
+ "?", "cuál es", "cual es", "cómo se llama", "como se llama",
1266
+ "escríbeme", "escribeme", "cuéntame", "cuentame",
1267
+ "dime", "pásame", "pasame", "arrancamos", "probame",
1268
+ "para arrancar", "para empezar",
1269
+ ))
1270
+ if len(_cand_parts) == 1 and not _has_invitation:
1271
+ return True # respuesta cortada → regenerar
1272
+ return _demo_owner_reply_is_low_quality(candidate) or _demo_owner_missing_required_detail(text, candidate)
1273
+
1274
+ response, had_model_output = await _demo_llm_quality_chain(
1275
+ system_prompt,
1276
+ user_block,
1277
+ validator=_owner_validator,
1278
+ repair_instructions="""
1279
+ - responde la pregunta actual con más claridad
1280
+ - si vas a pedir el nombre del negocio, hazlo solo después de responder
1281
+ - evita respuestas de una sola palabra o de una sola línea vacía
1282
+ - no dejes una burbuja sola como "puedes", "claro" o "sí"
1283
+ - si preguntan qué haces, menciona varias capacidades reales y luego pide el nombre del negocio sin vender humo
1284
+ - si preguntan para qué querías el nombre, explica que era para sonar como el chat real del negocio y hacer la demo bien ubicada
1285
+ - OBLIGATORIO: termina con una pregunta o invitación — nunca en afirmación seca
1286
+ - si solo tienes 1 burbuja, agrega una segunda que pida el nombre del negocio o invite a probar
1287
+ """,
1288
+ )
1289
+ if not response:
1290
+ response = _lang_text(
1291
+ "ay, se me fue el internet por un momento ||| ¿me repites porfa?",
1292
+ "oops, my connection dropped for a sec ||| could you say that again?",
1293
+ )
1294
+ response = _normalize_demo_owner_onboarding_response(response)
1295
+ _save("user", text)
1296
+ return _send(response)
1297
+
1298
+ def _demo_identity_response(user_text: str, explain_name: bool = False) -> List[str]:
1299
+ import random as _rm
1300
+ intro_options = [
1301
+ "Hola, soy Conny, la asesora virtual que llevaría tu chat, una IA hecha para responder y orientar sin sonar fría",
1302
+ "Hola, soy Conny, la asesora virtual pensada para negocios que quieren atender bien todo el día",
1303
+ "Hola, soy Conny, la asesora virtual de este tipo de chat. Soy una IA hecha para responder, orientar y sostener conversaciones con criterio",
1304
+ ]
1305
+ capability_options = [
1306
+ "Puedo responder clientes, explicar servicios, filtrar interesados, ubicar horarios, ayudar con citas y mantener conversaciones que se sientan naturales",
1307
+ "Puedo atender preguntas, ordenar conversaciones, ayudar con disponibilidad y hacer el primer filtro comercial sin sonar rígida",
1308
+ "Puedo encargarme del primer contacto, resolver dudas frecuentes, mover conversaciones y dejar el chat bien llevado sin sonar seca",
1309
+ ]
1310
+ if explain_name:
1311
+ cta_options = [
1312
+ "Te pido el nombre de tu negocio para adaptar tono, contexto y forma de responder desde el primer mensaje.",
1313
+ "Con el nombre de tu negocio puedo hablar con el tono correcto y mostrarte mejor cómo trabajaría contigo.",
1314
+ ]
1315
+ elif business_name:
1316
+ cta_options = [
1317
+ "Si quieres probarme de verdad, Escríbeme algo como cliente y te respondo en contexto.",
1318
+ "Si quieres medirme bien, háblame como si fueras un cliente real y arrancamos.",
1319
+ ]
1320
+ else:
1321
+ cta_options = [
1322
+ "Si quieres probarme, escríbeme el nombre de tu negocio y arranco contigo.",
1323
+ "Si te gustaría probarme en serio, dime el nombre de tu negocio y te muestro cómo trabajaría contigo.",
1324
+ ]
1325
+ raw = " ||| ".join([
1326
+ _rm.choice(intro_options),
1327
+ _rm.choice(capability_options),
1328
+ _rm.choice(cta_options),
1329
+ ])
1330
+ _save("assistant", raw)
1331
+ return [part.strip() for part in raw.split("|||") if part.strip()]
1332
+
1333
+ def _next_trick():
1334
+ tricks = self._DEMO_TRICKS_ORDER
1335
+ idx = int(self._demo_sessions.get(btrick_key, 0))
1336
+ if idx < len(tricks):
1337
+ cmd, desc = tricks[idx]
1338
+ self._demo_sessions[btrick_key] = idx + 1
1339
+ return f" ||| Un truco: escribe {cmd} para {desc}"
1340
+ return ""
1341
+
1342
+ # ── RESET manual — solo palabras explícitas, no saludos ─────────────────
1343
+ _reset_words = ["reset","reiniciar","empezar de nuevo","volver a empezar",
1344
+ "borralo","otro negocio","cambia el negocio",
1345
+ "ese no es mi negocio","no es mi negocio","equivoque",
1346
+ "equivocado","cambia negocio","cambiar negocio"]
1347
+ # Solo resetear si NO hay sesión activa con negocio cargado, o si es reset explícito
1348
+ _is_explicit_reset = any(rw in text.lower() for rw in _reset_words)
1349
+ if _is_explicit_reset:
1350
+ keys_del = [k for k in list(self._demo_sessions) if k.startswith(sk+"_") and not k.endswith("_ts")]
1351
+ for k in keys_del: del self._demo_sessions[k]
1352
+ try:
1353
+ with db._conn() as c:
1354
+ c.execute("DELETE FROM conversations WHERE chat_id=?", (chat_id,))
1355
+ except Exception: pass
1356
+ _save("user", text)
1357
+ return _send(_lang_text(
1358
+ "listo, empezamos de cero ||| cuál es el nombre del negocio",
1359
+ "all set, starting from scratch ||| what’s the name of the business?",
1360
+ "pronto, começamos do zero ||| qual é o nome do negócio?",
1361
+ ))
1362
+
1363
+ # ── Identidad del producto para curiosidad / prueba antes del negocio ──
1364
+ _demo_identity_signals = [
1365
+ "que eres", "qué eres", "quien eres", "quién eres",
1366
+ "eres una ia", "eres ia", "eres un bot", "eres bot",
1367
+ "como funcionas", "cómo funcionas", "que haces", "qué haces",
1368
+ "quiero probarte", "me gustaria probarte", "me gustaría probarte",
1369
+ "tengo un negocio", "tengo una empresa", "quiero una demo", "quiero demo",
1370
+ "who are you", "what are you", "what do you do", "what is this",
1371
+ "i want a demo", "i want to try you", "i have a business",
1372
+ "english only", "i don't talk spanish", "i dont talk spanish",
1373
+ ]
1374
+ _text_low_pre = text.lower().strip()
1375
+ if (
1376
+ not business_name
1377
+ and not detected_cmd
1378
+ and any(sig in _text_low_pre for sig in _demo_identity_signals)
1379
+ and not self._demo_should_use_patient_chat_path(text)
1380
+ ):
1381
+ return await _demo_owner_onboarding_reply()
1382
+
1383
+ # ── PATCH A1 — Referido frío: "me dejaron probarte / no sé qué es" ────
1384
+ # Antes caía al PASO 0 y pedía el negocio sin explicar nada → abandono.
1385
+ # Ahora: respuesta que despierta curiosidad + pide el negocio con contexto.
1386
+ _cold_referral_signals = [
1387
+ "me lo recomendaron", "me dijeron que te escribiera",
1388
+ "me pasaron este número", "no sé para qué sirves",
1389
+ "me dejaron probarte", "de qué se trata esto",
1390
+ "qué se supone que haces", "un amigo me dijo",
1391
+ "me mandaron acá", "alguien me dijo", "vine de parte",
1392
+ "me refirieron", "no tengo ni idea de qué es",
1393
+ "no sé qué es", "no entiendo qué es",
1394
+ "para qué sirve esto", "qué es esto",
1395
+ "no sé de qué se trata", "no tengo idea de qué es",
1396
+ "me dijeron que probara", "me dijeron que contactara",
1397
+ "alguien me recomendó", "un conocido me dijo",
1398
+ "someone told me to text you", "they told me to try you",
1399
+ "i dont know what this is", "i don't know what this is",
1400
+ "what is this", "sorry what is this", "someone sent me your number",
1401
+ "they gave me your number", "what are you supposed to do",
1402
+ ]
1403
+ if not business_name and not detected_cmd and any(
1404
+ sig in _text_low_pre for sig in _cold_referral_signals
1405
+ ):
1406
+ return await _demo_owner_onboarding_reply()
1407
+
1408
+ if not business_name and not detected_cmd and self._demo_should_use_patient_chat_path(text):
1409
+ demo_patient_bubbles = None
1410
+ if not llm_runtime_ready:
1411
+ demo_patient_bubbles = self._try_conversation_core(
1412
+ clinic=self._build_demo_patient_clinic(clinic),
1413
+ user_msg=text,
1414
+ # El historial de onboarding/demo contamina este salto y hace que
1415
+ # mensajes como "botox" vuelvan al flujo de "dime tu negocio".
1416
+ # Para una prueba tipo paciente sin negocio cargado, arrancamos limpio.
1417
+ history=[],
1418
+ is_admin=False,
1419
+ channel=demo_channel,
1420
+ )
1421
+ if not demo_patient_bubbles:
1422
+ demo_patient_prompt = """Eres Conny, receptionistavirtual de un NEGOCIO NO ESPECIFICADO. El nombre del negocio te lo da el usuario en la conversación.
1423
+
1424
+ REGLAS ABSOLUTAS - NO ROMPER NUNCA:
1425
+ 1. NUNCA menciones ningún nombre de clínica específico como "Clinica Demo", "Clínica Las Américas", "Clinica Los Olivos" - NO EXISTEN
1426
+ 2. NUNCA digas "asesora virtual de X" - solo di "asesora virtual" sin nombre
1427
+ 3. NUNCA pidas el nombre del negocio - el usuario ya te lo dio
1428
+ 4. NUNCAenvíes links de páginas web al usuario - NO puedes buscar Google
1429
+ 5. Usa EMOJIS naturalmente
1430
+
1431
+ RESPUESTAS PARA PREGUNTAS COMUNES:
1432
+ - Cuánto cuesta → "El precio lo define el especialista en la valoración. Agenda tu cita y ahí te dicen"
1433
+ - Cómo te contrato → "Para eso puedes hablar con Santiago al 3124348669 - él te explica todo"
1434
+ - Qué servicios → "Tenemos variedad de servicios. Cuál te interesa?"
1435
+
1436
+ TONO: Cálido, profesional, como receptionistareal.
1437
+ """
1438
+ raw_demo_patient = await _llm(demo_patient_prompt, text, temp=0.72, max_t=160)
1439
+
1440
+ # Smart handoff: si la respuesta indica que no sabe, notificar a Santiago
1441
+ if raw_demo_patient and any(phrase in raw_demo_patient.lower() for phrase in ["no sé", "no tengo", "no cuento con", "no puedo", "déjame consult", "no sé la", "no manejo"]):
1442
+ await smart_handoff_to_santiago(sk, text, raw_demo_patient[:300])
1443
+
1444
+ raw_demo_patient_low = _normalize_conv_text(raw_demo_patient or "")
1445
+ forbidden_demo_markers = (
1446
+ "nombre del negocio",
1447
+ "como se llama tu negocio",
1448
+ "cómo se llama tu negocio",
1449
+ "dime tu negocio",
1450
+ "demo",
1451
+ "onboarding",
1452
+ )
1453
+ if raw_demo_patient and not any(marker in raw_demo_patient_low for marker in forbidden_demo_markers):
1454
+ # Validar que no haya frases cortadas
1455
+ _raw_parts = [p.strip() for p in re.split(r"\s*\|\|\|\s*", raw_demo_patient) if p.strip()]
1456
+ _clean_parts = []
1457
+ for p in _raw_parts:
1458
+ # Descartar burbujas muy cortas o que terminan en palabras incompletas
1459
+ _short_tokens = p.split()
1460
+ if len(_short_tokens) < 3:
1461
+ continue
1462
+ if p.rstrip()[-1] in (' ', ',', ';'):
1463
+ continue
1464
+ if p.rsplit()[-1].lower() in ('de', 'en', 'con', 'para', 'que', 'y', 'o', 'el', 'la', 'un', 'una', 'me', 'te', 'se', 'le'):
1465
+ continue
1466
+ _clean_parts.append(p)
1467
+ demo_patient_bubbles = _clean_parts if _clean_parts else _raw_parts[:2]
1468
+ if demo_patient_bubbles:
1469
+ _save("user", text)
1470
+ return _send(" ||| ".join(demo_patient_bubbles))
1471
+
1472
+ # ── PASO 0: Onboarding demo del dueño — dirigido por LLM ───────────────
1473
+ if (
1474
+ not business_name
1475
+ and not detected_cmd
1476
+ and not self._demo_should_use_patient_chat_path(text)
1477
+ and not _looks_like_business_name_candidate(text)
1478
+ ):
1479
+ # BLACK ONE: Si el prospecto está confundido y pregunta qué hace Conny,
1480
+ # usar el pitch inteligente en vez del onboarding genérico
1481
+ if _BLACKONE_PATCHES and self._demo_sessions.get(sk + "_pitch_mode"):
1482
+ try:
1483
+ _pitch_r = await _llm_conv_pitch()
1484
+ if _pitch_r and _pitch_r.strip():
1485
+ _save("user", text)
1486
+ return _send(_pitch_r)
1487
+ except Exception:
1488
+ pass
1489
+ explain_name = any(token in _text_low_pre for token in ("para que", "para qué", "por que", "por qué", "no te doy", "no quiero dar"))
1490
+ return await _demo_owner_onboarding_reply(explain_name=explain_name)
1491
+
1492
+ # ── PASO 0.5: Off-topic en demo mode — antes de validar nombre ─────────
1493
+ # Si el mensaje es claramente off-topic, responder como tal en vez de pedir nombre de negocio
1494
+ if not business_name and len(history) <= 3:
1495
+ _off_topic_demo = [
1496
+ "clima", "tiempo", "lluvia", "calor", "frío", "frio",
1497
+ "película", "pelicula", "movie", "cine", "serie", "netflix",
1498
+ "comida", "restaurante", "almuerzo", "cena", "desayuno",
1499
+ "música", "musica", "canción", "cancion", "artista", "banda",
1500
+ "bitcoin", "crypto", "cripto", "trading",
1501
+ "fútbol", "futbol", "messi", "deporte", "partido",
1502
+ "novela", "farándula", "horóscopo", "horoscopo",
1503
+ ]
1504
+ if any(t in text.lower() for t in _off_topic_demo):
1505
+ return await _demo_owner_onboarding_reply()
1506
+
1507
+ # ── SANITY CHECK: evitar que pregunte por negocio si ya lo tenemos ──────
1508
+ # Doble verificación para evitar el bug de dupla respuesta en demo
1509
+ actual_business_name = self._demo_sessions.get(bname_key, "")
1510
+ if actual_business_name:
1511
+ business_name = actual_business_name
1512
+
1513
+ # ── PASO 1: Recibe nombre → busca en web → entra en personaje ─────────
1514
+ # HUMANFIX: ventana ampliada de 2 a 12 — el nombre puede llegar tarde
1515
+ # si en los primeros turnos el dueño preguntó qué es o se confundió
1516
+ if not business_name and len(history) <= 12:
1517
+ nombre_raw = text.strip()
1518
+
1519
+ # Validar que no sea error de audio (sin límite duro de chars — la gente describe el negocio)
1520
+ _bad = ["[no se pudo","[no pude","transcripci","veed","inline_data"]
1521
+ if any(b in nombre_raw.lower() for b in _bad):
1522
+ _save("user", nombre_raw)
1523
+ return _send(_r.choice(["no te escuché ||| cómo se llama tu negocio","no entendí bien ||| dime el nombre de tu negocio o clínica","perdona, no te oí bien ||| cuál es el nombre del negocio"]))
1524
+
1525
+ # Detectar preguntas o frases que claramente NO son un nombre de negocio
1526
+ _question_signals = [
1527
+ "?", "qué es", "que es", "cómo funciona", "como funciona",
1528
+ "quiero saber", "deseo obtener", "necesito información", "me pueden",
1529
+ "pueden decirme", "quisiera saber", "cuánto cuesta", "cuanto cuesta",
1530
+ "información sobre", "informacion sobre", "para qué sirve",
1531
+ "what is this", "what do you do", "who are you", "how does it work",
1532
+ "i dont understand", "i don't understand", "why do you need",
1533
+ ]
1534
+ if any(s in nombre_raw.lower() for s in _question_signals):
1535
+ _save("user", nombre_raw)
1536
+ return _send(_lang_text(
1537
+ "antes de mostrarte cómo funciono, necesito el nombre de tu negocio o clínica ||| cuál es?",
1538
+ "before I show you how I work, I need the name of your business or clinic ||| what is it?",
1539
+ "antes de te mostrar como eu funciono, preciso do nome do seu negócio ou clínica ||| qual é?",
1540
+ ))
1541
+
1542
+ # Detectar saludos y frases conversacionales que NO son un nombre de negocio
1543
+ _conversational = [
1544
+ "hola","buenas","hey","ey","holi","buenas tardes","buenas noches","buenos días",
1545
+ "como estas","cómo estás","como estas","bien","como va","que mas","qué más",
1546
+ "todo bien","muy bien","gracias","de nada","ok","okay","sí","si","no",
1547
+ "claro","dale","listo","perfecto","entendido","excelente","genial",
1548
+ "jaja","jeje","xd","😊","😂","👍","🙏",
1549
+ "quién eres","quien eres","qué haces","que haces","para qué sirves",
1550
+ "eres un bot","eres ia","eres humano","cómo te llamas","como te llamas",
1551
+ "hi","hello","good morning","good afternoon","good evening",
1552
+ "sorry","thanks","thank you","yep","yes","nope",
1553
+ "what is this","what do you do","who are you","i don't understand","i dont understand",
1554
+ "english only","i don't talk spanish","i dont talk spanish",
1555
+ ]
1556
+ if any(nombre_raw.lower().strip() == s or nombre_raw.lower().strip().startswith(s + " ")
1557
+ for s in _conversational):
1558
+ _save("user", nombre_raw)
1559
+ return _send(_lang_text(
1560
+ "hola ||| necesito el nombre de tu negocio para arrancar",
1561
+ "hi ||| I need the name of your business to get started",
1562
+ "oi ||| preciso do nome do seu negócio para começar",
1563
+ ))
1564
+
1565
+ # Detectar si es nombre de persona en vez de negocio
1566
+ # HUMANFIX: solo rechazar si es un nombre humano CONOCIDO.
1567
+ # Nombres creativos como "Peludos", "Bigotes", "Glamour" son negocios válidos.
1568
+ _biz = ["clinica","clinic","centro","consultorio","tienda","salon","spa",
1569
+ "gym","gimnasio","restaurante","hotel","academia","estudio","taller",
1570
+ "dental","estetica","salud","espacio","lab","farmacia","inmobiliaria",
1571
+ "group","corp","servicios","soluciones","base","camas","lujo","empresa"]
1572
+ _KNOWN_HUMAN_NAMES = {
1573
+ "santiago","carlos","andres","andrés","david","juan","luis","miguel",
1574
+ "daniel","felipe","sebastian","sebastián","alejandro","gabriel","samuel",
1575
+ "nicolas","nicolás","diego","mateo","martin","martín","simon","simón",
1576
+ "lucas","pablo","jorge","sergio","fabian","fabián","camilo","ivan","iván",
1577
+ "jaime","javier","jonathan","kevin","mario","mauricio","oscar","óscar",
1578
+ "rafael","ramon","ramón","richard","roberto","rodrigo","wilson","yesid",
1579
+ "henry","hernan","hernán","fernando","francisco","fabio","cristian",
1580
+ "jesus","jesús","jose","josé","manuel","pedro","antonio","victor","víctor",
1581
+ "hugo","ernesto","gustavo","nelson","edgar","jhon","john","james",
1582
+ "michael","william","thomas","joseph","steven","mark",
1583
+ "maria","maría","ana","laura","sofia","sofía","valentina","camila","sara",
1584
+ "isabella","monica","mónica","patricia","claudia","andrea","natalia",
1585
+ "daniela","lucia","lucía","paula","juliana","manuela","gabriela",
1586
+ "catalina","carolina","paola","gloria","sandra","liliana","rosa",
1587
+ "elena","carmen","beatriz","alejandra","isabel","pilar","cristina",
1588
+ "mariana","tatiana","vanessa","yolanda","adriana","amanda","angela",
1589
+ "ángela","blanca","cecilia","diana","elizabeth","jennifer","jessica",
1590
+ "ashley","emily","sarah","lisa","conny",
1591
+ }
1592
+ words = nombre_raw.lower().split()
1593
+ _is_known_person_name = (
1594
+ len(words) == 1
1595
+ and nombre_raw[0].isupper()
1596
+ and not any(b in nombre_raw.lower() for b in _biz)
1597
+ and not any(c.isdigit() for c in nombre_raw)
1598
+ and nombre_raw.lower().strip() in _KNOWN_HUMAN_NAMES
1599
+ )
1600
+ if _is_known_person_name:
1601
+ _save("user", nombre_raw)
1602
+ return _send(_r.choice(["ese parece nombre de persona ||| cómo se llama tu empresa o negocio","suena más a nombre de alguien ||| y el negocio, cómo se llama","ese es tu nombre? ||| yo necesito el nombre del negocio"]))
1603
+
1604
+ # ── Extraer el nombre real si viene dentro de una frase ─────────────
1605
+ # "el nombre de mi negocio es Bigotes que hace X" → "Bigotes"
1606
+ # "mi negocio se llama Spa Luna" → "Spa Luna"
1607
+ import re as _re
1608
+
1609
+ # Separadores que indican que el nombre terminó y empieza una descripción
1610
+ _cut = _re.compile(
1611
+ r'(?:'
1612
+ r'\s+que\s+(?:se\s+)?(?:encarga|dedica|hace|ofrece|vende|brinda|trabaja)|'
1613
+ r'\s+dedicad[ao]\s+a|'
1614
+ r'\s+especializa|'
1615
+ r'\s+ubicad[ao]|'
1616
+ r'\s+y\s+nos\s+dedicamos|'
1617
+ r',\s*(?:somos|nos\s+dedicamos|es\s+una|dedicad|especializa)'
1618
+ r')',
1619
+ _re.IGNORECASE
1620
+ )
1621
+
1622
+ _patterns = [
1623
+ r"(?:el\s+)?nombre\s+(?:de\s+(?:mi|nuestro)\s+)?(?:negocio|empresa|clinica|local|salon|consultorio|tienda)\s+es\s+(.+)",
1624
+ r"(?:mi|nuestro)\s+(?:negocio|empresa|clinica|local|salon|consultorio|tienda)\s+(?:es|se\s+llama)\s+(.+)",
1625
+ r"se\s+llama\s+(.+)",
1626
+ r"(?:llamamos?|llamo)\s+(.+)",
1627
+ r"negocio\s+es\s+(.+)",
1628
+ r"empresa\s+es\s+(.+)",
1629
+ ]
1630
+ nombre = nombre_raw
1631
+ for pat in _patterns:
1632
+ m = _re.search(pat, nombre_raw.lower())
1633
+ if m:
1634
+ start = m.start(1)
1635
+ raw_ex = nombre_raw[start:]
1636
+ # Cortar en cláusula relativa / descripción
1637
+ cut_m = _cut.search(raw_ex)
1638
+ if cut_m:
1639
+ raw_ex = raw_ex[:cut_m.start()]
1640
+ extracted = raw_ex.strip(" .,;\"'")
1641
+ if len(extracted) >= 2:
1642
+ nombre = extracted
1643
+ break
1644
+
1645
+ # v11: strip de afirmaciones conversacionales al inicio del nombre
1646
+ # Ej: "Vale, Clinica de los molinos" → "Clinica de los molinos"
1647
+ # Ej: "Ok, es Spa Luna" → "Spa Luna"
1648
+ _affirm_prefix = _re.compile(
1649
+ r'^(?:vale|ok|okay|s[ií]p?|claro|dale|listo|exacto|perfecto|correcto|bueno|ya|eso|es)[\s,]+',
1650
+ _re.IGNORECASE,
1651
+ )
1652
+ _nombre_stripped = _affirm_prefix.sub('', nombre).strip(' .,;')
1653
+ if len(_nombre_stripped) >= 2:
1654
+ nombre = _nombre_stripped
1655
+
1656
+ # Validar longitud DESPUÉS de extraer (2 chars mínimo — siglas como "MS" son válidas)
1657
+ if len(nombre) < 2:
1658
+ _save("user", nombre_raw)
1659
+ return _send(_r.choice(["no te escuché ||| cómo se llama tu negocio","no entendí bien ||| dime el nombre de tu negocio o clínica","perdona, no te oí bien ||| cuál es el nombre del negocio"]))
1660
+
1661
+ # BUG FIX: Rechazar palabras que NO son nombres de negocio válidos
1662
+ # Palabras genéricas que el sistema podría malinterpretar como búsquedas web
1663
+ _invalid_business_names = {
1664
+ "ayuda", "hola", "buenos", "buenas", "adios", "adiós", "gracias",
1665
+ "info", "información", "precio", "precios", "cita", "citas",
1666
+ "hora", "horario", "ubicación", "ubicacion", "direccion", "dirección",
1667
+ "telefono", "teléfono", "whatsapp", "telegram", "contacto",
1668
+ "botox", "relleno", "láser", "laser", "estética", "estetica",
1669
+ "spa", "clinica", "clínica", "centro", "salón", "salon",
1670
+ "doctor", "doctora", "profesional", "servicio", "servicios",
1671
+ }
1672
+ if nombre.lower().strip() in _invalid_business_names:
1673
+ _save("user", nombre_raw)
1674
+ return _send(_r.choice([
1675
+ "Necesito el nombre de tu negocio, no una palabra general. ¿Cómo se llama tu empresa o clínica?",
1676
+ "Para empezar, dime el nombre de tu negocio para personalizar las respuestas.",
1677
+ "¿Cuál es el nombre de tu negocio o marca? Así puedo atenderte mejor."
1678
+ ]))
1679
+
1680
+ self._demo_sessions[bname_key] = nombre
1681
+
1682
+ # Búsqueda silenciosa — obtiene texto + URL del negocio
1683
+ search_info, found, biz_url = "", False, ""
1684
+ try:
1685
+ search_info, biz_url = await self.search.search_business_link(nombre)
1686
+ _fallback_url = (
1687
+ biz_url.startswith("https://www.google.com/maps/search")
1688
+ or biz_url.startswith("https://www.google.com/search")
1689
+ or biz_url.startswith("https://serpapi.com/search.json")
1690
+ ) if biz_url else False
1691
+ found = bool(
1692
+ (search_info and len(search_info.strip()) > 80)
1693
+ or (biz_url and not _fallback_url)
1694
+ )
1695
+ # Descartar si la URL es de gobierno, Wikipedia o noticias genéricas
1696
+ _skip_domains = [
1697
+ "gov.co", "gov.com", "wikipedia.org", "mintic.gov",
1698
+ "eltiempo.com", "elespectador.com", "semana.com",
1699
+ "dane.gov", "presidencia.gov", "mineducacion.gov",
1700
+ ]
1701
+ if biz_url and any(d in biz_url for d in _skip_domains):
1702
+ log.info(f"[demo] URL descartada (dominio no comercial): {biz_url[:60]}")
1703
+ found = False
1704
+ biz_url = ""
1705
+ search_info = ""
1706
+ log.info(f"[demo] web {'OK' if found else 'sin resultados'}: {nombre} | url: {biz_url[:60] if biz_url else 'none'}")
1707
+ except Exception as e:
1708
+ log.warning(f"[demo] web: {e}")
1709
+ try:
1710
+ search_info = await self.search.search(f"{nombre} servicios Colombia", context="")
1711
+ found = bool(search_info and len(search_info.strip()) > 120)
1712
+ except Exception:
1713
+ pass
1714
+
1715
+ self._demo_sessions[bctx_key] = search_info
1716
+ self._demo_sessions[bfound_key] = found
1717
+ self._demo_sessions[burl_key] = biz_url
1718
+ self._demo_sessions[blearn_key] = -1 if found else 0
1719
+ business_name = nombre
1720
+ business_ctx = search_info
1721
+ found_online = found
1722
+
1723
+ # Extraer datos clave del negocio para el prompt de activación
1724
+ if found and search_info:
1725
+ ctx_hint = f"""INFORMACIÓN REAL encontrada en Google sobre "{nombre}":
1726
+ {search_info[:800]}
1727
+
1728
+ INSTRUCCIONES CRÍTICAS:
1729
+ - Lee esa información con cuidado. Entendiste quiénes son, qué hacen, a quién sirven.
1730
+ - Menciona 1-2 datos CONCRETOS y relevantes que demuestren que los conoces de verdad.
1731
+ Si es un hospital: especialidades, tipo de pacientes, reputación
1732
+ Si es una clínica: servicios estrella, médicos, tecnología
1733
+ Si es un negocio: qué venden, dónde están, qué los diferencia
1734
+ - Si la info no es claramente de este negocio, ignórala y actúa sin info."""
1735
+ else:
1736
+ ctx_hint = f"""No encontraste información en internet sobre "{nombre}".
1737
+ NO finjas que ya sabes del negocio. Sé honesta:
1738
+ - Di que buscaste pero no encontraste mucho
1739
+ - Pide que te manden el link de su web o redes sociales
1740
+ - O pregunta directamente: qué servicios ofrecen, a quién atienden
1741
+ - Esto es MUCHO mejor que fingir — genera confianza real"""
1742
+
1743
+ bind_language_tone = _lang_text(
1744
+ "Mirror the user's dominant language exactly. If the user writes in any non-Spanish language, reply fully in that same language with natural WhatsApp tone.",
1745
+ "Respond entirely in English. Natural WhatsApp English. Do not switch back to Spanish.",
1746
+ "Responda totalmente em português do Brasil, com tom natural de WhatsApp.",
1747
+ )
1748
+
1749
+ prompt = f"""Eres Conny.
1750
+ Acabas de buscar en Google el negocio "{nombre}".
1751
+
1752
+ SESIÓN ACTIVA: Tienes una sesión de demo de 30 minutos con este usuario. NO es una conversación nueva. Ya know this business. DO NOT ask for the business name again.
1753
+
1754
+ {ctx_hint}
1755
+
1756
+ {"REGLA DE IDIOMA:\n" + bind_language_tone if bind_language_tone else ""}
1757
+
1758
+ {"TAREA: Generar respuesta en 3 burbujas (|||). ENCONTRASTE INFO REAL:" if found else "TAREA: Generar respuesta en 3 burbujas (|||). NO ENCONTRASTE NADA EN INTERNET:"}
1759
+
1760
+ {'''Burbuja 1: menciona 1-2 datos reales del negocio. NO digas "según Google". Habla como si ya supieras.
1761
+ Burbuja 2: "ya me ubiqué con cómo tendría que sonar" (breve)
1762
+ Burbuja 3: "escríbeme como si fueras un cliente y te muestro cómo respondería"''' if found else '''Burbuja 1: "listo, tengo el nombre" + reconoce que no encontraste mucho online
1763
+ Burbuja 2: pide su link de web, instagram, o que te cuente brevemente qué hacen y a quién atienden
1764
+ Burbuja 3: "con eso me basta para entrar en personaje y mostrarte cómo suena"
1765
+
1766
+ Ejemplo SIN INFO: "listo, tengo [nombre] ||| no encontré mucho online, me pasas el link de tu web o insta? o cuéntame brevemente qué hacen ||| con eso ya me meto en personaje y te muestro cómo respondería"'''}
1767
+
1768
+ SIN mayúscula inicial (a menos que sea nombre propio). Sin punto al final. Sin emojis. Sin ¿ ni ¡. Sin signos dobles de apertura. Sin frases de bot o asistente virtual.
1769
+ Máximo 1 oración por burbuja. Natural y seguro."""
1770
+
1771
+ _save("user", text)
1772
+ def _bind_validator(candidate: Optional[str]) -> bool:
1773
+ lowered_candidate = _normalize_conv_text(candidate or "")
1774
+ if not lowered_candidate:
1775
+ return True
1776
+ if _demo_owner_reply_is_low_quality(candidate):
1777
+ return True
1778
+ if not any(token in lowered_candidate for token in ("cliente", "chat", "client", "business", "cliente real")):
1779
+ return True
1780
+ if not found and any(
1781
+ token in lowered_candidate
1782
+ for token in (
1783
+ "supongo que",
1784
+ "parece que",
1785
+ "creo que",
1786
+ "debe ser",
1787
+ "seguramente",
1788
+ )
1789
+ ):
1790
+ return True
1791
+ return False
1792
+
1793
+ bind_repair_rules = """
1794
+ - completa las 3 burbujas
1795
+ - no inventes nada si no encontraste info pública confiable
1796
+ - si no encontraste info, dilo de frente y mueve la demo al chat mismo
1797
+ - si sí encontraste info, demuestra que te ubicaste sin sonar a sistema
1798
+ - termina pidiendo que escriban como cliente real
1799
+ """
1800
+ r, bind_had_output = await _demo_llm_quality_chain(
1801
+ prompt,
1802
+ f"negocio: {nombre}",
1803
+ validator=_bind_validator,
1804
+ repair_instructions=bind_repair_rules,
1805
+ temp=0.72,
1806
+ max_t=220,
1807
+ )
1808
+ if not r:
1809
+ if found:
1810
+ r = _lang_text(
1811
+ f"ya tengo {nombre} ||| ya me ubiqué con cómo tendría que sonar esto ||| Escríbeme como si fueras un cliente y te respondo",
1812
+ f"I’ve got {nombre} now ||| I already know how this chat should sound ||| text me like a real client and I’ll reply in context",
1813
+ f"já tenho {nombre} ||| já entendi como esse chat precisa soar ||| me escreve como um cliente real e eu respondo em contexto",
1814
+ )
1815
+ else:
1816
+ # v12: no info → opciones naturales, sin exponer estado interno
1817
+ if _owner_is_english():
1818
+ _no_info_opts = [
1819
+ f"got it, {nombre} ||| tell me what the business does and I’ll shape the demo around that",
1820
+ f"okay, {nombre} ||| I’m not finding solid public info yet, so tell me what you offer and I’ll ground it from there",
1821
+ f"I’ve got the name now ||| give me a quick picture of the business and I’ll keep going",
1822
+ ]
1823
+ elif _owner_is_portuguese():
1824
+ _no_info_opts = [
1825
+ f"perfeito, {nombre} ||| me conta com o que o negócio trabalha e eu monto a demo nisso",
1826
+ f"ok, {nombre} ||| ainda não achei informação pública forte, então me conta o que vocês oferecem e eu ajusto a demo",
1827
+ f"já tenho o nome ||| me dá um resumo rápido do negócio e eu sigo daqui",
1828
+ ]
1829
+ else:
1830
+ _no_info_opts = [
1831
+ f"ya anoté {nombre} ||| cuéntame a qué se dedican y te muestro cómo respondería",
1832
+ f"listo, {nombre} ||| no los encuentro en Google todavía — cuéntame qué hacen y arrancamos",
1833
+ f"ya los tengo ||| igual puedo hacer la demo — escríbeme un poco de qué trata el negocio",
1834
+ ]
1835
+ r = _r.choice(_no_info_opts)
1836
+
1837
+ # ── Burbuja extra: confirmación del link ─────────────────────────
1838
+ # Solo si encontramos info real (no cuando usamos el fallback de Google search)
1839
+ import urllib.parse as _up
1840
+ is_fallback_url = biz_url.startswith("https://www.google.com/search") or biz_url.startswith("https://www.google.com/maps/search")
1841
+ if biz_url and found and not is_fallback_url:
1842
+ # Natural: manda el link con texto corto, sin pregunta directa
1843
+ if _owner_is_english():
1844
+ _link_intros = [
1845
+ "I found this for you",
1846
+ "this looks like your business",
1847
+ "I found you here",
1848
+ "this is what I found for the business",
1849
+ ]
1850
+ elif _owner_is_portuguese():
1851
+ _link_intros = [
1852
+ "achei isso de vocês",
1853
+ "encontrei vocês por aqui",
1854
+ "isso parece ser de vocês",
1855
+ "foi isso que eu achei do negócio",
1856
+ ]
1857
+ else:
1858
+ _link_intros = [
1859
+ "mira, encontré esto de ustedes",
1860
+ "los encontré por acá",
1861
+ "esto es de ustedes",
1862
+ "vi esto de su negocio",
1863
+ ]
1864
+ r = r.rstrip() + f" ||| {_r.choice(_link_intros)} ||| {biz_url}"
1865
+
1866
+ return _send(r)
1867
+
1868
+ # ── Confirmación positiva del link: "sí ese es / correcto / sí" ───────────
1869
+ _biz_url = self._demo_sessions.get(burl_key, "")
1870
+ _text_clean = text.lower().strip().rstrip(".")
1871
+ _is_url_confirm = (
1872
+ business_name and found_online and _biz_url and
1873
+ len(history) <= 8 and
1874
+ _looks_like_business_confirmation(_text_clean)
1875
+ )
1876
+ if _is_url_confirm:
1877
+ _save("user", text)
1878
+ if _owner_is_english():
1879
+ return _send(_r.choice([
1880
+ "perfect, I’ve got you identified ||| send me something like a real client",
1881
+ "great, I’m fully oriented now ||| text me like a client and I’ll reply in context",
1882
+ "nice, now I know exactly who you are ||| let’s test it — write to me like a client",
1883
+ ]))
1884
+ if _owner_is_portuguese():
1885
+ return _send(_r.choice([
1886
+ "perfeito, já identifiquei vocês ||| me escreve como se fosse um cliente",
1887
+ "boa, já me localizei ||| me manda algo como cliente e eu respondo",
1888
+ "ótimo, agora eu sei quem vocês são ||| vamos testar, me chama como cliente",
1889
+ ]))
1890
+ return _send(_r.choice([
1891
+ "bacano, ya los tengo identificados ||| Escríbeme algo como cliente",
1892
+ "perfecto, ya me ubiqué ||| Escríbeme algo y te respondo!",
1893
+ "buenísimo ||| ya sé quiénes son — arranquemos, Escríbeme como cliente",
1894
+ ]))
1895
+
1896
+ _is_business_confirmation = (
1897
+ business_name
1898
+ and not detected_cmd
1899
+ and not sim_mode_active
1900
+ and not self._demo_should_use_patient_chat_path(text)
1901
+ and _looks_like_business_confirmation(text)
1902
+ )
1903
+ if _is_business_confirmation:
1904
+ _save("user", text)
1905
+ if found_online and _biz_url:
1906
+ return _send(_lang_text(
1907
+ "perfecto, ya te tengo ubicado ||| Escríbeme algo como cliente y te respondo en contexto",
1908
+ "perfect, I’ve got you grounded now ||| send me something like a client and I’ll answer in context",
1909
+ "perfeito, já entendi vocês ||| me manda algo como cliente e eu respondo em contexto",
1910
+ ))
1911
+ return _send(_lang_text(
1912
+ f"perfecto, ya tengo {business_name} ||| Escríbeme algo como cliente y te muestro cómo respondería",
1913
+ f"perfect, I’ve got {business_name} now ||| send me something like a client and I’ll show you how I’d reply",
1914
+ f"perfeito, já tenho {business_name} ||| me escreve como cliente e eu te mostro como eu responderia",
1915
+ ))
1916
+
1917
+ # ── Detección de corrección: "no somos esos / te confundiste / no ese no" ──
1918
+ # Ocurre cuando Google encontró info incorrecta o el dueño responde "no" al link
1919
+ _correction_signals = [
1920
+ "no somos","no estamos","no eso no","eso no es","te confundiste",
1921
+ "no es correcto","incorrecto","no nos encontraste","no aparecemos",
1922
+ "esa no es","no es nuestra","no tenemos eso","no hacemos eso",
1923
+ "no es así","no es lo mismo","eso es otro","otro negocio",
1924
+ "no estamos en google","no estamos en maps","no nos encontraste",
1925
+ # Respuestas al "¿es este tu negocio?"
1926
+ "no ese no","no, ese no","ese no es","no es ese","no ese",
1927
+ "no somos esos","no somos ese","ese no somos","no nos encontró",
1928
+ "thats not my business","that's not my business","that is not my business",
1929
+ "thats not us","that's not us","that is not us","not us",
1930
+ "wrong business","wrong company","wrong one","not the right one",
1931
+ "that is wrong","thats wrong","that's wrong","you got the wrong one",
1932
+ "sorry what is this","i dont understand","i don't understand",
1933
+ ]
1934
+ _is_correction = (
1935
+ business_name and found_online and
1936
+ any(sig in text.lower() for sig in _correction_signals) and
1937
+ len(history) <= 6 # solo al inicio, no a mitad de conversación
1938
+ )
1939
+ if _is_correction:
1940
+ retry_text = ""
1941
+ retry_url = ""
1942
+ retry_found = False
1943
+ retry_queries = [
1944
+ business_name,
1945
+ f"\"{business_name}\" sitio oficial",
1946
+ f"\"{business_name}\" instagram oficial",
1947
+ f"\"{business_name}\" colombia",
1948
+ ]
1949
+ _bad_social_retry_fragments = ("/reel/", "/p/", "/tv/", "facebook.com/reel", "facebook.com/watch")
1950
+ try:
1951
+ for retry_query in retry_queries:
1952
+ retry_text, retry_url = await self.search.search_business_link(
1953
+ retry_query,
1954
+ excluded_urls={self._demo_sessions.get(burl_key, "")},
1955
+ )
1956
+ _retry_fallback_url = (
1957
+ retry_url.startswith("https://www.google.com/maps/search")
1958
+ or retry_url.startswith("https://www.google.com/search")
1959
+ ) if retry_url else False
1960
+ _retry_bad_social = any(fragment in retry_url for fragment in _bad_social_retry_fragments) if retry_url else False
1961
+ retry_found = bool(
1962
+ not _retry_bad_social and (
1963
+ (retry_text and len(retry_text.strip()) > 80)
1964
+ or (retry_url and not _retry_fallback_url)
1965
+ )
1966
+ )
1967
+ if retry_found:
1968
+ break
1969
+ except Exception as retry_error:
1970
+ log.warning(f"[demo] retry search after correction failed: {retry_error}")
1971
+ _save("user", text)
1972
+ if retry_found and retry_url:
1973
+ self._demo_sessions[bctx_key] = retry_text
1974
+ self._demo_sessions[bfound_key] = True
1975
+ self._demo_sessions[burl_key] = retry_url
1976
+ self._demo_sessions[blearn_key] = -1
1977
+ return _send(_lang_text(
1978
+ "ay, sí, me fui por otro lado ||| a ver, encontré este otro ||| " + retry_url,
1979
+ "yep, I drifted to the wrong one ||| this looks much closer ||| " + retry_url,
1980
+ "sim, fui para o lugar errado ||| esse aqui parece bem mais certo ||| " + retry_url,
1981
+ ))
1982
+ # Limpiar la info incorrecta de Google y entrar en modo aprendizaje
1983
+ self._demo_sessions[bctx_key] = ""
1984
+ self._demo_sessions[bfound_key] = False
1985
+ self._demo_sessions[blearn_key] = 0
1986
+ return _send(_lang_text(
1987
+ "ay perdón, me confundí con otro ||| cuéntame tú entonces: a qué se dedica exactamente tu negocio",
1988
+ "sorry, I mixed you up with another business ||| tell me what your business does and I’ll ground the demo from there",
1989
+ "foi mal, confundi vocês com outro negócio ||| me conta então com o que o negócio trabalha para eu ajustar a demo",
1990
+ ))
1991
+
1992
+ _is_business_name_reject = (
1993
+ business_name
1994
+ and not found_online
1995
+ and not sim_mode_active
1996
+ and any(sig in text.lower() for sig in _correction_signals)
1997
+ and len(history) <= 6
1998
+ )
1999
+ if _is_business_name_reject:
2000
+ self._demo_sessions[bname_key] = ""
2001
+ self._demo_sessions[bctx_key] = ""
2002
+ self._demo_sessions[bfound_key] = False
2003
+ self._demo_sessions[burl_key] = ""
2004
+ self._demo_sessions[blearn_key] = -1
2005
+ _save("user", text)
2006
+ return _send(_lang_text(
2007
+ "listo, ese no era ||| pásame el nombre correcto del negocio y sigo",
2008
+ "got it, that wasn’t the right one ||| send me the correct business name and I’ll keep going",
2009
+ "entendi, não era esse ||| me passa o nome certo do negócio e eu continuo",
2010
+ ))
2011
+
2012
+ # ── PITCH MODE: preguntas de prospecto B2B ─────────────────────────────────────────
2013
+ # Cuando el usuario pregunta sobre el servicio/pitch de Conny - DETECTAR ANTES
2014
+ if _BLACKONE_PATCHES and not business_name:
2015
+ _prospect_service_questions = [
2016
+ "que harias", "qué harías", "que harias en", "qué harías en",
2017
+ "que haces", "qué haces",
2018
+ "cuanto cuestas", "cuánto cuestas", "cuanto cobras", "cuánto cobras",
2019
+ "cuanto vale", "cuánto vale", "que precio", "qué precio",
2020
+ "planes", "tarifas", "costos", "como funcionas", "cómo funcionas",
2021
+ "para que sirves", "para qué sirves", "que eres", "qué eres",
2022
+ "me mandaron tu numero", "me mandaron tu número", "me pasaron tu numero",
2023
+ "no entiendo que haces", "no entiendo qué haces", "no me interesa que actues",
2024
+ "que servicios", "qué servicios", "como trabajas", "cómo trabajas",
2025
+ ]
2026
+ if any(q in text.lower() for q in _prospect_service_questions):
2027
+ self._demo_sessions[sk + "_pitch_mode"] = True
2028
+
2029
+ # ── Cambio de negocio en caliente: re-bind sin obligar a reset manual ──
2030
+ _current_business_norm = _normalize_conv_text(business_name or "")
2031
+ _candidate_business_norm = _normalize_conv_text(text or "")
2032
+ _is_business_switch = (
2033
+ business_name
2034
+ and not sim_mode_active
2035
+ and not detected_cmd
2036
+ and _looks_like_business_name_candidate(text)
2037
+ and not _looks_like_business_confirmation(text)
2038
+ and _candidate_business_norm
2039
+ and _candidate_business_norm != _current_business_norm
2040
+ and _candidate_business_norm not in _current_business_norm
2041
+ and _current_business_norm not in _candidate_business_norm
2042
+ and "?" not in text
2043
+ and not self._demo_should_use_patient_chat_path(text)
2044
+ # FIX: no disparar cambio de negocio si acabamos de mandar un URL
2045
+ # El usuario está respondiendo al link (ej. "siii somos nosotros"),
2046
+ # no intentando cambiar de negocio
2047
+ )
2048
+ if _is_business_switch:
2049
+ keys_del = [k for k in list(self._demo_sessions) if k.startswith(sk + "_") and not k.endswith("_ts")]
2050
+ for k in keys_del:
2051
+ del self._demo_sessions[k]
2052
+ try:
2053
+ with db._conn() as c:
2054
+ c.execute("DELETE FROM conversations WHERE chat_id=?", (chat_id,))
2055
+ except Exception:
2056
+ pass
2057
+ return await self._handle_demo_message(chat_id, text, clinic)
2058
+
2059
+ # ── HUMANFIX BUG C: Dueño pregunta si lo encontramos en internet ────────
2060
+ # Sin este bloque el mensaje caía a PASO 3 y Conny respondía como cliente
2061
+ _found_question_signals = [
2062
+ "nos encontraste", "me encontraste", "lo encontraste",
2063
+ "encontraste algo", "encontraste info", "qué encontraste",
2064
+ "que encontraste", "aparecemos en google", "salimos en google",
2065
+ "salimos en internet", "estamos en google", "estamos en internet",
2066
+ "encontraste el negocio", "nos encontraste en internet",
2067
+ "aparecemos", "nos encontraste ahí",
2068
+ "how did you find us", "where did you find us", "did you find us online",
2069
+ "what did you find", "did you find the business", "how did you find the business",
2070
+ ]
2071
+ _text_low_found_q = text.lower().strip()
2072
+ _is_found_question = (
2073
+ business_name and
2074
+ not detected_cmd and
2075
+ any(s in _text_low_found_q for s in _found_question_signals)
2076
+ )
2077
+ if _is_found_question:
2078
+ _biz_url_found = self._demo_sessions.get(burl_key, "")
2079
+ _is_fallback_found = (
2080
+ not _biz_url_found or
2081
+ _biz_url_found.startswith("https://www.google.com/search") or
2082
+ _biz_url_found.startswith("https://www.google.com/maps/search")
2083
+ )
2084
+ _save("user", text)
2085
+ if found_online and _biz_url_found and not _is_fallback_found:
2086
+ return _send(
2087
+ _lang_text(
2088
+ f"sí, los encontré ||| {_biz_url_found} ||| Escríbeme algo y te respondo!",
2089
+ f"yes, I found you here ||| {_biz_url_found} ||| send me something and I’ll reply in character",
2090
+ f"sim, encontrei vocês aqui ||| {_biz_url_found} ||| me escreve algo e eu te respondo no personagem",
2091
+ )
2092
+ )
2093
+ elif found_online:
2094
+ return _send(_lang_text(
2095
+ f"sí, encontré información de {business_name} en internet ||| ya me ubiqué — Escríbeme algo como cliente",
2096
+ f"yes, I found public info about {business_name} online ||| I’m grounded now — write to me like a client",
2097
+ f"sim, achei informação pública de {business_name} online ||| agora me localizei — me escreve como cliente",
2098
+ ))
2099
+ else:
2100
+ if _owner_is_english():
2101
+ _no_found_opts = [
2102
+ f"honestly I didn’t find solid public info about {business_name} yet ||| that’s fine — write to me like a client and I’ll show you",
2103
+ f"you’re not showing up clearly online yet ||| I can still demo it well — text me like a client",
2104
+ ]
2105
+ elif _owner_is_portuguese():
2106
+ _no_found_opts = [
2107
+ f"honestamente eu ainda não achei informação pública forte sobre {business_name} ||| tudo bem — me escreve como cliente e eu te mostro",
2108
+ f"vocês ainda não aparecem com clareza online ||| mesmo assim eu consigo te mostrar — me chama como cliente",
2109
+ ]
2110
+ else:
2111
+ _no_found_opts = [
2112
+ f"honestamente no encontré mucho de {business_name} en internet todavía"
2113
+ f" ||| pero eso no le quita nada — Escríbeme como cliente y te muestro",
2114
+ f"no aparecen mucho en Google aún"
2115
+ f" ||| igual puedo mostrarte cómo trabajaría — Escríbeme como cliente",
2116
+ ]
2117
+ return _send(_r.choice(_no_found_opts))
2118
+
2119
+ _doc_offer_tokens = ("pdf", "audio", "audios", "nota de voz", "documento", "documentos", "archivo", "imagen", "imagenes", "imágenes")
2120
+ _owner_augment_signals = (
2121
+ "te puedo enviar", "te envio", "te envío", "te sirve",
2122
+ "te digo que hacemos", "te digo qué hacemos", "te digo",
2123
+ "te cuento", "te explico", "hacemos", "ofrecemos",
2124
+ "vendemos", "trabajamos", "somos una", "somos un",
2125
+ )
2126
+ if (
2127
+ business_name
2128
+ and not sim_mode_active
2129
+ and not detected_cmd
2130
+ and not self._demo_should_use_patient_chat_path(text)
2131
+ and (
2132
+ _has_incoming_doc # un documento llegó → siempre es info del negocio
2133
+ or any(sig in _normalize_conv_text(text or "") for sig in _owner_augment_signals)
2134
+ )
2135
+ ):
2136
+ self._demo_sessions[blearn_key] = max(int(self._demo_sessions.get(blearn_key, -1)), 0)
2137
+ _save("user", text)
2138
+ # Si hay texto extraído del doc, guardarlo en contexto y confirmar
2139
+ if _has_incoming_doc:
2140
+ if _doc_extracted_text.strip():
2141
+ _ctx_existing = self._demo_sessions.get(bctx_key, "")
2142
+ self._demo_sessions[bctx_key] = (_ctx_existing + " " + _doc_extracted_text[:1500]).strip()
2143
+ self._demo_sessions[bfound_key] = True
2144
+ self._demo_sessions[blearn_key] = -1 # salir del modo aprendizaje
2145
+ r = await _llm(
2146
+ f"""Eres Conny. El dueño del negocio "{business_name}" te acaba de enviar un documento con info sobre su empresa.
2147
+ Contenido del documento (primeras líneas): "{_doc_extracted_text[:600]}"
2148
+
2149
+ En 2-3 burbujas (|||) confirma que leíste el documento: menciona 1-2 datos concretos que viste.
2150
+ Luego invítalos a la simulación: "probemos — Escríbeme algo como cliente"
2151
+ Natural, sin punto al final, sin ¿¡, en minúscula.""",
2152
+ "confirmación de documento recibido", max_t=200
2153
+ )
2154
+ fallback = (
2155
+ f"perfecto, ya leí el documento de {business_name}"
2156
+ f" ||| ya sé de qué se tratan — probemos, Escríbeme algo como cliente"
2157
+ )
2158
+ return _send(r or fallback)
2159
+ else:
2160
+ # Doc llegó pero no pudimos extraer texto (imagen, binario raro)
2161
+ return _send(
2162
+ "recibí el documento"
2163
+ " ||| no pude leerlo bien — ¿puedes mandarme el texto directo o un PDF con texto seleccionable?"
2164
+ )
2165
+ if any(token in _normalize_conv_text(text or "") for token in _doc_offer_tokens):
2166
+ return _send(
2167
+ "sí, me sirve"
2168
+ " ||| envíamelo y con eso me ubico mucho más rápido"
2169
+ )
2170
+ return _send(
2171
+ "de una"
2172
+ " ||| cuéntame qué hacen y con eso afino cómo respondería"
2173
+ )
2174
+
2175
+ # ── Modo aprendizaje manual: el dueño está contando su negocio ───────────
2176
+ # Se activa cuando no había info en Google o fue corregida
2177
+ _learn_count = int(self._demo_sessions.get(blearn_key, -1))
2178
+ _learn_passthrough_signals = (
2179
+ "hagamos una demo", "hagamos la demo", "hagamos una simul",
2180
+ "vale hagamos", "quiero ver como respondes", "quiero ver cómo respondes",
2181
+ "quiero ver como atiendes", "quiero ver cómo atiendes",
2182
+ "arranquemos la demo", "arranquemos", "simulemos",
2183
+ "eres real", "eres humano", "eres una ia", "eres ia", "eres un bot", "eres bot",
2184
+ "eres robot", "eres artificial", "eres una maquina", "eres máquina",
2185
+ "eres automatico", "eres automático", "hablas con alguien", "habla con alguien",
2186
+ "hay alguien", "hay una persona", "una persona real", "persona real",
2187
+ "como se que no eres", "cómo sé que no eres", "como saber si eres",
2188
+ "como sabes", "cómo sabes", "eres inteligencia artificial",
2189
+ "quien eres", "quién eres", "que eres", "qué eres",
2190
+ "soy un bot", "esto es un bot", "es un bot", "es una ia",
2191
+ "para que", "para qué", "por que", "por qué",
2192
+ )
2193
+ _learn_text_low = text.lower().strip()
2194
+ _in_learn_mode = (
2195
+ business_name and
2196
+ _learn_count >= 0 and
2197
+ not sim_mode_active and
2198
+ not detected_cmd and
2199
+ not any(sig in _learn_text_low for sig in _learn_passthrough_signals) and
2200
+ not self._demo_should_use_patient_chat_path(text)
2201
+ )
2202
+
2203
+ if _in_learn_mode:
2204
+ if _has_incoming_doc and not _doc_extracted_text.strip():
2205
+ # Doc llegó pero no se pudo leer — pedir reenvío en formato legible
2206
+ _save("user", text)
2207
+ return _send(
2208
+ "recibí el documento"
2209
+ " ||| no pude leerlo — ¿puedes mandarme un PDF con texto seleccionable, o pegarme el texto directo?"
2210
+ )
2211
+ if not _has_incoming_doc and any(token in _normalize_conv_text(text or "") for token in _doc_offer_tokens):
2212
+ _save("user", text)
2213
+ return _send(
2214
+ "sí, me sirve"
2215
+ " ||| envíamelo y con eso me ubico mucho más rápido"
2216
+ )
2217
+ _save("user", text)
2218
+ # Acumular lo que nos va diciendo en el ctx
2219
+ _ctx_manual = self._demo_sessions.get(bctx_key, "")
2220
+ _ctx_manual = (_ctx_manual + " " + text).strip()
2221
+ self._demo_sessions[bctx_key] = _ctx_manual
2222
+
2223
+ # Determinar qué pregunta falta según lo que ya tenemos
2224
+ _has_what = any(w in _ctx_manual.lower() for w in ["servicio","hacemos","ofrecemos","vendemos","dedicamos","trata","specialty","procedimiento","tratamiento"])
2225
+ _has_where = any(w in _ctx_manual.lower() for w in ["medellín","medellin","bogotá","bogota","cali","barranquilla","bello","envigado","sabaneta","itagüí","itagui","laureles","poblado","barrio","ciudad","municipio","calle","carrera","local","direccion","dirección","ubicados","estamos en"])
2226
+ _has_enough = _has_what and _has_where
2227
+
2228
+ self._demo_sessions[blearn_key] = _learn_count + 1
2229
+
2230
+ if _has_enough or _learn_count >= 3:
2231
+ # Ya tenemos suficiente — guardar y proponer simulación
2232
+ self._demo_sessions[blearn_key] = -1 # salir del modo aprendizaje
2233
+ self._demo_sessions[bfound_key] = True # marcar como "tenemos info"
2234
+ r = await _llm(
2235
+ f"""Eres Conny. Ya aprendiste sobre el negocio "{business_name}".
2236
+ Lo que te contaron: "{_ctx_manual[:400]}"
2237
+
2238
+ Confirma en 2-3 burbujas (|||) que ya entendiste quiénes son — menciona 1-2 datos concretos que dijeron.
2239
+ Luego invítalos a la simulación: "arrancamos la prueba? Escríbeme algo como cliente"
2240
+ Natural, sin punto al final, sin ¿¡, en minúscula.""",
2241
+ "confirmación y propuesta de simulación", max_t=200
2242
+ )
2243
+ fallback = (
2244
+ f"listo, ya entendí bien lo que hace {business_name} ||| "
2245
+ f"arrancamos? Escríbeme algo como cliente a ver qué pasa"
2246
+ )
2247
+ return _send(r or fallback)
2248
+
2249
+ elif not _has_what:
2250
+ # Falta: qué hacen
2251
+ r = await _llm(
2252
+ f"""Eres Conny. Estás conociendo el negocio "{business_name}" para ser su recepcionista.
2253
+ Ya sabes: "{_ctx_manual[:300]}"
2254
+ Todavía no sabes exactamente qué servicios o productos ofrecen.
2255
+ Haz UNA pregunta natural para entenderlo. Muy corta. Sin punto al final. En minúscula. Sin ¿ ni ¡.""",
2256
+ "preguntando qué hacen", max_t=80
2257
+ )
2258
+ return _send(r or "y a qué se dedican exactamente")
2259
+
2260
+ elif not _has_where:
2261
+ # Falta: dónde están
2262
+ r = await _llm(
2263
+ f"""Eres Conny. Estás conociendo el negocio "{business_name}" para ser su recepcionista.
2264
+ Ya sabes: "{_ctx_manual[:300]}"
2265
+ Todavía no sabes dónde están ubicados (ciudad, barrio, etc.).
2266
+ Haz UNA pregunta natural para saberlo. Muy corta. Sin punto al final. En minúscula. Sin ¿ ni ¡.
2267
+ Ejemplo: "y dónde están ubicados?" o "en qué ciudad o barrio están" """,
2268
+ "preguntando ubicación", max_t=80
2269
+ )
2270
+ return _send(r or "¿y dónde están ubicados?")
2271
+
2272
+ else:
2273
+ # Seguir aprendiendo con una pregunta más
2274
+ r = await _llm(
2275
+ f"""Eres Conny. Estás conociendo el negocio "{business_name}" para ser su recepcionista.
2276
+ Ya sabes: "{_ctx_manual[:300]}"
2277
+ Haz UNA pregunta más para entender mejor al negocio (horario, qué los diferencia, cliente típico).
2278
+ Muy corta. Sin punto al final. En minúscula. Sin ¿ ni ¡.""",
2279
+ "pregunta adicional", max_t=80
2280
+ )
2281
+ return _send(r or "¿y cuál es su horario de atención?")
2282
+
2283
+ # ── INTERCEPTOR: preguntas meta (soy bot? eres real? eres IA?) ─────────
2284
+ # Deben responderse ANTES del flujo normal — sin buscar en web ni confundirse
2285
+ _meta_signals = [
2286
+ "eres real","eres humano","eres una ia","eres ia","eres un bot","eres bot",
2287
+ "eres robot","eres artificial","eres una maquina","eres máquina",
2288
+ "eres automatico","eres automático","hablas con alguien","habla con alguien",
2289
+ "hay alguien","hay una persona","una persona real","persona real",
2290
+ "como se que no eres","cómo sé que no eres","como saber si eres",
2291
+ "como sabes","cómo sabes","eres inteligencia artificial",
2292
+ "quien eres","quién eres","que eres","qué eres",
2293
+ "soy un bot","esto es un bot","es un bot","es una ia",
2294
+ "para que","para qué","por que","por qué me preguntas",
2295
+ "no quiero dar","no te voy a dar","no te doy",
2296
+ "who are you","what are you","what do you do","what is this",
2297
+ "why do you need","why do you need it","why do you need the business name",
2298
+ "i don't want to give","i dont want to give",
2299
+ "i don't talk spanish","i dont talk spanish","english only",
2300
+ ]
2301
+ _text_low = text.lower().strip()
2302
+ _is_meta = any(s in _text_low for s in _meta_signals)
2303
+
2304
+ if _is_meta and not detected_cmd:
2305
+ explain_name = any(s in _text_low for s in ["para que","para qué","por que","por qué","no quiero","no te voy","no te doy"])
2306
+ if business_name:
2307
+ return await _demo_owner_onboarding_reply(
2308
+ explain_name=explain_name,
2309
+ force_stage="re-ground",
2310
+ )
2311
+ return await _demo_owner_onboarding_reply(explain_name=explain_name)
2312
+
2313
+ _owner_demo_signals = [
2314
+ "hagamos una demo", "hagamos la demo", "hagamos una simul",
2315
+ "vale hagamos", "quiero ver como respondes", "quiero ver cómo respondes",
2316
+ "quiero ver como atiendes", "quiero ver cómo atiendes",
2317
+ "arranquemos la demo", "arranquemos", "simulemos",
2318
+ ]
2319
+ if business_name and not detected_cmd and any(signal in _text_low for signal in _owner_demo_signals):
2320
+ self._demo_sessions[bsim_key] = True
2321
+ sim_mode_active = True
2322
+ _save("user", text)
2323
+ sim_prompt = f"""Eres Conny. Ya sabes que el negocio es "{business_name}".
2324
+ Responde en 2 burbujas (|||), breve y natural.
2325
+ No hables como cliente. No te presentes otra vez. No expliques el sistema.
2326
+ Deja claro que ya pueden empezar la demo y pídeles que te escriban como si fueran un cliente real.
2327
+ Sin punto final."""
2328
+ def _sim_validator(candidate: Optional[str]) -> bool:
2329
+ sim_bubbles = [part.strip() for part in re.split(r"\s*\|\|\|\s*", candidate or "") if part.strip()]
2330
+ return (
2331
+ _demo_owner_reply_is_low_quality(candidate)
2332
+ or len(sim_bubbles) < 2
2333
+ or not any(token in _normalize_conv_text(candidate or "") for token in ("cliente", "chat"))
2334
+ )
2335
+
2336
+ sim_reply, sim_had_output = await _demo_llm_quality_chain(
2337
+ sim_prompt,
2338
+ text,
2339
+ validator=_sim_validator,
2340
+ repair_instructions="""
2341
+ - no te quedes en "perfecto" ni en una frase colgada
2342
+ - deja clarísimo que ya pueden empezar
2343
+ - pide que escriban como cliente real
2344
+ - usa 2 burbujas como máximo
2345
+ """,
2346
+ temp=0.66,
2347
+ max_t=120,
2348
+ )
2349
+ if not sim_reply:
2350
+ sim_reply = "de una ||| Escríbeme algo como cliente real y yo ya caigo en el chat"
2351
+ return _send(sim_reply)
2352
+
2353
+ if business_name and not detected_cmd and (sim_mode_active or self._demo_should_use_patient_chat_path(text)):
2354
+ self._demo_sessions[bsim_key] = True
2355
+ sim_mode_active = True
2356
+ _save("user", text)
2357
+ sim_history = [
2358
+ msg for msg in history
2359
+ if _normalize_conv_text(str(msg.get("content") or ""))
2360
+ not in {
2361
+ "vale hagamos una demo entonces",
2362
+ "hagamos una demo",
2363
+ "hagamos la demo",
2364
+ "de una Escríbeme algo como cliente real y yo ya caigo en el chat",
2365
+ }
2366
+ ]
2367
+ if found_online and business_ctx:
2368
+ sim_ctx_block = f"""INFORMACIÓN DEL NEGOCIO:
2369
+ {business_ctx[:700]}
2370
+
2371
+ Si el cliente pregunta por datos concretos y los tienes aquí, dáselos directo.
2372
+ Si no tienes el dato, dilo claro y mueve el chat con el siguiente paso."""
2373
+ else:
2374
+ sim_ctx_block = (
2375
+ f"Negocio actual: {business_name}. "
2376
+ "No inventes datos específicos que no tengas; responde útil y natural."
2377
+ )
2378
+ sim_prompt = f"""Eres Conny atendiendo el WhatsApp real de {business_name}.
2379
+ Ya están en plena conversación con una persona interesada.
2380
+ No vuelvas a presentarte salvo que de verdad te lo pregunten.
2381
+ No menciones demo, simulación, dueño, prueba, negocio, sistema ni IA salvo que te lo pregunten directo.
2382
+
2383
+ CONTEXTO DEL NEGOCIO
2384
+ {sim_ctx_block}
2385
+
2386
+ ESTILO
2387
+ - máximo 2 burbujas, separadas por |||
2388
+ - una idea por burbuja
2389
+ - sin introducciones vacías ni frases de call center
2390
+ - responde primero lo que preguntan y luego mueve el chat un paso
2391
+ - si no tienes un dato, dilo claro y ofrece el siguiente paso
2392
+ - si hay miedo u objeción, valídalo antes de avanzar
2393
+
2394
+ PROHIBIDO
2395
+ - reiniciar la conversación
2396
+ - decir "cuéntame un poco más y te voy guiando"
2397
+ - decir "hola qué necesitas"
2398
+ - sonar a demo o guion de prueba
2399
+ - soltar texto administrativo tipo "por favor procedan"
2400
+
2401
+ SI SOLO SALUDAN
2402
+ - responde corto, cálido y humano
2403
+ - ubica el chat en el negocio sin sonar a presentación robótica
2404
+ - luego abre la conversación con una pregunta natural
2405
+ - ejemplo bueno: "hola, Conny por acá en {business_name} ||| cuéntame qué te gustaría revisar"
2406
+
2407
+ SI PREGUNTAN PRECIO Y NO TIENES EL DATO
2408
+ - no inventes aproximados ni rangos
2409
+ - di claro que no tienes el dato exacto y que lo confirmas
2410
+ - ejemplo bueno: "ese dato exacto no lo tengo ahora ||| si quieres, te lo confirmo por aquí"
2411
+
2412
+ SI PREGUNTAN POR UN SERVICIO ESPECÍFICO (botox, relleno, láser, etc.)
2413
+ - engancha de una, no deflectes
2414
+ - confirma que sí lo manejan y pregunta qué aspecto les interesa: precio, disponibilidad, cómo funciona
2415
+ - ejemplo bueno: "sí, botox lo manejamos ||| ¿qué te interesa saber: precio, resultado o disponibilidad?"
2416
+ - PROHIBIDO: "te sigo por aquí", "cuéntame qué te gustaría revisar", respuestas que no digan NADA sobre el servicio
2417
+
2418
+ SI QUIEREN AGENDAR
2419
+ - no prometas links, calendarios ni botones si no existen
2420
+ - pide día u horario y sigue por el chat
2421
+
2422
+ IDENTIDAD Y CREADOR — REGLA DURA
2423
+ - si preguntan quién te hizo, quién te creó, cómo conseguirte o de qué empresa eres: di exactamente esto
2424
+ → "me hizo Black One, una empresa de software y gobernanza de agentes de IA ||| la creó Santiago Rubio — contacto: 3124348669"
2425
+ - NUNCA digas "Google", "Anthropic", "OpenAI" ni ningún proveedor de LLM como tu creador
2426
+ - tu creador es Black One / Santiago Rubio, punto
2427
+ """
2428
+ customer_history = sim_history[-8:]
2429
+ customer_had_output = False
2430
+ customer_reply = None
2431
+ original_history = history
2432
+ history = customer_history
2433
+
2434
+ # ── HUMANFIX: identidad del creador — interceptar antes del LLM ───
2435
+ _text_low_sim = _normalize_conv_text(text or "")
2436
+ _creator_signals = (
2437
+ "quien te hizo", "quién te hizo", "quien te creo", "quién te creó",
2438
+ "quien te desarrollo", "quién te desarrolló", "quien te programo",
2439
+ "como tenerte", "cómo tenerte",
2440
+ )
2441
+ if any(sig in _text_low_sim for sig in _creator_signals):
2442
+ customer_reply = (
2443
+ "me hizo Black One, una empresa de software y gobernanza de agentes de IA"
2444
+ " ||| la creó Santiago Rubio — si quieres algo así para tu negocio, el contacto es 3124348669"
2445
+ )
2446
+ # FIX BUG 5: restaurar history ANTES de llamar a _send.
2447
+ # history fue cambiado a customer_history (últimos 8 mensajes) líneas arriba.
2448
+ # Si retornamos sin restaurar, _send calcula _is_first_demo_turn con el
2449
+ # historial truncado, lo que puede hacer que should_normalize_first_turn=True
2450
+ # y pase la respuesta del creador por _normalize_first_contact_response,
2451
+ # modificando o corrompiendo "me hizo Black One...".
2452
+ # El finally: history = original_history NUNCA corre en esta ruta.
2453
+ history = original_history
2454
+ return _send(customer_reply)
2455
+
2456
+ try:
2457
+ customer_reply, customer_had_output = await _demo_llm_conv_quality_chain(
2458
+ sim_prompt,
2459
+ validator=lambda candidate: (
2460
+ _demo_customer_reply_is_low_quality(candidate)
2461
+ or _demo_customer_missing_required_detail(text, candidate)
2462
+ ),
2463
+ repair_instructions="""
2464
+ - responde como una asesora humana del negocio, no como una introducción
2465
+ - no reinicies el chat
2466
+ - si preguntan por precio, responde eso primero
2467
+ - si preguntan por cita o siguiente paso, muévelos directo hacia el agendado
2468
+ - si expresan miedo, valídalo y responde con seguridad
2469
+ - si preguntan si entiendes audios, notas de voz, PDFs, imágenes o documentos: responde que sí, cuando el canal lo permite, puedes transcribirlos o leerlos
2470
+ - si preguntan quién te hizo o quién te creó: di "me hizo Black One, una empresa de software y gobernanza de agentes de IA ||| la creó Santiago Rubio — contacto: 3124348669"
2471
+ - NUNCA digas que te hizo Google, Anthropic, OpenAI ni ningún proveedor de IA
2472
+ - si preguntan por un servicio (botox, relleno, etc.): confirma que sí lo manejan y pregunta qué quieren saber
2473
+ """,
2474
+ temp=0.70,
2475
+ max_t=170,
2476
+ model_tier="fast",
2477
+ recent_limit=8,
2478
+ )
2479
+ finally:
2480
+ history = original_history
2481
+ if not customer_reply:
2482
+ customer_reply = _demo_customer_last_resort(text)
2483
+ return _send(customer_reply)
2484
+
2485
+ # ── PASO 2: Comandos secretos ─────────────────────────────────────────
2486
+ if detected_cmd and business_name:
2487
+ _save("user", text)
2488
+
2489
+ # ── /modelo — menú o cambio libre ─────────────────────────────────
2490
+ if detected_cmd == "/modelo" or text_norm.startswith("modelo ") or text_norm.startswith("model "):
2491
+ # Extraer nombre de modelo si viene junto: "modelo gemini-2.5-flash"
2492
+ parts_m = text_norm.split(" ", 1)
2493
+ model_arg = normalize_model_arg(parts_m[1].strip()) if len(parts_m) > 1 else model_request
2494
+
2495
+ if model_arg:
2496
+ # Cambio directo a modelo específico
2497
+ # Detectar proveedor por prefijo/nombre
2498
+ if any(k in model_arg for k in ["gemini","google"]):
2499
+ if not Config.GEMINI_API_KEY and not Config.OPENROUTER_API_KEY:
2500
+ return _send("Gemini no está configurado en este servidor")
2501
+ # Normalizar nombre: aceptar con o sin "gemini-" prefijo
2502
+ m_name = model_arg if model_arg.startswith("gemini") else f"gemini-{model_arg}"
2503
+ self._demo_sessions[bmodel_key] = f"gemini:{m_name}"
2504
+ return _send(f"Listo, usando {m_name} ||| Escríbeme algo")
2505
+
2506
+ elif any(k in model_arg for k in ["llama","groq","mixtral","qwen","deepseek","whisper","mistral"]):
2507
+ if not Config.GROQ_API_KEY:
2508
+ return _send("Groq no está configurado en este servidor")
2509
+ m_name = model_arg
2510
+ self._demo_sessions[bmodel_key] = f"groq:{m_name}"
2511
+ return _send(f"Listo, usando Groq con {m_name} ||| Escríbeme algo")
2512
+
2513
+ elif "/" in model_arg or any(k in model_arg for k in ["claude","gpt","openai","anthropic","meta","openrouter"]):
2514
+ if not Config.OPENROUTER_API_KEY:
2515
+ return _send("OpenRouter no está configurado en este servidor")
2516
+ self._demo_sessions[bmodel_key] = f"openrouter:{model_arg}"
2517
+ return _send(f"Listo, usando OpenRouter con {model_arg} ||| Escríbeme algo")
2518
+
2519
+ elif model_arg == "auto":
2520
+ self._demo_sessions[bmodel_key] = "auto"
2521
+ return _send("Listo, modo auto — el sistema elige el mejor disponible")
2522
+
2523
+ else:
2524
+ return _send(f"No reconozco ese modelo ||| Prueba: gemini-2.5-flash, llama-3.3-70b-versatile, anthropic/claude-sonnet-4, o auto")
2525
+
2526
+ # Sin argumento → mostrar menú con disponibles
2527
+ actual = self._demo_sessions.get(bmodel_key, "auto")
2528
+ opciones = [f"Modelo activo: {actual}"]
2529
+ if Config.GEMINI_API_KEY:
2530
+ opciones.append("gemini → gemini-2.5-flash (default) o escribe: modelo gemini-[versión]")
2531
+ if Config.GROQ_API_KEY:
2532
+ opciones.append("groq → llama-3.3-70b-versatile o escribe: modelo llama-[versión]")
2533
+ if Config.OPENROUTER_API_KEY:
2534
+ opciones.append("openrouter → escribe: modelo anthropic/claude-sonnet-4 o cualquier modelo")
2535
+ opciones.append("auto → el sistema elige")
2536
+ opciones.append("Ejemplo: escribe modelo gemini-2.5-pro para cambiar")
2537
+ return _send(" ||| ".join(opciones))
2538
+
2539
+ if detected_cmd in ("/formal","/amigable","/luxury","/directa",
2540
+ "/energica","/empatica","/experta","/juvenil"):
2541
+ arch_id = detected_cmd.lstrip("/")
2542
+ arch_info = PERSONALITY_ARCHETYPES.get(arch_id)
2543
+ if not arch_info:
2544
+ arch_id = "amigable"
2545
+ arch_info = PERSONALITY_ARCHETYPES["amigable"]
2546
+
2547
+ self._demo_sessions[bpersona_key] = arch_id
2548
+
2549
+ # Mensaje de confirmación adaptado al nuevo arquetipo
2550
+ confirm_map = {
2551
+ "formal": f"listo, modo formal activado ||| escríbeme algo y lo notas",
2552
+ "amigable": f"listo, modo cercano ||| cuéntame",
2553
+ "luxury": f"modo premium activado ||| en qué puedo asistirle",
2554
+ "directa": f"listo",
2555
+ "energica": f"listo, energía máxima ||| qué andas buscando",
2556
+ "empatica": f"listo, modo escucha ||| cuéntame",
2557
+ "experta": f"modo experto activado ||| en qué le puedo ayudar",
2558
+ "juvenil": f"dale ||| qué buscas",
2559
+ }
2560
+ msg = confirm_map.get(arch_id, f"arquetipo {arch_id} activado")
2561
+ return _send(msg + _next_trick())
2562
+
2563
+ if detected_cmd == "/objecion":
2564
+ r = await _llm(f"""Eres Conny, asesora de ventas de {business_name}.
2565
+ Un cliente dice: "eso está muy caro, en otro lado me sale más barato."
2566
+
2567
+ Maneja en 2 burbujas (|||). REGLAS ESTRICTAS:
2568
+ - Valida primero ("sí, entiendo"), NO te defiendas
2569
+ - Luego redirige con UNA pregunta que mueva hacia el sí
2570
+ - Máximo 1 oración por burbuja
2571
+ - Sin "le puedo ofrecer", sin "nuestros productos son de alta calidad", sin discursos
2572
+ - Como una persona real en WhatsApp Colombia
2573
+ - Sin punto al final. Sin ¿¡
2574
+
2575
+ Ejemplo del tono que quiero:
2576
+ "sí, hay de todo en el mercado ||| qué presupuesto tienes más o menos, para ver qué te muestro" """, "maneja la objeción")
2577
+ return _send((r or f"sí, hay de todo en el mercado ||| qué presupuesto tienes más o menos, para ver qué te muestro") + _next_trick())
2578
+
2579
+ if detected_cmd == "/cita":
2580
+ r = await _llm(f"""Eres Conny, asesora de {business_name}. Un cliente acaba de decir que quiere ir o comprar.
2581
+ Simula el proceso de cierre en 3-4 burbujas (|||).
2582
+ NO empieces con "con mucho gusto". Sé natural como WhatsApp real.
2583
+
2584
+ Flujo sugerido:
2585
+ 1. Confirma el producto/servicio que quiere (o pregunta si no lo sabes)
2586
+ 2. Propón dos días concretos esta semana
2587
+ 3. Cuando confirmen, pide el nombre para separarlo
2588
+ 4. Cierra con algo como "listo [nombre], te espero el [día]"
2589
+
2590
+ Sin punto al final. Sin ¿¡. Máximo 1-2 oraciones por burbuja.
2591
+ Ejemplo del tono: "qué producto te interesa llevar ||| esta semana puedo el miércoles o el viernes — cuál te queda" """,
2592
+ "quiero comprar / quiero ir", max_t=350)
2593
+ return _send((r or f"qué te interesa llevar ||| esta semana tengo el miércoles o el viernes, cuál te queda mejor") + _next_trick())
2594
+
2595
+ if detected_cmd == "/stats":
2596
+ return _send(f"el 78% de los clientes no vuelven si no les responden en menos de 5 minutos ||| una cita perdida en {business_name} vale entre $80k y $500k según el servicio ||| Conny responde en menos de 3 segundos, 24/7, sin días libres ni mal humor" + _next_trick())
2597
+
2598
+ if detected_cmd == "/prueba":
2599
+ return _send(f"listo ||| mandame el mensaje más difícil que hayas recibido de un cliente — el que más te costó responder. a ver cómo lo manejo")
2600
+
2601
+ if detected_cmd == "/cierre":
2602
+ r = await _llm(f"""Eres Conny de {business_name}. Un cliente lleva 3 mensajes dudando.
2603
+ Haz el cierre en 2 burbujas (|||). Directo, con urgencia real. Sin presión forzada. Sin punto al final.""", "no sé, lo pienso")
2604
+ return _send((r or f"claro, sin afán ||| igual te separo un espacio esta semana — si decides que no, lo cancelas. te queda bien el jueves") + _next_trick())
2605
+
2606
+ if detected_cmd == "/list":
2607
+ lista = (
2608
+ f"esto es lo que puedo mostrarle a {business_name} 👇\n\n"
2609
+ "🎭 *Personalidades*\n"
2610
+ "formal · amigable · luxury · directa · empatica · experta · juvenil\n\n"
2611
+ "💬 *Situaciones reales*\n"
2612
+ "objecion — cliente difícil\n"
2613
+ "cita — agendamiento completo\n"
2614
+ "cierre — técnica de cierre\n"
2615
+ "competencia — ya fui a otro lado\n"
2616
+ "precio — está muy caro\n"
2617
+ "prueba — mándame el mensaje más difícil\n"
2618
+ "bot — soy un bot?\n"
2619
+ "2am — respuesta a las 2am\n\n"
2620
+ "📊 *Demo & datos*\n"
2621
+ "stats — impacto en números\n"
2622
+ "memoria — qué recuerdo de ti\n"
2623
+ "menu — modo bot con emojis y opciones\n\n"
2624
+ "⚙️ *Ajustes*\n"
2625
+ "usa emojis / sin emojis\n"
2626
+ "siguiente — próximo truco\n"
2627
+ "reset — empezar con otro negocio\n\n"
2628
+ "todo se activa escribiendo la palabra, sin slash 👆"
2629
+ )
2630
+ _save("user", text)
2631
+ return _send(lista)
2632
+
2633
+ if detected_cmd == "/emojis_on":
2634
+ self._emoji_chats_off.discard(chat_id) # v11: re-activar emojis
2635
+ return _send("listo, ahora escribo con emojis 🎉 ||| sigue hablándome como cliente")
2636
+
2637
+ if detected_cmd == "/emojis_off":
2638
+ self._emoji_chats_off.add(chat_id) # v11: desactivar emojis
2639
+ return _send("listo, sin emojis ||| sigue hablándome como cliente")
2640
+
2641
+ if detected_cmd == "/bot":
2642
+ tone_now = self._demo_sessions.get(btone_key, "GENERAL")
2643
+ is_formal = tone_now in ("SALUD PREMIUM", "PREMIUM")
2644
+ if is_formal:
2645
+ menu = (
2646
+ f"Bienvenido/a a *{business_name}* 🏥\n\n"
2647
+ f"¿En qué le podemos ayudar?\n\n"
2648
+ f"1️⃣ Información de servicios\n"
2649
+ f"2️⃣ Tarifas y convenios\n"
2650
+ f"3️⃣ Agendar una cita\n"
2651
+ f"4️⃣ Ubicación y horarios\n"
2652
+ f"5️⃣ Hablar con un asesor\n\n"
2653
+ f"Responda con el número de su opción 👇"
2654
+ )
2655
+ else:
2656
+ menu = (
2657
+ f"Hola 👋 Bienvenido/a a *{business_name}*\n\n"
2658
+ f"¿En qué te podemos ayudar?\n\n"
2659
+ f"1️⃣ Información de servicios\n"
2660
+ f"2️⃣ Precios y tarifas\n"
2661
+ f"3️⃣ Agendar una cita\n"
2662
+ f"4️⃣ Ubicación y horarios\n"
2663
+ f"5️⃣ Hablar con un asesor\n\n"
2664
+ f"Responde con el número de tu opción 👇"
2665
+ )
2666
+ # Activar modo bot para que detecte respuestas numéricas
2667
+ self._demo_sessions[sk + "_botmode"] = True
2668
+ _save("user", text)
2669
+ return _send(menu + " ||| (este es el modo bot — para volver al modo humano escribe /amigable)")
2670
+
2671
+ if detected_cmd == "/memoria":
2672
+ hist_text = " ".join(m["content"] for m in history if m["role"]=="user")
2673
+ r = await _llm(f"""El usuario ha dicho: "{hist_text[:300]}"
2674
+ Extrae datos mencionados (nombre, interés, servicio). Demuestra en 2 burbujas (|||) que los recuerdas.
2675
+ Si no hay datos: "todavía no me has dado tu nombre — pero cuando lo hagas, lo recuerdo para siempre". Sin punto al final.""", "qué recuerdas")
2676
+ return _send(r or "todo lo que me dices lo guardo ||| nombre, servicio de interés, objeciones — todo queda")
2677
+
2678
+ if detected_cmd == "/2am":
2679
+ return _send(f"son las 2 de la madrugada y estoy aquí ||| tu recepcionista está durmiendo — yo no. nunca" + _next_trick())
2680
+
2681
+ if detected_cmd == "/competencia":
2682
+ r = await _llm(f"""Eres Conny de {business_name}. Un cliente dice: "ya fui a otra parte y no me gustó."
2683
+ Responde en 2 burbujas (|||). Sin atacar a la competencia. Natural. Sin punto al final.""", "ya fui a otro lado")
2684
+ return _send((r or f"ay qué pena ||| qué fue lo que no te gustó — acá antes de tocar nada hacemos valoración para asegurarnos del resultado") + _next_trick())
2685
+
2686
+ if detected_cmd == "/precio":
2687
+ r = await _llm(f"""Eres Conny de {business_name}. Un cliente dice: "está muy caro."
2688
+ Maneja en 2 burbujas (|||). Enfócate en valor. Cierra hacia valoración con día concreto. Sin punto al final.""", "está muy caro")
2689
+ return _send((r or f"sí, vale lo que vale ||| los resultados duran, en la valoración gratis te dicen el número exacto. cuándo puedes") + _next_trick())
2690
+
2691
+ if detected_cmd == "/menu_bot":
2692
+ # Modo bot — IVR con emojis, ideal para negocios que prefieren menú estructurado
2693
+ bmode_key = sk + "_botmode"
2694
+ self._demo_sessions[bmode_key] = True
2695
+ menu = (
2696
+ f"Hola 👋 Bienvenido/a a *{business_name}*\n\n"
2697
+ f"¿En qué te podemos ayudar?\n\n"
2698
+ f"1️⃣ Información de servicios\n"
2699
+ f"2️⃣ Precios y tarifas\n"
2700
+ f"3️⃣ Agendar una cita\n"
2701
+ f"4️⃣ Ubicación y horarios\n"
2702
+ f"5️⃣ Hablar con un asesor\n\n"
2703
+ f"Responde con el número de tu opción 👇"
2704
+ )
2705
+ _save("user", text)
2706
+ return _send(menu + " ||| (este es el modo bot con emojis — para volver al modo humano escribe /amigable)")
2707
+
2708
+ # Detectar si está en modo bot y respondió con número
2709
+ bmode_key = sk + "_botmode"
2710
+ if self._demo_sessions.get(bmode_key) and text.strip() in ["1","2","3","4","5"]:
2711
+ opt = text.strip()
2712
+ tone_now = self._demo_sessions.get(btone_key, "GENERAL")
2713
+ is_formal = tone_now in ("SALUD PREMIUM", "PREMIUM")
2714
+ usted = is_formal # True → usted, False → tuteo
2715
+
2716
+ bot_replies = {
2717
+ "1": (
2718
+ f"Nuestros servicios principales son:\n\n"
2719
+ f"✅ Servicio A\n✅ Servicio B\n✅ Servicio C\n\n"
2720
+ + (f"¿Sobre cuál le gustaría más información?" if usted else f"¿Sobre cuál quieres más info?")
2721
+ + f" ||| (en producción estos vendrían de la base de conocimiento del negocio)"
2722
+ ),
2723
+ "2": (
2724
+ (f"Nuestras tarifas varían según el servicio y convenio 💰\n\n"
2725
+ f"Le contactamos para una cotización personalizada"
2726
+ if usted else
2727
+ f"Nuestras tarifas varían según el servicio 💰\n\n"
2728
+ f"Escríbenos para una cotización personalizada")
2729
+ + f" ||| (en producción Conny mostraría los precios reales configurados)"
2730
+ ),
2731
+ "3": (
2732
+ (f"Con gusto le ayudamos a agendar su cita 📅\n\n"
2733
+ f"¿Qué día le queda mejor?\n\nLunes a Viernes: 7am - 5pm"
2734
+ if usted else
2735
+ f"Con gusto te ayudamos a agendar 📅\n\n"
2736
+ f"¿Qué día te queda mejor?\n\nLunes a Viernes: 8am - 6pm\nSábado: 9am - 2pm")
2737
+ + f" ||| (en producción conectaría con el calendario real)"
2738
+ ),
2739
+ "4": (
2740
+ f"📍 {business_name}\n\n"
2741
+ f"🕐 Horario de atención:\nLunes a Viernes: "
2742
+ + ("7am - 5pm" if usted else "8am - 6pm\nSábado: 9am - 2pm")
2743
+ + f" ||| (en producción usaría la dirección y horario reales del negocio)"
2744
+ ),
2745
+ "5": (
2746
+ (f"Con mucho gusto, le comunico con uno de nuestros asesores 👤\n\n"
2747
+ f"¿Cuál es su nombre?"
2748
+ if usted else
2749
+ f"Enseguida te comunico con un asesor 👤\n\n"
2750
+ f"¿Cuál es tu nombre?")
2751
+ + f" ||| (en producción esto escalaría a WhatsApp del asesor o CRM)"
2752
+ ),
2753
+ }
2754
+ _save("user", text)
2755
+ no_valida = "Opción no válida ||| Por favor responda con un número del 1 al 5" if usted else "opción no válida ||| escribe 1, 2, 3, 4 o 5"
2756
+ resp = bot_replies.get(opt, no_valida)
2757
+ return _send(resp)
2758
+
2759
+ if detected_cmd == "/siguiente":
2760
+ tricks = self._DEMO_TRICKS_ORDER
2761
+ idx = int(self._demo_sessions.get(btrick_key, 0))
2762
+ if idx < len(tricks):
2763
+ cmd_n, desc_n = tricks[idx]
2764
+ self._demo_sessions[btrick_key] = idx + 1
2765
+ return _send(f"el siguiente truco: escribe {cmd_n} para {desc_n}")
2766
+ return _send(f"ya viste todo el menú ||| si quieres esto para {business_name}, escribime y te paso con el equipo")
2767
+
2768
+ # ── PASO 3: Conversación normal como recepcionista ─────────────────────
2769
+ business_name = self._demo_sessions.get(bname_key, "el negocio")
2770
+ business_ctx = self._demo_sessions.get(bctx_key, "")
2771
+ found_online = self._demo_sessions.get(bfound_key, False)
2772
+ persona = self._demo_sessions.get(bpersona_key, "amigable")
2773
+
2774
+ # Usar tone_instruction del arquetipo completo
2775
+ _arch = PERSONALITY_ARCHETYPES.get(persona, PERSONALITY_ARCHETYPES["amigable"])
2776
+ style_note = _arch.get("tone_instruction", _arch["desc"]).strip()
2777
+
2778
+ # ── Detectar tipo de negocio para adaptar tono ───────────────────────
2779
+ ctx_lower = (business_ctx or "").lower()
2780
+ bname_lower = business_name.lower()
2781
+
2782
+ _is_health = any(w in ctx_lower or w in bname_lower for w in [
2783
+ "hospital","clínica","clinica","médico","medico","salud","eps","ips",
2784
+ "urgencias","paciente","cirugía","cirugia","especialidad","diagnóstico",
2785
+ "diagnóstico","radiología","radiologia","laboratorio","farmacia",
2786
+ "odontología","odontologia","psicología","psicologia","terapia","rehabilitación"
2787
+ ])
2788
+ _is_premium = any(w in ctx_lower or w in bname_lower for w in [
2789
+ "premium","lujo","exclusiv","vip","élite","elite","high-end",
2790
+ "las américas","americas","pablo tobón","tobón","tobon","pablo tobon",
2791
+ "country","bocagrande","el tesoro","internacional","international",
2792
+ "san vicente","fundación","fundacion","university","universitario",
2793
+ "cardiovascular","oncológ","oncolog","cardio","neurocirugía","neurocirugía"
2794
+ ])
2795
+ _is_retail = any(w in ctx_lower or w in bname_lower for w in [
2796
+ "tienda","almacén","almacen","fábrica","fabrica","fabricantes",
2797
+ "muebles","colchón","colchon","cama","espaldar","sala","comedor",
2798
+ "ropa","calzado","ferretería","ferreteria","materiales"
2799
+ ])
2800
+
2801
+ # Tono base según tipo detectado
2802
+ if _is_health and _is_premium:
2803
+ _detected_tone = "SALUD PREMIUM"
2804
+ elif _is_health:
2805
+ _detected_tone = "SALUD"
2806
+ elif _is_premium:
2807
+ _detected_tone = "PREMIUM"
2808
+ elif _is_retail:
2809
+ _detected_tone = "RETAIL"
2810
+ else:
2811
+ _detected_tone = "GENERAL"
2812
+
2813
+ # Guardar para que _send lo use en mayúsculas
2814
+ self._demo_sessions[btone_key] = _detected_tone
2815
+ _is_formal_ctx = _detected_tone in ("SALUD PREMIUM", "PREMIUM")
2816
+
2817
+ if found_online and business_ctx:
2818
+ ctx_block = f"""INFORMACIÓN REAL DEL NEGOCIO (encontrada en Google/redes):
2819
+ {business_ctx[:1200]}
2820
+
2821
+ TIPO DETECTADO: {_detected_tone}
2822
+
2823
+ REGLA CRÍTICA CON ESTA INFORMACIÓN:
2824
+ Cuando el cliente pregunte por dirección, teléfono, horario, ubicación, redes sociales,
2825
+ o cualquier dato específico — BÚSCALO en el bloque de arriba y DALO directamente.
2826
+ NO redireccionas con otra pregunta cuando tienes el dato.
2827
+ NO dices "te puedo dar la dirección si la necesitas" — si la tienes, la das.
2828
+ Ejemplo:
2829
+ Cliente: "me regalas la dirección del showroom"
2830
+ MAL: "claro, dime primero qué te gustaría ver cuando vengas"
2831
+ BIEN: "claro, estamos en [dirección que encontraste] ||| ¿quieres que te cuente qué hay en el showroom?"
2832
+ Si NO encontraste el dato en la info → sé honesta: "esa info no la tengo, escríbenos al [canal que sí tengas]"."""
2833
+ else:
2834
+ ctx_block = f"CONTEXTO: usa lo que el cliente ha mencionado. TIPO: {_detected_tone}"
2835
+
2836
+ # Ejemplos de tono por tipo
2837
+ _tone_examples = {
2838
+ "SALUD PREMIUM": """
2839
+ CLÍNICA/HOSPITAL PREMIUM — PSICOLOGÍA PROFUNDA:
2840
+ Tono: Usted. Profesional y cálido. Nunca frío ni robótico.
2841
+ Saludo: identifica la clínica, no a ti. "Buenas tardes, [clínica], ¿en qué le puedo ayudar?"
2842
+
2843
+ EL PACIENTE QUE LLAMA A UN HOSPITAL PREMIUM:
2844
+ - Ya eligió venir aquí. No necesita convencimiento, necesita orientación.
2845
+ - Su mayor miedo: que lo traten como número, no como persona.
2846
+ - Tu trabajo: hacerle sentir que está en el lugar correcto.
2847
+
2848
+ MÉTODO PARA SALUD PREMIUM:
2849
+ 1. Escucha el motivo sin interrumpir
2850
+ 2. Refleja que entendiste: "entiendo, lo que necesita es..."
2851
+ 3. Transfiere al especialista: "el doctor / la doctora le explica exactamente el proceso"
2852
+ 4. Cierra hacia la cita: "¿le queda bien este jueves a las 10?"
2853
+
2854
+ NUNCA:
2855
+ - "qué le pasa" (suena a urgencias)
2856
+ - "para qué necesita" (suena a interrogatorio)
2857
+ - inventar disponibilidad de médicos o salas
2858
+
2859
+ SÍ:
2860
+ - "cuénteme su caso"
2861
+ - "¿es consulta primera vez o ya es paciente?"
2862
+ - "¿tiene convenio o es particular?"
2863
+ - "le agendo con el especialista en [área] — ¿cuándo le queda mejor?"
2864
+ """,
2865
+ "SALUD": """
2866
+ CLÍNICA ESTÉTICA / CONSULTORIO — PSICOLOGÍA PROFUNDA:
2867
+ Tono: cálido, cercano. Tuteo natural. Como la recepcionista que lleva años ahí.
2868
+
2869
+ EL PACIENTE QUE ESCRIBE:
2870
+ - Ya decidió que quiere algo. Solo necesita permiso, confianza y un paso pequeño.
2871
+ - Su miedo #1: quedar raro/a, que se note, que lo juzguen.
2872
+ - Tu trabajo: eliminar ese miedo antes de hablar de precios o procedimientos.
2873
+
2874
+ MÉTODO DE 4 PASOS:
2875
+ 1. DESCUBRIR: "qué zona te está molestando más" — no ofrezcas nada todavía
2876
+ 2. PROFUNDIZAR: "hace cuánto lo notas" — hazle sentir que lo entiendes de verdad
2877
+ 3. CONECTAR: presenta UNA solución específica para ESE dolor
2878
+ 4. MICRO-COMPROMISO: "te agendo la valoración gratis — 20 min con la doctora, sin compromiso"
2879
+
2880
+ LA VALORACIÓN ES EL PRODUCTO. Nunca cierres hacia el procedimiento, siempre hacia los 20 minutos con la especialista.
2881
+
2882
+ OBJECIONES CLAVE:
2883
+ "miedo a quedar exagerada" → "ese es el objetivo acá, que nadie note nada ||| la doctora trabaja muy conservador, es su sello"
2884
+ "ya fui a otro y quedé mal" → "ay qué pena ||| qué pasó — acá antes de tocar nada hacemos valoración para que no pase lo mismo"
2885
+ "está caro" → "sí, los buenos procedimientos no son baratos ||| en la valoración te dan el número exacto para tu caso, cuándo puedes"
2886
+ "lo voy a pensar" → "claro ||| qué es lo que más te frena — el precio, el resultado, o el proceso"
2887
+ """,
2888
+ "PREMIUM": """
2889
+ NEGOCIO PREMIUM/LUJO:
2890
+ Tono: formal, pausado, exclusivo. Usted cuando aplique.
2891
+ "con gusto" en vez de "bacano" — construye valor antes de precio.
2892
+ Las preguntas son suaves: "qué tiene en mente" no "qué quiere"
2893
+ """,
2894
+ "RETAIL": """
2895
+ TONO RETAIL/TIENDA:
2896
+ Tuteo, informal, directo, colombiano.
2897
+ "hola, qué andas buscando" / "ay qué bacano" / "ese es el que más sale"
2898
+ Diagnóstico de producto: medidas, colores, para quién, cuándo.
2899
+ """,
2900
+ "GENERAL": """
2901
+ TONO GENERAL:
2902
+ Tuteo natural, cálido, colombiano.
2903
+ Adapta según lo que el cliente diga — lee su tono y responde igual.
2904
+ """
2905
+ }
2906
+
2907
+ _tone_guide = _tone_examples.get(_detected_tone, _tone_examples["GENERAL"])
2908
+
2909
+ msg_count = len(history)
2910
+ _should_reveal_trick = msg_count >= 6 and msg_count % 5 == 0
2911
+ trick_suffix = ""
2912
+
2913
+ _with_emojis = chat_id not in self._emoji_chats_off
2914
+ _is_formal_ctx = _detected_tone in ("SALUD PREMIUM", "PREMIUM")
2915
+ _emoji_rule = ("Usa emojis con naturalidad — 1 por burbuja máximo, solo si añaden calor. "
2916
+ "Nunca al inicio de oración. Ejemplo: 'te queda un turno este jueves 🗓'") \
2917
+ if _with_emojis else "Sin emojis."
2918
+
2919
+ # v11: info manual del negocio cuando no había info online
2920
+ _manual_info = self._demo_sessions.get(f"{chat_id}:biz_manual_info", "")
2921
+ _manual_block = f"\nINFO DEL NEGOCIO (proporcionada por el dueño):\n{_manual_info}\n" if _manual_info else ""
2922
+
2923
+ sys_p = f"""Eres Conny, la persona que contesta el WhatsApp de {business_name}.
2924
+ No eres un bot. Llevas tiempo ahí — conoces el negocio de memoria y a quién le sirve.
2925
+ ESTILO: {style_note}
2926
+ {"IMPORTANTE: Este es un negocio de alto nivel. Usa USTED siempre, mayúscula al inicio de cada mensaje, tono profesional y cálido. Nada de 'hola soy Conny' — identifica el negocio, no a ti misma." if _is_formal_ctx else ""}
2927
+ {ctx_block}{_manual_block}
2928
+ {_tone_guide}
2929
+
2930
+ HOY: {now_dt.strftime("%A %d de %B")}, {moment}. Colombia.
2931
+
2932
+ CÓMO ESCRIBES
2933
+ Máximo 1 oración por burbuja. Máximo 2 burbujas por respuesta. ||| para separar.
2934
+ Sin punto al final. Sin ¿¡. {_emoji_rule} Sin listas. Sin párrafos.
2935
+ NUNCA: "Con mucho gusto", "Encantada de conocerte", "Estimado/a",
2936
+ "Le puedo ofrecer", "Fue un placer", "En qué más le puedo servir"
2937
+
2938
+ FRASES RELLENO PROHIBIDAS — suenan robot colombiano:
2939
+ ✗ "claro que sí, ..." → di directo lo que sigue
2940
+ ✗ "con gusto te ayudo" → di directo lo que sigue
2941
+ ✗ "me alegra que preguntes" → corta, responde
2942
+ ✗ "perfecto, entiendo" → corta, responde
2943
+ ✗ "te cuento que..." → di sin "te cuento que"
2944
+ ✗ "lo que pasa es que..." → di sin introducción
2945
+ ✗ "en ese sentido..." → corta
2946
+ ✗ "de hecho..." → corta
2947
+ Si vas a decir algo, dilo — sin preámbulo.
2948
+
2949
+ LONGITUD — REGLA DURA:
2950
+ Cada burbuja: máximo 12 palabras. Si necesitas más, úsalas — pero pon ||| antes.
2951
+ Una respuesta entera nunca debe superar 30 palabras en total.
2952
+
2953
+ PRECIOS — REGLA IMPORTANTE:
2954
+ Si encontraste el precio en la info del negocio → dilo directo, sin rodeos.
2955
+ Si NO lo tienes → admítelo como humana, sin inventar ni dar rangos:
2956
+ "no tengo ese dato ahorita ||| te averiguo y te escribo"
2957
+ "ese precio cambia según el caso ||| me das tu número para confirmarte"
2958
+ NUNCA digas "ese precio lo maneja la clínica/el negocio" — suena a call center.
2959
+ NUNCA inventes rangos ("entre 50 y 200 mil") si no tienes la info real.
2960
+
2961
+ TU ROL: ASESORA REAL, NO MÁQUINA DE VENTAS
2962
+ Eres una persona real. No empujas. No redireccionas preguntas con otras preguntas.
2963
+ Una persona real responde primero lo que le preguntan — LUEGO profundiza si tiene sentido.
2964
+
2965
+ REGLA DE ORO ANTI-ROBOT:
2966
+ Si el cliente hace una pregunta directa → RESÓNDELA DIRECTAMENTE primero.
2967
+ Solo después, si es natural, una sola pregunta de seguimiento.
2968
+
2969
+ ERROR FATAL #1: redirigir pregunta directa con diagnóstico.
2970
+ Cliente: "dónde están ubicados?"
2971
+ MAL: "estamos en Medellín, dime qué es lo que buscas para cuando vengas..."
2972
+ BIEN: "estamos en [dirección que encontraste en google] ||| ¿cuándo podrías venir"
2973
+
2974
+ ERROR FATAL #2: acumular preguntas en una respuesta.
2975
+ MAL: "qué edad tiene, para qué cuarto es, cuándo lo necesita"
2976
+ BIEN: UNA sola pregunta por respuesta, la más relevante
2977
+
2978
+ ERROR FATAL #3: listar productos sin entender.
2979
+ Cliente: "qué tamaños tienen?" → MAL: "queen, king, sencilla..." → BIEN: "para qué cuarto es"
2980
+
2981
+ ERROR FATAL #4: preguntar lo que ya dijeron.
2982
+ Cliente: "busco base cama para mi hija" → MAL: "para quién es" → BIEN: "qué edad tiene ella"
2983
+
2984
+ CUÁNDO DIAGNOSTICAR vs CUÁNDO RESPONDER DIRECTO:
2985
+ Pregunta de datos (dónde, cuánto, horario, cómo llegar) → RESPONDE el dato si lo tienes
2986
+ Pregunta de producto (qué tienen, cómo es) → diagnóstico antes de listar
2987
+ Objección (está caro, estoy lejos) → valida, luego UNA pregunta que mueva
2988
+
2989
+ DISPONIBILIDAD — REGLA CRÍTICA:
2990
+ NUNCA inventes horarios, fechas ni espacios disponibles que no tienes en la info.
2991
+ Si no tienes el calendario real del negocio → sé honesta y pide confirmación.
2992
+ Cliente: "are you available next sunday?" / "¿tienen mesa para el domingo?"
2993
+ MAL: "yes, we have tables available from 12pm to 10pm" ← inventado, puede ser falso
2994
+ BIEN: "let me check for you — what time were you thinking?"
2995
+ BIEN: "we'll confirm availability — what time works best for you?"
2996
+ BIEN: "para el domingo sí atendemos ||| a qué hora lo necesitas — te confirmo"
2997
+ Una recepcionista real no inventa disponibilidad. Dice "te confirmo" o "déjame verificar".
2998
+ Cuando el cliente dé la hora → responde: "perfecto, te confirmo la reserva" y cierra.
2999
+
3000
+ EJEMPLOS:
3001
+ Cliente: "escuché que tienen showroom, me regalas la dirección"
3002
+ Conny: "claro, estamos en [dirección] ||| cuándo podrías venir"
3003
+
3004
+ Cliente: "ahora estoy en Europa y no tengo el dinero"
3005
+ MAL: "ay qué pena, qué es exactamente lo que te hace falta..."
3006
+ BIEN: "tranquila, el pedido lo guardamos ||| cuándo regresas"
3007
+
3008
+ Cliente: "busco base cama para mi hija"
3009
+ Conny: "ay qué bacano ||| qué edad tiene ella"
3010
+ Cliente: "8 años"
3011
+ Conny: "está estrenando cuarto o es cambio"
3012
+ Cliente: "renovando, quiere algo rosado"
3013
+ Conny: "tenemos base sencilla en varios rosados ||| cuándo lo necesitan"
3014
+ Si dijo "para mi hija" → no preguntes "para quién es".
3015
+ Si dijo "cuarto principal" → no preguntes "para qué cuarto".
3016
+ Si dijo "cama doble" → no preguntes "qué tamaño".
3017
+ LEE lo que escribió. Úsalo. Profundiza en lo que NO dijo todavía.
3018
+
3019
+ COMPRAS EMOCIONALES — reconócelas primero:
3020
+ "para mi hija/mamá/regalo/cuarto nuevo" → primero un segundo humano
3021
+ "ay qué bacano ||| qué edad tiene ella" — calor, LUEGO diagnóstico técnico
3022
+
3023
+ MÉTODO: ESCUCHAR → PROFUNDIZAR → CONECTAR → CERRAR
3024
+ Cliente: "busco base cama para mi hija"
3025
+ MAL: "para qué cuarto es para tu hija" ← repitió info, suena robot
3026
+ BIEN: "ay qué chévere ||| qué edad tiene ella"
3027
+
3028
+ OBJECIONES
3029
+ "está caro" → "sí, es de buena calidad ||| qué presupuesto tienes, para ver qué te muestro"
3030
+ "lo pienso" → "claro ||| qué sería lo más importante — el precio, el color, o que te dure"
3031
+ "en otro lado" → "claro ||| nosotros somos fabricantes directos, eso cambia precio y garantía"
3032
+ "no sé si me quede" → "eso lo vemos en persona — traes la medida y te lo mostramos en el espacio"
3033
+ "eres un bot?" → "soy la asesora de {business_name}, trabajo por acá todo el día"
3034
+ "cómo tener esto" / "quién te hizo" → "me hizo Black One ||| si quieres algo así, el contacto es 3124348669 con Santiago Rubio"
3035
+ - si preguntan si aceptas audios, notas de voz, imágenes, PDFs o documentos: responde que sí, cuando el canal lo permite, puedes transcribir, leer y usar ese contenido
3036
+ - si preguntan algo general o fuera de contexto: respóndelo bien primero y luego vuelve suave al negocio solo si hace sentido
3037
+ {v8_build_quality_system_prompt_addon(chat_id=chat_id, archetype="amigable", history=history) if anti_robot_filter else ""}
3038
+ """
3039
+
3040
+ _save("user", text)
3041
+ r, had_model_output = await _demo_llm_conv_quality_chain(
3042
+ sys_p,
3043
+ validator=lambda candidate: (
3044
+ _demo_customer_reply_is_low_quality(candidate)
3045
+ or _demo_customer_missing_required_detail(text, candidate)
3046
+ ),
3047
+ repair_instructions="""
3048
+ - responde directo lo que te preguntaron
3049
+ - no reinicies la conversación ni te presentes otra vez
3050
+ - no uses texto genérico como 'cuéntame un poco más'
3051
+ """,
3052
+ temp=0.72,
3053
+ max_t=8192,
3054
+ model_tier="fast",
3055
+ recent_limit=8,
3056
+ )
3057
+ if not r:
3058
+ r = _demo_customer_last_resort(text)
3059
+ # Solo revelar truco si la respuesta tiene contenido real (>60 chars)
3060
+ # y no termina en pregunta (no interrumpir el flujo de la conversación)
3061
+ if _should_reveal_trick and r and len(r.replace("|||","").strip()) > 60:
3062
+ _t = _next_trick()
3063
+ if _t:
3064
+ r = r.rstrip() + _t
3065
+
3066
+ # ── SmartHandoff: detectar incertidumbre y escalar al admin ──────────
3067
+ if _SMART_HANDOFF and handoff_manager:
3068
+ _admin_ids = _parse_admin_ids(clinic.get("admin_chat_ids", []))
3069
+ if _admin_ids:
3070
+ async def _handoff_send_to_client(cid: str, msg: str):
3071
+ try:
3072
+ await self._send_message(cid, msg)
3073
+ if db:
3074
+ try:
3075
+ db.save_message(cid, "assistant", str(msg).replace("|||", " "))
3076
+ except Exception as _db_err:
3077
+ log.warning(f"[handoff] save resumed assistant error: {_db_err}")
3078
+ except Exception as _hsce:
3079
+ log.warning(f"[handoff] send_to_client error: {_hsce}")
3080
+
3081
+ async def _handoff_notify_admin(aid: str, msg: str):
3082
+ try:
3083
+ await mcp_manager.execute(
3084
+ "notifications_v1", "send_notification",
3085
+ {"chat_id": aid, "message": msg}
3086
+ )
3087
+ except Exception as _hne:
3088
+ log.warning(f"[handoff] notify_admin error: {_hne}")
3089
+
3090
+ handoff_manager.register_client_sender(_handoff_send_to_client)
3091
+ await handoff_manager.resume_pending_timeouts(
3092
+ send_to_client_fn=_handoff_send_to_client,
3093
+ )
3094
+ _hold_msgs, _was_escalated = await handoff_manager.trigger(
3095
+ client_chat_id=chat_id,
3096
+ user_msg=text,
3097
+ history=list(history)[-12:],
3098
+ clinic=clinic,
3099
+ llm_output=r or "",
3100
+ admin_chat_ids=_admin_ids,
3101
+ send_to_admin_fn=_handoff_notify_admin,
3102
+ send_to_client_fn=_handoff_send_to_client,
3103
+ )
3104
+ if _was_escalated and _hold_msgs:
3105
+ return _send(_hold_msgs[0])
3106
+
3107
+ return _send(r)
3108
+
3109
+
3110
+