@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,275 @@
1
+ """
2
+ Conny V7.0 — Orquestador
3
+ ============================
4
+ Conecta router, memoria, agentes y postprocessor.
5
+ conny.py llama a orchestrator.process() en lugar de generator.generate().
6
+ Si algo falla, cae al generator clásico (backward compat).
7
+
8
+ "todo debe actuar como uno" — el paciente nunca sabe qué agente respondió.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import asyncio
13
+ import logging
14
+ import time
15
+ from typing import Dict, List, Optional, Any
16
+
17
+ from v7.router import router, AgentID, RouterResult
18
+ from v7.agents import build_registry
19
+ from v7.agents.base import AgentContext, AgentResponse
20
+ from v7.memory.patient_profile import ProfileStore, PatientProfile, FunnelStage
21
+ from v7.postprocess import postprocess, split_bubbles
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class ConnyOrchestrator:
27
+ """
28
+ Orquestador central de Conny V7.
29
+ Se instancia una vez al arranque de ConnyUltra.
30
+ """
31
+
32
+ def __init__(self, llm_engine, db):
33
+ self._agents = build_registry(llm_engine)
34
+ self._profile_store = ProfileStore(db)
35
+ self._llm = llm_engine
36
+ self._db = db
37
+ log.info(
38
+ "[orchestrator] V7 listo. Agentes: %s",
39
+ list(self._agents.keys())
40
+ )
41
+
42
+ async def process(
43
+ self,
44
+ chat_id: str,
45
+ text: str,
46
+ clinic: Dict,
47
+ patient: Dict,
48
+ history: List[Dict],
49
+ search_context: str = "",
50
+ kb_context: str = "",
51
+ calendar_info: str = "",
52
+ is_cron: bool = False,
53
+ cron_type: Optional[str] = None,
54
+ ) -> List[str]:
55
+ """
56
+ Punto de entrada principal.
57
+ Devuelve lista de burbujas lista para enviar.
58
+ """
59
+ t0 = time.perf_counter()
60
+
61
+ # 1. Cargar perfil del paciente
62
+ profile = self._profile_store.load(chat_id)
63
+
64
+ # 2. Routing de intención
65
+ route = router.route(
66
+ text=text,
67
+ funnel_state=profile.funnel_stage.value,
68
+ history_length=len(history),
69
+ is_cron=is_cron,
70
+ cron_type=cron_type,
71
+ )
72
+
73
+ log.info(
74
+ "[orchestrator] %s → agente=%s confianza=%.2f señales=%s funnel=%s",
75
+ chat_id[:8],
76
+ route.agent_id.value,
77
+ route.confidence,
78
+ route.signals[:2],
79
+ profile.funnel_stage.value,
80
+ )
81
+
82
+ # 3. Resolver agente
83
+ agent = self._agents.get(route.agent_id)
84
+ if not agent:
85
+ log.warning("[orchestrator] agente %s no encontrado, usando fallback", route.agent_id)
86
+ return None # señal para que conny.py use el generator clásico
87
+
88
+ # 4. Ensamblar contexto mínimo
89
+ ctx = self._build_context(
90
+ chat_id=chat_id,
91
+ text=text,
92
+ clinic=clinic,
93
+ profile=profile,
94
+ history=history,
95
+ context_keys=route.context_keys,
96
+ search_context=search_context,
97
+ kb_context=kb_context,
98
+ calendar_info=calendar_info,
99
+ cron_metadata={"follow_up_reason": cron_type} if is_cron else {},
100
+ )
101
+
102
+ # 5. Ejecutar agente
103
+ response: AgentResponse = await agent.run(ctx)
104
+
105
+ # 6. Actualizar memoria (async, no bloquea la respuesta)
106
+ asyncio.create_task(
107
+ self._update_memory(profile, response, route)
108
+ )
109
+
110
+ # 7. Si el agente detectó escalación a humano → notificar
111
+ if response.next_agent == "human":
112
+ asyncio.create_task(
113
+ self._notify_escalation(chat_id, text, clinic)
114
+ )
115
+
116
+ latency = (time.perf_counter() - t0) * 1000
117
+ log.info(
118
+ "[orchestrator] %s completado en %.0fms (agente=%.0fms)",
119
+ chat_id[:8], latency, response.latency_ms
120
+ )
121
+
122
+ return response.bubbles
123
+
124
+ # ── Ensamblado de contexto ────────────────────────────────────────────────
125
+
126
+ def _build_context(
127
+ self,
128
+ chat_id: str,
129
+ text: str,
130
+ clinic: Dict,
131
+ profile: PatientProfile,
132
+ history: List[Dict],
133
+ context_keys: List[str],
134
+ search_context: str,
135
+ kb_context: str,
136
+ calendar_info: str,
137
+ cron_metadata: Dict,
138
+ ) -> AgentContext:
139
+ """
140
+ Ensambla SOLO los campos que el agente declaró necesitar.
141
+ Clave para mantener el prompt corto.
142
+ """
143
+ # Tone detection
144
+ clinic_tone = self._detect_tone(clinic)
145
+
146
+ # Servicios en texto compacto
147
+ servicios_raw = clinic.get("services", [])
148
+ if isinstance(servicios_raw, list):
149
+ servicios = ", ".join(
150
+ s.get("name", "") if isinstance(s, dict) else str(s)
151
+ for s in servicios_raw[:8]
152
+ )
153
+ else:
154
+ servicios = str(servicios_raw)[:200]
155
+
156
+ return AgentContext(
157
+ chat_id=chat_id,
158
+ text=text,
159
+ platform=str(clinic.get("platform") or "whatsapp"),
160
+ patient_summary=profile.to_context_summary(context_keys),
161
+ funnel_stage=profile.funnel_stage.value,
162
+ patient_name=profile.nombre,
163
+ visits=profile.visitas,
164
+ objeciones_pasadas=profile.objeciones_pasadas,
165
+ clinic_name=clinic.get("name", ""),
166
+ clinic_tone=clinic_tone,
167
+ clinic_kb=kb_context[:500] if kb_context else "",
168
+ servicios=servicios,
169
+ precios=self._extract_pricing(clinic)[:300],
170
+ history=history[-8:],
171
+ search_context=search_context[:400] if search_context else "",
172
+ calendar_info=calendar_info,
173
+ metadata=cron_metadata,
174
+ )
175
+
176
+ def _detect_tone(self, clinic: Dict) -> str:
177
+ name = clinic.get("name", "").lower()
178
+ ctx = clinic.get("description", "").lower()
179
+ combined = name + " " + ctx
180
+
181
+ _premium_words = [
182
+ "hospital","las américas","pablo tobón","tobon","country",
183
+ "bocagrande","fundación","fundacion","universitario","cardiovascular",
184
+ "premium","internacional","vip","élite","elite",
185
+ ]
186
+ _health_words = [
187
+ "clínica","clinica","médico","medico","salud","consultorio",
188
+ "odontología","dental","psicología","terapia","estetica","estética",
189
+ ]
190
+ _retail_words = [
191
+ "tienda","almacén","almacen","muebles","ropa","calzado",
192
+ "restaurante","cafetería","ferretería",
193
+ ]
194
+
195
+ is_health = any(w in combined for w in _health_words)
196
+ is_premium = any(w in combined for w in _premium_words)
197
+ is_retail = any(w in combined for w in _retail_words)
198
+
199
+ if is_health and is_premium:
200
+ return "SALUD PREMIUM"
201
+ elif is_health:
202
+ return "SALUD"
203
+ elif is_premium:
204
+ return "PREMIUM"
205
+ elif is_retail:
206
+ return "RETAIL"
207
+ return "GENERAL"
208
+
209
+ def _extract_pricing(self, clinic: Dict) -> str:
210
+ pricing = clinic.get("pricing", {})
211
+ if not pricing:
212
+ return ""
213
+ if isinstance(pricing, dict):
214
+ lines = [f"{k}: {v}" for k, v in list(pricing.items())[:5]]
215
+ return "\n".join(lines)
216
+ return str(pricing)[:300]
217
+
218
+ # ── Actualización de memoria ──────────────────────────────────────────────
219
+
220
+ async def _update_memory(
221
+ self,
222
+ profile: PatientProfile,
223
+ response: AgentResponse,
224
+ route: RouterResult,
225
+ ) -> None:
226
+ """Actualiza el perfil del paciente con lo aprendido en este turno."""
227
+ try:
228
+ changed = False
229
+
230
+ if response.funnel_update:
231
+ try:
232
+ new_stage = FunnelStage(response.funnel_update)
233
+ if profile.advance_funnel(new_stage):
234
+ changed = True
235
+ except ValueError:
236
+ pass
237
+
238
+ if response.new_objecion:
239
+ profile.add_objecion(response.new_objecion)
240
+ changed = True
241
+
242
+ if response.learned_name:
243
+ profile.nombre = response.learned_name
244
+ changed = True
245
+
246
+ if response.learned_zona:
247
+ profile.add_zona(response.learned_zona)
248
+ changed = True
249
+
250
+ if changed:
251
+ self._profile_store.save(profile)
252
+
253
+ except Exception as e:
254
+ log.warning("[orchestrator] error actualizando memoria: %s", e)
255
+
256
+ async def _notify_escalation(
257
+ self, chat_id: str, text: str, clinic: Dict
258
+ ) -> None:
259
+ """Notifica a los admins cuando se detecta escalación."""
260
+ try:
261
+ admin_ids = clinic.get("admin_chat_ids", [])
262
+ if isinstance(admin_ids, str):
263
+ import json
264
+ admin_ids = json.loads(admin_ids) if admin_ids else []
265
+ clinic_name = clinic.get("name", "la clínica")
266
+ msg = (
267
+ f"ESCALACIÓN detectada en {clinic_name}\n"
268
+ f"Paciente: {chat_id}\n"
269
+ f"Mensaje: {text[:150]}"
270
+ )
271
+ log.warning("[escalacion] %s", msg)
272
+ # El envío real lo hace conny.py con _send_message
273
+ # Aquí solo dejamos el log — el handler de escalación ya respondió al paciente
274
+ except Exception as e:
275
+ log.warning("[orchestrator] error en notify_escalation: %s", e)
@@ -0,0 +1,127 @@
1
+ """
2
+ Conny V7.0 — Postprocessor Determinístico
3
+ =============================================
4
+ Humaniza la respuesta cruda del LLM sin llamar a ningún otro modelo.
5
+ Garantías: sin em dash, sin punto final, sin frases relleno, sin emojis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import re
10
+ import unicodedata
11
+ from typing import List
12
+
13
+
14
+ # ── Frases relleno que el LLM colea aunque el prompt las prohíba ──────────────
15
+ _STRIP_PHRASES = [
16
+ "con mucho gusto", "encantada de conocerte", "encantado de conocerte",
17
+ "es un placer", "fue un placer", "en qué más le puedo servir",
18
+ "en qué más puedo ayudarte", "estoy aquí para ayudarte",
19
+ "por supuesto,", "definitivamente,", "absolutamente,",
20
+ "claro que sí,", "claro que si,", "con gusto te ayudo",
21
+ "con gusto te cuento", "me alegra que preguntes",
22
+ "perfecto, entiendo,", "te cuento que", "lo que pasa es que",
23
+ "en ese sentido,", "de hecho,", "con todo gusto",
24
+ ]
25
+
26
+ # ── Emojis ────────────────────────────────────────────────────────────────────
27
+ _EMOJI_RE = re.compile(
28
+ "["
29
+ "\U0001F600-\U0001F64F"
30
+ "\U0001F300-\U0001F5FF"
31
+ "\U0001F680-\U0001F6FF"
32
+ "\U0001F1E0-\U0001F1FF"
33
+ "\U00002500-\U00002BEF"
34
+ "\U00002702-\U000027B0"
35
+ "\U0001F900-\U0001F9FF"
36
+ "\U0001FA00-\U0001FAFF"
37
+ "\u2600-\u26FF"
38
+ "\u2700-\u27BF"
39
+ "]+",
40
+ flags=re.UNICODE,
41
+ )
42
+
43
+
44
+ def postprocess(text: str, is_premium: bool = False) -> str:
45
+ """
46
+ Limpia y humaniza la respuesta. Sin LLM. Determinístico.
47
+ is_premium=True → conserva mayúscula inicial.
48
+ """
49
+ if not text:
50
+ return text
51
+
52
+ # 1. Em dash → espacio (siempre, sin excepción)
53
+ text = re.sub(r"\s*—\s*", " ", text)
54
+
55
+ # 2. Emojis
56
+ text = _EMOJI_RE.sub("", text)
57
+
58
+ # 3. ¿¡
59
+ text = text.replace("¿", "").replace("¡", "")
60
+
61
+ # 4. Espacios
62
+ text = re.sub(r"\s+", " ", text)
63
+ text = re.sub(r"\s*\|\|\|\s*", " ||| ", text)
64
+
65
+ # 5. Por burbuja
66
+ if "|||" in text:
67
+ bubbles = [_per_bubble(b, is_premium) for b in text.split("|||")]
68
+ text = " ||| ".join(b for b in bubbles if b.strip())
69
+ else:
70
+ text = _per_bubble(text, is_premium)
71
+
72
+ # 6. Frases relleno (después de por-burbuja para mayor coverage)
73
+ for phrase in _STRIP_PHRASES:
74
+ pattern = re.compile(re.escape(phrase), re.IGNORECASE)
75
+ text = pattern.sub("", text)
76
+
77
+ # 7. Limpiar residuos
78
+ text = re.sub(r"\s+", " ", text).strip()
79
+ text = re.sub(r"^\s*,\s*", "", text)
80
+
81
+ return text
82
+
83
+
84
+ def _per_bubble(s: str, is_premium: bool) -> str:
85
+ s = s.strip()
86
+ if not s:
87
+ return s
88
+
89
+ # Quitar punto final
90
+ if s.endswith(".") and not s.endswith("..."):
91
+ s = s[:-1].strip()
92
+
93
+ # Quitar guión al inicio/fin (residuo post em-dash)
94
+ s = re.sub(r"^\s*-\s+", "", s)
95
+ s = re.sub(r"\s+-\s*$", "", s)
96
+ s = s.strip()
97
+
98
+ # Truncar si supera 90 chars (burbuja demasiado larga)
99
+ if len(s) > 90:
100
+ cut = max(s.rfind(". ", 0, 90), s.rfind(", ", 0, 90), s.rfind(" y ", 0, 90))
101
+ if cut > 40:
102
+ s = s[:cut].strip().rstrip(",").rstrip(".")
103
+
104
+ # Mayúscula inicial solo para premium, minúscula para el resto
105
+ if s:
106
+ if is_premium:
107
+ # Premium: siempre mayúscula
108
+ s = s[0].upper() + s[1:]
109
+ else:
110
+ # General: minúscula excepto siglas o nombres propios obvios
111
+ first_word = s.split()[0] if s.split() else ""
112
+ is_acronym = (len(first_word) <= 5
113
+ and first_word == first_word.upper()
114
+ and len(first_word) > 1)
115
+ if not is_acronym:
116
+ s = s[0].lower() + s[1:]
117
+
118
+ return s
119
+
120
+
121
+ def split_bubbles(text: str, max_bubbles: int = 3) -> List[str]:
122
+ """Divide el texto en burbujas y limita la cantidad."""
123
+ if "|||" in text:
124
+ parts = [b.strip() for b in text.split("|||") if b.strip()]
125
+ else:
126
+ parts = [text.strip()] if text.strip() else []
127
+ return parts[:max_bubbles]
package/v7/router.py ADDED
@@ -0,0 +1,239 @@
1
+ """
2
+ Conny V7.0 — Router de Intención
3
+ ===================================
4
+ Clasifica el mensaje entrante al agente correcto en 3 capas:
5
+ Capa 1: señales exactas (regex) → 5-15ms, costo 0
6
+ Capa 2: señales semánticas (score) → 15-30ms, costo 0
7
+ Capa 3: LLM fallback → 400ms, solo si confianza < THRESHOLD
8
+
9
+ Nunca llama al LLM para los casos claros (>85% de los mensajes reales).
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import re
14
+ import time
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+ from typing import Dict, List, Optional, Tuple
18
+ from enum import Enum
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ CONFIDENCE_THRESHOLD = 0.65 # bajo esto → LLM fallback
23
+
24
+
25
+ # ── Agentes disponibles ───────────────────────────────────────────────────────
26
+
27
+ class AgentID(str, Enum):
28
+ CAPTACION = "captacion"
29
+ OBJECIONES = "objeciones"
30
+ AGENDA = "agenda"
31
+ SEGUIMIENTO = "seguimiento"
32
+ CONOCIMIENTO = "conocimiento"
33
+ ESCALACION = "escalacion"
34
+ ADMIN = "admin"
35
+ FALLBACK = "fallback" # generator clásico si ningún agente matchea
36
+
37
+
38
+ # ── Resultado del router ──────────────────────────────────────────────────────
39
+
40
+ @dataclass
41
+ class RouterResult:
42
+ agent_id: AgentID
43
+ confidence: float
44
+ signals: List[str] # qué señales dispararon la decisión
45
+ context_keys: List[str] # qué campos de memoria necesita el agente
46
+ latency_ms: float = 0.0
47
+
48
+ @property
49
+ def is_confident(self) -> bool:
50
+ return self.confidence >= CONFIDENCE_THRESHOLD
51
+
52
+
53
+ # ── Definición de señales por agente ─────────────────────────────────────────
54
+
55
+ # Cada señal tiene: patrón regex, peso (0-1), contexto_mínimo
56
+ _SIGNALS: Dict[AgentID, List[Tuple[str, float]]] = {
57
+
58
+ AgentID.ESCALACION: [
59
+ # Alta prioridad — se evalúa PRIMERO
60
+ (r"\b(emergencia|urgente|me duele mucho|reacción|alergia|complicación|demanda|abogado|denunciar)\b", 0.95),
61
+ (r"\b(hablar con (alguien|una persona|el dueño|el doctor)|quiero quejarme)\b", 0.90),
62
+ (r"\b(me quedó mal|quedé horrible|daño|perjuicio)\b", 0.85),
63
+ ],
64
+
65
+ AgentID.OBJECIONES: [
66
+ (r"\b(caro|costoso|muy caro|no tengo plata|presupuesto|precio alto)\b", 0.90),
67
+ (r"\b(pensarlo|lo pienso|déjame pensar|no sé si|lo consulto|mi (pareja|esposo|esposa|mamá))\b", 0.88),
68
+ (r"\b(miedo|da miedo|me asusta|quede (rara|exagerada|tiesa|mal)|se note|natural)\b", 0.90),
69
+ (r"\b(ya fui|fui a otro|otra clínica|otro lugar|en otro lado)\b", 0.88),
70
+ (r"\b(no funciona|no sirve|pura carreta|no creo|esceptic|dudas?)\b", 0.85),
71
+ (r"\b(no tengo tiempo|muy ocupad|trabajo mucho|no puedo ir)\b", 0.82),
72
+ (r"\b(vergüenza|pena|me da pena|qué dirán)\b", 0.85),
73
+ (r"\b(bogotá|exterior|fuera del país|afuera)\b", 0.75),
74
+ ],
75
+
76
+ AgentID.AGENDA: [
77
+ (r"\b(agendar|agenda|cita|turno|hora|reservar|apartar)\b", 0.92),
78
+ (r"\b(cuándo (tienen|puedo|hay|están|podría))\b", 0.88),
79
+ (r"\b(disponibilidad|disponible|libre|espacio)\b", 0.85),
80
+ (r"\b(lunes|martes|miércoles|miercoles|jueves|viernes|sábado|sabado|domingo)\b", 0.75),
81
+ (r"\b(mañana|esta semana|próxima semana|este mes)\b", 0.70),
82
+ (r"\b(puedo ir|quiero ir|ir a la clínica|ir a consulta)\b", 0.80),
83
+ (r"\b(valoración|valoracion|consulta)\b", 0.72),
84
+ ],
85
+
86
+ AgentID.CONOCIMIENTO: [
87
+ (r"\b(qué es|que es|cómo funciona|como funciona|qué hace|explica)\b", 0.88),
88
+ (r"\b(cuánto dura|tiempo de recuperación|recuperación|efectos|riesgos|contraindicaciones)\b", 0.90),
89
+ (r"\b(diferencia entre|diferencia del|cuál es mejor|cuál recomiendas)\b", 0.85),
90
+ (r"\b(botox|relleno|rellenos|láser|laser|hilos|mesoterapia|peeling|facelift|bichectomía|rinoplastia|liposucción)\b", 0.72),
91
+ (r"\b(cuántas sesiones|cuánto tiempo|cuándo veo resultados|resultados)\b", 0.80),
92
+ (r"\b(antes y después|fotos de resultados|casos)\b", 0.78),
93
+ ],
94
+
95
+ AgentID.CAPTACION: [
96
+ (r"\b(hola|buenas|buenos días|buenos dias|buenas tardes|buenas noches)\b", 0.70),
97
+ (r"\b(información|informacion|info|quiero saber|me interesa|quisiera)\b", 0.75),
98
+ (r"\b(primera vez|nunca he ido|primer vez)\b", 0.85),
99
+ (r"\b(me recomendaron|me dijeron|escuché de|leí sobre)\b", 0.78),
100
+ (r"\b(qué servicios|qué ofrecen|qué hacen|cuáles (son|tienen))\b", 0.80),
101
+ (r"^(hola|buenas|hi|hey|buen día)[.!?\s]*$", 0.85), # solo saludo
102
+ ],
103
+
104
+ AgentID.SEGUIMIENTO: [
105
+ # Mayormente activado por cron, pero también por texto
106
+ (r"\b(cómo quedé|cómo me veo|cómo salió|resultados?\s*de\s*mi)\b", 0.88),
107
+ (r"\b(ya me lo hice|ya fui|ya vine|me lo hicieron)\b", 0.82),
108
+ (r"\b(seguimiento|control|revisión|revision)\b", 0.85),
109
+ ],
110
+ }
111
+
112
+ # Contexto que necesita cada agente de la memoria
113
+ _CONTEXT_KEYS: Dict[AgentID, List[str]] = {
114
+ AgentID.CAPTACION: ["patient_name", "visits", "funnel_state", "clinic_tone"],
115
+ AgentID.OBJECIONES: ["patient_name", "objeciones_pasadas", "funnel_state", "clinic_tone", "servicios_relevantes"],
116
+ AgentID.AGENDA: ["patient_name", "funnel_state", "ultima_cita", "calendar_available"],
117
+ AgentID.CONOCIMIENTO: ["servicios_clinica", "precios", "clinic_kb_excerpt"],
118
+ AgentID.SEGUIMIENTO: ["patient_name", "ultima_cita", "procedimiento_realizado"],
119
+ AgentID.ESCALACION: ["patient_name", "admin_chat_ids", "clinic_phone"],
120
+ AgentID.FALLBACK: ["full_context"], # fallback recibe todo
121
+ }
122
+
123
+
124
+ # ── Router principal ──────────────────────────────────────────────────────────
125
+
126
+ class IntentRouter:
127
+ """
128
+ Router de tres capas.
129
+ Se instancia una vez al arranque y se reutiliza para todos los mensajes.
130
+ """
131
+
132
+ def __init__(self):
133
+ # Precompilar todos los patrones
134
+ self._compiled: Dict[AgentID, List[Tuple[re.Pattern, float]]] = {}
135
+ for agent_id, signals in _SIGNALS.items():
136
+ self._compiled[agent_id] = [
137
+ (re.compile(pat, re.IGNORECASE | re.UNICODE), weight)
138
+ for pat, weight in signals
139
+ ]
140
+ log.info("[router] IntentRouter listo — %d agentes registrados", len(self._compiled))
141
+
142
+ def route(
143
+ self,
144
+ text: str,
145
+ funnel_state: str = "primer_contacto",
146
+ history_length: int = 0,
147
+ is_cron: bool = False,
148
+ cron_type: Optional[str] = None,
149
+ ) -> RouterResult:
150
+ """
151
+ Clasifica el mensaje y devuelve el agente más apropiado.
152
+ Esta función NUNCA llama al LLM.
153
+ """
154
+ t0 = time.perf_counter()
155
+
156
+ # Cron triggers van directamente a seguimiento
157
+ if is_cron:
158
+ return RouterResult(
159
+ agent_id=AgentID.SEGUIMIENTO,
160
+ confidence=1.0,
161
+ signals=[f"cron:{cron_type}"],
162
+ context_keys=_CONTEXT_KEYS[AgentID.SEGUIMIENTO],
163
+ latency_ms=(time.perf_counter() - t0) * 1000,
164
+ )
165
+
166
+ # Capa 1: scoring por señales regex
167
+ scores: Dict[AgentID, Tuple[float, List[str]]] = {}
168
+ for agent_id, patterns in self._compiled.items():
169
+ hits = []
170
+ total_weight = 0.0
171
+ for pattern, weight in patterns:
172
+ if pattern.search(text):
173
+ hits.append(pattern.pattern[:40])
174
+ total_weight = max(total_weight, weight)
175
+ if hits:
176
+ scores[agent_id] = (total_weight, hits)
177
+
178
+ # Capa 2: ajuste por estado del funnel
179
+ scores = self._adjust_by_funnel(scores, funnel_state, history_length)
180
+
181
+ # Elegir ganador
182
+ if scores:
183
+ best_agent = max(scores, key=lambda a: scores[a][0])
184
+ best_score, best_signals = scores[best_agent]
185
+ else:
186
+ best_agent = AgentID.CAPTACION
187
+ best_score = 0.40
188
+ best_signals = ["sin_señales_explícitas"]
189
+
190
+ latency = (time.perf_counter() - t0) * 1000
191
+ log.debug(
192
+ "[router] %s confianza=%.2f señales=%s latencia=%.1fms",
193
+ best_agent.value, best_score, best_signals[:2], latency
194
+ )
195
+
196
+ return RouterResult(
197
+ agent_id=best_agent,
198
+ confidence=best_score,
199
+ signals=best_signals,
200
+ context_keys=_CONTEXT_KEYS.get(best_agent, _CONTEXT_KEYS[AgentID.FALLBACK]),
201
+ latency_ms=latency,
202
+ )
203
+
204
+ def _adjust_by_funnel(
205
+ self,
206
+ scores: Dict[AgentID, Tuple[float, List[str]]],
207
+ funnel_state: str,
208
+ history_length: int,
209
+ ) -> Dict[AgentID, Tuple[float, List[str]]]:
210
+ """
211
+ Ajusta los scores según el contexto del funnel.
212
+ El funnel no sobreescribe señales fuertes — solo desempata.
213
+ """
214
+ adjusted = dict(scores)
215
+
216
+ # Si es primer contacto y no hay señales claras → captacion gana
217
+ if funnel_state == "primer_contacto" and history_length == 0:
218
+ if AgentID.CAPTACION not in adjusted:
219
+ adjusted[AgentID.CAPTACION] = (0.65, ["funnel:primer_contacto"])
220
+ else:
221
+ sc, sig = adjusted[AgentID.CAPTACION]
222
+ adjusted[AgentID.CAPTACION] = (min(sc + 0.10, 1.0), sig + ["funnel:boost_primer_contacto"])
223
+
224
+ # Si tiene intención confirmada y hay señal de agenda → boost agenda
225
+ if funnel_state == "con_intencion" and AgentID.AGENDA in adjusted:
226
+ sc, sig = adjusted[AgentID.AGENDA]
227
+ adjusted[AgentID.AGENDA] = (min(sc + 0.12, 1.0), sig + ["funnel:boost_con_intencion"])
228
+
229
+ # Escalacion siempre tiene prioridad — no se reduce
230
+ if AgentID.ESCALACION in adjusted:
231
+ sc, sig = adjusted[AgentID.ESCALACION]
232
+ adjusted[AgentID.ESCALACION] = (max(sc, 0.92), sig)
233
+
234
+ return adjusted
235
+
236
+
237
+ # ── Instancia global (singleton) ─────────────────────────────────────────────
238
+ # Se importa desde conny.py: from router import router
239
+ router = IntentRouter()
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ """Verificar que todas las funciones de conversación están presentes."""
3
+ import re
4
+
5
+ with open('conny-omni.py', 'r') as f:
6
+ content = f.read()
7
+
8
+ checks = {
9
+ 'get_recent_conversations': (r'async def get_recent_conversations\(', 'Obtener conversaciones recientes'),
10
+ 'get_active_conversations': (r'async def get_active_conversations\(', 'Obtener conversaciones activas'),
11
+ 'format_active_conversations': (r'def format_active_conversations\(', 'Formatear lista de conversaciones'),
12
+ 'format_conversation_detail': (r'def format_conversation_detail\(', 'Formatear detalle de conversación'),
13
+ '/conversations handler': (r"text\.lower\(\)\.startswith\(\"/conversations\"\)", 'Manejador /conversations'),
14
+ '/enter handler': (r'text\.startswith\(\"/enter ', 'Manejador /enter'),
15
+ '/list handler': (r'text\.lower\(\) == \"/list\"', 'Manejador /list'),
16
+ 'Natural intent detection': (r'conversation_keywords = \[', 'Detección de intención natural'),
17
+ 'muéstrame/dame detection': (r"any\(kw in text_lower for kw in \[\"muéstrame\",", 'Detección muéstrame/dame'),
18
+ 'quién está detection': (r"any\(kw in text_lower for kw in \[\"quién\", \"quien\"", 'Detección quién está'),
19
+ 'System prompt update': (r'"También eres GESTORA DE CONVERSACIONES"', 'System prompt actualizado'),
20
+ 'Help text update': (r"\*Gestión de Conversaciones:\*", 'Help text actualizado'),
21
+ }
22
+
23
+ print("=" * 70)
24
+ print("✅ VERIFICACIÓN: Conny Omni v2.1 - Conversation Features")
25
+ print("=" * 70)
26
+ print()
27
+
28
+ all_ok = True
29
+ for name, (pattern, desc) in checks.items():
30
+ if re.search(pattern, content, re.MULTILINE):
31
+ print(f"✅ {name:30} - {desc}")
32
+ else:
33
+ print(f"❌ {name:30} - {desc}")
34
+ all_ok = False
35
+
36
+ print()
37
+ print("=" * 70)
38
+ if all_ok:
39
+ print("✅ TODAS LAS VERIFICACIONES PASARON")
40
+ print("🎉 Omni v2.1 está LISTO para producción")
41
+ else:
42
+ print("❌ ALGUNAS VERIFICACIONES FALLARON")
43
+
44
+ print("=" * 70)
45
+
46
+ # Contar líneas de código nuevo
47
+ conv_section = content[content.find('async def get_recent_conversations'):content.find('# ══════════════════════════════════════════════════════════════════════════════\n# NOTIFICACIONES')]
48
+ print(f"\nLíneas de código para conversaciones: ~{len(conv_section.splitlines())} líneas")