@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,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)
@@ -0,0 +1,2 @@
1
+ """Utility modules: i18n, helpers, logging."""
2
+ from .i18n import get_i18n, detect_user_language, SUPPORTED_LANGUAGES
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+ import hashlib
3
+ import json
4
+ import secrets
5
+ import re
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ ACTIVATION_PREFIX = "ACTV-"
9
+ INVITE_PREFIX = "JINV-"
10
+
11
+ def is_activation_token(text: str) -> bool:
12
+ """Detecta si el mensaje es un token de activacion."""
13
+ t = text.strip().upper()
14
+ return t.startswith(ACTIVATION_PREFIX) and len(t) >= 30
15
+
16
+ def is_invite_token(text: str) -> bool:
17
+ """Detecta si el mensaje es un token de invitacion."""
18
+ t = text.strip().upper()
19
+ return t.startswith(INVITE_PREFIX) and len(t) >= 15
20
+
21
+ def hash_password(password: str) -> str:
22
+ """Hash de contrasena con PBKDF2 + salt."""
23
+ salt = secrets.token_hex(16)
24
+ key = hashlib.pbkdf2_hmac(
25
+ "sha256",
26
+ password.encode("utf-8"),
27
+ salt.encode("utf-8"),
28
+ 260_000
29
+ ).hex()
30
+ return f"{salt}:{key}"
31
+
32
+ def verify_password(password: str, stored_hash: str) -> bool:
33
+ """Verifica contrasena contra hash almacenado."""
34
+ try:
35
+ salt, key = stored_hash.split(":", 1)
36
+ test = hashlib.pbkdf2_hmac(
37
+ "sha256",
38
+ password.encode("utf-8"),
39
+ salt.encode("utf-8"),
40
+ 260_000
41
+ ).hex()
42
+ return test == key
43
+ except Exception:
44
+ return False
45
+
46
+ def _parse_admin_ids(raw) -> list:
47
+ """Parsea admin_chat_ids de forma segura."""
48
+ if not raw: return []
49
+ if isinstance(raw, list): return [str(i) for i in raw]
50
+ if isinstance(raw, str):
51
+ try:
52
+ data = json.loads(raw)
53
+ if isinstance(data, list): return [str(i) for i in data]
54
+ return [str(data)]
55
+ except Exception:
56
+ return [i.strip() for i in raw.split(",") if i.strip()]
57
+ return []
58
+
59
+ def extract_model_request_from_text(text: str) -> Optional[str]:
60
+ """Extrae solicitud de cambio de modelo del lenguaje natural."""
61
+ t = text.lower().strip()
62
+ if not t.startswith("/modelo"):
63
+ if "cambia el modelo a" in t: return t.split("cambia el modelo a")[-1].strip()
64
+ if "usa el modelo" in t: return t.split("usa el modelo")[-1].strip()
65
+ return None
66
+ parts = t.split()
67
+ return parts[1] if len(parts) > 1 else "reset"
68
+
69
+ def normalize_model_arg(arg: str) -> str:
70
+ """Normaliza el nombre del modelo solicitado."""
71
+ m = arg.lower().strip()
72
+ if m in ("flash", "gemini"): return "google/gemini-2.5-flash"
73
+ if m in ("pro", "sonnet"): return "anthropic/claude-3-5-sonnet"
74
+ if m in ("fast", "haiku"): return "anthropic/claude-3-haiku"
75
+ return m