@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,273 @@
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
+ except Exception as e:
273
+ log.warning("[orchestrator] error en notify_escalation: %s", e)
@@ -0,0 +1,353 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ log = logging.getLogger("conny.production")
7
+
8
+
9
+ class ConnyProduction:
10
+ """
11
+ Producción: Atención a pacientes reales via LLM directo.
12
+ 100% inteligencia artificial, sin templates hardcodeados.
13
+ """
14
+
15
+ def __init__(self, conny):
16
+ self.conny = conny
17
+
18
+ async def handle(self, chat_id: str, text: str, clinic: Dict,
19
+ history: List[Dict], conv_state: Dict) -> List[str]:
20
+ """Procesa un mensaje de paciente real via LLM directo."""
21
+ from conny import db, llm_engine, kb, v8_process_response
22
+
23
+ start_time = time.time()
24
+ instance_id = getattr(self.conny, "_instance_id", "default")
25
+
26
+ # Smart features: memory, language, sentiment, time
27
+ patient_context = ""
28
+ lang_instruction = ""
29
+ try:
30
+ from conny_smart_features import (
31
+ CrossSessionMemory, SentimentTracker, LanguageDetector,
32
+ get_time_greeting, is_conversation_ending, get_natural_closing,
33
+ )
34
+ # Cross-session memory
35
+ mem = CrossSessionMemory(instance_id)
36
+ patient_data = mem.recall_patient(chat_id)
37
+ patient_context = mem.get_context_for_prompt(chat_id)
38
+
39
+ # Language detection
40
+ lang_det = LanguageDetector()
41
+ detected_lang = lang_det.detect(text)
42
+ lang_instruction = lang_det.get_language_instruction(detected_lang)
43
+
44
+ # Explicit human request detection
45
+ human_request_signals = ["hablar con humano", "hablar con una persona", "hablar con alguien",
46
+ "quiero hablar con", "pasame con", "pásame con", "un humano",
47
+ "una persona real", "talk to a human", "real person"]
48
+ wants_human = any(s in text.lower() for s in human_request_signals)
49
+
50
+ # Sentiment check → auto-escalate if frustrated
51
+ sentiment = SentimentTracker()
52
+ should_esc, esc_reason = sentiment.should_escalate(text, history)
53
+ if should_esc or wants_human:
54
+ admin_ids = clinic.get("admin_chat_ids", [])
55
+ if isinstance(admin_ids, str):
56
+ import json as _j2
57
+ admin_ids = _j2.loads(admin_ids) if admin_ids else []
58
+ if admin_ids:
59
+ admin_jid_esc = str(admin_ids[0])
60
+ reason_text = "quiere hablar con alguien" if wants_human else esc_reason.replace('_', ' ')
61
+ alert = f"oye, un paciente ({chat_id.split('@')[0][-4:]}) {reason_text}:\n\"{text[:150]}\""
62
+ try:
63
+ await self.conny._send_message(admin_jid_esc, alert)
64
+ log.info(f"[production] escalation alert sent: {reason_text}")
65
+ except Exception as _e:
66
+ log.warning(f"[production] escalation alert failed: {_e}")
67
+
68
+ # If wants human → respond directly and return
69
+ if wants_human:
70
+ human_response = "ya le aviso a alguien del equipo que te escriba ||| dame un momentito"
71
+ try:
72
+ db.save_message(chat_id, "user", text)
73
+ db.save_message(chat_id, "assistant", human_response.replace("|||", " "))
74
+ except Exception:
75
+ pass
76
+ return self.conny._split_bubbles(human_response, chat_id=chat_id)
77
+
78
+ # Conversation ending detection
79
+ if is_conversation_ending(text):
80
+ tone = "casual"
81
+ try:
82
+ from pathlib import Path
83
+ import json as _j3
84
+ ov = Path(f"personas/{instance_id}/runtime_override.json")
85
+ if ov.exists():
86
+ tone = _j3.loads(ov.read_text()).get("tone", "casual")
87
+ except Exception:
88
+ pass
89
+ closing = get_natural_closing(tone)
90
+ db.save_message(chat_id, "user", text)
91
+ db.save_message(chat_id, "assistant", closing)
92
+ # Save last topic to memory
93
+ if history:
94
+ last_user_msgs = [m["content"] for m in history[-4:] if m.get("role") == "user"]
95
+ topic = last_user_msgs[0][:50] if last_user_msgs else ""
96
+ mem.remember_patient(chat_id, {"last_topic": topic})
97
+ return self.conny._split_bubbles(closing, chat_id=chat_id)
98
+
99
+ # Time awareness for greeting
100
+ time_greeting = get_time_greeting()
101
+ except ImportError:
102
+ time_greeting = "hola"
103
+ except Exception:
104
+ time_greeting = "hola"
105
+
106
+ clinic_name = clinic.get("name", "el negocio")
107
+ services = clinic.get("services", [])
108
+ if isinstance(services, str):
109
+ services = [s.strip() for s in services.split(",") if s.strip()]
110
+ services_str = ", ".join(services[:10]) if services else "consulta general"
111
+ schedule = clinic.get("schedule", "")
112
+ if isinstance(schedule, dict):
113
+ schedule = " | ".join(f"{k}: {v}" for k, v in schedule.items())
114
+
115
+ # Load persona override (tone changes from admin)
116
+ persona_tone = "colombian_warm"
117
+ try:
118
+ from pathlib import Path
119
+ import json as _json
120
+ override_path = Path(f"personas/{instance_id}/runtime_override.json")
121
+ if override_path.exists():
122
+ override = _json.loads(override_path.read_text())
123
+ persona_tone = override.get("tone", persona_tone)
124
+ except Exception:
125
+ pass
126
+
127
+ # Load soul knowledge
128
+ soul_context = ""
129
+ try:
130
+ soul_file = Path(f"soul/{instance_id}/knowledge.md")
131
+ if soul_file.exists():
132
+ soul_context = soul_file.read_text()[-2000:]
133
+ except Exception:
134
+ pass
135
+
136
+ tone_instructions = {
137
+ "luxury": "Tono LUXURY: sofisticada, elegante, exclusiva. Usa lenguaje premium. Nunca suenes informal ni uses jerga. Transmite exclusividad en cada palabra.",
138
+ "formal": "Tono FORMAL: profesional, respetuosa, precisa. Sin jerga. Usted en vez de tú.",
139
+ "casual": "Tono CASUAL: cercana, relajada, como amiga. Tutea. Usa expresiones naturales.",
140
+ "colombian_warm": "Tono COLOMBIANO CÁLIDO: cercana pero profesional, calidez natural, expresiones colombianas sutiles.",
141
+ "warm_energetic": "Tono ALEGRE: energética, positiva, con chispa. Emojis permitidos.",
142
+ }
143
+ tone_instruction = tone_instructions.get(persona_tone, tone_instructions["colombian_warm"])
144
+
145
+ sys_prompt = f"""Eres Conny, recepcionista virtual de {clinic_name}.
146
+ Servicios: {services_str}
147
+ Horario: {schedule or 'consultar'}
148
+
149
+ TONO: {tone_instruction}
150
+
151
+ {f"CONOCIMIENTO DEL NEGOCIO:{chr(10)}{soul_context}" if soul_context else ""}
152
+
153
+ {f"SOBRE ESTE PACIENTE:{chr(10)}{patient_context}" if patient_context else ""}
154
+ {f"{chr(10)}{lang_instruction}" if lang_instruction else ""}
155
+
156
+ REGLA #1 — RESPONDE CON LO QUE SABES:
157
+ - Si la respuesta está en la sección "RESPUESTAS QUE YA SABES" → DALA DIRECTAMENTE sin dudar
158
+ - Si NO encuentras la respuesta en ninguna sección → "me confirmo y te aviso"
159
+ - Las RESPUESTAS QUE YA SABES son información VERIFICADA por el dueño. Úsalas con total confianza.
160
+
161
+ REGLAS GENERALES:
162
+ - {tone_instruction.split(':')[0]} — aplica este tono en CADA respuesta
163
+ - Una sola pregunta por turno, enfocada en avanzar la conversación
164
+ - Si el paciente quiere cita: pide nombre, servicio, fecha preferida
165
+ - NUNCA digas "no tengo capacidad", "está fuera de mi alcance"
166
+ - Si preguntan "eres IA?" → responde HONESTA y breve: "sí, soy una IA 😊 pero estoy aquí pa ayudarte, dime en qué te puedo servir"
167
+ - NUNCA evadas la pregunta de si eres IA. Sé directa, no insistas ni te pongas a la defensiva
168
+ - NUNCA uses formato markdown (**, *, _, #, `)
169
+ - Usa máximo 2-3 burbujas separadas por |||
170
+ - Sé concisa (máx 40 palabras por burbuja)
171
+ - Escribe EXACTAMENTE como una persona de 28 años en WhatsApp: mensajes cortos, naturales
172
+ - Emojis: usa MÁXIMO 1 por conversación, y solo en el saludo inicial. Después de eso, 0 emojis. Nada de 😊 genérico en cada mensaje.
173
+ - Si ya saludaste, no vuelvas a saludar
174
+ - NUNCA te presentes con "Soy Conny tu recepcionista de X" — eso suena a bot
175
+ - Si es el primer mensaje, saluda así: "{time_greeting}! hablas con Conny 😊 ||| en qué te puedo ayudar?"
176
+ - Separa SIEMPRE en 2-3 burbujas cortas (|||), nunca un solo bloque largo
177
+ - NUNCA digas el nombre completo de la clínica en el saludo — el paciente ya sabe dónde escribió"""
178
+
179
+ messages = [{"role": "system", "content": sys_prompt}]
180
+ for m in history[-12:]:
181
+ messages.append({"role": m.get("role", "user"), "content": m.get("content", "")})
182
+ messages.append({"role": "user", "content": text})
183
+
184
+ # KB context
185
+ kb_context = ""
186
+ if kb:
187
+ try:
188
+ if hasattr(kb, "has_content") and kb.has_content():
189
+ kb_context = kb.query(text)
190
+ except Exception:
191
+ pass
192
+ if kb_context:
193
+ messages[0]["content"] += f"\n\nCONTEXTO DEL NEGOCIO:\n{kb_context[:1000]}"
194
+
195
+ # Teachings injection — THIS IS YOUR KNOWLEDGE, USE IT
196
+ try:
197
+ from conny_learning import learning_engine
198
+ teachings = await learning_engine.get_teachings(instance_id, limit=20)
199
+ if teachings:
200
+ qa_lines = []
201
+ for t in teachings:
202
+ q = t.get("question", "").replace("[admin enseñó] ", "")
203
+ a = t.get("answer", "")
204
+ if a and not q.startswith("["):
205
+ qa_lines.append(f"Si preguntan: \"{q}\" → Responde: \"{a}\"")
206
+ elif a:
207
+ qa_lines.append(f"Regla: {a[:150]}")
208
+ if qa_lines:
209
+ # Inject ABOVE the rules, as part of the clinic facts
210
+ messages[0]["content"] = messages[0]["content"].replace(
211
+ "REGLA #1",
212
+ "DATOS CONFIRMADOS POR EL DUEÑO (responde con estos sin dudar):\n" + "\n".join(qa_lines) + "\n\nREGLA #1"
213
+ )
214
+ except Exception:
215
+ pass
216
+
217
+ # Admin rules injection (things admin said to ask first)
218
+ try:
219
+ from pathlib import Path
220
+ rules_file = Path(f"soul/{instance_id}/admin_rules.json")
221
+ if rules_file.exists():
222
+ import json as _j
223
+ rules = _j.loads(rules_file.read_text())
224
+ if rules:
225
+ rules_text = "\n".join(f"- Si preguntan sobre '{r['topic']}': {r['action']}" for r in rules[-10:])
226
+ messages[0]["content"] += f"\n\nINSTRUCCIONES DEL DUEÑO:\n{rules_text}"
227
+ except Exception:
228
+ pass
229
+
230
+ # LLM call
231
+ response = ""
232
+ model_used = "llm"
233
+ if llm_engine:
234
+ try:
235
+ response, meta = await llm_engine.complete(
236
+ messages, model_tier="fast", temperature=0.75,
237
+ max_tokens=2048, use_cache=False,
238
+ )
239
+ model_used = meta.get("model", "llm")
240
+ log.info(f"[production] {meta.get('provider','?')} latency={time.time()-start_time:.1f}s")
241
+ except Exception as e:
242
+ log.error(f"[production] LLM error: {e}")
243
+
244
+ if not response or not response.strip():
245
+ response = "cuéntame en qué te puedo ayudar"
246
+
247
+ # Strip ALL markdown — patients must get pure human text (no *, **, `, #)
248
+ import re as _re
249
+ response = _re.sub(r'\*\*(.+?)\*\*', r'\1', response)
250
+ response = _re.sub(r'\*(.+?)\*', r'\1', response)
251
+ response = _re.sub(r'`(.+?)`', r'\1', response)
252
+ response = _re.sub(r'^#+\s*', '', response, flags=_re.MULTILINE)
253
+ response = _re.sub(r'_(.+?)_', r'\1', response) # no italics either
254
+
255
+ response = v8_process_response(response, chat_id=chat_id)
256
+
257
+ # Uncertainty check + admin escalation
258
+ try:
259
+ from conny_uncertainty import uncertainty_detector
260
+ confidence = uncertainty_detector.confidence_score(response, text, history)
261
+
262
+ # If response contains data from teachings, trust it (don't override)
263
+ try:
264
+ from conny_learning import learning_engine as _le
265
+ _teachings = await _le.get_teachings(instance_id, limit=30)
266
+ for t in _teachings:
267
+ if t.get("answer", "")[:20].lower() in response.lower():
268
+ confidence = max(confidence, 0.8)
269
+ break
270
+ except Exception:
271
+ pass
272
+
273
+ # Get admin JID for alerts
274
+ admin_ids = clinic.get("admin_chat_ids", [])
275
+ if isinstance(admin_ids, str):
276
+ import json as _j
277
+ admin_ids = _j.loads(admin_ids) if admin_ids else []
278
+ admin_jid = str(admin_ids[0]) if admin_ids else ""
279
+
280
+ # Skip alert if we have teachings that match the question
281
+ has_relevant_teaching = False
282
+ try:
283
+ from conny_learning import learning_engine as _le
284
+ _t = await _le.get_teachings(instance_id, limit=30)
285
+ user_low = text.lower()
286
+ resp_low = response.lower()
287
+ for t in _t:
288
+ q = t.get("question", "").lower().replace("[admin enseñó] ", "")
289
+ a = t.get("answer", "").lower()
290
+ # Fuzzy: check if root words overlap (horario/hora, atencion/atienden)
291
+ q_stems = set(w[:4] for w in q.split() if len(w) > 3)
292
+ user_stems = set(w[:4] for w in user_low.split() if len(w) > 3)
293
+ if q_stems & user_stems:
294
+ has_relevant_teaching = True
295
+ break
296
+ if a and a[:12] in resp_low:
297
+ has_relevant_teaching = True
298
+ break
299
+ except Exception:
300
+ pass
301
+
302
+ if confidence < 0.5 and admin_jid and not has_relevant_teaching:
303
+ await uncertainty_detector.log_gap(instance_id, text, response, confidence, chat_id)
304
+ alert_msg = (
305
+ f"oye, me acaba de escribir un paciente preguntando: \"{text[:150]}\"\n\n"
306
+ f"no tengo esa info todavía, qué le digo?"
307
+ )
308
+ try:
309
+ await self.conny._send_message(admin_jid, alert_msg)
310
+ log.info(f"[production] admin alerted: confidence={confidence:.2f} question='{text[:50]}'")
311
+ except Exception as e:
312
+ log.warning(f"[production] failed to alert admin: {e}")
313
+
314
+ # Override response: tell patient we're checking (only if no teaching matches)
315
+ response = "dame un momento que verifico eso ||| ya te confirmo"
316
+ elif has_relevant_teaching and confidence < 0.5:
317
+ # We have the answer in teachings but LLM still deflected — don't override
318
+ pass
319
+
320
+ except Exception as e:
321
+ log.error(f"[production] uncertainty check FAILED: {e}", exc_info=True)
322
+
323
+ # Save to DB
324
+ try:
325
+ db.save_message(chat_id, "user", text)
326
+ db.save_message(chat_id, "assistant", response.replace("|||", " "),
327
+ model=model_used,
328
+ latency=int((time.time() - start_time) * 1000))
329
+ except Exception:
330
+ pass
331
+
332
+ # Real-time learning from turn
333
+ try:
334
+ from conny_learning import learning_engine
335
+ await learning_engine.learn_from_turn(instance_id, text, response)
336
+ except Exception:
337
+ pass
338
+
339
+ # Save patient memory (name extraction, last topic, visit count)
340
+ try:
341
+ from conny_smart_features import CrossSessionMemory
342
+ mem = CrossSessionMemory(instance_id)
343
+ import re as _re2
344
+ # Extract name if patient says it
345
+ name_match = _re2.search(r'(?:me llamo|soy|mi nombre es)\s+([A-ZÁÉÍÓÚ][a-záéíóú]+(?:\s+[A-ZÁÉÍÓÚ][a-záéíóú]+)?)', text)
346
+ patient_update = {"last_topic": text[:50]}
347
+ if name_match:
348
+ patient_update["name"] = name_match.group(1)
349
+ mem.remember_patient(chat_id, patient_update)
350
+ except Exception:
351
+ pass
352
+
353
+ return self.conny._split_bubbles(response, chat_id=chat_id)