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