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