@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,1150 @@
1
+ """
2
+ smart_handoff.py — Sistema de Escala Inteligente Admin v1.1
3
+ ════════════════════════════════════════════════════════════════════════════════
4
+
5
+ BUG-3:
6
+ - Persiste el contexto completo del handoff en SQLite
7
+ - Devuelve el ack al cliente sin esperar la notificación al admin
8
+ - Evita loops cuando llegan ecos o acuses del admin
9
+ - Expira tickets a los 10 minutos con fallback elegante
10
+
11
+ INTEGRACIÓN ACTUAL EN conny.py:
12
+ `try_intercept_admin_reply(...)` y `trigger(...)` ya se llaman.
13
+
14
+ INTEGRACIÓN ADICIONAL NECESARIA PARA EL TIMEOUT AUTOMÁTICO:
15
+ Conny todavía no pasa un sender de cliente al `trigger(...)`, por eso el
16
+ timeout puede persistirse y marcarse como expirado, pero no podrá empujar el
17
+ fallback al paciente hasta integrar exactamente estas llamadas:
18
+
19
+ async def _handoff_send_to_client(cid: str, msg: str):
20
+ await mcp_manager.execute(
21
+ "whatsapp_v1",
22
+ "send_message",
23
+ {"chat_id": cid, "message": msg},
24
+ )
25
+
26
+ handoff_manager.register_client_sender(_handoff_send_to_client)
27
+ await handoff_manager.resume_pending_timeouts(
28
+ send_to_client_fn=_handoff_send_to_client,
29
+ )
30
+
31
+ _hold_msgs, _was_escalated = await handoff_manager.trigger(
32
+ ...,
33
+ send_to_admin_fn=_handoff_notify_admin,
34
+ send_to_client_fn=_handoff_send_to_client,
35
+ )
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import json
42
+ import logging
43
+ import random
44
+ import re
45
+ import sqlite3
46
+ import time
47
+ import uuid
48
+ from dataclasses import dataclass, field
49
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
50
+
51
+ log = logging.getLogger("conny.handoff")
52
+
53
+ AsyncMessageSender = Callable[[str, str], Awaitable[None]]
54
+ AsyncLLMFn = Callable[..., Awaitable[str]]
55
+
56
+
57
+ # ════════════════════════════════════════════════════════════════════════════════
58
+ # SEÑALES DE INCERTIDUMBRE
59
+ # ════════════════════════════════════════════════════════════════════════════════
60
+
61
+ _UNCERTAINTY_PHRASES = [
62
+ "te confirmo",
63
+ "te averiguo",
64
+ "déjame confirmar",
65
+ "deja me confirmar",
66
+ "te escribo en un momento",
67
+ "te cuento más tarde",
68
+ "no tengo ese dato",
69
+ "no tengo esa información",
70
+ "no cuento con ese dato",
71
+ "voy a confirmar",
72
+ "en cuanto sepa te escribo",
73
+ "te lo confirmo",
74
+ "te digo",
75
+ "averiguo y te cuento",
76
+ "ahorita no tengo",
77
+ "no lo tengo a mano",
78
+ "no tengo acceso a eso",
79
+ "eso no lo sé",
80
+ "no sé eso",
81
+ "voy a preguntar",
82
+ "tengo que confirmar",
83
+ "lo reviso y te digo",
84
+ ]
85
+
86
+ _UNCERTAINTY_PATTERNS = [
87
+ r"no\s+(?:tengo|cuento\s+con|tengo\s+acceso\s+a)\s+(?:ese|esa|el|la)\s+dato",
88
+ r"te\s+(?:confirmo|averiguo|cuento|digo)\s+(?:más\s+tarde|luego|después|ahorita|ya)",
89
+ r"(?:voy\s+a|déjame|deja\s+me)\s+(?:confirmar|averiguar|preguntar|revisar)",
90
+ ]
91
+
92
+ _ADMIN_ACK_ONLY = {
93
+ "ok",
94
+ "ok.",
95
+ "oki",
96
+ "dale",
97
+ "listo",
98
+ "listo.",
99
+ "gracias",
100
+ "gracias.",
101
+ "recibido",
102
+ "recibido.",
103
+ "enterado",
104
+ "entendido",
105
+ "👍",
106
+ "👌",
107
+ }
108
+
109
+
110
+ # ════════════════════════════════════════════════════════════════════════════════
111
+ # SQLITE
112
+ # ════════════════════════════════════════════════════════════════════════════════
113
+
114
+ _DB_PATH = "conny_handoff.db"
115
+ _SCHEMA_READY: set[str] = set()
116
+
117
+ _CREATE_TABLES = """
118
+ CREATE TABLE IF NOT EXISTS handoff_queue (
119
+ id TEXT PRIMARY KEY,
120
+ client_chat_id TEXT NOT NULL,
121
+ admin_chat_id TEXT DEFAULT '',
122
+ admin_chat_ids_json TEXT DEFAULT '[]',
123
+ client_message TEXT NOT NULL,
124
+ context_json TEXT DEFAULT '{}',
125
+ clinic_name TEXT DEFAULT '',
126
+ clinic_json TEXT DEFAULT '{}',
127
+ llm_output TEXT DEFAULT '',
128
+ hold_message TEXT DEFAULT '',
129
+ fallback_message TEXT DEFAULT '',
130
+ status TEXT DEFAULT 'pending', -- pending | replied | expired
131
+ admin_raw_reply TEXT DEFAULT '',
132
+ conny_reply TEXT DEFAULT '',
133
+ uncertainty_why TEXT DEFAULT '',
134
+ created_at REAL NOT NULL,
135
+ expires_at REAL NOT NULL,
136
+ replied_at REAL DEFAULT 0,
137
+ expired_at REAL DEFAULT 0,
138
+ fallback_sent_at REAL DEFAULT 0
139
+ );
140
+
141
+ CREATE TABLE IF NOT EXISTS handoff_learnings (
142
+ id TEXT PRIMARY KEY,
143
+ clinic_key TEXT NOT NULL,
144
+ question_norm TEXT NOT NULL,
145
+ admin_raw TEXT NOT NULL,
146
+ conny_polish TEXT NOT NULL,
147
+ learned_at REAL NOT NULL,
148
+ times_used INTEGER DEFAULT 0
149
+ );
150
+ """
151
+
152
+ _CREATE_INDEXES = """
153
+ CREATE INDEX IF NOT EXISTS idx_hq_client ON handoff_queue(client_chat_id, status);
154
+ CREATE INDEX IF NOT EXISTS idx_hq_admin ON handoff_queue(admin_chat_id, status);
155
+ CREATE INDEX IF NOT EXISTS idx_hq_status_expires ON handoff_queue(status, expires_at);
156
+ CREATE INDEX IF NOT EXISTS idx_hl_clinic ON handoff_learnings(clinic_key);
157
+ """
158
+
159
+ _LEGACY_MIGRATIONS = {
160
+ "admin_chat_ids_json": "ALTER TABLE handoff_queue ADD COLUMN admin_chat_ids_json TEXT DEFAULT '[]'",
161
+ "clinic_json": "ALTER TABLE handoff_queue ADD COLUMN clinic_json TEXT DEFAULT '{}'",
162
+ "llm_output": "ALTER TABLE handoff_queue ADD COLUMN llm_output TEXT DEFAULT ''",
163
+ "hold_message": "ALTER TABLE handoff_queue ADD COLUMN hold_message TEXT DEFAULT ''",
164
+ "fallback_message": "ALTER TABLE handoff_queue ADD COLUMN fallback_message TEXT DEFAULT ''",
165
+ "expires_at": "ALTER TABLE handoff_queue ADD COLUMN expires_at REAL DEFAULT 0",
166
+ "expired_at": "ALTER TABLE handoff_queue ADD COLUMN expired_at REAL DEFAULT 0",
167
+ "fallback_sent_at": "ALTER TABLE handoff_queue ADD COLUMN fallback_sent_at REAL DEFAULT 0",
168
+ }
169
+
170
+
171
+ def _ensure_schema(conn: sqlite3.Connection) -> None:
172
+ conn.executescript(_CREATE_TABLES)
173
+ cols = {
174
+ row["name"]
175
+ for row in conn.execute("PRAGMA table_info(handoff_queue)").fetchall()
176
+ }
177
+ for column, ddl in _LEGACY_MIGRATIONS.items():
178
+ if column not in cols:
179
+ conn.execute(ddl)
180
+ conn.executescript(_CREATE_INDEXES)
181
+ conn.commit()
182
+
183
+
184
+ def _get_db() -> sqlite3.Connection:
185
+ conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
186
+ conn.row_factory = sqlite3.Row
187
+ if _DB_PATH not in _SCHEMA_READY:
188
+ _ensure_schema(conn)
189
+ _SCHEMA_READY.add(_DB_PATH)
190
+ return conn
191
+
192
+
193
+ def _json_dumps(value: Any) -> str:
194
+ return json.dumps(value, ensure_ascii=False)
195
+
196
+
197
+ def _json_loads(value: Any, default: Any) -> Any:
198
+ if value in (None, ""):
199
+ return default
200
+ try:
201
+ return json.loads(value)
202
+ except Exception:
203
+ return default
204
+
205
+
206
+ # ════════════════════════════════════════════════════════════════════════════════
207
+ # DATACLASSES
208
+ # ════════════════════════════════════════════════════════════════════════════════
209
+
210
+ @dataclass
211
+ class HandoffTicket:
212
+ id: str
213
+ client_chat_id: str
214
+ admin_chat_id: str
215
+ client_message: str
216
+ context: List[Dict[str, Any]]
217
+ clinic_name: str
218
+ status: str
219
+ uncertainty_why: str
220
+ admin_chat_ids: List[str] = field(default_factory=list)
221
+ clinic_snapshot: Dict[str, Any] = field(default_factory=dict)
222
+ llm_output: str = ""
223
+ hold_message: str = ""
224
+ fallback_message: str = ""
225
+ admin_raw_reply: str = ""
226
+ conny_reply: str = ""
227
+ created_at: float = field(default_factory=time.time)
228
+ expires_at: float = 0.0
229
+ replied_at: float = 0.0
230
+ expired_at: float = 0.0
231
+ fallback_sent_at: float = 0.0
232
+
233
+
234
+ @dataclass
235
+ class HandoffLearning:
236
+ id: str
237
+ clinic_key: str
238
+ question_norm: str
239
+ admin_raw: str
240
+ conny_polish: str
241
+ learned_at: float
242
+ times_used: int = 0
243
+
244
+
245
+ # ════════════════════════════════════════════════════════════════════════════════
246
+ # MENSAJES
247
+ # ════════════════════════════════════════════════════════════════════════════════
248
+
249
+ _HOLD_MESSAGES = [
250
+ "Permíteme un momento, te confirmo eso ya",
251
+ "Dame un segundito, me aseguro de darte el dato exacto",
252
+ "Ya te confirmo eso, un momento",
253
+ "Permíteme verificar eso contigo en un momento",
254
+ "Un segundito, voy a asegurarme de darte la info correcta",
255
+ "Dame un momentico, te confirmo",
256
+ ]
257
+
258
+ _TIMEOUT_FALLBACKS = [
259
+ "Gracias por esperar. Sigo validando ese dato con la clínica y prefiero no inventarte información. Dejé tu solicitud priorizada y apenas tenga confirmación te escribo por aquí.",
260
+ "Gracias por la paciencia. Todavía estoy revisando ese dato con la clínica para darte algo exacto. Lo dejé escalado y te escribo por aquí apenas me respondan.",
261
+ ]
262
+
263
+
264
+ def _pick_hold_message() -> str:
265
+ return random.choice(_HOLD_MESSAGES)
266
+
267
+
268
+ def _pick_timeout_fallback() -> str:
269
+ return random.choice(_TIMEOUT_FALLBACKS)
270
+
271
+
272
+ def _format_admin_notification(ticket: HandoffTicket) -> str:
273
+ lines = [
274
+ "⚡ *Conny necesita tu ayuda*",
275
+ "",
276
+ f"*Cliente:* `{ticket.client_chat_id}`",
277
+ f"*Clínica:* {ticket.clinic_name or 'N/A'}",
278
+ "*Preguntó:*",
279
+ f"_{ticket.client_message}_",
280
+ "",
281
+ ]
282
+
283
+ if ticket.context:
284
+ lines.append("*Últimos mensajes:*")
285
+ for turn in ticket.context[-4:]:
286
+ role_label = "Cliente" if turn.get("role") == "user" else "Conny"
287
+ content = str(turn.get("content", ""))[:160]
288
+ lines.append(f" [{role_label}] {content}")
289
+ lines.append("")
290
+
291
+ lines += [
292
+ f"*Motivo:* {ticket.uncertainty_why}",
293
+ f"*Expira en:* 10 minutos",
294
+ "",
295
+ "*Respóndeme con el dato o la instrucción.*",
296
+ "Yo le aviso al cliente en mi estilo.",
297
+ "",
298
+ f"📌 Ticket: `{ticket.id[:8]}`",
299
+ ]
300
+ return "\n".join(lines)
301
+
302
+
303
+ def _build_context_payload(ticket: HandoffTicket) -> Dict[str, Any]:
304
+ return {
305
+ "version": 2,
306
+ "history": ticket.context,
307
+ "clinic_snapshot": ticket.clinic_snapshot,
308
+ "llm_output": ticket.llm_output,
309
+ "admin_chat_ids": ticket.admin_chat_ids,
310
+ "hold_message": ticket.hold_message,
311
+ "fallback_message": ticket.fallback_message,
312
+ "uncertainty_why": ticket.uncertainty_why,
313
+ }
314
+
315
+
316
+ def _looks_like_handoff_system_echo(text: str) -> bool:
317
+ normalized = (text or "").strip().lower()
318
+ return normalized.startswith("⚡ *conny necesita tu ayuda*".lower()) or normalized.startswith(
319
+ "✅ listo, le dije al cliente:".lower()
320
+ )
321
+
322
+
323
+ def _is_admin_ack_only(text: str) -> bool:
324
+ return (text or "").strip().lower() in _ADMIN_ACK_ONLY
325
+
326
+
327
+ def _format_expired_admin_notice(ticket: HandoffTicket) -> str:
328
+ age_min = max(10, int((time.time() - ticket.created_at) / 60))
329
+ return (
330
+ "⏱️ Ese ticket ya expiró y no reenvié tu mensaje al cliente automáticamente.\n"
331
+ f"Cliente: `{ticket.client_chat_id}`\n"
332
+ f"Ticket: `{ticket.id[:8]}`\n"
333
+ f"Edad: {age_min} min"
334
+ )
335
+
336
+
337
+ # ════════════════════════════════════════════════════════════════════════════════
338
+ # DETECTOR DE INCERTIDUMBRE
339
+ # ════════════════════════════════════════════════════════════════════════════════
340
+
341
+ def detect_uncertainty(
342
+ llm_output: str,
343
+ user_msg: str,
344
+ clinic: Dict[str, Any],
345
+ *,
346
+ intent_confidence: float = 1.0,
347
+ ) -> Optional[str]:
348
+ out_low = (llm_output or "").lower()
349
+ user_low = (user_msg or "").lower()
350
+
351
+ for phrase in _UNCERTAINTY_PHRASES:
352
+ if phrase in out_low:
353
+ return f"LLM expresó incertidumbre: '{phrase}'"
354
+
355
+ for pattern in _UNCERTAINTY_PATTERNS:
356
+ if re.search(pattern, out_low):
357
+ return "Patrón de incertidumbre detectado en output"
358
+
359
+ prices_text = json.dumps(clinic.get("prices", {}) or {}).lower()
360
+ schedule_text = json.dumps(clinic.get("schedule", {}) or {}).lower()
361
+
362
+ if any(kw in user_low for kw in ["precio", "costo", "cuánto", "cuanto", "tarifa", "valor"]):
363
+ if not prices_text or prices_text in ("null", "{}", "[]", '""'):
364
+ return "Cliente pregunta precio pero clinic no tiene 'prices' configurado"
365
+
366
+ if any(kw in user_low for kw in ["horario", "hora", "cuando abren", "cuándo abren"]):
367
+ if not schedule_text or schedule_text in ("null", "{}", "[]", '""'):
368
+ return "Cliente pregunta horario pero clinic no tiene 'schedule' configurado"
369
+
370
+ if intent_confidence < 0.45:
371
+ return f"Confianza de intención baja: {intent_confidence:.2f}"
372
+
373
+ return None
374
+
375
+
376
+ # ════════════════════════════════════════════════════════════════════════════════
377
+ # PULIDOR ADMIN → CONNY
378
+ # ════════════════════════════════════════════════════════════════════════════════
379
+
380
+ async def _polish_admin_reply(
381
+ admin_raw: str,
382
+ client_message: str,
383
+ clinic: Dict[str, Any],
384
+ llm_fn: AsyncLLMFn,
385
+ ) -> str:
386
+ clinic_name = (clinic.get("name") or "la clínica").strip()
387
+ system = f"""Eres Conny, la recepcionista virtual de {clinic_name}.
388
+ Un administrador te dio esta instrucción sobre cómo responder a un cliente.
389
+ Transforma esa instrucción en un mensaje natural, cálido y en tu estilo.
390
+
391
+ REGLAS:
392
+ - Escribe como si le hablaras directamente al cliente en WhatsApp
393
+ - Tono conversacional colombiano, sin sonar a robot
394
+ - Sin inventar datos que el admin no dio
395
+ - Máximo 2-3 oraciones
396
+ - No menciones que hubo una consulta interna al admin
397
+
398
+ Responde SOLO con el mensaje final para el cliente."""
399
+
400
+ user_prompt = (
401
+ f'Pregunta del cliente: "{client_message}"\n'
402
+ f'Instrucción del admin: "{admin_raw}"\n\n'
403
+ "Transforma en respuesta natural de Conny:"
404
+ )
405
+
406
+ try:
407
+ result = await llm_fn(system, user_prompt, temp=0.75, max_t=300, model_tier="fast")
408
+ cleaned = (result or "").strip().strip('"').strip("'").strip()
409
+ if cleaned and len(cleaned) > 5:
410
+ return cleaned
411
+ except Exception as exc:
412
+ log.warning(f"[handoff] Error puliendo respuesta admin: {exc}")
413
+
414
+ return admin_raw.strip()
415
+
416
+
417
+ # ════════════════════════════════════════════════════════════════════════════════
418
+ # LEARNINGS
419
+ # ════════════════════════════════════════════════════════════════════════════════
420
+
421
+ def _normalize_question(text: str) -> str:
422
+ text = text.lower().strip()
423
+ text = re.sub(r"[áàä]", "a", text)
424
+ text = re.sub(r"[éèë]", "e", text)
425
+ text = re.sub(r"[íìï]", "i", text)
426
+ text = re.sub(r"[óòö]", "o", text)
427
+ text = re.sub(r"[úùü]", "u", text)
428
+ text = re.sub(r"ñ", "n", text)
429
+ text = re.sub(r"[^a-z0-9\s]", " ", text)
430
+ text = re.sub(r"\s+", " ", text).strip()
431
+ stops = {
432
+ "me",
433
+ "puedes",
434
+ "puede",
435
+ "decir",
436
+ "dices",
437
+ "hay",
438
+ "tiene",
439
+ "tienen",
440
+ "cuanto",
441
+ "cual",
442
+ "como",
443
+ "cuando",
444
+ "donde",
445
+ "que",
446
+ "es",
447
+ "son",
448
+ "una",
449
+ "un",
450
+ "la",
451
+ "el",
452
+ "los",
453
+ "las",
454
+ }
455
+ return " ".join(token for token in text.split() if token not in stops)
456
+
457
+
458
+ def find_learned_answer(
459
+ user_msg: str,
460
+ clinic_key: str,
461
+ threshold: float = 0.65,
462
+ ) -> Optional[str]:
463
+ try:
464
+ conn = _get_db()
465
+ rows = conn.execute(
466
+ "SELECT * FROM handoff_learnings WHERE clinic_key = ? ORDER BY times_used DESC LIMIT 50",
467
+ (clinic_key,),
468
+ ).fetchall()
469
+ conn.close()
470
+ except Exception as exc:
471
+ log.warning(f"[handoff] Error buscando learnings: {exc}")
472
+ return None
473
+
474
+ q_norm = set(_normalize_question(user_msg).split())
475
+ if not q_norm:
476
+ return None
477
+
478
+ best_score = 0.0
479
+ best_answer = None
480
+
481
+ for row in rows:
482
+ stored = set((row["question_norm"] or "").split())
483
+ if not stored:
484
+ continue
485
+ union = q_norm | stored
486
+ score = len(q_norm & stored) / len(union) if union else 0.0
487
+ if score > best_score:
488
+ best_score = score
489
+ best_answer = row["conny_polish"]
490
+
491
+ if best_score >= threshold and best_answer:
492
+ log.info(f"[handoff] Learning encontrado (score={best_score:.2f})")
493
+ return best_answer
494
+
495
+ return None
496
+
497
+
498
+ # ════════════════════════════════════════════════════════════════════════════════
499
+ # MANAGER
500
+ # ════════════════════════════════════════════════════════════════════════════════
501
+
502
+ class SmartAdminHandoff:
503
+ TICKET_TTL = 60 * 10
504
+
505
+ def __init__(self, db_path: str = _DB_PATH):
506
+ global _DB_PATH
507
+ _DB_PATH = db_path
508
+ conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
509
+ conn.row_factory = sqlite3.Row
510
+ _ensure_schema(conn)
511
+ conn.close()
512
+ _SCHEMA_READY.add(_DB_PATH)
513
+ self._pending_admin_map: Dict[str, str] = {}
514
+ self._timeout_tasks: Dict[str, asyncio.Task[Any]] = {}
515
+ self._background_tasks: set[asyncio.Task[Any]] = set()
516
+ self._default_client_sender: Optional[AsyncMessageSender] = None
517
+
518
+ def register_client_sender(self, send_to_client_fn: AsyncMessageSender) -> None:
519
+ self._default_client_sender = send_to_client_fn
520
+
521
+ async def resume_pending_timeouts(
522
+ self,
523
+ *,
524
+ send_to_client_fn: Optional[AsyncMessageSender] = None,
525
+ ) -> int:
526
+ sender = send_to_client_fn or self._default_client_sender
527
+ expired = await self.expire_due_tickets(send_to_client_fn=sender)
528
+ for ticket in self.get_pending_tickets():
529
+ self._schedule_timeout(ticket, sender)
530
+ return len(expired)
531
+
532
+ def detect_uncertainty(
533
+ self,
534
+ llm_output: str,
535
+ user_msg: str,
536
+ clinic: Dict[str, Any],
537
+ intent_confidence: float = 1.0,
538
+ ) -> Optional[str]:
539
+ return detect_uncertainty(llm_output, user_msg, clinic, intent_confidence=intent_confidence)
540
+
541
+ async def trigger(
542
+ self,
543
+ *,
544
+ client_chat_id: str,
545
+ user_msg: str,
546
+ history: List[Dict[str, Any]],
547
+ clinic: Dict[str, Any],
548
+ llm_output: str = "",
549
+ admin_chat_ids: List[str],
550
+ send_to_admin_fn: AsyncMessageSender,
551
+ send_to_client_fn: Optional[AsyncMessageSender] = None,
552
+ intent_confidence: float = 1.0,
553
+ ) -> Tuple[List[str], bool]:
554
+ sender = send_to_client_fn or self._default_client_sender
555
+ self._schedule_background(self.expire_due_tickets(send_to_client_fn=sender))
556
+
557
+ clinic_key = str(clinic.get("id") or clinic.get("name") or "default")
558
+ learned = find_learned_answer(user_msg, clinic_key)
559
+ if learned:
560
+ return [learned], False
561
+
562
+ reason = detect_uncertainty(llm_output, user_msg, clinic, intent_confidence=intent_confidence)
563
+ if not reason:
564
+ return [], False
565
+
566
+ existing = self._get_pending_ticket_for_client(client_chat_id)
567
+ if existing:
568
+ self._schedule_timeout(existing, sender)
569
+ return [existing.hold_message or _pick_hold_message()], True
570
+
571
+ now = time.time()
572
+ hold_message = _pick_hold_message()
573
+ ticket = HandoffTicket(
574
+ id=str(uuid.uuid4()),
575
+ client_chat_id=client_chat_id,
576
+ admin_chat_id=admin_chat_ids[0] if admin_chat_ids else "",
577
+ admin_chat_ids=list(admin_chat_ids or []),
578
+ client_message=user_msg,
579
+ context=list(history or []),
580
+ clinic_name=str(clinic.get("name") or ""),
581
+ clinic_snapshot=dict(clinic or {}),
582
+ llm_output=llm_output or "",
583
+ hold_message=hold_message,
584
+ fallback_message=_pick_timeout_fallback(),
585
+ status="pending",
586
+ uncertainty_why=reason,
587
+ created_at=now,
588
+ expires_at=now + self.TICKET_TTL,
589
+ )
590
+
591
+ self._save_ticket(ticket)
592
+ self._schedule_background(self._notify_admins(ticket, send_to_admin_fn))
593
+ self._schedule_timeout(ticket, sender)
594
+ log.info(f"[handoff] Ticket {ticket.id[:8]} creado para {client_chat_id}")
595
+
596
+ return [hold_message], True
597
+
598
+ async def try_intercept_admin_reply(
599
+ self,
600
+ *,
601
+ admin_chat_id: str,
602
+ admin_text: str,
603
+ clinic: Dict[str, Any],
604
+ llm_fn: AsyncLLMFn,
605
+ send_to_client_fn: AsyncMessageSender,
606
+ ) -> Tuple[bool, List[str]]:
607
+ await self.expire_due_tickets(send_to_client_fn=self._default_client_sender or send_to_client_fn)
608
+
609
+ text = (admin_text or "").strip()
610
+ if text.startswith("/"):
611
+ return False, []
612
+
613
+ ticket_id = self._pending_admin_map.get(admin_chat_id)
614
+ if not ticket_id:
615
+ ticket_id = self._find_pending_ticket_by_admin(admin_chat_id)
616
+ if not ticket_id:
617
+ return False, []
618
+
619
+ ticket = self._load_ticket(ticket_id)
620
+ if not ticket:
621
+ self._pending_admin_map.pop(admin_chat_id, None)
622
+ return False, []
623
+
624
+ if ticket.status == "expired":
625
+ self._clear_ticket_mappings(ticket)
626
+ return True, [_format_expired_admin_notice(ticket)]
627
+
628
+ if ticket.status != "pending":
629
+ self._pending_admin_map.pop(admin_chat_id, None)
630
+ return False, []
631
+
632
+ if _looks_like_handoff_system_echo(text):
633
+ return True, []
634
+
635
+ if not text or _is_admin_ack_only(text):
636
+ return True, ["Recibido. Cuando tengas la respuesta para el cliente, escríbemela aquí y yo se la paso."]
637
+
638
+ polished = await _polish_admin_reply(
639
+ admin_raw=text,
640
+ client_message=ticket.client_message,
641
+ clinic=clinic,
642
+ llm_fn=llm_fn,
643
+ )
644
+
645
+ try:
646
+ await send_to_client_fn(ticket.client_chat_id, polished)
647
+ except Exception as exc:
648
+ log.error(f"[handoff] Error enviando al cliente: {exc}")
649
+ return True, [f"⚠️ Error enviando al cliente: {exc}"]
650
+
651
+ # Conny aprende de la respuesta del admin (V9)
652
+ try:
653
+ from knowledge_base import kb as _kb
654
+ if _kb and _kb.has_content():
655
+ # Obtenemos el mensaje original del paciente del ticket
656
+ with self._conn() as c:
657
+ row = c.execute("SELECT patient_raw FROM handoff_queue WHERE id=?", (ticket_id,)).fetchone()
658
+ if row:
659
+ _kb.save_learned_fact(row["patient_raw"], polished, source="admin_handoff")
660
+ log.info(f"[handoff] Aprendizaje registrado para ticket {ticket_id}")
661
+ except Exception as e:
662
+ log.warning(f"[handoff] Error guardando aprendizaje: {e}")
663
+
664
+ self._mark_replied(ticket_id, admin_raw=text, conny_reply=polished)
665
+ self._cancel_timeout(ticket_id)
666
+ self._clear_ticket_mappings(ticket)
667
+ self._save_learning(
668
+ clinic_key=str(clinic.get("id") or clinic.get("name") or "default"),
669
+ question=ticket.client_message,
670
+ admin_raw=text,
671
+ conny_polish=polished,
672
+ )
673
+
674
+ return True, [
675
+ "✅ Listo, le dije al cliente:\n"
676
+ f"_{polished}_\n\n"
677
+ "📚 Aprendido para la próxima vez que alguien pregunte algo similar."
678
+ ]
679
+
680
+ async def expire_due_tickets(
681
+ self,
682
+ *,
683
+ send_to_client_fn: Optional[AsyncMessageSender] = None,
684
+ ) -> List[HandoffTicket]:
685
+ due = self._list_due_tickets()
686
+ if not due:
687
+ return []
688
+
689
+ sender = send_to_client_fn or self._default_client_sender
690
+ expired: List[HandoffTicket] = []
691
+
692
+ for ticket in due:
693
+ if not self._mark_expired(ticket.id):
694
+ continue
695
+
696
+ ticket = self._load_ticket(ticket.id) or ticket
697
+ self._cancel_timeout(ticket.id)
698
+ self._clear_ticket_mappings(ticket)
699
+ expired.append(ticket)
700
+
701
+ if sender and not ticket.fallback_sent_at:
702
+ try:
703
+ await sender(ticket.client_chat_id, ticket.fallback_message)
704
+ self._mark_fallback_sent(ticket.id)
705
+ except Exception as exc:
706
+ log.warning(f"[handoff] Error enviando fallback de timeout {ticket.id[:8]}: {exc}")
707
+
708
+ return expired
709
+
710
+ def get_learnings(self, clinic_key: str) -> List[HandoffLearning]:
711
+ try:
712
+ conn = _get_db()
713
+ rows = conn.execute(
714
+ "SELECT * FROM handoff_learnings WHERE clinic_key = ? ORDER BY learned_at DESC",
715
+ (clinic_key,),
716
+ ).fetchall()
717
+ conn.close()
718
+ return [
719
+ HandoffLearning(
720
+ id=row["id"],
721
+ clinic_key=row["clinic_key"],
722
+ question_norm=row["question_norm"],
723
+ admin_raw=row["admin_raw"],
724
+ conny_polish=row["conny_polish"],
725
+ learned_at=row["learned_at"],
726
+ times_used=row["times_used"],
727
+ )
728
+ for row in rows
729
+ ]
730
+ except Exception as exc:
731
+ log.warning(f"[handoff] Error leyendo learnings: {exc}")
732
+ return []
733
+
734
+ def delete_learning(self, learning_id: str) -> bool:
735
+ try:
736
+ conn = _get_db()
737
+ conn.execute("DELETE FROM handoff_learnings WHERE id = ?", (learning_id,))
738
+ conn.commit()
739
+ conn.close()
740
+ return True
741
+ except Exception as exc:
742
+ log.warning(f"[handoff] Error borrando learning {learning_id}: {exc}")
743
+ return False
744
+
745
+ def get_pending_tickets(self) -> List[HandoffTicket]:
746
+ self._expire_due_tickets_sync()
747
+ try:
748
+ conn = _get_db()
749
+ rows = conn.execute(
750
+ "SELECT * FROM handoff_queue WHERE status = 'pending' ORDER BY created_at DESC"
751
+ ).fetchall()
752
+ conn.close()
753
+ return [self._row_to_ticket(row) for row in rows]
754
+ except Exception as exc:
755
+ log.warning(f"[handoff] Error listando tickets: {exc}")
756
+ return []
757
+
758
+ async def _notify_admins(
759
+ self,
760
+ ticket: HandoffTicket,
761
+ send_to_admin_fn: AsyncMessageSender,
762
+ ) -> None:
763
+ if not ticket.admin_chat_ids:
764
+ return
765
+
766
+ async def _notify_one(admin_id: str) -> None:
767
+ try:
768
+ outbound_ticket = self._clone_ticket(ticket, admin_chat_id=admin_id)
769
+ await send_to_admin_fn(admin_id, _format_admin_notification(outbound_ticket))
770
+ self._pending_admin_map[admin_id] = ticket.id
771
+ except Exception as exc:
772
+ log.warning(f"[handoff] Error notificando admin {admin_id}: {exc}")
773
+
774
+ await asyncio.gather(*(_notify_one(admin_id) for admin_id in ticket.admin_chat_ids))
775
+
776
+ def _clone_ticket(self, ticket: HandoffTicket, *, admin_chat_id: Optional[str] = None) -> HandoffTicket:
777
+ return HandoffTicket(
778
+ id=ticket.id,
779
+ client_chat_id=ticket.client_chat_id,
780
+ admin_chat_id=admin_chat_id if admin_chat_id is not None else ticket.admin_chat_id,
781
+ client_message=ticket.client_message,
782
+ context=list(ticket.context),
783
+ clinic_name=ticket.clinic_name,
784
+ status=ticket.status,
785
+ uncertainty_why=ticket.uncertainty_why,
786
+ admin_chat_ids=list(ticket.admin_chat_ids),
787
+ clinic_snapshot=dict(ticket.clinic_snapshot),
788
+ llm_output=ticket.llm_output,
789
+ hold_message=ticket.hold_message,
790
+ fallback_message=ticket.fallback_message,
791
+ admin_raw_reply=ticket.admin_raw_reply,
792
+ conny_reply=ticket.conny_reply,
793
+ created_at=ticket.created_at,
794
+ expires_at=ticket.expires_at,
795
+ replied_at=ticket.replied_at,
796
+ expired_at=ticket.expired_at,
797
+ fallback_sent_at=ticket.fallback_sent_at,
798
+ )
799
+
800
+ def _schedule_background(self, coro: Awaitable[Any]) -> None:
801
+ try:
802
+ loop = asyncio.get_running_loop()
803
+ except RuntimeError:
804
+ return
805
+ def _start() -> None:
806
+ task = loop.create_task(coro)
807
+ self._background_tasks.add(task)
808
+ task.add_done_callback(self._background_tasks.discard)
809
+
810
+ # Desacopla la creación real del task del request actual para que
811
+ # trigger() no quede penalizado por el primer await del envío al admin.
812
+ loop.call_soon(_start)
813
+
814
+ def _schedule_timeout(
815
+ self,
816
+ ticket: HandoffTicket,
817
+ send_to_client_fn: Optional[AsyncMessageSender],
818
+ ) -> None:
819
+ if not send_to_client_fn:
820
+ return
821
+
822
+ self._cancel_timeout(ticket.id)
823
+
824
+ delay = max(0.0, ticket.expires_at - time.time())
825
+
826
+ async def _timeout_worker() -> None:
827
+ try:
828
+ await asyncio.sleep(delay)
829
+ await self.expire_due_tickets(send_to_client_fn=send_to_client_fn)
830
+ except asyncio.CancelledError:
831
+ raise
832
+
833
+ try:
834
+ loop = asyncio.get_running_loop()
835
+ except RuntimeError:
836
+ return
837
+
838
+ task = loop.create_task(_timeout_worker())
839
+ self._timeout_tasks[ticket.id] = task
840
+ task.add_done_callback(lambda _: self._timeout_tasks.pop(ticket.id, None))
841
+
842
+ def _cancel_timeout(self, ticket_id: str) -> None:
843
+ task = self._timeout_tasks.pop(ticket_id, None)
844
+ if task and not task.done():
845
+ task.cancel()
846
+
847
+ def _clear_ticket_mappings(self, ticket: HandoffTicket) -> None:
848
+ for admin_id in set(ticket.admin_chat_ids + ([ticket.admin_chat_id] if ticket.admin_chat_id else [])):
849
+ if self._pending_admin_map.get(admin_id) == ticket.id:
850
+ self._pending_admin_map.pop(admin_id, None)
851
+
852
+ def _save_ticket(self, ticket: HandoffTicket) -> None:
853
+ try:
854
+ conn = _get_db()
855
+ conn.execute(
856
+ """
857
+ INSERT INTO handoff_queue (
858
+ id, client_chat_id, admin_chat_id, admin_chat_ids_json,
859
+ client_message, context_json, clinic_name, clinic_json,
860
+ llm_output, hold_message, fallback_message, status,
861
+ uncertainty_why, created_at, expires_at
862
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
863
+ """,
864
+ (
865
+ ticket.id,
866
+ ticket.client_chat_id,
867
+ ticket.admin_chat_id,
868
+ _json_dumps(ticket.admin_chat_ids),
869
+ ticket.client_message,
870
+ _json_dumps(_build_context_payload(ticket)),
871
+ ticket.clinic_name,
872
+ _json_dumps(ticket.clinic_snapshot),
873
+ ticket.llm_output,
874
+ ticket.hold_message,
875
+ ticket.fallback_message,
876
+ ticket.uncertainty_why,
877
+ ticket.created_at,
878
+ ticket.expires_at,
879
+ ),
880
+ )
881
+ conn.commit()
882
+ conn.close()
883
+ except Exception as exc:
884
+ log.warning(f"[handoff] Error guardando ticket: {exc}")
885
+
886
+ def _load_ticket(self, ticket_id: str) -> Optional[HandoffTicket]:
887
+ try:
888
+ conn = _get_db()
889
+ row = conn.execute(
890
+ "SELECT * FROM handoff_queue WHERE id = ?",
891
+ (ticket_id,),
892
+ ).fetchone()
893
+ conn.close()
894
+ return self._row_to_ticket(row) if row else None
895
+ except Exception as exc:
896
+ log.warning(f"[handoff] Error cargando ticket {ticket_id}: {exc}")
897
+ return None
898
+
899
+ def _row_to_ticket(self, row: sqlite3.Row) -> HandoffTicket:
900
+ payload = _json_loads(row["context_json"], {})
901
+ if isinstance(payload, list):
902
+ history = payload
903
+ payload = {}
904
+ else:
905
+ history = payload.get("history") or payload.get("context") or []
906
+
907
+ admin_chat_ids = _json_loads(row["admin_chat_ids_json"], payload.get("admin_chat_ids", []))
908
+ if not isinstance(admin_chat_ids, list):
909
+ admin_chat_ids = []
910
+
911
+ clinic_snapshot = _json_loads(row["clinic_json"], payload.get("clinic_snapshot", {}))
912
+ if not isinstance(clinic_snapshot, dict):
913
+ clinic_snapshot = {}
914
+
915
+ return HandoffTicket(
916
+ id=row["id"],
917
+ client_chat_id=row["client_chat_id"],
918
+ admin_chat_id=row["admin_chat_id"] or "",
919
+ admin_chat_ids=admin_chat_ids,
920
+ client_message=row["client_message"],
921
+ context=history if isinstance(history, list) else [],
922
+ clinic_name=row["clinic_name"] or "",
923
+ clinic_snapshot=clinic_snapshot,
924
+ llm_output=row["llm_output"] or payload.get("llm_output", ""),
925
+ hold_message=row["hold_message"] or payload.get("hold_message", ""),
926
+ fallback_message=row["fallback_message"] or payload.get("fallback_message", ""),
927
+ status=row["status"],
928
+ uncertainty_why=row["uncertainty_why"] or payload.get("uncertainty_why", ""),
929
+ admin_raw_reply=row["admin_raw_reply"] or "",
930
+ conny_reply=row["conny_reply"] or "",
931
+ created_at=row["created_at"],
932
+ expires_at=row["expires_at"] or 0.0,
933
+ replied_at=row["replied_at"] or 0.0,
934
+ expired_at=row["expired_at"] or 0.0,
935
+ fallback_sent_at=row["fallback_sent_at"] or 0.0,
936
+ )
937
+
938
+ def _mark_replied(self, ticket_id: str, admin_raw: str, conny_reply: str) -> None:
939
+ try:
940
+ conn = _get_db()
941
+ conn.execute(
942
+ """
943
+ UPDATE handoff_queue
944
+ SET status = 'replied',
945
+ admin_raw_reply = ?,
946
+ conny_reply = ?,
947
+ replied_at = ?
948
+ WHERE id = ? AND status = 'pending'
949
+ """,
950
+ (admin_raw, conny_reply, time.time(), ticket_id),
951
+ )
952
+ conn.commit()
953
+ conn.close()
954
+ except Exception as exc:
955
+ log.warning(f"[handoff] Error actualizando ticket {ticket_id}: {exc}")
956
+
957
+ def _mark_expired(self, ticket_id: str) -> bool:
958
+ try:
959
+ conn = _get_db()
960
+ cur = conn.execute(
961
+ """
962
+ UPDATE handoff_queue
963
+ SET status = 'expired',
964
+ expired_at = ?
965
+ WHERE id = ? AND status = 'pending'
966
+ """,
967
+ (time.time(), ticket_id),
968
+ )
969
+ conn.commit()
970
+ changed = cur.rowcount > 0
971
+ conn.close()
972
+ return changed
973
+ except Exception as exc:
974
+ log.warning(f"[handoff] Error expirando ticket {ticket_id}: {exc}")
975
+ return False
976
+
977
+ def _mark_fallback_sent(self, ticket_id: str) -> None:
978
+ try:
979
+ conn = _get_db()
980
+ conn.execute(
981
+ "UPDATE handoff_queue SET fallback_sent_at = ? WHERE id = ?",
982
+ (time.time(), ticket_id),
983
+ )
984
+ conn.commit()
985
+ conn.close()
986
+ except Exception as exc:
987
+ log.warning(f"[handoff] Error marcando fallback enviado {ticket_id}: {exc}")
988
+
989
+ def _get_pending_ticket_for_client(self, client_chat_id: str) -> Optional[HandoffTicket]:
990
+ self._expire_due_tickets_sync()
991
+ try:
992
+ conn = _get_db()
993
+ row = conn.execute(
994
+ """
995
+ SELECT * FROM handoff_queue
996
+ WHERE client_chat_id = ? AND status = 'pending'
997
+ ORDER BY created_at DESC
998
+ LIMIT 1
999
+ """,
1000
+ (client_chat_id,),
1001
+ ).fetchone()
1002
+ conn.close()
1003
+ return self._row_to_ticket(row) if row else None
1004
+ except Exception as exc:
1005
+ log.warning(f"[handoff] Error buscando ticket cliente: {exc}")
1006
+ return None
1007
+
1008
+ def _find_pending_ticket_by_admin(self, admin_chat_id: str) -> Optional[str]:
1009
+ self._expire_due_tickets_sync()
1010
+ try:
1011
+ conn = _get_db()
1012
+ rows = conn.execute(
1013
+ """
1014
+ SELECT * FROM handoff_queue
1015
+ WHERE status = 'pending'
1016
+ ORDER BY created_at DESC
1017
+ LIMIT 50
1018
+ """
1019
+ ).fetchall()
1020
+ conn.close()
1021
+ except Exception as exc:
1022
+ log.warning(f"[handoff] Error buscando ticket por admin: {exc}")
1023
+ return None
1024
+
1025
+ for row in rows:
1026
+ ticket = self._row_to_ticket(row)
1027
+ if admin_chat_id == ticket.admin_chat_id or admin_chat_id in ticket.admin_chat_ids:
1028
+ return ticket.id
1029
+ return None
1030
+
1031
+ def _list_due_tickets(self) -> List[HandoffTicket]:
1032
+ try:
1033
+ conn = _get_db()
1034
+ rows = conn.execute(
1035
+ """
1036
+ SELECT * FROM handoff_queue
1037
+ WHERE status = 'pending'
1038
+ AND expires_at > 0
1039
+ AND expires_at <= ?
1040
+ ORDER BY created_at ASC
1041
+ """,
1042
+ (time.time(),),
1043
+ ).fetchall()
1044
+ conn.close()
1045
+ return [self._row_to_ticket(row) for row in rows]
1046
+ except Exception as exc:
1047
+ log.warning(f"[handoff] Error listando expirados: {exc}")
1048
+ return []
1049
+
1050
+ def _expire_due_tickets_sync(self) -> None:
1051
+ due = self._list_due_tickets()
1052
+ for ticket in due:
1053
+ if self._mark_expired(ticket.id):
1054
+ self._cancel_timeout(ticket.id)
1055
+ self._clear_ticket_mappings(ticket)
1056
+
1057
+ def _save_learning(
1058
+ self,
1059
+ clinic_key: str,
1060
+ question: str,
1061
+ admin_raw: str,
1062
+ conny_polish: str,
1063
+ ) -> None:
1064
+ q_norm = _normalize_question(question)
1065
+ try:
1066
+ conn = _get_db()
1067
+ existing = conn.execute(
1068
+ "SELECT id FROM handoff_learnings WHERE clinic_key = ? AND question_norm = ?",
1069
+ (clinic_key, q_norm),
1070
+ ).fetchone()
1071
+
1072
+ if existing:
1073
+ conn.execute(
1074
+ """
1075
+ UPDATE handoff_learnings
1076
+ SET admin_raw = ?, conny_polish = ?, learned_at = ?
1077
+ WHERE id = ?
1078
+ """,
1079
+ (admin_raw, conny_polish, time.time(), existing["id"]),
1080
+ )
1081
+ else:
1082
+ conn.execute(
1083
+ """
1084
+ INSERT INTO handoff_learnings
1085
+ (id, clinic_key, question_norm, admin_raw, conny_polish, learned_at)
1086
+ VALUES (?, ?, ?, ?, ?, ?)
1087
+ """,
1088
+ (str(uuid.uuid4()), clinic_key, q_norm, admin_raw, conny_polish, time.time()),
1089
+ )
1090
+
1091
+ conn.commit()
1092
+ conn.close()
1093
+ except Exception as exc:
1094
+ log.warning(f"[handoff] Error guardando learning: {exc}")
1095
+
1096
+
1097
+ # ════════════════════════════════════════════════════════════════════════════════
1098
+ # INSTANCIA GLOBAL
1099
+ # ════════════════════════════════════════════════════════════════════════════════
1100
+
1101
+ handoff_manager = SmartAdminHandoff()
1102
+
1103
+
1104
+ # ════════════════════════════════════════════════════════════════════════════════
1105
+ # COMANDOS ADMIN
1106
+ # ════════════════════════════════════════════════════════════════════════════════
1107
+
1108
+ async def handle_handoff_admin_command(
1109
+ cmd: str,
1110
+ clinic: Dict[str, Any],
1111
+ ) -> Optional[List[str]]:
1112
+ cmd = (cmd or "").strip().lower()
1113
+ clinic_key = str(clinic.get("id") or clinic.get("name") or "default")
1114
+
1115
+ if cmd in ("/handoff", "/handoffs", "/escalaciones"):
1116
+ tickets = handoff_manager.get_pending_tickets()
1117
+ if not tickets:
1118
+ return ["No hay tickets pendientes ahora mismo."]
1119
+ lines = [f"📬 *{len(tickets)} ticket(s) pendiente(s):*\n"]
1120
+ for ticket in tickets[:10]:
1121
+ age_min = int((time.time() - ticket.created_at) / 60)
1122
+ lines.append(
1123
+ f"• `{ticket.id[:8]}` | {ticket.client_chat_id} | hace {age_min}min\n"
1124
+ f" _{ticket.client_message[:80]}_"
1125
+ )
1126
+ return ["\n".join(lines)]
1127
+
1128
+ if cmd in ("/handoff-aprendizajes", "/handoff-learnings", "/lo-que-aprendi"):
1129
+ learnings = handoff_manager.get_learnings(clinic_key)
1130
+ if not learnings:
1131
+ return ["Conny aún no ha aprendido nada vía handoff para esta clínica."]
1132
+ lines = [f"📚 *{len(learnings)} aprendizaje(s):*\n"]
1133
+ for learning in learnings[:10]:
1134
+ lines.append(
1135
+ f"• `{learning.id[:8]}` (usado {learning.times_used}x)\n"
1136
+ f" Pregunta: _{learning.question_norm[:60]}_\n"
1137
+ f" Respuesta: _{learning.conny_polish[:80]}_"
1138
+ )
1139
+ return ["\n".join(lines)]
1140
+
1141
+ if cmd.startswith("/handoff-borrar "):
1142
+ learning_id_prefix = cmd.split("/handoff-borrar ", 1)[1].strip()
1143
+ learnings = handoff_manager.get_learnings(clinic_key)
1144
+ match = next((learning for learning in learnings if learning.id.startswith(learning_id_prefix)), None)
1145
+ if not match:
1146
+ return [f"No encontré un learning con ID `{learning_id_prefix}`."]
1147
+ handoff_manager.delete_learning(match.id)
1148
+ return [f"✅ Learning `{match.id[:8]}` borrado."]
1149
+
1150
+ return None