@innvisor/conny-ai 9.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. package/verify_conversation_impl.py +48 -0
@@ -0,0 +1,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)