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