@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,804 @@
|
|
|
1
|
+
"""
|
|
2
|
+
conny_brain_v10.py
|
|
3
|
+
════════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
CEREBRO v10.1 — LLM PRIMERO, PLANTILLAS COMO ÚLTIMO RECURSO
|
|
5
|
+
════════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
CAMBIOS v10.1 (este archivo):
|
|
8
|
+
- Detección de frustración del cliente (loop de preguntas repetidas)
|
|
9
|
+
- format_memory_block ahora señala frustración al LLM para romper el loop
|
|
10
|
+
- Señales de zona ya respondida — evita repregunta infinita
|
|
11
|
+
- FRUSTRATION_SIGNALS integrado en extract_short_memory
|
|
12
|
+
- conversation_stage incluye "frustrated" como etapa especial
|
|
13
|
+
- Instrucción anti-loop en format_memory_block
|
|
14
|
+
|
|
15
|
+
CÓMO USAR (al final de conny.py, en init o en el bloque de startup):
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from conny_brain_v10 import patch_llm_first, init_brain
|
|
19
|
+
init_brain()
|
|
20
|
+
patch_llm_first(generator)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
log.warning(f"[brain_v10] no se pudo parchear: {e}")
|
|
23
|
+
|
|
24
|
+
════════════════════════════════════════════════════════════════════════════════
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
import re
|
|
31
|
+
import logging
|
|
32
|
+
import random
|
|
33
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("conny.brain_v10")
|
|
36
|
+
|
|
37
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
38
|
+
# 1. MEMORIA CORTA — extrae señales clave del historial reciente
|
|
39
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
_NAME_PATTERNS = [
|
|
42
|
+
r"(?:me\s+llamo|soy|mi\s+nombre\s+es|me\s+dicen)\s+([A-ZÁÉÍÓÚÑ][a-záéíóúñ]{2,20})",
|
|
43
|
+
r"(?:habla|escribe)\s+([A-ZÁÉÍÓÚÑ][a-záéíóúñ]{2,20})",
|
|
44
|
+
r"NOMBRE:\{\"name\":\"([^\"]+)\"\}",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_FEAR_SIGNALS = [
|
|
48
|
+
"miedo", "medo", "da pena", "me da pena", "nerviosa", "nervioso",
|
|
49
|
+
"asustada", "asustado", "no sé", "no se", "dudas", "incómoda",
|
|
50
|
+
"incómodo", "incomoda", "incomodo", "preocupa", "preocupada",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
_PRICE_OBJECTION_SIGNALS = [
|
|
54
|
+
"muy caro", "muy cara", "está caro", "esta caro", "demasiado",
|
|
55
|
+
"no tengo plata", "no tengo dinero", "es mucho", "precio alto",
|
|
56
|
+
"sale caro", "muy costoso", "costosa", "no me alcanza",
|
|
57
|
+
"muy costosa",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
_HESITATION_SIGNALS = [
|
|
61
|
+
"lo voy a pensar", "voy a pensar", "pensarlo", "no sé todavía",
|
|
62
|
+
"no estoy segura", "no estoy seguro", "tal vez", "quizás", "quizas",
|
|
63
|
+
"déjame ver", "dejame ver", "lo consulto", "consultarlo",
|
|
64
|
+
"hablar con", "más adelante", "luego",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# ── NUEVO v10.1: Señales de frustración por respuestas repetitivas ─────────
|
|
68
|
+
_FRUSTRATION_SIGNALS = [
|
|
69
|
+
"que fastidio", "qué fastidio", "ya le dije", "ya te dije",
|
|
70
|
+
"ya dije", "ya lo dije", "te lo dije", "le dije",
|
|
71
|
+
"no me entiendes", "no entiendes", "me repites",
|
|
72
|
+
"otra vez lo mismo", "siempre lo mismo", "otra vez",
|
|
73
|
+
"dime ya", "dime el precio", "dime y ya", "y ya",
|
|
74
|
+
"que pesado", "qué pesado", "eso ya lo dije",
|
|
75
|
+
"pregunta lo mismo", "mismo cuento",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# ── NUEVO v10.1: Señales de que el cliente ya dio la zona ──────────────────
|
|
79
|
+
_ZONE_GIVEN_SIGNALS = [
|
|
80
|
+
"frente", "entrecejo", "pómulos", "pomulos", "labios", "mandíbula",
|
|
81
|
+
"mandibula", "ojeras", "nariz", "cejas", "mentón", "menton",
|
|
82
|
+
"mejillas", "cuello", "rostro", "cara", "facial", "ojos",
|
|
83
|
+
"marcar", "definir", "levantar", "relleno", "volumen",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
_SERVICE_KEYWORDS = [
|
|
87
|
+
"limpieza", "botox", "implante", "blanqueamiento", "ortodoncia",
|
|
88
|
+
"consulta", "cita", "masaje", "faciales", "depilación", "depilacion",
|
|
89
|
+
"inscripción", "inscripcion", "membresía", "membresia", "sesión",
|
|
90
|
+
"sesion", "valoración", "valoracion", "examen", "control",
|
|
91
|
+
"vacuna", "cirugía", "cirugia", "tratamiento", "servicio",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extract_short_memory(history: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
96
|
+
"""
|
|
97
|
+
Lee el historial reciente y extrae:
|
|
98
|
+
- name: nombre del cliente
|
|
99
|
+
- service: servicio de interés principal
|
|
100
|
+
- has_fear: si expresó miedo o incomodidad
|
|
101
|
+
- has_price_objection: si objetó precio
|
|
102
|
+
- is_hesitating: si está indeciso
|
|
103
|
+
- is_frustrated: NUEVO — si está frustrado por preguntas repetidas
|
|
104
|
+
- zone_already_given: NUEVO — si ya dio la zona del cuerpo/cara
|
|
105
|
+
- repeated_question_detected: NUEVO — si Conny hizo la misma pregunta 2+ veces
|
|
106
|
+
- turn_count: número de turnos del cliente
|
|
107
|
+
- last_client_msg: último mensaje del cliente
|
|
108
|
+
- client_tone: "urgente" | "casual" | "desconfiado" | "interesado" | "frustrado"
|
|
109
|
+
"""
|
|
110
|
+
if not history:
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
mem: Dict[str, Any] = {
|
|
114
|
+
"name": None,
|
|
115
|
+
"service": None,
|
|
116
|
+
"has_fear": False,
|
|
117
|
+
"has_price_objection": False,
|
|
118
|
+
"is_hesitating": False,
|
|
119
|
+
"is_frustrated": False, # NUEVO
|
|
120
|
+
"zone_already_given": False, # NUEVO
|
|
121
|
+
"repeated_question_detected": False, # NUEVO
|
|
122
|
+
"turn_count": 0,
|
|
123
|
+
"last_client_msg": "",
|
|
124
|
+
"client_tone": "casual",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
client_messages: List[str] = []
|
|
128
|
+
# Para detectar preguntas repetidas de Conny
|
|
129
|
+
assistant_questions: List[str] = []
|
|
130
|
+
|
|
131
|
+
for msg in history:
|
|
132
|
+
role = (msg.get("role") or "").lower()
|
|
133
|
+
content = str(msg.get("content") or "").strip()
|
|
134
|
+
if not content:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
content_lower = content.lower()
|
|
138
|
+
|
|
139
|
+
if role == "user":
|
|
140
|
+
mem["turn_count"] += 1
|
|
141
|
+
mem["last_client_msg"] = content
|
|
142
|
+
client_messages.append(content_lower)
|
|
143
|
+
|
|
144
|
+
# Extraer nombre
|
|
145
|
+
if not mem["name"]:
|
|
146
|
+
for pat in _NAME_PATTERNS:
|
|
147
|
+
m = re.search(pat, content, re.IGNORECASE)
|
|
148
|
+
if m:
|
|
149
|
+
mem["name"] = m.group(1).strip().capitalize()
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
# Detectar servicio
|
|
153
|
+
if not mem["service"]:
|
|
154
|
+
for kw in _SERVICE_KEYWORDS:
|
|
155
|
+
if kw in content_lower:
|
|
156
|
+
mem["service"] = kw
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# Detectar señales emocionales
|
|
160
|
+
if any(s in content_lower for s in _FEAR_SIGNALS):
|
|
161
|
+
mem["has_fear"] = True
|
|
162
|
+
if any(s in content_lower for s in _PRICE_OBJECTION_SIGNALS):
|
|
163
|
+
mem["has_price_objection"] = True
|
|
164
|
+
if any(s in content_lower for s in _HESITATION_SIGNALS):
|
|
165
|
+
mem["is_hesitating"] = True
|
|
166
|
+
|
|
167
|
+
# NUEVO: frustración explícita
|
|
168
|
+
if any(s in content_lower for s in _FRUSTRATION_SIGNALS):
|
|
169
|
+
mem["is_frustrated"] = True
|
|
170
|
+
|
|
171
|
+
# NUEVO: zona ya dada
|
|
172
|
+
if any(s in content_lower for s in _ZONE_GIVEN_SIGNALS):
|
|
173
|
+
mem["zone_already_given"] = True
|
|
174
|
+
|
|
175
|
+
if role == "assistant":
|
|
176
|
+
# Extraer nombre de metadato
|
|
177
|
+
m = re.search(r'NOMBRE:\{"name":"([^"]+)"\}', content)
|
|
178
|
+
if m and not mem["name"]:
|
|
179
|
+
mem["name"] = m.group(1).strip().capitalize()
|
|
180
|
+
|
|
181
|
+
# NUEVO: detectar si Conny está repitiendo la misma pregunta
|
|
182
|
+
# Extraer preguntas del asistente (frases que terminan en ?)
|
|
183
|
+
questions_in_msg = re.findall(r'[^.!|]+\?', content_lower)
|
|
184
|
+
for q in questions_in_msg:
|
|
185
|
+
q_clean = q.strip()[:80] # primeros 80 chars de la pregunta
|
|
186
|
+
if q_clean:
|
|
187
|
+
# Si esta pregunta ya apareció antes → loop detectado
|
|
188
|
+
if any(
|
|
189
|
+
_text_similarity(q_clean, prev) > 0.6
|
|
190
|
+
for prev in assistant_questions
|
|
191
|
+
):
|
|
192
|
+
mem["repeated_question_detected"] = True
|
|
193
|
+
assistant_questions.append(q_clean)
|
|
194
|
+
|
|
195
|
+
# Inferir tono del cliente
|
|
196
|
+
all_client_text = " ".join(client_messages)
|
|
197
|
+
if mem["is_frustrated"]:
|
|
198
|
+
mem["client_tone"] = "frustrado"
|
|
199
|
+
elif mem["has_price_objection"] or mem["is_hesitating"]:
|
|
200
|
+
mem["client_tone"] = "desconfiado"
|
|
201
|
+
elif mem["has_fear"]:
|
|
202
|
+
mem["client_tone"] = "desconfiado"
|
|
203
|
+
elif any(w in all_client_text for w in ["urgente", "hoy", "ahora", "ya", "rápido", "rapido"]):
|
|
204
|
+
mem["client_tone"] = "urgente"
|
|
205
|
+
elif mem["turn_count"] >= 3:
|
|
206
|
+
mem["client_tone"] = "interesado"
|
|
207
|
+
|
|
208
|
+
return mem
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _text_similarity(a: str, b: str) -> float:
|
|
212
|
+
"""
|
|
213
|
+
Similitud simple entre dos strings: proporción de palabras compartidas.
|
|
214
|
+
Evita importar librerías externas.
|
|
215
|
+
"""
|
|
216
|
+
words_a = set(a.lower().split())
|
|
217
|
+
words_b = set(b.lower().split())
|
|
218
|
+
if not words_a or not words_b:
|
|
219
|
+
return 0.0
|
|
220
|
+
intersection = words_a & words_b
|
|
221
|
+
union = words_a | words_b
|
|
222
|
+
return len(intersection) / len(union)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def format_memory_block(mem: Dict[str, Any]) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Convierte el dict de memoria en contexto puro para el system prompt.
|
|
228
|
+
|
|
229
|
+
v10.1: incluye instrucciones anti-loop cuando hay frustración o
|
|
230
|
+
preguntas repetidas. El LLM recibe la señal de que debe cambiar de enfoque.
|
|
231
|
+
"""
|
|
232
|
+
if not mem:
|
|
233
|
+
return ""
|
|
234
|
+
|
|
235
|
+
facts: List[str] = []
|
|
236
|
+
tone_signals: List[str] = []
|
|
237
|
+
anti_loop_instruction: str = ""
|
|
238
|
+
|
|
239
|
+
# Hechos concretos
|
|
240
|
+
if mem.get("name"):
|
|
241
|
+
facts.append(f"ya dijo que se llama {mem['name']}")
|
|
242
|
+
|
|
243
|
+
if mem.get("service"):
|
|
244
|
+
facts.append(f"mencionó {mem['service']}")
|
|
245
|
+
|
|
246
|
+
# NUEVO v10.1: zona ya dada
|
|
247
|
+
if mem.get("zone_already_given"):
|
|
248
|
+
facts.append("ya dio información sobre zona o área de interés")
|
|
249
|
+
|
|
250
|
+
if mem.get("has_price_objection"):
|
|
251
|
+
tone_signals.append("objetó el precio")
|
|
252
|
+
|
|
253
|
+
if mem.get("has_fear"):
|
|
254
|
+
tone_signals.append("expresó miedo o incomodidad")
|
|
255
|
+
|
|
256
|
+
if mem.get("is_hesitating"):
|
|
257
|
+
tone_signals.append("está indeciso")
|
|
258
|
+
|
|
259
|
+
tone = mem.get("client_tone", "casual")
|
|
260
|
+
if tone == "urgente":
|
|
261
|
+
tone_signals.append("tiene urgencia")
|
|
262
|
+
elif tone == "desconfiado":
|
|
263
|
+
tone_signals.append("viene con desconfianza")
|
|
264
|
+
|
|
265
|
+
# NUEVO v10.1: señales de frustración / loop
|
|
266
|
+
if mem.get("is_frustrated") or mem.get("repeated_question_detected"):
|
|
267
|
+
tone_signals.append("está frustrado porque siente que no lo están escuchando")
|
|
268
|
+
anti_loop_instruction = (
|
|
269
|
+
"CRÍTICO: el cliente ya respondió tus preguntas anteriores. "
|
|
270
|
+
"NO repitas la misma pregunta. "
|
|
271
|
+
"Toma lo que ya dijo, avanza con esa información, "
|
|
272
|
+
"y dale algo concreto (precio, rango, próximo paso)."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if not facts and not tone_signals and not anti_loop_instruction:
|
|
276
|
+
return ""
|
|
277
|
+
|
|
278
|
+
parts = facts + tone_signals
|
|
279
|
+
block = "en esta conversación: " + ", ".join(parts) + "."
|
|
280
|
+
|
|
281
|
+
if anti_loop_instruction:
|
|
282
|
+
block += f"\n{anti_loop_instruction}"
|
|
283
|
+
|
|
284
|
+
return block
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
288
|
+
# 2. VALIDADOR DE RESPUESTA — detecta si suena a plantilla o a LLM real
|
|
289
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
290
|
+
|
|
291
|
+
_TEMPLATE_TELLS = [
|
|
292
|
+
"con mucho gusto",
|
|
293
|
+
"es un placer atenderte",
|
|
294
|
+
"gracias por contactarnos",
|
|
295
|
+
"¿en qué le puedo ayudar?",
|
|
296
|
+
"¿en qué te puedo ayudar hoy?",
|
|
297
|
+
"estoy aquí para ayudarte",
|
|
298
|
+
"no dudes en consultar",
|
|
299
|
+
"fue un placer",
|
|
300
|
+
"que tenga un buen día",
|
|
301
|
+
"estimado cliente",
|
|
302
|
+
"estimada cliente",
|
|
303
|
+
"a continuación",
|
|
304
|
+
"por favor seleccione",
|
|
305
|
+
"selecciona una opción",
|
|
306
|
+
"• opción",
|
|
307
|
+
"1. ",
|
|
308
|
+
"2. ",
|
|
309
|
+
"3. ",
|
|
310
|
+
# ── Frases de identidad rota — v10.1 fix ─────────────────────────────
|
|
311
|
+
"hay confusión",
|
|
312
|
+
"hay confusion",
|
|
313
|
+
"no sé cuál es el negocio",
|
|
314
|
+
"no se cual es el negocio",
|
|
315
|
+
"mi función es",
|
|
316
|
+
"mi funcion es",
|
|
317
|
+
"aquí lo que hago es",
|
|
318
|
+
"aqui lo que hago es",
|
|
319
|
+
"me doy cuenta de que",
|
|
320
|
+
"soy una ia",
|
|
321
|
+
"soy un bot",
|
|
322
|
+
"soy una asistente virtual",
|
|
323
|
+
"soy un asistente virtual",
|
|
324
|
+
"como asistente de ia",
|
|
325
|
+
"hola. aquí lo que hago",
|
|
326
|
+
"hola. aqui lo que hago",
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
_LLM_STRUCTURAL_TELLS = {
|
|
330
|
+
"min_unique_words": 4,
|
|
331
|
+
"min_length": 8,
|
|
332
|
+
"conversational_markers": [
|
|
333
|
+
"?", "|||",
|
|
334
|
+
],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
LLM_FIRST_FALLBACK_TRIGGERS = ("empty", "exception", "below_threshold")
|
|
338
|
+
LLM_FIRST_QUALITY_THRESHOLD = 0.45
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(frozen=True)
|
|
342
|
+
class LLMFirstVerdict:
|
|
343
|
+
failure_kind: str
|
|
344
|
+
failure_signal: str
|
|
345
|
+
quality_score: float
|
|
346
|
+
quality_threshold: float
|
|
347
|
+
should_normalize: bool
|
|
348
|
+
should_fallback: bool
|
|
349
|
+
|
|
350
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
351
|
+
return {
|
|
352
|
+
"decision_priority": "llm_first",
|
|
353
|
+
"fallback_triggers": list(LLM_FIRST_FALLBACK_TRIGGERS),
|
|
354
|
+
"failure_kind": self.failure_kind,
|
|
355
|
+
"failure_signal": self.failure_signal,
|
|
356
|
+
"quality_score": self.quality_score,
|
|
357
|
+
"quality_threshold": self.quality_threshold,
|
|
358
|
+
"below_threshold": self.failure_kind == "below_threshold",
|
|
359
|
+
"should_normalize": self.should_normalize,
|
|
360
|
+
"should_fallback": self.should_fallback,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class LLMResponseValidator:
|
|
365
|
+
"""
|
|
366
|
+
Valida si una respuesta parece generada por el LLM o por código/plantilla.
|
|
367
|
+
v10.1 — también detecta respuestas en loop (repite la misma pregunta).
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def is_template_response(self, response: str) -> bool:
|
|
371
|
+
if not response:
|
|
372
|
+
return True
|
|
373
|
+
r_low = response.lower()
|
|
374
|
+
|
|
375
|
+
if re.search(r"^\s*[1-9]\.\s", response, re.MULTILINE):
|
|
376
|
+
return True
|
|
377
|
+
if "• opción" in r_low or "seleccione una opción" in r_low:
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
template_score = sum(1 for t in _TEMPLATE_TELLS if t in r_low)
|
|
381
|
+
|
|
382
|
+
has_substance = (
|
|
383
|
+
len(response.strip()) > 60
|
|
384
|
+
or "?" in response
|
|
385
|
+
or "|||" in response
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if template_score >= 2 and not has_substance:
|
|
389
|
+
return True
|
|
390
|
+
if template_score >= 4:
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
def is_empty_or_useless(self, response: str) -> bool:
|
|
396
|
+
cleaned = (response or "").strip()
|
|
397
|
+
return len(cleaned) < 3
|
|
398
|
+
|
|
399
|
+
def looks_like_question_only(self, response: str) -> bool:
|
|
400
|
+
cleaned = (response or "").strip()
|
|
401
|
+
if len(cleaned) > 80:
|
|
402
|
+
return False
|
|
403
|
+
return bool(re.match(r"^[¿]?\w{1,12}\?$", cleaned.strip()))
|
|
404
|
+
|
|
405
|
+
def is_low_quality_first_turn(self, response: str) -> bool:
|
|
406
|
+
cleaned = (response or "").strip()
|
|
407
|
+
if not cleaned:
|
|
408
|
+
return True
|
|
409
|
+
normalized = cleaned.lower()
|
|
410
|
+
if len(cleaned.split()) <= 3:
|
|
411
|
+
return True
|
|
412
|
+
if any(
|
|
413
|
+
marker in normalized
|
|
414
|
+
for marker in (
|
|
415
|
+
"asistente virtual",
|
|
416
|
+
"recepcionista virtual",
|
|
417
|
+
"soy conny",
|
|
418
|
+
"te habla conny",
|
|
419
|
+
)
|
|
420
|
+
):
|
|
421
|
+
return True
|
|
422
|
+
if re.search(r"\bhoy\?$", normalized):
|
|
423
|
+
return True
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
def score_first_turn_response(self, response: str) -> float:
|
|
427
|
+
cleaned = (response or "").strip()
|
|
428
|
+
if not cleaned:
|
|
429
|
+
return 0.0
|
|
430
|
+
unique_words = {word for word in re.findall(r"\w+", cleaned.lower()) if len(word) > 1}
|
|
431
|
+
score = 0.52
|
|
432
|
+
if len(cleaned) >= 40:
|
|
433
|
+
score += 0.12
|
|
434
|
+
if len(unique_words) >= _LLM_STRUCTURAL_TELLS["min_unique_words"]:
|
|
435
|
+
score += 0.12
|
|
436
|
+
if any(marker in cleaned for marker in _LLM_STRUCTURAL_TELLS["conversational_markers"]):
|
|
437
|
+
score += 0.10
|
|
438
|
+
if cleaned.lower().startswith(("hola", "buenas", "hey")):
|
|
439
|
+
score += 0.06
|
|
440
|
+
return round(min(score, 0.95), 2)
|
|
441
|
+
|
|
442
|
+
def assess_first_turn_response(self, response: str) -> LLMFirstVerdict:
|
|
443
|
+
if self.is_empty_or_useless(response):
|
|
444
|
+
return LLMFirstVerdict(
|
|
445
|
+
failure_kind="empty",
|
|
446
|
+
failure_signal="empty_response",
|
|
447
|
+
quality_score=0.0,
|
|
448
|
+
quality_threshold=LLM_FIRST_QUALITY_THRESHOLD,
|
|
449
|
+
should_normalize=True,
|
|
450
|
+
should_fallback=True,
|
|
451
|
+
)
|
|
452
|
+
if self.is_template_response(response):
|
|
453
|
+
return LLMFirstVerdict(
|
|
454
|
+
failure_kind="below_threshold",
|
|
455
|
+
failure_signal="template_response",
|
|
456
|
+
quality_score=0.18,
|
|
457
|
+
quality_threshold=LLM_FIRST_QUALITY_THRESHOLD,
|
|
458
|
+
should_normalize=True,
|
|
459
|
+
should_fallback=True,
|
|
460
|
+
)
|
|
461
|
+
if self.looks_like_question_only(response):
|
|
462
|
+
return LLMFirstVerdict(
|
|
463
|
+
failure_kind="below_threshold",
|
|
464
|
+
failure_signal="question_only",
|
|
465
|
+
quality_score=0.26,
|
|
466
|
+
quality_threshold=LLM_FIRST_QUALITY_THRESHOLD,
|
|
467
|
+
should_normalize=True,
|
|
468
|
+
should_fallback=True,
|
|
469
|
+
)
|
|
470
|
+
if self.is_low_quality_first_turn(response):
|
|
471
|
+
return LLMFirstVerdict(
|
|
472
|
+
failure_kind="below_threshold",
|
|
473
|
+
failure_signal="low_quality_first_turn",
|
|
474
|
+
quality_score=0.34,
|
|
475
|
+
quality_threshold=LLM_FIRST_QUALITY_THRESHOLD,
|
|
476
|
+
should_normalize=True,
|
|
477
|
+
should_fallback=True,
|
|
478
|
+
)
|
|
479
|
+
score = self.score_first_turn_response(response)
|
|
480
|
+
return LLMFirstVerdict(
|
|
481
|
+
failure_kind="ok",
|
|
482
|
+
failure_signal="accepted",
|
|
483
|
+
quality_score=score,
|
|
484
|
+
quality_threshold=LLM_FIRST_QUALITY_THRESHOLD,
|
|
485
|
+
should_normalize=False,
|
|
486
|
+
should_fallback=False,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def is_repeating_previous(self, response: str, history: List[Dict[str, Any]]) -> bool:
|
|
490
|
+
"""
|
|
491
|
+
NUEVO v10.1: True si la respuesta repite casi exactamente
|
|
492
|
+
una respuesta anterior de Conny.
|
|
493
|
+
Previene el loop "Cuénteme qué quiere ajustar" x3.
|
|
494
|
+
"""
|
|
495
|
+
if not response or not history:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
response_clean = response.lower().strip()
|
|
499
|
+
for msg in history[-6:]:
|
|
500
|
+
if msg.get("role") != "assistant":
|
|
501
|
+
continue
|
|
502
|
+
prev = str(msg.get("content", "")).lower().strip()
|
|
503
|
+
if not prev:
|
|
504
|
+
continue
|
|
505
|
+
# Si la similitud es > 0.75, es la misma respuesta
|
|
506
|
+
if _text_similarity(response_clean[:100], prev[:100]) > 0.75:
|
|
507
|
+
log.debug(f"[brain_v10] respuesta repetida detectada — similitud alta")
|
|
508
|
+
return True
|
|
509
|
+
return False
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# Instancia global
|
|
513
|
+
_validator = LLMResponseValidator()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def assess_llm_first_response(
|
|
517
|
+
response: Optional[str],
|
|
518
|
+
*,
|
|
519
|
+
exception: Optional[BaseException] = None,
|
|
520
|
+
) -> Dict[str, Any]:
|
|
521
|
+
if exception is not None:
|
|
522
|
+
return LLMFirstVerdict(
|
|
523
|
+
failure_kind="exception",
|
|
524
|
+
failure_signal=exception.__class__.__name__,
|
|
525
|
+
quality_score=0.0,
|
|
526
|
+
quality_threshold=LLM_FIRST_QUALITY_THRESHOLD,
|
|
527
|
+
should_normalize=True,
|
|
528
|
+
should_fallback=True,
|
|
529
|
+
).as_dict()
|
|
530
|
+
return _validator.assess_first_turn_response(response or "").as_dict()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
534
|
+
# 3. PATCH LLM-FIRST — monkeypatches al ResponseGenerator
|
|
535
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
536
|
+
|
|
537
|
+
def _make_llm_first_normalize(original_fn):
|
|
538
|
+
"""
|
|
539
|
+
Wrapper de _normalize_first_patient_turn.
|
|
540
|
+
v10.1: si la respuesta repite una anterior, fuerza regeneración.
|
|
541
|
+
"""
|
|
542
|
+
def llm_first_normalize(self, response, clinic, personality, user_msg, history):
|
|
543
|
+
is_first = not any(m.get("role") == "assistant" for m in (history or []))
|
|
544
|
+
if not is_first:
|
|
545
|
+
# NUEVO v10.1: si no es primer turno pero la respuesta repite → regenerar
|
|
546
|
+
if _validator.is_repeating_previous(response, history or []):
|
|
547
|
+
log.debug("[brain_v10] respuesta repetida detectada en turno N → aplicando normalize")
|
|
548
|
+
return original_fn(self, response, clinic, personality, user_msg, history)
|
|
549
|
+
return original_fn(self, response, clinic, personality, user_msg, history)
|
|
550
|
+
|
|
551
|
+
verdict = _validator.assess_first_turn_response(response)
|
|
552
|
+
if verdict.should_normalize:
|
|
553
|
+
log.debug(
|
|
554
|
+
"[brain_v10] respuesta degradada en primer turno "
|
|
555
|
+
f"(kind={verdict.failure_kind} signal={verdict.failure_signal} "
|
|
556
|
+
f"score={verdict.quality_score:.2f}) → aplicando normalize"
|
|
557
|
+
)
|
|
558
|
+
return original_fn(self, response, clinic, personality, user_msg, history)
|
|
559
|
+
|
|
560
|
+
log.debug(
|
|
561
|
+
"[brain_v10] respuesta LLM OK en primer turno "
|
|
562
|
+
f"(score={verdict.quality_score:.2f}) → bypass normalize"
|
|
563
|
+
)
|
|
564
|
+
return response
|
|
565
|
+
|
|
566
|
+
return llm_first_normalize
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _make_llm_first_generate(original_generate):
|
|
570
|
+
"""
|
|
571
|
+
Wrapper de ResponseGenerator.generate.
|
|
572
|
+
v10.1: elimina seeded_first_turn y previene loops de preguntas.
|
|
573
|
+
"""
|
|
574
|
+
async def llm_first_generate(
|
|
575
|
+
self,
|
|
576
|
+
user_msg,
|
|
577
|
+
analysis,
|
|
578
|
+
reasoning,
|
|
579
|
+
clinic,
|
|
580
|
+
patient,
|
|
581
|
+
history,
|
|
582
|
+
search_context,
|
|
583
|
+
personality=None,
|
|
584
|
+
kb_context=None,
|
|
585
|
+
chat_id=None,
|
|
586
|
+
):
|
|
587
|
+
# Si seeded_first_turn → forzar LLM
|
|
588
|
+
meta_model = (reasoning or {}).get("_metadata", {}).get("model", "")
|
|
589
|
+
if meta_model == "seeded_first_turn":
|
|
590
|
+
log.info("[brain_v10] seeded_first_turn detectado → forzando LLM en primer turno")
|
|
591
|
+
reasoning = dict(reasoning or {})
|
|
592
|
+
reasoning["_metadata"] = {
|
|
593
|
+
**reasoning.get("_metadata", {}),
|
|
594
|
+
"model": "llm_first_v10",
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
# NUEVO v10.1: inyectar memoria de frustración en kb_context
|
|
598
|
+
if history:
|
|
599
|
+
mem = extract_short_memory(history)
|
|
600
|
+
memory_block = format_memory_block(mem)
|
|
601
|
+
if memory_block:
|
|
602
|
+
if kb_context:
|
|
603
|
+
kb_context = f"{kb_context}\n\n{memory_block}"
|
|
604
|
+
else:
|
|
605
|
+
kb_context = memory_block
|
|
606
|
+
log.debug(f"[brain_v10] memoria inyectada: {memory_block[:80]}...")
|
|
607
|
+
|
|
608
|
+
return await original_generate(
|
|
609
|
+
self,
|
|
610
|
+
user_msg,
|
|
611
|
+
analysis,
|
|
612
|
+
reasoning,
|
|
613
|
+
clinic,
|
|
614
|
+
patient,
|
|
615
|
+
history,
|
|
616
|
+
search_context,
|
|
617
|
+
personality=personality,
|
|
618
|
+
kb_context=kb_context,
|
|
619
|
+
chat_id=chat_id,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
return llm_first_generate
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def patch_llm_first(generator) -> bool:
|
|
626
|
+
"""
|
|
627
|
+
Parchea una instancia de ResponseGenerator para operar en modo LLM-first.
|
|
628
|
+
Retorna True si el patch se aplicó correctamente.
|
|
629
|
+
"""
|
|
630
|
+
if generator is None:
|
|
631
|
+
log.warning("[brain_v10] generator es None — patch no aplicado")
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
import types
|
|
636
|
+
|
|
637
|
+
original_normalize = generator._normalize_first_patient_turn
|
|
638
|
+
generator._normalize_first_patient_turn = types.MethodType(
|
|
639
|
+
_make_llm_first_normalize(
|
|
640
|
+
original_normalize.__func__
|
|
641
|
+
if hasattr(original_normalize, "__func__")
|
|
642
|
+
else original_normalize
|
|
643
|
+
),
|
|
644
|
+
generator,
|
|
645
|
+
)
|
|
646
|
+
log.info("[brain_v10] _normalize_first_patient_turn parchado ✓")
|
|
647
|
+
|
|
648
|
+
original_generate = generator.generate
|
|
649
|
+
generator.generate = types.MethodType(
|
|
650
|
+
_make_llm_first_generate(
|
|
651
|
+
original_generate.__func__
|
|
652
|
+
if hasattr(original_generate, "__func__")
|
|
653
|
+
else original_generate
|
|
654
|
+
),
|
|
655
|
+
generator,
|
|
656
|
+
)
|
|
657
|
+
log.info("[brain_v10] generate parchado (anti-seeded_first_turn + anti-loop) ✓")
|
|
658
|
+
|
|
659
|
+
return True
|
|
660
|
+
|
|
661
|
+
except Exception as e:
|
|
662
|
+
log.error(f"[brain_v10] error en patch_llm_first: {e}")
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
667
|
+
# 4. INIT — punto de entrada limpio
|
|
668
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
669
|
+
|
|
670
|
+
_brain_initialized = False
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def init_brain() -> None:
|
|
674
|
+
global _brain_initialized
|
|
675
|
+
if _brain_initialized:
|
|
676
|
+
return
|
|
677
|
+
_brain_initialized = True
|
|
678
|
+
log.info("[brain_v10] cerebro v10.1 inicializado — modo LLM-first + anti-loop activo")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
682
|
+
# 5. AUTO-PATCH
|
|
683
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
684
|
+
|
|
685
|
+
def auto_patch() -> bool:
|
|
686
|
+
import sys
|
|
687
|
+
|
|
688
|
+
conny_module = sys.modules.get("__main__") or sys.modules.get("conny")
|
|
689
|
+
if conny_module is None:
|
|
690
|
+
for name, mod in sys.modules.items():
|
|
691
|
+
if name == "conny" or (name == "__main__" and hasattr(mod, "generator")):
|
|
692
|
+
conny_module = mod
|
|
693
|
+
break
|
|
694
|
+
|
|
695
|
+
if conny_module is None:
|
|
696
|
+
log.warning("[brain_v10] no se encontró módulo conny — auto_patch fallido")
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
gen = getattr(conny_module, "generator", None)
|
|
700
|
+
if gen is None:
|
|
701
|
+
log.warning("[brain_v10] generator no encontrado en módulo — auto_patch fallido")
|
|
702
|
+
return False
|
|
703
|
+
|
|
704
|
+
init_brain()
|
|
705
|
+
return patch_llm_first(gen)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
709
|
+
# 6. UTILIDADES EXTRA
|
|
710
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
711
|
+
|
|
712
|
+
def should_ask_for_name(history: List[Dict[str, Any]]) -> bool:
|
|
713
|
+
mem = extract_short_memory(history)
|
|
714
|
+
return mem.get("name") is None and mem.get("turn_count", 0) >= 2
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def get_client_name(history: List[Dict[str, Any]]) -> Optional[str]:
|
|
718
|
+
return extract_short_memory(history).get("name")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def conversation_stage(history: List[Dict[str, Any]]) -> str:
|
|
722
|
+
"""
|
|
723
|
+
Infiere la etapa de la conversación.
|
|
724
|
+
v10.1 agrega "frustrated" como etapa de máxima prioridad.
|
|
725
|
+
"""
|
|
726
|
+
mem = extract_short_memory(history)
|
|
727
|
+
tc = mem.get("turn_count", 0)
|
|
728
|
+
|
|
729
|
+
if tc == 0:
|
|
730
|
+
return "first_contact"
|
|
731
|
+
|
|
732
|
+
# NUEVO v10.1: frustración tiene prioridad
|
|
733
|
+
if mem.get("is_frustrated") or mem.get("repeated_question_detected"):
|
|
734
|
+
return "frustrated"
|
|
735
|
+
|
|
736
|
+
if mem.get("has_price_objection") or mem.get("is_hesitating"):
|
|
737
|
+
return "objecting"
|
|
738
|
+
if tc >= 4 and mem.get("service"):
|
|
739
|
+
return "ready_to_book"
|
|
740
|
+
return "exploring"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def dynamic_temperature(history: List[Dict[str, Any]]) -> float:
|
|
744
|
+
"""
|
|
745
|
+
Temperatura dinámica según etapa.
|
|
746
|
+
v10.1: temperatura más alta cuando hay frustración → más variedad léxica,
|
|
747
|
+
menos riesgo de repetir la misma frase.
|
|
748
|
+
"""
|
|
749
|
+
mem = extract_short_memory(history)
|
|
750
|
+
tc = mem.get("turn_count", 0)
|
|
751
|
+
|
|
752
|
+
# Frustración → temperatura alta para forzar variedad
|
|
753
|
+
if mem.get("is_frustrated") or mem.get("repeated_question_detected"):
|
|
754
|
+
return 0.92
|
|
755
|
+
|
|
756
|
+
base = 0.45
|
|
757
|
+
ceiling = 0.88
|
|
758
|
+
step = (ceiling - base) / 8
|
|
759
|
+
return round(min(ceiling, base + step * tc), 2)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
763
|
+
# 7. SECTOR LAYER BUILDER — helper para mejorar prompts de sector
|
|
764
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
765
|
+
|
|
766
|
+
def build_estetica_sector_layer(is_poblado: bool = False) -> str:
|
|
767
|
+
"""
|
|
768
|
+
Retorna el sector layer mejorado para clínicas estéticas.
|
|
769
|
+
|
|
770
|
+
v10.1: corrige el loop de zona. Instrucción explícita de
|
|
771
|
+
"pregunta zona UNA SOLA VEZ y avanza con lo que el cliente diga".
|
|
772
|
+
|
|
773
|
+
Usar en conny.py reemplazando el _sector_layer de estetica no-Poblado:
|
|
774
|
+
from conny_brain_v10 import build_estetica_sector_layer
|
|
775
|
+
_sector_layer = build_estetica_sector_layer(is_poblado=_is_poblado)
|
|
776
|
+
"""
|
|
777
|
+
if is_poblado:
|
|
778
|
+
return (
|
|
779
|
+
"la clienta ya viene con algo en mente — no hay que convencerla, "
|
|
780
|
+
"hay que escucharla bien y resolver sus dudas sin presionarla. "
|
|
781
|
+
"el miedo más común es quedar exagerada o diferente. "
|
|
782
|
+
"lo que genera confianza es mostrar que la dra trabaja conservador "
|
|
783
|
+
"y que la valoración es sin compromiso."
|
|
784
|
+
)
|
|
785
|
+
else:
|
|
786
|
+
return """PERFIL CLÍNICA ESTÉTICA:
|
|
787
|
+
Tu clienta ya sabe lo que quiere — no expliques qué es botox.
|
|
788
|
+
|
|
789
|
+
REGLA DE ZONA (crítica): Pregunta qué zona le interesa UNA SOLA VEZ.
|
|
790
|
+
Si ya respondió algo sobre zona o resultado (aunque sea vago como "rostro", "cara",
|
|
791
|
+
"marcar más", "definir", "levantar"), toma esa información y avanza.
|
|
792
|
+
NO vuelvas a preguntar la zona después de que el cliente ya respondió.
|
|
793
|
+
|
|
794
|
+
Si la respuesta de zona es ambigua:
|
|
795
|
+
- "rostro" o "cara" → ofrece opciones concretas: frente, pómulos, mandíbula, relleno de labios
|
|
796
|
+
- "marcar más" o "definir" → puede ser relleno (no botox puro) — explica brevemente y ofrece valoración
|
|
797
|
+
- "levantar" → puede ser hilo tensor o toxina — menciona las dos opciones
|
|
798
|
+
|
|
799
|
+
REGLA DE PRECIO: Si el cliente pide precio directamente dos veces seguidas,
|
|
800
|
+
da un rango aproximado ("entre X y Y dependiendo de la zona y cantidad")
|
|
801
|
+
y cierra hacia la valoración. No vuelvas a preguntar.
|
|
802
|
+
|
|
803
|
+
El cierre siempre es hacia la valoración gratuita con la especialista.
|
|
804
|
+
Nunca repitas la misma pregunta dos veces en la misma conversación."""
|