@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,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conny V7.0 — Agente de Objeciones
|
|
3
|
+
=====================================
|
|
4
|
+
Maneja toda resistencia del paciente.
|
|
5
|
+
La objeción dicha raramente es la objeción real.
|
|
6
|
+
Prompt: ~580 tokens.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import re
|
|
11
|
+
from v7.agents.base import AgentBase, AgentContext, AgentResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Mapa objeción detectada → clave para guardar en perfil
|
|
15
|
+
_OBJECION_MAP = [
|
|
16
|
+
(r"\b(caro|costoso|precio|plata|presupuesto)\b", "precio"),
|
|
17
|
+
(r"\b(pensar|pensarlo|consultar|pareja|esposo|esposa|mamá)\b", "indecision"),
|
|
18
|
+
(r"\b(miedo|asusta|raro|exagerada|tiesa|natural|se note)\b", "miedo_resultado"),
|
|
19
|
+
(r"\b(ya fui|otro lado|otra clínica|no me gustó)\b", "experiencia_previa"),
|
|
20
|
+
(r"\b(tiempo|ocupad|trabajo|no puedo ir)\b", "tiempo"),
|
|
21
|
+
(r"\b(vergüenza|pena|dirán)\b", "vergüenza"),
|
|
22
|
+
(r"\b(no funciona|carreta|esceptic|no creo)\b", "escepticismo"),
|
|
23
|
+
(r"\b(bogotá|exterior|fuera del país)\b", "distancia"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def _detect_objecion(text: str) -> str:
|
|
27
|
+
for pattern, key in _OBJECION_MAP:
|
|
28
|
+
if re.search(pattern, text, re.IGNORECASE):
|
|
29
|
+
return key
|
|
30
|
+
return "otra"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AgenteObjeciones(AgentBase):
|
|
34
|
+
|
|
35
|
+
agent_id = "objeciones"
|
|
36
|
+
context_keys = ["patient_name", "objeciones_pasadas", "funnel_state",
|
|
37
|
+
"clinic_tone", "servicios_relevantes", "miedo_principal"]
|
|
38
|
+
|
|
39
|
+
def _build_prompt(self, ctx: AgentContext) -> str:
|
|
40
|
+
objeciones_previas = (
|
|
41
|
+
f"Ya manejó estas objeciones antes: {', '.join(ctx.objeciones_pasadas)}. No repitas las mismas respuestas."
|
|
42
|
+
if ctx.objeciones_pasadas else ""
|
|
43
|
+
)
|
|
44
|
+
usted = ctx.usted
|
|
45
|
+
|
|
46
|
+
return f"""Eres la persona que contesta el WhatsApp de {ctx.clinic_name or "la clínica"}.
|
|
47
|
+
{self._tone_header(ctx)}
|
|
48
|
+
|
|
49
|
+
MISIÓN: resolver la resistencia del paciente con psicología, no con argumentos.
|
|
50
|
+
|
|
51
|
+
LO QUE EL PACIENTE NUNCA DICE PERO SIEMPRE SIENTE:
|
|
52
|
+
La objeción que dice ("está caro") raramente es la real.
|
|
53
|
+
Detrás hay uno de estos miedos:
|
|
54
|
+
miedo al resultado / miedo a equivocarse / vergüenza social / miedo médico
|
|
55
|
+
|
|
56
|
+
RESPONDE A LO QUE NO DIJERON, NO A LO QUE DIJERON:
|
|
57
|
+
Si dice "está caro" → el miedo real suele ser "¿vale la pena?"
|
|
58
|
+
Si dice "lo voy a pensar" → hay una objeción no dicha. Descúbrela.
|
|
59
|
+
Si dice "me da miedo quedar rara" → valida primero, luego transfiere al especialista
|
|
60
|
+
|
|
61
|
+
RESPUESTAS EXACTAS POR OBJECIÓN:
|
|
62
|
+
"está caro/costoso":
|
|
63
|
+
"sí, los buenos procedimientos no son baratos ||| lo que vale es el criterio de la doctora, cuánto manda, dónde, cómo. en la valoración te da el número exacto para tu caso"
|
|
64
|
+
|
|
65
|
+
"lo voy a pensar":
|
|
66
|
+
"claro, sin afán ||| qué es lo que más te frena, el precio, el resultado, o el proceso"
|
|
67
|
+
(espera la respuesta — esa es la objeción real)
|
|
68
|
+
|
|
69
|
+
"miedo a quedar exagerada/tiesa/rara":
|
|
70
|
+
"ese miedo es el más común de todos ||| el objetivo acá es que te veas descansada, no diferente. la doctora trabaja muy conservador, es su sello"
|
|
71
|
+
|
|
72
|
+
"ya fui a otro lado y quedé mal":
|
|
73
|
+
"ay qué pena, eso es muy frustrante ||| qué fue lo que pasó, dónde te lo hicieron"
|
|
74
|
+
(si fue en un spa/no-clínica → eso explica todo, diferénciate)
|
|
75
|
+
|
|
76
|
+
"no tengo tiempo":
|
|
77
|
+
"cuánto tiempo tienes, la valoración son 20 minutos, puedes en el almuerzo"
|
|
78
|
+
|
|
79
|
+
"lo consulto con mi pareja/mamá":
|
|
80
|
+
"claro ||| qué crees que le preocuparía más, el precio, el resultado, o la recuperación"
|
|
81
|
+
|
|
82
|
+
"vergüenza/pena":
|
|
83
|
+
"la mayoría llega con eso ||| acá es muy privado, solo tú con la doctora, sin testigos"
|
|
84
|
+
|
|
85
|
+
"no funciona/pura carreta":
|
|
86
|
+
"entiendo el escepticismo, es válido ||| qué te haría creer que sí funciona"
|
|
87
|
+
|
|
88
|
+
TÉCNICA CLAVE: TRANSFERIR AL ESPECIALISTA
|
|
89
|
+
Cuando hay duda o miedo médico real → no tomes la decisión tú.
|
|
90
|
+
"eso lo ve la doctora, ella te dice con honestidad si aplica para tu caso"
|
|
91
|
+
|
|
92
|
+
CIERRE DESPUÉS DE RESOLVER:
|
|
93
|
+
Propón UN día concreto.
|
|
94
|
+
"esta semana tienes el jueves, te queda bien"
|
|
95
|
+
NUNCA dos días: "jueves o viernes" suena a que no sabes.
|
|
96
|
+
|
|
97
|
+
{objeciones_previas}
|
|
98
|
+
{self._writing_rules()}
|
|
99
|
+
{self._patient_block(ctx)}"""
|
|
100
|
+
|
|
101
|
+
def _parse_response(self, raw: str, ctx: AgentContext) -> AgentResponse:
|
|
102
|
+
from v7.postprocess import postprocess, split_bubbles
|
|
103
|
+
clean = postprocess(raw, is_premium=ctx.is_premium)
|
|
104
|
+
bubbles = split_bubbles(clean)
|
|
105
|
+
objecion = _detect_objecion(ctx.text)
|
|
106
|
+
return AgentResponse(
|
|
107
|
+
bubbles=bubbles or [clean],
|
|
108
|
+
new_objecion=objecion,
|
|
109
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conny V7.0 — Agente de Seguimiento
|
|
3
|
+
======================================
|
|
4
|
+
Reactivación de pacientes inactivos, no-shows, post-procedimiento.
|
|
5
|
+
Se activa por cron O por mensaje del paciente que ya tuvo cita.
|
|
6
|
+
Prompt: ~290 tokens.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from v7.agents.base import AgentBase, AgentContext, AgentResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgenteSeguimiento(AgentBase):
|
|
14
|
+
|
|
15
|
+
agent_id = "seguimiento"
|
|
16
|
+
context_keys = ["patient_name", "ultima_cita", "procedimiento_realizado",
|
|
17
|
+
"clinic_tone", "funnel_state"]
|
|
18
|
+
|
|
19
|
+
def _build_prompt(self, ctx: AgentContext) -> str:
|
|
20
|
+
razon = ctx.metadata.get("follow_up_reason", "reactivacion")
|
|
21
|
+
nombre = ctx.patient_name or ""
|
|
22
|
+
usted = ctx.usted
|
|
23
|
+
|
|
24
|
+
razon_map = {
|
|
25
|
+
"no_show": f"{"No vino a su cita" if usted else "No fue a la cita"}. Tono: calidez, sin reproche.",
|
|
26
|
+
"post_cita_48h": f"Tuvo la valoración hace 2 días. Tono: genuino interés.",
|
|
27
|
+
"post_procedimiento": "Ya le hicieron el procedimiento. Revisa cómo le fue.",
|
|
28
|
+
"reactivacion_30d": f"Lleva más de 30 días sin escribir. Tono: suave, sin presión.",
|
|
29
|
+
"reactivacion": f"{"Había mostrado interés." if not nombre else f"{nombre} había mostrado interés."}. Retomar suave.",
|
|
30
|
+
}
|
|
31
|
+
contexto_razon = razon_map.get(razon, razon_map["reactivacion"])
|
|
32
|
+
nombre_bloque = f"{"Su nombre es" if usted else "Se llama"} {nombre}." if nombre else ""
|
|
33
|
+
|
|
34
|
+
return f"""Eres la persona que contesta el WhatsApp de {ctx.clinic_name or "la clínica"}.
|
|
35
|
+
{self._tone_header(ctx)}
|
|
36
|
+
|
|
37
|
+
SITUACIÓN: {contexto_razon}
|
|
38
|
+
{nombre_bloque}
|
|
39
|
+
|
|
40
|
+
MISIÓN: retomar el contacto de forma natural. Sin presión. Sin drama.
|
|
41
|
+
|
|
42
|
+
GUÍAS POR SITUACIÓN:
|
|
43
|
+
No-show:
|
|
44
|
+
No preguntes por qué no fue. Solo retoma.
|
|
45
|
+
{"¿Tuvo algún imprevisto? Podemos reagendar cuando le quede bien." if usted else "oye, quedaste de venir, ¿te pasó algo? podemos reagendar sin problema"}
|
|
46
|
+
|
|
47
|
+
Post-valoración (48h):
|
|
48
|
+
Pregunta genuinamente cómo le fue, sin venta.
|
|
49
|
+
{"¿Cómo le quedaron las recomendaciones de la doctora?" if usted else "oye, cómo te fue con lo que te dijo la doctora"}
|
|
50
|
+
|
|
51
|
+
Post-procedimiento:
|
|
52
|
+
Interés real en el resultado. Abre espacio para que cuente.
|
|
53
|
+
{"¿Cómo se ha sentido?" if usted else "qué tal cómo quedaste, ya se ven los resultados?"}
|
|
54
|
+
|
|
55
|
+
Reactivación larga (30+ días):
|
|
56
|
+
Suave, sin recordar que desapareció.
|
|
57
|
+
{"Queríamos saber cómo está" if usted else "oye, hace rato no sabemos de ti, ¿cómo has estado?"}
|
|
58
|
+
|
|
59
|
+
SI HAY APERTURA → reagendar en el mismo mensaje
|
|
60
|
+
"esta semana tienes el jueves, te queda bien"
|
|
61
|
+
|
|
62
|
+
{self._writing_rules()}"""
|
|
63
|
+
|
|
64
|
+
def _parse_response(self, raw: str, ctx: AgentContext) -> AgentResponse:
|
|
65
|
+
from v7.postprocess import postprocess, split_bubbles
|
|
66
|
+
clean = postprocess(raw, is_premium=ctx.is_premium)
|
|
67
|
+
bubbles = split_bubbles(clean)
|
|
68
|
+
return AgentResponse(
|
|
69
|
+
bubbles=bubbles or [clean],
|
|
70
|
+
funnel_update="reactivacion",
|
|
71
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conny V7.0 — Registry de Agentes
|
|
3
|
+
=====================================
|
|
4
|
+
Registra todos los agentes disponibles.
|
|
5
|
+
El orquestador los carga una sola vez al arranque.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Dict, Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from v7.agents.base import AgentBase
|
|
13
|
+
|
|
14
|
+
from v7.agents.captacion import AgenteCaptacion
|
|
15
|
+
from v7.agents.objeciones import AgenteObjeciones
|
|
16
|
+
from v7.agents.agenda import AgenteAgenda
|
|
17
|
+
from v7.agents.conocimiento import AgenteConocimiento
|
|
18
|
+
from v7.agents.seguimiento import AgenteSeguimiento
|
|
19
|
+
from v7.agents.escalacion import AgenteEscalacion
|
|
20
|
+
from v7.router import AgentID
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_registry(llm_engine) -> Dict[str, "AgentBase"]:
|
|
24
|
+
"""
|
|
25
|
+
Construye el diccionario de agentes instanciados.
|
|
26
|
+
Llamar UNA SOLA VEZ al arranque, pasar a ConnyUltra.
|
|
27
|
+
"""
|
|
28
|
+
return {
|
|
29
|
+
AgentID.CAPTACION: AgenteCaptacion(llm_engine),
|
|
30
|
+
AgentID.OBJECIONES: AgenteObjeciones(llm_engine),
|
|
31
|
+
AgentID.AGENDA: AgenteAgenda(llm_engine),
|
|
32
|
+
AgentID.CONOCIMIENTO: AgenteConocimiento(llm_engine),
|
|
33
|
+
AgentID.SEGUIMIENTO: AgenteSeguimiento(llm_engine),
|
|
34
|
+
AgentID.ESCALACION: AgenteEscalacion(llm_engine),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"build_registry",
|
|
40
|
+
"AgenteCaptacion",
|
|
41
|
+
"AgenteObjeciones",
|
|
42
|
+
"AgenteAgenda",
|
|
43
|
+
"AgenteConocimiento",
|
|
44
|
+
"AgenteSeguimiento",
|
|
45
|
+
"AgenteEscalacion",
|
|
46
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conny V7.0 — Memoria Clínica Persistente
|
|
3
|
+
============================================
|
|
4
|
+
PatientProfile: perfil enriquecido del paciente (sobrevive reinicios).
|
|
5
|
+
FunnelState: máquina de estados del proceso de conversión.
|
|
6
|
+
|
|
7
|
+
Diseño: capa delgada sobre el SQLite existente.
|
|
8
|
+
No reemplaza db.py — lo enriquece con lógica clínica.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass, field, asdict
|
|
16
|
+
from typing import Dict, List, Optional, Any
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Máquina de estados del funnel ────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
class FunnelStage(str, Enum):
|
|
25
|
+
PRIMER_CONTACTO = "primer_contacto" # nunca ha escrito antes
|
|
26
|
+
EXPLORANDO = "explorando" # haciendo preguntas, sin compromiso
|
|
27
|
+
CON_INTENCION = "con_intencion" # expresó interés real
|
|
28
|
+
CITA_AGENDADA = "cita_agendada" # tiene valoración agendada
|
|
29
|
+
POST_CITA = "post_cita" # ya tuvo la valoración o procedimiento
|
|
30
|
+
REACTIVACION = "reactivacion" # inactivo > 30 días, volvió
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Transiciones válidas — qué puede seguir a qué
|
|
34
|
+
_TRANSITIONS: Dict[FunnelStage, List[FunnelStage]] = {
|
|
35
|
+
FunnelStage.PRIMER_CONTACTO: [FunnelStage.EXPLORANDO, FunnelStage.CON_INTENCION],
|
|
36
|
+
FunnelStage.EXPLORANDO: [FunnelStage.CON_INTENCION, FunnelStage.PRIMER_CONTACTO],
|
|
37
|
+
FunnelStage.CON_INTENCION: [FunnelStage.CITA_AGENDADA, FunnelStage.EXPLORANDO],
|
|
38
|
+
FunnelStage.CITA_AGENDADA: [FunnelStage.POST_CITA, FunnelStage.CON_INTENCION],
|
|
39
|
+
FunnelStage.POST_CITA: [FunnelStage.CON_INTENCION, FunnelStage.REACTIVACION],
|
|
40
|
+
FunnelStage.REACTIVACION: [FunnelStage.EXPLORANDO, FunnelStage.CON_INTENCION],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Perfil del paciente ───────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PatientProfile:
|
|
48
|
+
chat_id: str
|
|
49
|
+
nombre: Optional[str] = None
|
|
50
|
+
zona_interes: List[str] = field(default_factory=list) # ["frente", "ojeras"]
|
|
51
|
+
miedo_principal: Optional[str] = None # "quedar exagerada"
|
|
52
|
+
objeciones_pasadas: List[str] = field(default_factory=list) # ["precio", "tiempo"]
|
|
53
|
+
funnel_stage: FunnelStage = FunnelStage.PRIMER_CONTACTO
|
|
54
|
+
visitas: int = 0
|
|
55
|
+
ultima_interaccion: float = 0.0
|
|
56
|
+
ultima_cita: Optional[str] = None
|
|
57
|
+
procedimiento_hecho: Optional[str] = None
|
|
58
|
+
score_conversion: float = 0.0 # 0-1
|
|
59
|
+
idioma: str = "es"
|
|
60
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
# ── Transición de funnel ──────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def advance_funnel(self, new_stage: FunnelStage) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
Avanza el funnel si la transición es válida.
|
|
67
|
+
Devuelve True si se hizo la transición.
|
|
68
|
+
"""
|
|
69
|
+
valid = _TRANSITIONS.get(self.funnel_stage, [])
|
|
70
|
+
if new_stage in valid:
|
|
71
|
+
log.info(
|
|
72
|
+
"[funnel] %s: %s → %s",
|
|
73
|
+
self.chat_id[:8], self.funnel_stage.value, new_stage.value
|
|
74
|
+
)
|
|
75
|
+
self.funnel_stage = new_stage
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def add_objecion(self, objecion: str) -> None:
|
|
80
|
+
if objecion and objecion not in self.objeciones_pasadas:
|
|
81
|
+
self.objeciones_pasadas.append(objecion)
|
|
82
|
+
if len(self.objeciones_pasadas) > 10:
|
|
83
|
+
self.objeciones_pasadas = self.objeciones_pasadas[-10:]
|
|
84
|
+
|
|
85
|
+
def add_zona(self, zona: str) -> None:
|
|
86
|
+
if zona and zona not in self.zona_interes:
|
|
87
|
+
self.zona_interes.append(zona)
|
|
88
|
+
|
|
89
|
+
def days_inactive(self) -> float:
|
|
90
|
+
if not self.ultima_interaccion:
|
|
91
|
+
return 999.0
|
|
92
|
+
return (time.time() - self.ultima_interaccion) / 86400
|
|
93
|
+
|
|
94
|
+
def to_context_summary(self, keys: List[str]) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Serializa solo los campos solicitados por el agente.
|
|
97
|
+
Evita pasar 4,000 tokens cuando el agente solo necesita 200.
|
|
98
|
+
"""
|
|
99
|
+
parts = []
|
|
100
|
+
if "patient_name" in keys and self.nombre:
|
|
101
|
+
parts.append(f"Nombre: {self.nombre}")
|
|
102
|
+
if "visits" in keys:
|
|
103
|
+
parts.append(f"Visitas: {self.visitas}")
|
|
104
|
+
if "funnel_state" in keys:
|
|
105
|
+
parts.append(f"Estado funnel: {self.funnel_stage.value}")
|
|
106
|
+
if "objeciones_pasadas" in keys and self.objeciones_pasadas:
|
|
107
|
+
parts.append(f"Objeciones previas: {', '.join(self.objeciones_pasadas)}")
|
|
108
|
+
if "zona_interes" in keys and self.zona_interes:
|
|
109
|
+
parts.append(f"Zona de interés: {', '.join(self.zona_interes)}")
|
|
110
|
+
if "miedo_principal" in keys and self.miedo_principal:
|
|
111
|
+
parts.append(f"Miedo principal: {self.miedo_principal}")
|
|
112
|
+
if "ultima_cita" in keys and self.ultima_cita:
|
|
113
|
+
parts.append(f"Última cita: {self.ultima_cita}")
|
|
114
|
+
if "score_conversion" in keys:
|
|
115
|
+
parts.append(f"Score conversión: {self.score_conversion:.0%}")
|
|
116
|
+
return "\n".join(parts) if parts else ""
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> Dict:
|
|
119
|
+
d = asdict(self)
|
|
120
|
+
d["funnel_stage"] = self.funnel_stage.value
|
|
121
|
+
return d
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: Dict) -> "PatientProfile":
|
|
125
|
+
if "funnel_stage" in data:
|
|
126
|
+
try:
|
|
127
|
+
data["funnel_stage"] = FunnelStage(data["funnel_stage"])
|
|
128
|
+
except ValueError:
|
|
129
|
+
data["funnel_stage"] = FunnelStage.PRIMER_CONTACTO
|
|
130
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── Capa de persistencia ──────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
class ProfileStore:
|
|
136
|
+
"""
|
|
137
|
+
Lee y escribe PatientProfile desde el SQLite existente.
|
|
138
|
+
Usa la columna 'metadata' de la tabla patients para guardar el perfil V7.
|
|
139
|
+
Sin migración de schema — retrocompatible con V6.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
_PROFILE_KEY = "v7_profile"
|
|
143
|
+
|
|
144
|
+
def __init__(self, db):
|
|
145
|
+
self._db = db
|
|
146
|
+
|
|
147
|
+
def load(self, chat_id: str) -> PatientProfile:
|
|
148
|
+
"""Carga el perfil. Si no existe, crea uno vacío."""
|
|
149
|
+
try:
|
|
150
|
+
patient_row = self._db.get_or_create_patient(chat_id)
|
|
151
|
+
raw_meta = patient_row.get("metadata") or "{}"
|
|
152
|
+
if isinstance(raw_meta, str):
|
|
153
|
+
meta = json.loads(raw_meta)
|
|
154
|
+
else:
|
|
155
|
+
meta = raw_meta
|
|
156
|
+
|
|
157
|
+
profile_data = meta.get(self._PROFILE_KEY)
|
|
158
|
+
if profile_data:
|
|
159
|
+
profile = PatientProfile.from_dict(profile_data)
|
|
160
|
+
else:
|
|
161
|
+
# Primera vez — construir desde campos existentes
|
|
162
|
+
profile = PatientProfile(
|
|
163
|
+
chat_id=chat_id,
|
|
164
|
+
nombre=patient_row.get("name"),
|
|
165
|
+
visitas=patient_row.get("visits", 0),
|
|
166
|
+
idioma=patient_row.get("language", "es"),
|
|
167
|
+
ultima_interaccion=time.time(),
|
|
168
|
+
)
|
|
169
|
+
profile.chat_id = chat_id
|
|
170
|
+
return profile
|
|
171
|
+
except Exception as e:
|
|
172
|
+
log.warning("[profile] error cargando %s: %s", chat_id[:8], e)
|
|
173
|
+
return PatientProfile(chat_id=chat_id)
|
|
174
|
+
|
|
175
|
+
def save(self, profile: PatientProfile) -> None:
|
|
176
|
+
"""Persiste el perfil en la columna metadata del paciente."""
|
|
177
|
+
try:
|
|
178
|
+
patient_row = self._db.get_or_create_patient(profile.chat_id)
|
|
179
|
+
raw_meta = patient_row.get("metadata") or "{}"
|
|
180
|
+
if isinstance(raw_meta, str):
|
|
181
|
+
meta = json.loads(raw_meta)
|
|
182
|
+
else:
|
|
183
|
+
meta = dict(raw_meta)
|
|
184
|
+
|
|
185
|
+
profile.ultima_interaccion = time.time()
|
|
186
|
+
meta[self._PROFILE_KEY] = profile.to_dict()
|
|
187
|
+
|
|
188
|
+
with self._db._conn() as c:
|
|
189
|
+
c.execute(
|
|
190
|
+
"UPDATE patients SET metadata=?, name=? WHERE chat_id=?",
|
|
191
|
+
(json.dumps(meta), profile.nombre or "", profile.chat_id)
|
|
192
|
+
)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
log.error("[profile] error guardando %s: %s", profile.chat_id[:8], e)
|
|
195
|
+
|
|
196
|
+
def update_funnel(self, chat_id: str, new_stage: FunnelStage) -> None:
|
|
197
|
+
"""Atajo para avanzar el funnel sin cargar todo el perfil."""
|
|
198
|
+
profile = self.load(chat_id)
|
|
199
|
+
if profile.advance_funnel(new_stage):
|
|
200
|
+
self.save(profile)
|