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