@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-chat.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
conny-chat — Interfaz de lenguaje natural para administrar Conny.
|
|
4
|
+
|
|
5
|
+
Uso:
|
|
6
|
+
python3 conny-chat.py → conecta a instancia base (puerto 8001)
|
|
7
|
+
python3 conny-chat.py clinica-bella → conecta a instancia específica
|
|
8
|
+
python3 conny-chat.py --port 8005 → puerto específico
|
|
9
|
+
|
|
10
|
+
Ejemplo de conversación:
|
|
11
|
+
Tu: "Conny tenemos nuevo cliente, la clínica se llama Estética Sofía,
|
|
12
|
+
búscala en Google. El admin se llama Carolina, su número es 3124567890.
|
|
13
|
+
Dame el token y me lo envío."
|
|
14
|
+
Conny: "Listo Santiago. Busqué Estética Sofía...
|
|
15
|
+
[resumen de lo encontrado]
|
|
16
|
+
Token de activación: ACTV-SOFIA-2025-XXXXXX
|
|
17
|
+
Envíaselo a Carolina al 3124567890. Expira en 72h."
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
import readline # historial de comandos con flecha arriba
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
# ── Colores ANSI ───────────────────────────────────────────────────────────────
|
|
33
|
+
R = "\033[0;31m" # rojo
|
|
34
|
+
G = "\033[0;32m" # verde
|
|
35
|
+
Y = "\033[1;33m" # amarillo
|
|
36
|
+
B = "\033[0;34m" # azul
|
|
37
|
+
P = "\033[38;2;139;92;246m" # morado
|
|
38
|
+
ROSA = "\033[38;2;236;72;153m" # rosa
|
|
39
|
+
C = "\033[0;36m" # cyan
|
|
40
|
+
W = "\033[1;37m" # blanco brillante
|
|
41
|
+
DIM= "\033[2m" # dimmed
|
|
42
|
+
NC = "\033[0m" # reset
|
|
43
|
+
|
|
44
|
+
def clear_line():
|
|
45
|
+
sys.stdout.write("\033[2K\033[1G")
|
|
46
|
+
sys.stdout.flush()
|
|
47
|
+
|
|
48
|
+
def print_typing(name: str = "Conny"):
|
|
49
|
+
"""Muestra animación de 'escribiendo...'"""
|
|
50
|
+
chars = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]
|
|
51
|
+
for i in range(12):
|
|
52
|
+
clear_line()
|
|
53
|
+
sys.stdout.write(f" {P}{name}{NC} {DIM}{chars[i % len(chars)]} escribiendo...{NC}")
|
|
54
|
+
sys.stdout.flush()
|
|
55
|
+
time.sleep(0.08)
|
|
56
|
+
clear_line()
|
|
57
|
+
|
|
58
|
+
# ── Config ─────────────────────────────────────────────────────────────────────
|
|
59
|
+
INSTANCES_DIR = "/home/ubuntu/conny-instances"
|
|
60
|
+
TEMPLATE_DIR = "/home/ubuntu/conny"
|
|
61
|
+
SERP_API_KEY = "" # se lee del .env
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_env(env_path: str) -> dict:
|
|
65
|
+
"""Lee un archivo .env y retorna dict."""
|
|
66
|
+
env = {}
|
|
67
|
+
try:
|
|
68
|
+
with open(env_path) as f:
|
|
69
|
+
for line in f:
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if line and not line.startswith('#') and '=' in line:
|
|
72
|
+
k, _, v = line.partition('=')
|
|
73
|
+
env[k.strip()] = v.strip().strip('"').strip("'")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
return env
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_instance(arg: Optional[str]) -> dict:
|
|
80
|
+
"""
|
|
81
|
+
Dado un nombre de instancia (o None para la base), retorna su config.
|
|
82
|
+
Retorna: {port, base_url, master_key, name, env_path}
|
|
83
|
+
"""
|
|
84
|
+
if not arg:
|
|
85
|
+
env = load_env(f"{TEMPLATE_DIR}/.env")
|
|
86
|
+
return {
|
|
87
|
+
"name": "base",
|
|
88
|
+
"port": int(env.get("PORT", 8001)),
|
|
89
|
+
"base_url": f"http://localhost:{env.get('PORT', 8001)}",
|
|
90
|
+
"master_key": env.get("MASTER_API_KEY", ""),
|
|
91
|
+
"env_path": f"{TEMPLATE_DIR}/.env",
|
|
92
|
+
"env": env,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Slugify
|
|
96
|
+
slug = arg.lower().strip()
|
|
97
|
+
inst_dir = f"{INSTANCES_DIR}/{slug}"
|
|
98
|
+
env_path = f"{inst_dir}/.env"
|
|
99
|
+
|
|
100
|
+
if not os.path.exists(env_path):
|
|
101
|
+
# Buscar por aproximación
|
|
102
|
+
for d in os.listdir(INSTANCES_DIR) if os.path.exists(INSTANCES_DIR) else []:
|
|
103
|
+
if slug in d:
|
|
104
|
+
inst_dir = f"{INSTANCES_DIR}/{d}"
|
|
105
|
+
env_path = f"{inst_dir}/.env"
|
|
106
|
+
slug = d
|
|
107
|
+
break
|
|
108
|
+
else:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
env = load_env(env_path)
|
|
112
|
+
port = int(env.get("PORT", 8002))
|
|
113
|
+
return {
|
|
114
|
+
"name": slug,
|
|
115
|
+
"port": port,
|
|
116
|
+
"base_url": f"http://localhost:{port}",
|
|
117
|
+
"master_key": env.get("MASTER_API_KEY", ""),
|
|
118
|
+
"env_path": env_path,
|
|
119
|
+
"env": env,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── API Client ─────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async def api_get(base_url: str, path: str, master_key: str = "") -> dict:
|
|
126
|
+
headers = {}
|
|
127
|
+
if master_key:
|
|
128
|
+
headers["X-Master-Key"] = master_key
|
|
129
|
+
async with httpx.AsyncClient(timeout=15.0) as c:
|
|
130
|
+
r = await c.get(f"{base_url}{path}", headers=headers)
|
|
131
|
+
r.raise_for_status()
|
|
132
|
+
return r.json()
|
|
133
|
+
|
|
134
|
+
async def api_post(base_url: str, path: str, data: dict, master_key: str = "") -> dict:
|
|
135
|
+
headers = {"Content-Type": "application/json"}
|
|
136
|
+
if master_key:
|
|
137
|
+
headers["X-Master-Key"] = master_key
|
|
138
|
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
|
139
|
+
r = await c.post(f"{base_url}{path}", json=data, headers=headers)
|
|
140
|
+
r.raise_for_status()
|
|
141
|
+
return r.json()
|
|
142
|
+
|
|
143
|
+
async def get_health(base_url: str) -> dict:
|
|
144
|
+
try:
|
|
145
|
+
return await api_get(base_url, "/health")
|
|
146
|
+
except Exception:
|
|
147
|
+
return {"status": "offline"}
|
|
148
|
+
|
|
149
|
+
async def search_clinic_web(clinic_name: str, serp_key: str, city: str = "Medellin") -> str:
|
|
150
|
+
"""Busca la clínica en Google vía SerpAPI."""
|
|
151
|
+
if not serp_key:
|
|
152
|
+
return ""
|
|
153
|
+
try:
|
|
154
|
+
async with httpx.AsyncClient(timeout=12.0) as c:
|
|
155
|
+
r = await c.get("https://serpapi.com/search", params={
|
|
156
|
+
"engine": "google",
|
|
157
|
+
"q": f"{clinic_name} {city} clinica estetica servicios precios horario",
|
|
158
|
+
"api_key": serp_key,
|
|
159
|
+
"hl": "es", "gl": "co", "num": 5
|
|
160
|
+
})
|
|
161
|
+
r.raise_for_status()
|
|
162
|
+
data = r.json()
|
|
163
|
+
|
|
164
|
+
parts = []
|
|
165
|
+
ab = data.get("answer_box", {})
|
|
166
|
+
if ab.get("snippet"):
|
|
167
|
+
parts.append(ab["snippet"][:400])
|
|
168
|
+
for res in data.get("organic_results", [])[:4]:
|
|
169
|
+
if res.get("snippet"):
|
|
170
|
+
parts.append(f"{res.get('title','')}: {res['snippet'][:200]}")
|
|
171
|
+
return "\n".join(parts)[:1000]
|
|
172
|
+
except Exception as e:
|
|
173
|
+
return f"[búsqueda fallida: {e}]"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def create_activation_token(base_url: str, master_key: str, clinic_label: str) -> dict:
|
|
177
|
+
"""Crea un token de activación para una nueva clínica."""
|
|
178
|
+
return await api_post(base_url, "/api/tokens/create",
|
|
179
|
+
{"clinic_label": clinic_label}, master_key)
|
|
180
|
+
|
|
181
|
+
async def get_recent_chats(base_url: str, master_key: str) -> list:
|
|
182
|
+
try:
|
|
183
|
+
r = await api_get(base_url, "/conversations/patients?limit=5", master_key)
|
|
184
|
+
return r.get("conversations", [])
|
|
185
|
+
except Exception:
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
async def get_stats(base_url: str, master_key: str) -> dict:
|
|
189
|
+
try:
|
|
190
|
+
h = await api_get(base_url, "/health")
|
|
191
|
+
a = await api_get(base_url, "/analytics/summary?days=7", master_key)
|
|
192
|
+
return {**h, **a}
|
|
193
|
+
except Exception as e:
|
|
194
|
+
return {"error": str(e)}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── LLM Brain para el CLI ──────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
async def llm_interpret(
|
|
200
|
+
user_input: str,
|
|
201
|
+
history: list,
|
|
202
|
+
instance: dict,
|
|
203
|
+
health: dict,
|
|
204
|
+
serp_key: str,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""
|
|
207
|
+
Usa el LLM de la instancia activa para interpretar el mensaje del usuario
|
|
208
|
+
y decidir qué acciones tomar. Retorna la respuesta final.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
# Intentar todos los LLMs disponibles
|
|
212
|
+
env = instance.get("env", {})
|
|
213
|
+
|
|
214
|
+
providers = []
|
|
215
|
+
if env.get("GROQ_API_KEY"):
|
|
216
|
+
providers.append(("groq", "https://api.groq.com/openai/v1", env["GROQ_API_KEY"], "llama-3.3-70b-versatile"))
|
|
217
|
+
for k in ["GEMINI_API_KEY", "GEMINI_API_KEY_2", "GEMINI_API_KEY_3"]:
|
|
218
|
+
if env.get(k):
|
|
219
|
+
providers.append(("gemini", "native", env[k], "gemini-2.0-flash"))
|
|
220
|
+
if env.get("OPENROUTER_API_KEY"):
|
|
221
|
+
providers.append(("openrouter", "https://openrouter.ai/api/v1", env["OPENROUTER_API_KEY"], "google/gemini-2.0-flash-001"))
|
|
222
|
+
if env.get("OPENAI_API_KEY"):
|
|
223
|
+
providers.append(("openai", "https://api.openai.com/v1", env["OPENAI_API_KEY"], "gpt-4o-mini"))
|
|
224
|
+
|
|
225
|
+
if not providers:
|
|
226
|
+
return "No hay LLMs configurados en el .env. Revisa las claves de API."
|
|
227
|
+
|
|
228
|
+
# Contexto de la instancia
|
|
229
|
+
clinic_name = health.get("clinic", "desconocida")
|
|
230
|
+
status = health.get("status", "offline")
|
|
231
|
+
|
|
232
|
+
hist_text = ""
|
|
233
|
+
for h in history[-6:]:
|
|
234
|
+
role = "Santiago" if h["role"] == "user" else "Conny"
|
|
235
|
+
hist_text += f"{role}: {h['content']}\n"
|
|
236
|
+
|
|
237
|
+
system = f"""Eres Conny, asistente virtual de administración de clínicas estéticas.
|
|
238
|
+
Santiago (el dueño del sistema) te está hablando desde la terminal de su servidor.
|
|
239
|
+
|
|
240
|
+
INSTANCIA ACTIVA:
|
|
241
|
+
- Nombre: {instance['name']}
|
|
242
|
+
- Puerto: {instance['port']}
|
|
243
|
+
- Clínica: {clinic_name}
|
|
244
|
+
- Estado: {status}
|
|
245
|
+
- Master Key: {instance['master_key'][:20]}...
|
|
246
|
+
|
|
247
|
+
ACCIONES QUE PUEDES HACER (cuando las detectes en el mensaje):
|
|
248
|
+
1. Crear token de activación para nueva clínica
|
|
249
|
+
2. Buscar clínica en Google
|
|
250
|
+
3. Ver conversaciones recientes de pacientes
|
|
251
|
+
4. Ver estadísticas
|
|
252
|
+
5. Dar información sobre configuración
|
|
253
|
+
6. Responder preguntas sobre el sistema
|
|
254
|
+
|
|
255
|
+
FORMATO DE RESPUESTA:
|
|
256
|
+
Responde en español natural, directo, como una asistente que conoce el sistema.
|
|
257
|
+
Si necesitas ejecutar una acción, incluye al FINAL del mensaje UNA línea con el JSON:
|
|
258
|
+
ACTION:{{"type": "create_token", "clinic_label": "...", "admin_name": "...", "admin_phone": "..."}}
|
|
259
|
+
ACTION:{{"type": "search_clinic", "clinic_name": "...", "city": "Medellin"}}
|
|
260
|
+
ACTION:{{"type": "show_chats"}}
|
|
261
|
+
ACTION:{{"type": "show_stats"}}
|
|
262
|
+
|
|
263
|
+
Si no hay acción, no incluyas ninguna línea ACTION.
|
|
264
|
+
|
|
265
|
+
REGLAS:
|
|
266
|
+
- Si te piden crear un nuevo cliente/clínica: extrae el nombre de la clínica y crea el token
|
|
267
|
+
- Si mencionan buscar en Google: extrae el nombre y busca
|
|
268
|
+
- Si preguntan por mensajes/pacientes: muestra los chats recientes
|
|
269
|
+
- Respuestas cortas y directas — máximo 4 oraciones antes de la acción
|
|
270
|
+
|
|
271
|
+
CONVERSACIÓN RECIENTE:
|
|
272
|
+
{hist_text or "(inicio)"}"""
|
|
273
|
+
|
|
274
|
+
messages = [
|
|
275
|
+
{"role": "system", "content": system},
|
|
276
|
+
{"role": "user", "content": user_input}
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
for provider_name, base_url, api_key, model in providers:
|
|
280
|
+
try:
|
|
281
|
+
if provider_name == "gemini":
|
|
282
|
+
# API nativa de Gemini
|
|
283
|
+
system_parts = [m for m in messages if m["role"] == "system"]
|
|
284
|
+
contents = []
|
|
285
|
+
for m in messages:
|
|
286
|
+
if m["role"] == "user":
|
|
287
|
+
contents.append({"role": "user", "parts": [{"text": m["content"]}]})
|
|
288
|
+
elif m["role"] == "assistant":
|
|
289
|
+
contents.append({"role": "model", "parts": [{"text": m["content"]}]})
|
|
290
|
+
payload = {
|
|
291
|
+
"contents": contents,
|
|
292
|
+
"generationConfig": {"temperature": 0.4, "maxOutputTokens": 600}
|
|
293
|
+
}
|
|
294
|
+
if system_parts:
|
|
295
|
+
payload["systemInstruction"] = {"parts": [{"text": system_parts[0]["content"]}]}
|
|
296
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
|
297
|
+
async with httpx.AsyncClient(timeout=20.0) as c:
|
|
298
|
+
r = await c.post(url, json=payload)
|
|
299
|
+
r.raise_for_status()
|
|
300
|
+
data = r.json()
|
|
301
|
+
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
302
|
+
else:
|
|
303
|
+
# OpenAI-compatible
|
|
304
|
+
async with httpx.AsyncClient(timeout=20.0) as c:
|
|
305
|
+
r = await c.post(
|
|
306
|
+
f"{base_url}/chat/completions",
|
|
307
|
+
headers={
|
|
308
|
+
"Authorization": f"Bearer {api_key}",
|
|
309
|
+
"Content-Type": "application/json",
|
|
310
|
+
"HTTP-Referer": "https://conny.ai",
|
|
311
|
+
},
|
|
312
|
+
json={
|
|
313
|
+
"model": model,
|
|
314
|
+
"messages": messages,
|
|
315
|
+
"temperature": 0.4,
|
|
316
|
+
"max_tokens": 600
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
r.raise_for_status()
|
|
320
|
+
data = r.json()
|
|
321
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
322
|
+
|
|
323
|
+
except Exception as e:
|
|
324
|
+
continue # siguiente proveedor
|
|
325
|
+
|
|
326
|
+
return "No pude conectarme al LLM. Revisa las claves de API en el .env."
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def extract_action(response: str) -> Optional[dict]:
|
|
330
|
+
"""Extrae el JSON de acción del final de la respuesta."""
|
|
331
|
+
m = re.search(r'ACTION:\s*(\{[^\n]+\})', response)
|
|
332
|
+
if m:
|
|
333
|
+
try:
|
|
334
|
+
return json.loads(m.group(1))
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def clean_response(response: str) -> str:
|
|
341
|
+
"""Quita la línea ACTION del texto mostrado al usuario."""
|
|
342
|
+
return re.sub(r'\nACTION:\s*\{[^\n]+\}', '', response).strip()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ── Ejecutor de acciones ───────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
async def execute_action(action: dict, instance: dict, serp_key: str) -> str:
|
|
348
|
+
"""Ejecuta la acción y retorna texto adicional para mostrar."""
|
|
349
|
+
base_url = instance["base_url"]
|
|
350
|
+
master_key = instance["master_key"]
|
|
351
|
+
action_type = action.get("type", "")
|
|
352
|
+
|
|
353
|
+
if action_type == "create_token":
|
|
354
|
+
clinic_label = action.get("clinic_label", "Nueva Clínica")
|
|
355
|
+
admin_name = action.get("admin_name", "")
|
|
356
|
+
admin_phone = action.get("admin_phone", "")
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
result = await create_activation_token(base_url, master_key, clinic_label)
|
|
360
|
+
token = result.get("token", "")
|
|
361
|
+
expires = result.get("expires_at", "")[:16] if result.get("expires_at") else ""
|
|
362
|
+
|
|
363
|
+
lines = [
|
|
364
|
+
f"\n {G}Token creado:{NC}",
|
|
365
|
+
f" {W}{token}{NC}",
|
|
366
|
+
]
|
|
367
|
+
if expires:
|
|
368
|
+
lines.append(f" {DIM}Expira: {expires}{NC}")
|
|
369
|
+
if admin_name and admin_phone:
|
|
370
|
+
lines.append(f"\n {Y}Envíaselo a {admin_name} al {admin_phone}:{NC}")
|
|
371
|
+
lines.append(f" Mensaje sugerido:")
|
|
372
|
+
lines.append(f" {DIM}\"Hola {admin_name}! Para activar tu asistente virtual")
|
|
373
|
+
lines.append(f" envía este código al bot de Telegram: {token}\"{NC}")
|
|
374
|
+
lines.append(f"\n {DIM}Instrucciones de activación:{NC}")
|
|
375
|
+
lines.append(f" {DIM}1. El admin escribe el token al bot{NC}")
|
|
376
|
+
lines.append(f" {DIM}2. El bot le pide nombre, correo y contraseña{NC}")
|
|
377
|
+
lines.append(f" {DIM}3. Queda activado como owner de la clínica{NC}")
|
|
378
|
+
lines.append(f" {DIM}4. Puede invitar a su equipo con /addadmin{NC}")
|
|
379
|
+
return "\n".join(lines)
|
|
380
|
+
|
|
381
|
+
except Exception as e:
|
|
382
|
+
return f"\n {R}Error creando token: {e}{NC}"
|
|
383
|
+
|
|
384
|
+
elif action_type == "search_clinic":
|
|
385
|
+
clinic_name = action.get("clinic_name", "")
|
|
386
|
+
city = action.get("city", "Medellin")
|
|
387
|
+
|
|
388
|
+
if not clinic_name:
|
|
389
|
+
return f"\n {Y}No especificaste el nombre de la clínica a buscar.{NC}"
|
|
390
|
+
|
|
391
|
+
sys.stdout.write(f"\n {DIM}Buscando '{clinic_name}' en Google...{NC}")
|
|
392
|
+
sys.stdout.flush()
|
|
393
|
+
result = await search_clinic_web(clinic_name, serp_key, city)
|
|
394
|
+
clear_line()
|
|
395
|
+
|
|
396
|
+
if not result:
|
|
397
|
+
return f"\n {Y}No encontré información de '{clinic_name}' en Google.{NC}"
|
|
398
|
+
|
|
399
|
+
lines = [f"\n {C}Lo que encontré de {clinic_name}:{NC}", ""]
|
|
400
|
+
for line in result.split("\n")[:8]:
|
|
401
|
+
if line.strip():
|
|
402
|
+
lines.append(f" {line[:100]}")
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
elif action_type == "show_chats":
|
|
406
|
+
try:
|
|
407
|
+
chats = await get_recent_chats(base_url, master_key)
|
|
408
|
+
if not chats:
|
|
409
|
+
return f"\n {DIM}No hay conversaciones de pacientes todavía.{NC}"
|
|
410
|
+
|
|
411
|
+
lines = [f"\n {C}Últimas conversaciones:{NC}", ""]
|
|
412
|
+
for c in chats[:5]:
|
|
413
|
+
name = c.get("name") or "Desconocido"
|
|
414
|
+
cid = c.get("chat_id", "")[-6:]
|
|
415
|
+
n = c.get("message_count", 0)
|
|
416
|
+
last = (c.get("last_user_msg") or "")[:50]
|
|
417
|
+
lines.append(f" • {W}{name}{NC} (...{cid}) — {n} msgs")
|
|
418
|
+
if last:
|
|
419
|
+
lines.append(f" {DIM}\"{last}\"{NC}")
|
|
420
|
+
return "\n".join(lines)
|
|
421
|
+
except Exception as e:
|
|
422
|
+
return f"\n {R}Error: {e}{NC}"
|
|
423
|
+
|
|
424
|
+
elif action_type == "show_stats":
|
|
425
|
+
try:
|
|
426
|
+
stats = await get_stats(base_url, master_key)
|
|
427
|
+
lines = [f"\n {C}Estadísticas (últimos 7 días):{NC}", ""]
|
|
428
|
+
if "total_conversations" in stats:
|
|
429
|
+
lines.append(f" Conversaciones: {stats['total_conversations']}")
|
|
430
|
+
if "total_appointments" in stats:
|
|
431
|
+
lines.append(f" Citas agendadas: {stats['total_appointments']}")
|
|
432
|
+
if "conversion_rate" in stats:
|
|
433
|
+
lines.append(f" Conversión: {stats['conversion_rate']}%")
|
|
434
|
+
if "clinic" in stats:
|
|
435
|
+
lines.append(f" Clínica: {stats['clinic']}")
|
|
436
|
+
return "\n".join(lines)
|
|
437
|
+
except Exception as e:
|
|
438
|
+
return f"\n {R}Error: {e}{NC}"
|
|
439
|
+
|
|
440
|
+
return ""
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ── REPL Principal ─────────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
async def repl(instance: dict):
|
|
446
|
+
"""Loop principal de chat."""
|
|
447
|
+
base_url = instance["base_url"]
|
|
448
|
+
master_key = instance["master_key"]
|
|
449
|
+
name = instance["name"]
|
|
450
|
+
env = instance.get("env", {})
|
|
451
|
+
serp_key = env.get("SERP_API_KEY", "")
|
|
452
|
+
|
|
453
|
+
# Verificar conexión
|
|
454
|
+
health = await get_health(base_url)
|
|
455
|
+
status = health.get("status", "offline")
|
|
456
|
+
clinic = health.get("clinic", "sin configurar")
|
|
457
|
+
|
|
458
|
+
# Header
|
|
459
|
+
print(f"\n{P}{'═'*54}{NC}")
|
|
460
|
+
print(f"{ROSA} CONNY CHAT{NC} — {W}{name}{NC}")
|
|
461
|
+
print(f" Puerto: {C}{instance['port']}{NC} Clínica: {W}{clinic}{NC}")
|
|
462
|
+
status_color = G if status == "online" else R
|
|
463
|
+
print(f" Estado: {status_color}{status}{NC}")
|
|
464
|
+
print(f"{P}{'═'*54}{NC}")
|
|
465
|
+
print(f"\n {DIM}Escríbeme en lenguaje natural.")
|
|
466
|
+
print(f" Ej: 'nuevo cliente, clínica La Bella, admin Juan 3124567890'")
|
|
467
|
+
print(f" Comandos rápidos: /chats /stats /exit{NC}\n")
|
|
468
|
+
|
|
469
|
+
history = []
|
|
470
|
+
readline.set_history_length(100)
|
|
471
|
+
|
|
472
|
+
while True:
|
|
473
|
+
try:
|
|
474
|
+
user_input = input(f"{W} Tú:{NC} ").strip()
|
|
475
|
+
except (EOFError, KeyboardInterrupt):
|
|
476
|
+
print(f"\n\n {DIM}Hasta luego.{NC}\n")
|
|
477
|
+
break
|
|
478
|
+
|
|
479
|
+
if not user_input:
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
# Comandos rápidos
|
|
483
|
+
if user_input.lower() in ("/exit", "/quit", "salir", "exit", "quit"):
|
|
484
|
+
print(f"\n {DIM}Hasta luego.{NC}\n")
|
|
485
|
+
break
|
|
486
|
+
|
|
487
|
+
if user_input.lower() in ("/chats", "chats"):
|
|
488
|
+
user_input = "muéstrame las conversaciones recientes de pacientes"
|
|
489
|
+
elif user_input.lower() in ("/stats", "stats", "estadísticas"):
|
|
490
|
+
user_input = "dame las estadísticas"
|
|
491
|
+
elif user_input.lower() in ("/help", "help", "ayuda"):
|
|
492
|
+
print(f"""
|
|
493
|
+
{C}Lo que puedo hacer:{NC}
|
|
494
|
+
• Crear token para nuevo cliente:
|
|
495
|
+
"nuevo cliente, la clínica se llama X, el admin es Juan, número 3124567890"
|
|
496
|
+
• Buscar clínica en Google:
|
|
497
|
+
"busca la clínica X en Google"
|
|
498
|
+
• Ver conversaciones:
|
|
499
|
+
"quién me ha escrito?" o /chats
|
|
500
|
+
• Ver estadísticas:
|
|
501
|
+
"cómo vamos?" o /stats
|
|
502
|
+
• Cualquier pregunta sobre el sistema
|
|
503
|
+
""")
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
# Procesar con LLM
|
|
507
|
+
print()
|
|
508
|
+
print_typing("Conny")
|
|
509
|
+
|
|
510
|
+
response = await llm_interpret(user_input, history, instance, health, serp_key)
|
|
511
|
+
|
|
512
|
+
# Extraer y ejecutar acción si hay
|
|
513
|
+
action = extract_action(response)
|
|
514
|
+
reply = clean_response(response)
|
|
515
|
+
|
|
516
|
+
# Mostrar respuesta
|
|
517
|
+
print(f"{P} Conny:{NC} {reply}")
|
|
518
|
+
|
|
519
|
+
# Ejecutar acción y mostrar resultado
|
|
520
|
+
if action:
|
|
521
|
+
action_output = await execute_action(action, instance, serp_key)
|
|
522
|
+
if action_output:
|
|
523
|
+
print(action_output)
|
|
524
|
+
|
|
525
|
+
print()
|
|
526
|
+
|
|
527
|
+
# Guardar en historial
|
|
528
|
+
history.append({"role": "user", "content": user_input})
|
|
529
|
+
history.append({"role": "assistant", "content": reply})
|
|
530
|
+
if len(history) > 20:
|
|
531
|
+
history = history[-20:]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ── Entry point ────────────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
def main():
|
|
537
|
+
# Parsear argumentos
|
|
538
|
+
instance_name = None
|
|
539
|
+
port_override = None
|
|
540
|
+
|
|
541
|
+
args = sys.argv[1:]
|
|
542
|
+
i = 0
|
|
543
|
+
while i < len(args):
|
|
544
|
+
if args[i] == "--port" and i + 1 < len(args):
|
|
545
|
+
port_override = int(args[i + 1])
|
|
546
|
+
i += 2
|
|
547
|
+
elif not args[i].startswith("--"):
|
|
548
|
+
instance_name = args[i]
|
|
549
|
+
i += 1
|
|
550
|
+
else:
|
|
551
|
+
i += 1
|
|
552
|
+
|
|
553
|
+
# Resolver instancia
|
|
554
|
+
instance = resolve_instance(instance_name)
|
|
555
|
+
|
|
556
|
+
if instance is None:
|
|
557
|
+
print(f"\n{R} Instancia '{instance_name}' no encontrada.{NC}")
|
|
558
|
+
if os.path.exists(INSTANCES_DIR):
|
|
559
|
+
available = os.listdir(INSTANCES_DIR)
|
|
560
|
+
if available:
|
|
561
|
+
print(f" Instancias disponibles: {', '.join(available)}")
|
|
562
|
+
print(f" Crea una con: ./conny-cli nuevo-cliente\n")
|
|
563
|
+
sys.exit(1)
|
|
564
|
+
|
|
565
|
+
if port_override:
|
|
566
|
+
instance["port"] = port_override
|
|
567
|
+
instance["base_url"] = f"http://localhost:{port_override}"
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
asyncio.run(repl(instance))
|
|
571
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
if __name__ == "__main__":
|
|
575
|
+
main()
|
|
576
|
+
|
|
577
|
+
# Alias directo: también se puede llamar como script standalone
|
|
578
|
+
# python3 conny-chat.py → instancia base
|
|
579
|
+
# python3 conny-chat.py nombre → instancia específica
|