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