@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.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
package/smart_handoff.py
ADDED
|
@@ -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
|