@innvisor/conny-ai 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
package/conny_config.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
conny_config.py — All constants, templates and string literals from conny.py
|
|
4
|
+
Extracted for Phase 3 split.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Set, Tuple
|
|
12
|
+
|
|
13
|
+
_CONNY_HOME = os.getenv("CONNY_HOME", str(Path.home() / ".conny"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Config:
|
|
17
|
+
"""Configuración centralizada con validación."""
|
|
18
|
+
|
|
19
|
+
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "")
|
|
20
|
+
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
|
|
21
|
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
|
22
|
+
GEMINI_API_KEY_2 = os.getenv("GEMINI_API_KEY_2", "")
|
|
23
|
+
GEMINI_API_KEY_3 = os.getenv("GEMINI_API_KEY_3", "")
|
|
24
|
+
GEMINI_API_KEY_4 = os.getenv("GEMINI_API_KEY_4", "")
|
|
25
|
+
GEMINI_API_KEY_5 = os.getenv("GEMINI_API_KEY_5", "")
|
|
26
|
+
GEMINI_API_KEY_6 = os.getenv("GEMINI_API_KEY_6", "")
|
|
27
|
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
|
28
|
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
|
29
|
+
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
|
30
|
+
BRAVE_API_KEY = os.getenv("BRAVE_API_KEY", "")
|
|
31
|
+
APIFY_API_KEY = os.getenv("APIFY_API_KEY", "")
|
|
32
|
+
SERP_API_KEY = os.getenv("SERP_API_KEY", "")
|
|
33
|
+
CALENDLY_LINK = os.getenv("CALENDLY_LINK", "")
|
|
34
|
+
GCAL_ACCESS_TOKEN = os.getenv("GCAL_ACCESS_TOKEN", "")
|
|
35
|
+
GCAL_REFRESH_TOKEN = os.getenv("GCAL_REFRESH_TOKEN", "")
|
|
36
|
+
GCAL_CLIENT_ID = os.getenv("GCAL_CLIENT_ID", "")
|
|
37
|
+
GCAL_CLIENT_SECRET = os.getenv("GCAL_CLIENT_SECRET", "")
|
|
38
|
+
GCAL_CALENDAR_ID = os.getenv("GCAL_CALENDAR_ID", "primary")
|
|
39
|
+
META_APP_ID = os.getenv("META_APP_ID", "")
|
|
40
|
+
META_APP_SECRET = os.getenv("META_APP_SECRET", "")
|
|
41
|
+
NOVA_URL = os.getenv("NOVA_URL", "http://localhost:9003")
|
|
42
|
+
NOVA_TOKEN = os.getenv("NOVA_TOKEN", "")
|
|
43
|
+
NOVA_API_KEY = os.getenv("NOVA_API_KEY", "")
|
|
44
|
+
NOVA_ENABLED = os.getenv("NOVA_ENABLED", "false").lower() == "true"
|
|
45
|
+
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "conny_ultra_5")
|
|
46
|
+
BASE_URL = os.getenv("BASE_URL", "")
|
|
47
|
+
TELEGRAM_SHARED = os.getenv("TELEGRAM_SHARED", "false").lower() == "true"
|
|
48
|
+
TELEGRAM_SHARED_ROUTER = os.getenv("TELEGRAM_SHARED_ROUTER", "false").lower() == "true"
|
|
49
|
+
TELEGRAM_SHARED_SECRET = os.getenv("TELEGRAM_SHARED_SECRET", "conny_shared_telegram")
|
|
50
|
+
TELEGRAM_DEFAULT_INSTANCE = os.getenv("TELEGRAM_DEFAULT_INSTANCE", "").strip()
|
|
51
|
+
TELEGRAM_SHARED_ROUTES_PATH = os.getenv(
|
|
52
|
+
"TELEGRAM_SHARED_ROUTES_PATH",
|
|
53
|
+
str(Path(_CONNY_HOME) / "shared_telegram_routes.json"),
|
|
54
|
+
)
|
|
55
|
+
TELEGRAM_SHARED_INSTANCES_DIR = os.getenv(
|
|
56
|
+
"TELEGRAM_SHARED_INSTANCES_DIR",
|
|
57
|
+
str(Path(_CONNY_HOME) / "instances"),
|
|
58
|
+
)
|
|
59
|
+
PLATFORM = os.getenv("PLATFORM", "telegram")
|
|
60
|
+
WHATSAPP_BRIDGE_URL = os.getenv("WHATSAPP_BRIDGE_URL", "http://localhost:3000")
|
|
61
|
+
SECTOR = os.getenv("SECTOR", "otro")
|
|
62
|
+
WA_PHONE_ID = os.getenv("WA_PHONE_ID", "")
|
|
63
|
+
WA_ACCESS_TOKEN = os.getenv("WA_ACCESS_TOKEN", "")
|
|
64
|
+
WA_VERIFY_TOKEN = os.getenv("WA_VERIFY_TOKEN", "")
|
|
65
|
+
EVOLUTION_URL = os.getenv("EVOLUTION_URL", "")
|
|
66
|
+
EVOLUTION_API_KEY = os.getenv("EVOLUTION_API_KEY", "")
|
|
67
|
+
EVOLUTION_INSTANCE = os.getenv("EVOLUTION_INSTANCE", "conny")
|
|
68
|
+
MASTER_API_KEY = os.getenv("MASTER_API_KEY", "")
|
|
69
|
+
N8N_WEBHOOK_URL = os.getenv("N8N_WEBHOOK_URL", "")
|
|
70
|
+
TOKEN_EXPIRY_HOURS = int(os.getenv("TOKEN_EXPIRY_HOURS", "72"))
|
|
71
|
+
DEMO_MODE = os.getenv("DEMO_MODE", "false").lower() == "true"
|
|
72
|
+
DEMO_BUSINESS_NAME = os.getenv("DEMO_BUSINESS_NAME", "tu negocio")
|
|
73
|
+
DEMO_SECTOR = os.getenv("DEMO_SECTOR", "estetica")
|
|
74
|
+
DEMO_SESSION_TTL = int(os.getenv("DEMO_SESSION_TTL", "1800"))
|
|
75
|
+
GREETING_ONLY_IDLE_SECONDS = int(os.getenv("GREETING_ONLY_IDLE_SECONDS", "300"))
|
|
76
|
+
V8_ACTIVE_MODEL_REASONING = os.getenv("V8_ACTIVE_MODEL_REASONING", "")
|
|
77
|
+
V8_ACTIVE_MODEL_FAST = os.getenv("V8_ACTIVE_MODEL_FAST", "")
|
|
78
|
+
V8_ACTIVE_MODEL_LITE = os.getenv("V8_ACTIVE_MODEL_LITE", "")
|
|
79
|
+
V8_QUALITY_THRESHOLD = float(os.getenv("V8_QUALITY_THRESHOLD", "0.72"))
|
|
80
|
+
V8_MAX_RETRIES = int(os.getenv("V8_MAX_RETRIES", "3"))
|
|
81
|
+
CONNY_COMPACT_PROMPT = os.getenv("CONNY_COMPACT_PROMPT", "true").lower() in ("1", "true", "yes", "on")
|
|
82
|
+
CONNY_CONTEXT_RECENT_MESSAGES = int(os.getenv("CONNY_CONTEXT_RECENT_MESSAGES", "12"))
|
|
83
|
+
CONNY_CORE_ENABLED = os.getenv("CONNY_CORE_ENABLED", "true").lower() in ("1", "true", "yes", "on")
|
|
84
|
+
CONNY_CORE_PERSONAS_DIR = os.getenv(
|
|
85
|
+
"CONNY_CORE_PERSONAS_DIR",
|
|
86
|
+
str(Path(__file__).resolve().parent / "personas" / "conny" / "base"),
|
|
87
|
+
)
|
|
88
|
+
V8_FILTER_LEVEL = int(os.getenv("V8_FILTER_LEVEL", "2"))
|
|
89
|
+
DB_PATH = os.getenv("DB_PATH", "/home/ubuntu/conny/conny_ultra.db")
|
|
90
|
+
VECTOR_DB_PATH = os.getenv("VECTOR_DB_PATH", "/home/ubuntu/conny/vectors.db")
|
|
91
|
+
LLM_MODELS = {
|
|
92
|
+
"reasoning": os.getenv("LLM_REASONING", "google/gemini-2.5-pro"),
|
|
93
|
+
"fast": os.getenv("LLM_FAST", "google/gemini-2.5-flash"),
|
|
94
|
+
"lite": os.getenv("LLM_LITE", "google/gemini-2.5-flash-lite"),
|
|
95
|
+
"embedding": os.getenv("LLM_EMBEDDING", "openai/text-embedding-3-small"),
|
|
96
|
+
}
|
|
97
|
+
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "openai/whisper-large-v3")
|
|
98
|
+
BUFFER_WAIT_MIN = int(os.getenv("BUFFER_WAIT_MIN", "25"))
|
|
99
|
+
BUFFER_WAIT_MAX = int(os.getenv("BUFFER_WAIT_MAX", "45"))
|
|
100
|
+
BUBBLE_PAUSE_MIN = float(os.getenv("BUBBLE_PAUSE_MIN", "1.2"))
|
|
101
|
+
BUBBLE_PAUSE_MAX = float(os.getenv("BUBBLE_PAUSE_MAX", "3.0"))
|
|
102
|
+
BRAND_ASSETS_BASE_DIR = os.getenv(
|
|
103
|
+
"BRAND_ASSETS_BASE_DIR",
|
|
104
|
+
"/home/ubuntu/conny/brand-assets",
|
|
105
|
+
)
|
|
106
|
+
SELF_IMPROVE_INTERVAL = int(os.getenv("SELF_IMPROVE_INTERVAL", "3600"))
|
|
107
|
+
LEARNING_RATE = float(os.getenv("LEARNING_RATE", "0.1"))
|
|
108
|
+
MAX_CONTEXT_MESSAGES = int(os.getenv("MAX_CONTEXT", "50"))
|
|
109
|
+
MAX_MEMORY_ITEMS = int(os.getenv("MAX_MEMORY", "1000"))
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def validate(cls) -> List[str]:
|
|
113
|
+
errors = []
|
|
114
|
+
if not cls.TELEGRAM_TOKEN:
|
|
115
|
+
errors.append("TELEGRAM_TOKEN requerido")
|
|
116
|
+
if not cls.OPENROUTER_API_KEY and not cls.GEMINI_API_KEY:
|
|
117
|
+
errors.append("Se requiere al menos OPENROUTER_API_KEY o GEMINI_API_KEY")
|
|
118
|
+
return errors
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
DEMO_COMMANDS: Dict[str, str] = {
|
|
122
|
+
"/formal":"/formal", "/amigable":"/amigable", "/luxury":"/luxury",
|
|
123
|
+
"/directa":"/directa", "/energica":"/energica", "/empatica":"/empatica",
|
|
124
|
+
"/experta":"/experta", "/juvenil":"/juvenil",
|
|
125
|
+
"/objecion":"/objecion", "/cita":"/cita", "/stats":"/stats",
|
|
126
|
+
"/prueba":"/prueba", "/cierre":"/cierre", "/bot":"/bot",
|
|
127
|
+
"/memoria":"/memoria", "/2am":"/2am", "/competencia":"/competencia",
|
|
128
|
+
"/precio":"/precio", "/siguiente":"/siguiente",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
DEMO_TRICKS_ORDER: List[Tuple[str, str]] = [
|
|
132
|
+
("/objecion", "ver cómo manejo objeciones en vivo"),
|
|
133
|
+
("/cita", "ver cómo agendo una cita completa"),
|
|
134
|
+
("/luxury", "activar personalidad premium"),
|
|
135
|
+
("/empatica", "cambiar a modo empático y de escucha"),
|
|
136
|
+
("/stats", "ver el impacto en números reales"),
|
|
137
|
+
("/prueba", "lanzarme el mensaje más difícil que tengas"),
|
|
138
|
+
("/cierre", "ver cómo cierro una venta"),
|
|
139
|
+
("/directa", "activar modo al grano sin rodeos"),
|
|
140
|
+
("/menu", "ver modo bot con emojis y menú numerado"),
|
|
141
|
+
("/2am", "verme responder a las 2 de la madrugada"),
|
|
142
|
+
("/memoria", "ver qué recuerdo de esta conversación"),
|
|
143
|
+
("/experta", "cambiar a modo técnico y preciso"),
|
|
144
|
+
("/modelo", "cambiar el modelo de IA que me impulsa"),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
DEMO_CMD_ALIASES: Dict[str, str] = {
|
|
148
|
+
"formal":"formal","amigable":"amigable","luxury":"luxury","lujo":"luxury",
|
|
149
|
+
"directa":"directa","energica":"energica","enérgica":"energica",
|
|
150
|
+
"empatica":"empatica","empática":"empatica","experta":"experta",
|
|
151
|
+
"juvenil":"juvenil","joven":"juvenil","profesional":"formal","objecion":"objecion","objeción":"objecion",
|
|
152
|
+
"cita":"cita","agendar":"cita","stats":"stats","estadisticas":"stats",
|
|
153
|
+
"prueba":"prueba","reto":"prueba","cierre":"cierre","bot":"bot",
|
|
154
|
+
"memoria":"memoria","recuerdas":"memoria","2am":"2am","de noche":"2am",
|
|
155
|
+
"competencia":"competencia","precio":"precio","caro":"precio",
|
|
156
|
+
"siguiente":"siguiente","que mas":"siguiente","qué más":"siguiente",
|
|
157
|
+
"menu":"menu_bot","menú":"menu_bot","modo bot":"menu_bot","bot menu":"menu_bot",
|
|
158
|
+
"list":"list","lista":"list","comandos":"list","ayuda":"list","help":"list",
|
|
159
|
+
"qué puedes hacer":"list","que puedes hacer":"list",
|
|
160
|
+
"emojis":"emojis_on","con emojis":"emojis_on","activa emojis":"emojis_on",
|
|
161
|
+
"sin emojis":"emojis_off","quita emojis":"emojis_off","desactiva emojis":"emojis_off",
|
|
162
|
+
"modelo":"modelo","model":"modelo","cambiar modelo":"modelo",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
MODEL_CATALOG: Dict[str, Tuple[str, str, str]] = {
|
|
166
|
+
"claude-opus": ("anthropic/claude-opus-4", "reasoning", "Más inteligente. Más caro."),
|
|
167
|
+
"claude-sonnet": ("anthropic/claude-sonnet-4", "reasoning", "Balance inteligencia/costo."),
|
|
168
|
+
"claude-haiku": ("anthropic/claude-haiku-3-5", "fast", "Rapidísimo y económico."),
|
|
169
|
+
"gemini-pro": ("google/gemini-2.5-pro", "reasoning", "Google Pro."),
|
|
170
|
+
"gemini-flash": ("google/gemini-2.5-flash", "fast", "Velocidad + calidad."),
|
|
171
|
+
"gemini-lite": ("google/gemini-2.5-flash-lite", "lite", "El más económico."),
|
|
172
|
+
"llama-70b": ("meta-llama/llama-3.3-70b-instruct","fast", "Open source, excelente español."),
|
|
173
|
+
"llama-8b": ("meta-llama/llama-3.1-8b-instruct", "lite", "Ultrarrápido, básico."),
|
|
174
|
+
"gpt4o": ("openai/gpt-4o", "reasoning", "OpenAI flagship."),
|
|
175
|
+
"gpt4o-mini": ("openai/gpt-4o-mini", "fast", "OpenAI económico."),
|
|
176
|
+
"mistral-large": ("mistralai/mistral-large", "reasoning", "Europeo, buen español."),
|
|
177
|
+
"mistral-small": ("mistralai/mistral-small", "fast", "Rápido y asequible."),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
INTERNAL_PHRASES_TO_BLOCK: List[str] = [
|
|
181
|
+
"todavía no tengo este chat enlazado",
|
|
182
|
+
"ya recibí tu mensaje",
|
|
183
|
+
"no tengo este chat",
|
|
184
|
+
"[error",
|
|
185
|
+
"[internal",
|
|
186
|
+
"{",
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
AUDIO_ERROR_MSG = "Recibí tu audio pero no lo pude procesar. ¿Puedes escribirlo?"
|
|
190
|
+
JSON_ERROR_MSG = "Entendido, déjame verificar eso."
|
|
191
|
+
UNKNOWN_CMD_MSG = "Comando no reconocido. Escribe /help para ver los disponibles."
|
|
192
|
+
|
|
193
|
+
DEMO_HELP_FULL = (
|
|
194
|
+
"esto es lo que puedo mostrarte 👇\n\n"
|
|
195
|
+
"🎭 Personalidades: /formal · /amigable · /luxury · /directa · /empatica · /experta · /juvenil\n\n"
|
|
196
|
+
"💬 Situaciones reales:\n"
|
|
197
|
+
"objecion — cliente difícil\n"
|
|
198
|
+
"cita — agendamiento completo\n"
|
|
199
|
+
"cierre — técnica de cierre\n"
|
|
200
|
+
"competencia — ya fui a otro lado\n"
|
|
201
|
+
"precio — está muy caro\n"
|
|
202
|
+
"prueba — mándame el más difícil\n"
|
|
203
|
+
"bot — soy un bot?\n"
|
|
204
|
+
"2am — respuesta a las 2am\n\n"
|
|
205
|
+
"📊 Demo y datos:\n"
|
|
206
|
+
"stats — impacto en números\n"
|
|
207
|
+
"memoria — qué recuerdo de ti\n"
|
|
208
|
+
"menu — modo bot con emojis\n\n"
|
|
209
|
+
"⚙️ Ajustes:\n"
|
|
210
|
+
"usa emojis / sin emojis\n"
|
|
211
|
+
"siguiente — próximo truco\n"
|
|
212
|
+
"/modelo — cambiar el modelo\n"
|
|
213
|
+
"reset — empezar de nuevo\n\n"
|
|
214
|
+
"escribe sin slash para activar"
|
|
215
|
+
)
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .persona_registry import PersonaProfile, PersonaRegistry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ConversationTurnResult:
|
|
12
|
+
handled: bool
|
|
13
|
+
bubbles: List[str]
|
|
14
|
+
reason: str = ""
|
|
15
|
+
persona_key: str = "default"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConversationEngine:
|
|
19
|
+
def __init__(self, registry: PersonaRegistry):
|
|
20
|
+
self.registry = registry
|
|
21
|
+
|
|
22
|
+
def handle(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
clinic: Dict[str, Any],
|
|
26
|
+
user_msg: str,
|
|
27
|
+
history: Optional[List[Dict[str, Any]]] = None,
|
|
28
|
+
is_admin: bool = False,
|
|
29
|
+
channel: str = "",
|
|
30
|
+
) -> ConversationTurnResult:
|
|
31
|
+
history = history or []
|
|
32
|
+
persona = self.registry.resolve_for_clinic(clinic)
|
|
33
|
+
first_turn = not any(msg.get("role") == "assistant" for msg in history)
|
|
34
|
+
normalized = self._normalize(user_msg)
|
|
35
|
+
|
|
36
|
+
if is_admin:
|
|
37
|
+
return ConversationTurnResult(False, [], reason="admin_route", persona_key=persona.key)
|
|
38
|
+
|
|
39
|
+
if self._is_identity_probe(normalized):
|
|
40
|
+
return ConversationTurnResult(
|
|
41
|
+
True,
|
|
42
|
+
self._build_identity_probe(persona, clinic, normalized, history, first_turn=first_turn),
|
|
43
|
+
reason="identity_probe",
|
|
44
|
+
persona_key=persona.key,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if self._is_meta_followup_probe(normalized):
|
|
48
|
+
return ConversationTurnResult(
|
|
49
|
+
True,
|
|
50
|
+
self._build_meta_followup(persona, clinic, normalized),
|
|
51
|
+
reason="meta_followup",
|
|
52
|
+
persona_key=persona.key,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if self._is_greeting_only(normalized):
|
|
56
|
+
return ConversationTurnResult(False, [], reason="llm_greeting", persona_key=persona.key)
|
|
57
|
+
|
|
58
|
+
if first_turn and self._looks_like_contextual_first_turn(persona, clinic, normalized):
|
|
59
|
+
return ConversationTurnResult(
|
|
60
|
+
True,
|
|
61
|
+
self._build_first_contextual_followup(persona, clinic, normalized),
|
|
62
|
+
reason="first_turn_contextual",
|
|
63
|
+
persona_key=persona.key,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return ConversationTurnResult(False, [], reason="not_handled", persona_key=persona.key)
|
|
67
|
+
|
|
68
|
+
def _normalize(self, text: str) -> str:
|
|
69
|
+
return (text or "").strip().lower()
|
|
70
|
+
|
|
71
|
+
def _is_greeting_only(self, normalized: str) -> bool:
|
|
72
|
+
cleaned = normalized.replace("0", "o")
|
|
73
|
+
cleaned = cleaned.replace("!", "").replace("?", "").replace("¡", "").replace("¿", "").strip()
|
|
74
|
+
cleaned = cleaned.replace(",", " ").replace(".", " ")
|
|
75
|
+
cleaned = " ".join(cleaned.split())
|
|
76
|
+
return cleaned in {
|
|
77
|
+
"hola",
|
|
78
|
+
"hola que tal",
|
|
79
|
+
"hola como vas",
|
|
80
|
+
"hola como estas",
|
|
81
|
+
"hola que mas",
|
|
82
|
+
"hola buenas",
|
|
83
|
+
"buenas",
|
|
84
|
+
"buenas tardes",
|
|
85
|
+
"buenos dias",
|
|
86
|
+
"buenos días",
|
|
87
|
+
"buenas noches",
|
|
88
|
+
"hey",
|
|
89
|
+
"holi",
|
|
90
|
+
"como vas",
|
|
91
|
+
"como estas",
|
|
92
|
+
"todo bien",
|
|
93
|
+
"que mas",
|
|
94
|
+
"qué más",
|
|
95
|
+
"que tal",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def _is_identity_probe(self, normalized: str) -> bool:
|
|
99
|
+
probes = (
|
|
100
|
+
"que eres",
|
|
101
|
+
"qué eres",
|
|
102
|
+
"quien eres",
|
|
103
|
+
"quién eres",
|
|
104
|
+
"eres una ia",
|
|
105
|
+
"eres ia",
|
|
106
|
+
"eres un bot",
|
|
107
|
+
"eres bot",
|
|
108
|
+
"como funcionas",
|
|
109
|
+
"cómo funcionas",
|
|
110
|
+
"que haces",
|
|
111
|
+
"qué haces",
|
|
112
|
+
"quiero probarte",
|
|
113
|
+
"me gustaria probarte",
|
|
114
|
+
"me gustaría probarte",
|
|
115
|
+
"tengo un negocio",
|
|
116
|
+
"tengo una empresa",
|
|
117
|
+
"quiero una demo",
|
|
118
|
+
"quiero demo",
|
|
119
|
+
"soy curioso",
|
|
120
|
+
"quiero saber quien eres",
|
|
121
|
+
"quiero saber quién eres",
|
|
122
|
+
)
|
|
123
|
+
return any(marker in normalized for marker in probes)
|
|
124
|
+
|
|
125
|
+
def _is_meta_followup_probe(self, normalized: str) -> bool:
|
|
126
|
+
probes = (
|
|
127
|
+
"como trabajas aqui",
|
|
128
|
+
"cómo trabajas aquí",
|
|
129
|
+
"como trabajas por aqui",
|
|
130
|
+
"cómo trabajas por aquí",
|
|
131
|
+
"lo llevas tu sola",
|
|
132
|
+
"lo llevas tú sola",
|
|
133
|
+
"atiendes como secretaria",
|
|
134
|
+
"atiendes como asesora",
|
|
135
|
+
"si te pregunto por un procedimiento",
|
|
136
|
+
"si te pregunto por precio",
|
|
137
|
+
"quiero entender si recuerdas",
|
|
138
|
+
"recuerdas lo que te digo",
|
|
139
|
+
"como recuerdas",
|
|
140
|
+
"cómo recuerdas",
|
|
141
|
+
)
|
|
142
|
+
return any(marker in normalized for marker in probes)
|
|
143
|
+
|
|
144
|
+
def _looks_like_first_contact_request(self, normalized: str) -> bool:
|
|
145
|
+
signals = (
|
|
146
|
+
"hola",
|
|
147
|
+
"buenas",
|
|
148
|
+
"quiero probarte",
|
|
149
|
+
"me gustaria probarte",
|
|
150
|
+
"me gustaría probarte",
|
|
151
|
+
"tengo un negocio",
|
|
152
|
+
"tengo una empresa",
|
|
153
|
+
)
|
|
154
|
+
return any(sig in normalized for sig in signals)
|
|
155
|
+
|
|
156
|
+
def _looks_like_contextual_first_turn(
|
|
157
|
+
self,
|
|
158
|
+
persona: PersonaProfile,
|
|
159
|
+
clinic: Dict[str, Any],
|
|
160
|
+
normalized: str,
|
|
161
|
+
) -> bool:
|
|
162
|
+
if self._looks_like_first_contact_request(normalized):
|
|
163
|
+
return True
|
|
164
|
+
if self._extract_topic(persona, clinic, normalized):
|
|
165
|
+
return True
|
|
166
|
+
return any(token in normalized for token in ("precio", "cuanto", "cuánto", "horario", "agenda", "cita", "disponibilidad"))
|
|
167
|
+
|
|
168
|
+
def _build_first_turn(self, persona: PersonaProfile, clinic: Dict[str, Any]) -> List[str]:
|
|
169
|
+
clinic_name = str(clinic.get("name") or "").strip()
|
|
170
|
+
intro_template = self._choose(
|
|
171
|
+
persona.first_turn_variants
|
|
172
|
+
or [f"Hola, soy {persona.identity}, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}"],
|
|
173
|
+
clinic_name or persona.identity,
|
|
174
|
+
)
|
|
175
|
+
intro = self._render_persona_line(intro_template, persona, clinic_name)
|
|
176
|
+
|
|
177
|
+
if persona.capabilities:
|
|
178
|
+
if clinic.get("sector") == "estetica":
|
|
179
|
+
capabilities = (
|
|
180
|
+
f"Te ayudo con {', '.join(persona.capabilities[:3])}. "
|
|
181
|
+
"Si quieres, cuéntame qué te interesa o qué tratamiento estás mirando."
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
capabilities = (
|
|
185
|
+
f"Te ayudo con {', '.join(persona.capabilities[:3])}. "
|
|
186
|
+
"Cuéntame qué te gustaría revisar."
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
capabilities = "Te ayudo con información, valoración y disponibilidad"
|
|
190
|
+
|
|
191
|
+
return [intro, capabilities]
|
|
192
|
+
|
|
193
|
+
def _build_returning_greeting(
|
|
194
|
+
self,
|
|
195
|
+
persona: PersonaProfile,
|
|
196
|
+
clinic: Dict[str, Any],
|
|
197
|
+
history: Optional[List[Dict[str, Any]]],
|
|
198
|
+
normalized: str,
|
|
199
|
+
) -> List[str]:
|
|
200
|
+
history = history or []
|
|
201
|
+
gap_hours = self._hours_since_last_turn(history)
|
|
202
|
+
recent_topic = self._extract_recent_topic_from_history(persona, clinic, history)
|
|
203
|
+
|
|
204
|
+
if any(marker in normalized for marker in ("como estas", "cómo estás", "como vas", "cómo vas", "que tal", "qué tal")):
|
|
205
|
+
if gap_hours is not None and gap_hours >= 24:
|
|
206
|
+
intro = "Hola. Todo bien por acá."
|
|
207
|
+
else:
|
|
208
|
+
intro = "Hola. Todo bien por acá, gracias por preguntar."
|
|
209
|
+
else:
|
|
210
|
+
intro = "Hola."
|
|
211
|
+
|
|
212
|
+
if recent_topic:
|
|
213
|
+
followup = f"Si sigues con lo de {recent_topic}, dime y lo vemos."
|
|
214
|
+
else:
|
|
215
|
+
followup = "Cuéntame qué quieres revisar y lo vemos."
|
|
216
|
+
|
|
217
|
+
return [intro, followup]
|
|
218
|
+
|
|
219
|
+
def _build_identity_probe(
|
|
220
|
+
self,
|
|
221
|
+
persona: PersonaProfile,
|
|
222
|
+
clinic: Dict[str, Any],
|
|
223
|
+
normalized: str,
|
|
224
|
+
history: Optional[List[Dict[str, Any]]] = None,
|
|
225
|
+
first_turn: bool = True,
|
|
226
|
+
) -> List[str]:
|
|
227
|
+
clinic_name = str(clinic.get("name") or "").strip()
|
|
228
|
+
history = history or []
|
|
229
|
+
if not first_turn and any(marker in normalized for marker in (
|
|
230
|
+
"eres una ia",
|
|
231
|
+
"eres ia",
|
|
232
|
+
"eres una persona",
|
|
233
|
+
"persona real",
|
|
234
|
+
"eres un bot",
|
|
235
|
+
"eres bot",
|
|
236
|
+
)):
|
|
237
|
+
return [
|
|
238
|
+
f"Soy Conny, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}",
|
|
239
|
+
"Soy una IA hecha para orientar, responder y ayudarte a avanzar sin sonar acartonada",
|
|
240
|
+
]
|
|
241
|
+
prior_identity = any(
|
|
242
|
+
any(marker in self._normalize(str(msg.get("content") or "")) for marker in (
|
|
243
|
+
"recepcionista virtual",
|
|
244
|
+
"asesora virtual",
|
|
245
|
+
"soy conny",
|
|
246
|
+
"trabaja por tu negocio",
|
|
247
|
+
"disponible por aqui",
|
|
248
|
+
"disponible por aquí",
|
|
249
|
+
))
|
|
250
|
+
for msg in history
|
|
251
|
+
if msg.get("role") == "assistant"
|
|
252
|
+
)
|
|
253
|
+
if prior_identity:
|
|
254
|
+
return [
|
|
255
|
+
f"Sigo siendo Conny, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}",
|
|
256
|
+
"Sigo siendo una IA hecha para orientarte bien, sostener la conversación y ayudarte sin respuestas de plantilla",
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
intro = self._choose(
|
|
260
|
+
persona.identity_probe_variants or [
|
|
261
|
+
f"Soy {persona.identity}, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}. También soy la IA que sostiene este chat para orientarte bien",
|
|
262
|
+
f"Soy {persona.identity}, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}. Soy una IA hecha para resolver dudas y ayudarte a avanzar sin enredos",
|
|
263
|
+
f"Soy {persona.identity}, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}. Soy una IA pensada para que este chat se sienta claro, útil y bien llevado",
|
|
264
|
+
],
|
|
265
|
+
normalized,
|
|
266
|
+
)
|
|
267
|
+
intro = self._render_persona_line(intro, persona, clinic_name)
|
|
268
|
+
if persona.capabilities:
|
|
269
|
+
capabilities = (
|
|
270
|
+
"Te puedo ayudar con "
|
|
271
|
+
+ ", ".join(persona.capabilities[:3])
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
capabilities = "Te puedo ayudar con información, horarios, disponibilidad, valoración y orientación inicial"
|
|
275
|
+
|
|
276
|
+
if clinic_name:
|
|
277
|
+
cta = "Si quieres, cuéntame qué te gustaría revisar y lo vemos desde ahí"
|
|
278
|
+
else:
|
|
279
|
+
cta = "Cuéntame qué te gustaría revisar y te ubico"
|
|
280
|
+
return [intro, capabilities, cta]
|
|
281
|
+
|
|
282
|
+
def _render_persona_line(self, template: str, persona: PersonaProfile, clinic_name: str) -> str:
|
|
283
|
+
raw = str(template or "").strip()
|
|
284
|
+
if not raw:
|
|
285
|
+
return f"Soy {persona.identity}, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}"
|
|
286
|
+
clinic_label = clinic_name.strip() if clinic_name else ""
|
|
287
|
+
if "{clinic_name}" in raw and not clinic_label:
|
|
288
|
+
clinic_label = "la clínica"
|
|
289
|
+
try:
|
|
290
|
+
rendered = raw.format(clinic_name=clinic_label, identity=persona.identity).strip()
|
|
291
|
+
except Exception:
|
|
292
|
+
rendered = raw
|
|
293
|
+
rendered = rendered.replace("de .", "de la clínica")
|
|
294
|
+
rendered = " ".join(rendered.split())
|
|
295
|
+
return rendered
|
|
296
|
+
|
|
297
|
+
def _build_meta_followup(
|
|
298
|
+
self,
|
|
299
|
+
persona: PersonaProfile,
|
|
300
|
+
clinic: Dict[str, Any],
|
|
301
|
+
normalized: str,
|
|
302
|
+
) -> List[str]:
|
|
303
|
+
if any(marker in normalized for marker in (
|
|
304
|
+
"lo llevas tu sola",
|
|
305
|
+
"lo llevas tú sola",
|
|
306
|
+
)):
|
|
307
|
+
return [
|
|
308
|
+
"Yo sostengo este canal y el hilo de la conversación, pero no me pongo a improvisar donde toca confirmación real.",
|
|
309
|
+
"Si algo depende de una valoración o de validar un dato del negocio, te lo digo directo y lo aterrizo sin humo.",
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
if any(marker in normalized for marker in (
|
|
313
|
+
"como trabajas aqui",
|
|
314
|
+
"cómo trabajas aquí",
|
|
315
|
+
"como trabajas por aqui",
|
|
316
|
+
"cómo trabajas por aquí",
|
|
317
|
+
)):
|
|
318
|
+
return [
|
|
319
|
+
"Trabajo llevando la conversación, entendiendo qué necesitas y guiándote hacia lo útil, no soltando respuestas al azar.",
|
|
320
|
+
"Y si algo toca confirmarlo con el negocio, te lo digo claro en vez de inventártelo.",
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
if any(marker in normalized for marker in ("atiendes como secretaria", "atiendes como asesora")):
|
|
324
|
+
return [
|
|
325
|
+
"Un poco de las dos, pero bien hecho: recibo, oriento y también ayudo a mover la conversación hacia una decisión o una cita.",
|
|
326
|
+
"La idea es que se sienta como alguien del equipo, no como un formulario con patas.",
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
if any(marker in normalized for marker in ("si te pregunto por un procedimiento", "si te pregunto por precio")):
|
|
330
|
+
return [
|
|
331
|
+
"Te respondo lo que sí pueda orientarte con claridad y te aterrizo el siguiente paso útil.",
|
|
332
|
+
"Si algo depende de valoración o de confirmación del negocio, te lo digo así, sin humo ni datos inventados.",
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
if any(marker in normalized for marker in (
|
|
336
|
+
"quiero entender si recuerdas",
|
|
337
|
+
"recuerdas lo que te digo",
|
|
338
|
+
"como recuerdas",
|
|
339
|
+
"cómo recuerdas",
|
|
340
|
+
)):
|
|
341
|
+
return [
|
|
342
|
+
"Sí, la idea es ir guardando lo importante de la conversación para no hacerte repetir todo.",
|
|
343
|
+
"Y si algo no me queda claro, prefiero confirmártelo a fingir que me acuerdo de algo que no tengo bien amarrado.",
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
clinic_name = str(clinic.get("name") or "").strip()
|
|
347
|
+
return [
|
|
348
|
+
f"Soy {persona.identity}, la asesora virtual{f' de {clinic_name}' if clinic_name else ''}",
|
|
349
|
+
"Soy una IA hecha para llevar este canal con contexto, seguimiento y criterio para que la conversación avance bien",
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
def _build_first_contextual_followup(
|
|
353
|
+
self,
|
|
354
|
+
persona: PersonaProfile,
|
|
355
|
+
clinic: Dict[str, Any],
|
|
356
|
+
normalized: str,
|
|
357
|
+
) -> List[str]:
|
|
358
|
+
intro = self._build_first_turn(persona, clinic)[0]
|
|
359
|
+
topic = self._extract_topic(persona, clinic, normalized)
|
|
360
|
+
if topic:
|
|
361
|
+
if persona.contextual_followups:
|
|
362
|
+
for key, value in persona.contextual_followups.items():
|
|
363
|
+
if self._normalize(str(key)) == self._normalize(topic):
|
|
364
|
+
return [intro, str(value).strip()]
|
|
365
|
+
sector = clinic.get("sector")
|
|
366
|
+
if sector == "estetica":
|
|
367
|
+
return [intro, f"{topic} lo manejan acá. Si quieres, te cuento cómo lo trabajan y qué suelen revisar para que se vea natural."]
|
|
368
|
+
return [intro, f"{topic} lo manejan acá. Si quieres, te cuento cómo lo trabajan o revisamos valoración y disponibilidad."]
|
|
369
|
+
followup = "Cuéntame qué estás buscando y te ubico rápido."
|
|
370
|
+
if clinic.get("sector") == "estetica":
|
|
371
|
+
followup = "Cuéntame qué te gustaría mejorar o qué tratamiento estás mirando, y te ubico rápido."
|
|
372
|
+
return [intro, followup]
|
|
373
|
+
|
|
374
|
+
def _extract_topic(self, persona: PersonaProfile, clinic: Dict[str, Any], normalized: str) -> str:
|
|
375
|
+
overrides = persona.contextual_followups or {}
|
|
376
|
+
for key in overrides.keys():
|
|
377
|
+
key_norm = self._normalize(str(key))
|
|
378
|
+
if key_norm and key_norm in normalized:
|
|
379
|
+
return str(key).strip()
|
|
380
|
+
|
|
381
|
+
services = clinic.get("services") if isinstance(clinic.get("services"), list) else []
|
|
382
|
+
for service in services:
|
|
383
|
+
service_text = str(service).strip()
|
|
384
|
+
if not service_text:
|
|
385
|
+
continue
|
|
386
|
+
if self._normalize(service_text) in normalized:
|
|
387
|
+
return service_text
|
|
388
|
+
|
|
389
|
+
topics = {
|
|
390
|
+
"botox": "Botox",
|
|
391
|
+
"relleno": "Rellenos",
|
|
392
|
+
"rellenos": "Rellenos",
|
|
393
|
+
"laser": "Láser",
|
|
394
|
+
"láser": "Láser",
|
|
395
|
+
"peeling": "Peeling",
|
|
396
|
+
"mesoterapia": "Mesoterapia",
|
|
397
|
+
"precio": "Precio",
|
|
398
|
+
"horario": "Horario",
|
|
399
|
+
"agenda": "Cita",
|
|
400
|
+
"cita": "Cita",
|
|
401
|
+
"disponibilidad": "Disponibilidad",
|
|
402
|
+
}
|
|
403
|
+
for marker, label in topics.items():
|
|
404
|
+
if marker in normalized:
|
|
405
|
+
return label
|
|
406
|
+
return ""
|
|
407
|
+
|
|
408
|
+
def _extract_recent_topic_from_history(
|
|
409
|
+
self,
|
|
410
|
+
persona: PersonaProfile,
|
|
411
|
+
clinic: Dict[str, Any],
|
|
412
|
+
history: Optional[List[Dict[str, Any]]],
|
|
413
|
+
) -> str:
|
|
414
|
+
history = history or []
|
|
415
|
+
generic_fallback = ""
|
|
416
|
+
generic_labels = {"Precio", "Horario", "Cita", "Disponibilidad"}
|
|
417
|
+
for msg in reversed(history):
|
|
418
|
+
if msg.get("role") != "user":
|
|
419
|
+
continue
|
|
420
|
+
topic = self._extract_topic(persona, clinic, self._normalize(str(msg.get("content") or "")))
|
|
421
|
+
if not topic:
|
|
422
|
+
continue
|
|
423
|
+
if topic not in generic_labels:
|
|
424
|
+
return topic
|
|
425
|
+
if not generic_fallback:
|
|
426
|
+
generic_fallback = topic
|
|
427
|
+
return generic_fallback
|
|
428
|
+
|
|
429
|
+
def _hours_since_last_turn(self, history: Optional[List[Dict[str, Any]]]) -> Optional[float]:
|
|
430
|
+
history = history or []
|
|
431
|
+
for msg in reversed(history):
|
|
432
|
+
raw_ts = str(msg.get("ts") or "").strip()
|
|
433
|
+
if not raw_ts:
|
|
434
|
+
continue
|
|
435
|
+
try:
|
|
436
|
+
then = datetime.fromisoformat(raw_ts)
|
|
437
|
+
except ValueError:
|
|
438
|
+
continue
|
|
439
|
+
return max(0.0, (datetime.now() - then).total_seconds() / 3600.0)
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
def _choose(self, variants: List[str], normalized: str) -> str:
|
|
443
|
+
if not variants:
|
|
444
|
+
return ""
|
|
445
|
+
index = sum(ord(ch) for ch in normalized) % len(variants)
|
|
446
|
+
return variants[index]
|