@innvisor/conny-ai 9.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. package/verify_conversation_impl.py +48 -0
@@ -0,0 +1,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."""