@innvisor/conny-ai 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conny Uncertainty Detector — Identifies knowledge gaps and alerts admins.
|
|
3
|
+
|
|
4
|
+
Detects when Conny is uncertain about her answers and logs gaps
|
|
5
|
+
for future training / admin intervention.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("conny.uncertainty")
|
|
18
|
+
|
|
19
|
+
UNCERTAINTY_MARKERS_ES = [
|
|
20
|
+
"no sé",
|
|
21
|
+
"no tengo información",
|
|
22
|
+
"no estoy segura",
|
|
23
|
+
"no puedo ayudar",
|
|
24
|
+
"no cuento con",
|
|
25
|
+
"no manejo esa información",
|
|
26
|
+
"no tengo datos",
|
|
27
|
+
"desconozco",
|
|
28
|
+
"no sabría decirte",
|
|
29
|
+
"tendría que consultar",
|
|
30
|
+
"no tengo acceso",
|
|
31
|
+
"no dispongo de",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
UNCERTAINTY_MARKERS_EN = [
|
|
35
|
+
"i don't know",
|
|
36
|
+
"i'm not sure",
|
|
37
|
+
"i can't help with that",
|
|
38
|
+
"i don't have that information",
|
|
39
|
+
"let me check",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UncertaintyDetector:
|
|
44
|
+
def __init__(self, threshold: float = 0.4):
|
|
45
|
+
self.threshold = threshold
|
|
46
|
+
self._gaps_dir = Path("knowledge_gaps")
|
|
47
|
+
self._gaps_dir.mkdir(exist_ok=True)
|
|
48
|
+
|
|
49
|
+
def detect_uncertainty_markers(self, text: str) -> bool:
|
|
50
|
+
"""Return True if the text contains any uncertainty marker."""
|
|
51
|
+
text_low = text.lower()
|
|
52
|
+
return any(
|
|
53
|
+
marker in text_low
|
|
54
|
+
for marker in UNCERTAINTY_MARKERS_ES + UNCERTAINTY_MARKERS_EN
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def confidence_score(self, response: str, user_msg: str, history: list) -> float:
|
|
58
|
+
"""Score confidence 0.0-1.0. Detects evasion, not just explicit uncertainty."""
|
|
59
|
+
score = 1.0
|
|
60
|
+
text_low = response.lower()
|
|
61
|
+
user_low = user_msg.lower()
|
|
62
|
+
|
|
63
|
+
# Penalty for uncertainty markers
|
|
64
|
+
marker_count = sum(
|
|
65
|
+
1
|
|
66
|
+
for m in UNCERTAINTY_MARKERS_ES + UNCERTAINTY_MARKERS_EN
|
|
67
|
+
if m in text_low
|
|
68
|
+
)
|
|
69
|
+
score -= marker_count * 0.35
|
|
70
|
+
|
|
71
|
+
# Extra penalty if response IS the uncertainty (very short + marker)
|
|
72
|
+
if len(response.split()) < 8 and marker_count > 0:
|
|
73
|
+
score -= 0.15
|
|
74
|
+
|
|
75
|
+
# Penalty for very short responses to complex questions
|
|
76
|
+
if len(user_msg.split()) > 8 and len(response.split()) < 10:
|
|
77
|
+
score -= 0.2
|
|
78
|
+
|
|
79
|
+
# Penalty for deflection patterns
|
|
80
|
+
deflection = ["pregunta al", "consulta con", "contacta a", "llama a"]
|
|
81
|
+
if any(d in text_low for d in deflection):
|
|
82
|
+
score -= 0.15
|
|
83
|
+
|
|
84
|
+
# === NEW: Detect PRICE evasion ===
|
|
85
|
+
# User asked about price but response has no numbers/amounts
|
|
86
|
+
price_signals = ["cuanto", "cuánto", "precio", "vale", "cuesta", "cobran", "tarifa", "costo", "promo"]
|
|
87
|
+
user_asked_price = any(s in user_low for s in price_signals)
|
|
88
|
+
response_has_price = bool(re.search(r'\$?\d[\d.,]+', response)) or any(
|
|
89
|
+
w in text_low for w in ("gratis", "sin costo", "incluido")
|
|
90
|
+
)
|
|
91
|
+
if user_asked_price and not response_has_price:
|
|
92
|
+
score -= 0.45 # Heavy penalty: user asked price, we don't know it
|
|
93
|
+
|
|
94
|
+
# === NEW: Detect SERVICE evasion ===
|
|
95
|
+
# User asked about specific service but response says "we don't do that" or redirects
|
|
96
|
+
service_denial = [
|
|
97
|
+
"no manejamos", "no ofrecemos", "no realizamos", "no hacemos",
|
|
98
|
+
"no contamos con", "nuestra especialidad es", "solo manejamos",
|
|
99
|
+
"solo ofrecemos", "no tenemos ese",
|
|
100
|
+
]
|
|
101
|
+
if any(d in text_low for d in service_denial):
|
|
102
|
+
score -= 0.3 # She might be wrong — she doesn't actually know the full service list
|
|
103
|
+
|
|
104
|
+
# === Detect INFO evasion (horarios, disponibilidad, etc) ===
|
|
105
|
+
info_signals = ["horario", "atienden", "abren", "cierran", "disponibilidad", "abierto", "cuando"]
|
|
106
|
+
user_asked_info = any(s in user_low for s in info_signals)
|
|
107
|
+
response_deflects = any(d in text_low for d in ["verificar", "confirmo", "consultar", "le confirmo"])
|
|
108
|
+
if user_asked_info and response_deflects:
|
|
109
|
+
score -= 0.55 # She doesn't know the schedule — must alert admin
|
|
110
|
+
|
|
111
|
+
# === Detect "depende" evasion ===
|
|
112
|
+
vague_deflectors = [
|
|
113
|
+
"depende del servicio", "depende de la valoración", "depende del caso",
|
|
114
|
+
"te confirmo", "déjame verificar", "let me check",
|
|
115
|
+
]
|
|
116
|
+
if any(d in text_low for d in vague_deflectors) and user_asked_price:
|
|
117
|
+
score -= 0.35 # Vague + user wanted specifics = she doesn't know
|
|
118
|
+
|
|
119
|
+
# Bonus for specific information (contains numbers/concrete data)
|
|
120
|
+
if any(c.isdigit() for c in response):
|
|
121
|
+
score += 0.1
|
|
122
|
+
|
|
123
|
+
return max(0.0, min(1.0, score))
|
|
124
|
+
|
|
125
|
+
async def log_gap(
|
|
126
|
+
self,
|
|
127
|
+
instance_id: str,
|
|
128
|
+
user_msg: str,
|
|
129
|
+
bot_response: str,
|
|
130
|
+
confidence: float,
|
|
131
|
+
chat_id: str = "",
|
|
132
|
+
):
|
|
133
|
+
"""Log a knowledge gap to JSONL file."""
|
|
134
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
135
|
+
gap_file = self._gaps_dir / f"{today}.jsonl"
|
|
136
|
+
entry = {
|
|
137
|
+
"ts": datetime.now().isoformat(),
|
|
138
|
+
"instance_id": instance_id,
|
|
139
|
+
"chat_id": chat_id,
|
|
140
|
+
"user_msg": user_msg,
|
|
141
|
+
"bot_response": bot_response,
|
|
142
|
+
"confidence": round(confidence, 3),
|
|
143
|
+
}
|
|
144
|
+
with open(gap_file, "a") as f:
|
|
145
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
146
|
+
log.info(
|
|
147
|
+
f"[uncertainty] gap logged for {instance_id}: confidence={confidence:.2f}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def alert_admin(
|
|
151
|
+
self,
|
|
152
|
+
instance_id: str,
|
|
153
|
+
user_msg: str,
|
|
154
|
+
bot_response: str,
|
|
155
|
+
confidence: float,
|
|
156
|
+
send_fn=None,
|
|
157
|
+
):
|
|
158
|
+
"""Send WhatsApp alert to admin when confidence is low."""
|
|
159
|
+
alert_text = (
|
|
160
|
+
f"Hola, soy Conny de {instance_id}. "
|
|
161
|
+
f"Un paciente preguntó: '{user_msg[:200]}' "
|
|
162
|
+
f"y no supe responderle bien (confianza: {confidence:.0%}). "
|
|
163
|
+
f"¿Me puedes dar la información correcta para aprender?"
|
|
164
|
+
)
|
|
165
|
+
if send_fn:
|
|
166
|
+
try:
|
|
167
|
+
await send_fn(alert_text)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
log.error(f"[uncertainty] failed to alert admin: {e}")
|
|
170
|
+
return alert_text
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Singleton
|
|
174
|
+
uncertainty_detector = UncertaintyDetector()
|
package/conny_utils.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
ACTIVATION_PREFIX = "ACTV-"
|
|
9
|
+
INVITE_PREFIX = "JINV-"
|
|
10
|
+
|
|
11
|
+
def is_activation_token(text: str) -> bool:
|
|
12
|
+
"""Detecta si el mensaje es un token de activacion."""
|
|
13
|
+
t = text.strip().upper()
|
|
14
|
+
return t.startswith(ACTIVATION_PREFIX) and len(t) >= 30
|
|
15
|
+
|
|
16
|
+
def is_invite_token(text: str) -> bool:
|
|
17
|
+
"""Detecta si el mensaje es un token de invitacion."""
|
|
18
|
+
t = text.strip().upper()
|
|
19
|
+
return t.startswith(INVITE_PREFIX) and len(t) >= 15
|
|
20
|
+
|
|
21
|
+
def generate_activation_token(label: str) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Genera un token de activacion de alta entropia.
|
|
24
|
+
Formato: ACTV-[label_sanitizado]-[32_chars_hex]
|
|
25
|
+
"""
|
|
26
|
+
import string
|
|
27
|
+
sanitized = re.sub(r'[^a-zA-Z0-9]', '', label.lower())[:10]
|
|
28
|
+
if not sanitized:
|
|
29
|
+
sanitized = "generic"
|
|
30
|
+
entropy = secrets.token_hex(16).upper()
|
|
31
|
+
return f"{ACTIVATION_PREFIX}{sanitized}-{entropy}"
|
|
32
|
+
|
|
33
|
+
def hash_password(password: str) -> str:
|
|
34
|
+
"""Hash de contrasena con PBKDF2 + salt."""
|
|
35
|
+
salt = secrets.token_hex(16)
|
|
36
|
+
key = hashlib.pbkdf2_hmac(
|
|
37
|
+
"sha256",
|
|
38
|
+
password.encode("utf-8"),
|
|
39
|
+
salt.encode("utf-8"),
|
|
40
|
+
260_000
|
|
41
|
+
).hex()
|
|
42
|
+
return f"{salt}:{key}"
|
|
43
|
+
|
|
44
|
+
def verify_password(password: str, stored_hash: str) -> bool:
|
|
45
|
+
"""Verifica contrasena contra hash almacenado."""
|
|
46
|
+
try:
|
|
47
|
+
salt, key = stored_hash.split(":", 1)
|
|
48
|
+
test = hashlib.pbkdf2_hmac(
|
|
49
|
+
"sha256",
|
|
50
|
+
password.encode("utf-8"),
|
|
51
|
+
salt.encode("utf-8"),
|
|
52
|
+
260_000
|
|
53
|
+
).hex()
|
|
54
|
+
return test == key
|
|
55
|
+
except Exception:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def _parse_admin_ids(raw) -> list:
|
|
59
|
+
"""Parsea admin_chat_ids de forma segura."""
|
|
60
|
+
if not raw: return []
|
|
61
|
+
if isinstance(raw, list): return [str(i) for i in raw]
|
|
62
|
+
if isinstance(raw, str):
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(raw)
|
|
65
|
+
if isinstance(data, list): return [str(i) for i in data]
|
|
66
|
+
return [str(data)]
|
|
67
|
+
except Exception:
|
|
68
|
+
return [i.strip() for i in raw.split(",") if i.strip()]
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
def extract_model_request_from_text(text: str) -> Optional[str]:
|
|
72
|
+
"""Extrae solicitud de cambio de modelo del lenguaje natural."""
|
|
73
|
+
t = text.lower().strip()
|
|
74
|
+
if not t.startswith("/modelo"):
|
|
75
|
+
if "cambia el modelo a" in t: return t.split("cambia el modelo a")[-1].strip()
|
|
76
|
+
if "usa el modelo" in t: return t.split("usa el modelo")[-1].strip()
|
|
77
|
+
return None
|
|
78
|
+
parts = t.split()
|
|
79
|
+
return parts[1] if len(parts) > 1 else "reset"
|
|
80
|
+
|
|
81
|
+
def normalize_model_arg(arg: str) -> str:
|
|
82
|
+
"""Normaliza el nombre del modelo solicitado."""
|
|
83
|
+
m = arg.lower().strip()
|
|
84
|
+
if m in ("flash", "gemini"): return "google/gemini-2.5-flash"
|
|
85
|
+
if m in ("pro", "sonnet"): return "anthropic/claude-3-5-sonnet"
|
|
86
|
+
if m in ("fast", "haiku"): return "anthropic/claude-3-haiku"
|
|
87
|
+
return m
|
package/conny_voice.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
conny_voice.py — Humanization engine v2.
|
|
3
|
+
Post-processes every LLM response to eliminate robot patterns and inject natural warmth.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import re, random, logging
|
|
7
|
+
from typing import Optional, Dict, List
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("conny.voice")
|
|
10
|
+
|
|
11
|
+
# Patterns that MUST be removed/replaced from any response
|
|
12
|
+
ROBOT_PATTERNS = [
|
|
13
|
+
(re.compile(r"como (IA|inteligencia artificial|(?:asistente|asesora)\s+virtual|bot)", re.I), ""),
|
|
14
|
+
(re.compile(r"no (tengo|tenemos) (la )?capacidad", re.I), "no manejo eso ahora mismo"),
|
|
15
|
+
(re.compile(r"está fuera de mi (alcance|capacidades)", re.I), "eso tendría que verificarlo"),
|
|
16
|
+
(re.compile(r"no (puedo|podemos) (procesar|entender)", re.I), "no tengo esa información ahora"),
|
|
17
|
+
(re.compile(r"^(Hola|Buenos días|Buenas tardes)[,!]?\s*(Soy|Me llamo)\s*Conny(?:,\s*(?:tu|la)\s+(?:asistente|asesora)\s+virtual)?[.!?]?(?:\s+|$)", re.I), ""),
|
|
18
|
+
(re.compile(r"¡?Por supuesto[,!]?\s*", re.I), ""),
|
|
19
|
+
(re.compile(r"¡?Claro que sí[,!]?\s*", re.I), ""),
|
|
20
|
+
(re.compile(r"¡?Con gusto[,!]?\s*", re.I), ""),
|
|
21
|
+
(re.compile(r"¡+", re.I), ""), # Remove excessive exclamation
|
|
22
|
+
(re.compile(r"^\s*¡\s*", re.I), ""),
|
|
23
|
+
(re.compile(r",?\s*(?:tu|la)\s+(?:asistente|asesora)\s+virtual[.,]?\s*", re.I), ". "),
|
|
24
|
+
(re.compile(r"(?:como|en mi rol de) (?:asistente|asesora)\s+virtual", re.I), ""),
|
|
25
|
+
(re.compile(r"(?:soy\s+)?(?:tu|la\s+)?(?:asistente|asesora)\s+virtual", re.I), ""),
|
|
26
|
+
(re.compile(r"estoy aquí para (servirte|atenderte)", re.I), ""),
|
|
27
|
+
(re.compile(r"no dudes en", re.I), ""),
|
|
28
|
+
(re.compile(r"estaré encantad[ao] de", re.I), ""),
|
|
29
|
+
(re.compile(r"será un placer", re.I), ""),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Natural fillers by sector
|
|
33
|
+
SECTOR_FILLERS = {
|
|
34
|
+
"estetica": ["con mucho gusto", "perfecto", "listo", "dale"],
|
|
35
|
+
"salud": ["con gusto", "perfecto", "entendido", "listo"],
|
|
36
|
+
"restaurante": ["dale", "perfecto", "listo", "va"],
|
|
37
|
+
"retail": ["dale", "listo", "perfecto", "genial"],
|
|
38
|
+
"default": ["con gusto", "perfecto", "listo", "entendido"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Thinking block to prepend to system prompts
|
|
42
|
+
THINKING_BLOCK_ES = """INSTRUCCIÓN INTERNA (no mostrar al usuario):
|
|
43
|
+
Antes de responder, piensa en silencio:
|
|
44
|
+
1. ¿Qué quiere realmente este usuario? (intención real)
|
|
45
|
+
2. ¿Qué información tengo disponible para responderle?
|
|
46
|
+
3. ¿Hay alguna ambigüedad? ¿Necesito pedir aclaración?
|
|
47
|
+
4. ¿Cómo lo diría una recepcionista colombiana real, cálida, natural?
|
|
48
|
+
5. ¿Mi respuesta suena robótica? Si sí, reescríbela.
|
|
49
|
+
|
|
50
|
+
SOLO después de pensar, escribe la respuesta al usuario.
|
|
51
|
+
NUNCA menciones este proceso. NUNCA uses frases como "como IA" o "no tengo la capacidad"."""
|
|
52
|
+
|
|
53
|
+
THINKING_BLOCK_EN = """INTERNAL INSTRUCTION (do not show to user):
|
|
54
|
+
Before responding, think silently:
|
|
55
|
+
1. What does this user actually want?
|
|
56
|
+
2. What information do I have to answer them?
|
|
57
|
+
3. Is there any ambiguity? Should I ask for clarification?
|
|
58
|
+
4. How would a real, warm receptionist say this?
|
|
59
|
+
5. Does my response sound robotic? If yes, rewrite it.
|
|
60
|
+
|
|
61
|
+
ONLY after thinking, write the response to the user.
|
|
62
|
+
NEVER mention this process."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConnyVoice:
|
|
66
|
+
"""Post-processes LLM output to sound human."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, sector: str = "default", tone: str = "warm"):
|
|
69
|
+
self.sector = sector
|
|
70
|
+
self.tone = tone
|
|
71
|
+
self._fillers = SECTOR_FILLERS.get(sector, SECTOR_FILLERS["default"])
|
|
72
|
+
|
|
73
|
+
def humanize(self, text: str, persona_override: Optional[Dict] = None) -> str:
|
|
74
|
+
"""Main post-processing pipeline."""
|
|
75
|
+
if not text or not text.strip():
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
result = text
|
|
79
|
+
|
|
80
|
+
# 1. Remove robot patterns
|
|
81
|
+
for pattern, replacement in ROBOT_PATTERNS:
|
|
82
|
+
result = pattern.sub(replacement, result)
|
|
83
|
+
|
|
84
|
+
# 2. Clean up whitespace artifacts
|
|
85
|
+
result = re.sub(r'\s{2,}', ' ', result)
|
|
86
|
+
result = re.sub(r'\n{3,}', '\n\n', result)
|
|
87
|
+
result = result.strip()
|
|
88
|
+
|
|
89
|
+
# 3. Limit exclamation marks (max 1 per response)
|
|
90
|
+
excl_count = result.count('!')
|
|
91
|
+
if excl_count > 1:
|
|
92
|
+
# Keep only the first one
|
|
93
|
+
parts = result.split('!')
|
|
94
|
+
result = parts[0] + '!' + ''.join(parts[1:])
|
|
95
|
+
|
|
96
|
+
# 4. Response must NOT start with bot name
|
|
97
|
+
result = re.sub(r'^Conny[,:.]?\s*', '', result, flags=re.I)
|
|
98
|
+
|
|
99
|
+
# 5. Capitalize first letter
|
|
100
|
+
if result and result[0].islower():
|
|
101
|
+
result = result[0].upper() + result[1:]
|
|
102
|
+
|
|
103
|
+
# 6. Apply persona overrides if present
|
|
104
|
+
if persona_override:
|
|
105
|
+
forbidden = persona_override.get("forbidden_topics", [])
|
|
106
|
+
for topic in forbidden:
|
|
107
|
+
if topic.lower() in result.lower():
|
|
108
|
+
result = re.sub(re.escape(topic), "[tema no disponible]", result, flags=re.I)
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def inject_thinking_block(self, system_prompt: str, lang: str = "es", thinking_budget: int = 0) -> str:
|
|
113
|
+
"""Prepend chain-of-thought instruction to system prompt."""
|
|
114
|
+
block = THINKING_BLOCK_ES if lang == "es" else THINKING_BLOCK_EN
|
|
115
|
+
return block + "\n\n" + system_prompt
|
|
116
|
+
|
|
117
|
+
def split_long_response(self, text: str, max_chars: int = 300) -> List[str]:
|
|
118
|
+
"""Split long responses into multiple WhatsApp-style messages."""
|
|
119
|
+
if len(text) <= max_chars:
|
|
120
|
+
return [text]
|
|
121
|
+
|
|
122
|
+
# Split on sentence boundaries
|
|
123
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
124
|
+
bubbles = []
|
|
125
|
+
current = ""
|
|
126
|
+
|
|
127
|
+
for sentence in sentences:
|
|
128
|
+
if len(current) + len(sentence) + 1 > max_chars and current:
|
|
129
|
+
bubbles.append(current.strip())
|
|
130
|
+
current = sentence
|
|
131
|
+
else:
|
|
132
|
+
current = (current + " " + sentence).strip() if current else sentence
|
|
133
|
+
|
|
134
|
+
if current:
|
|
135
|
+
bubbles.append(current.strip())
|
|
136
|
+
|
|
137
|
+
return bubbles if bubbles else [text]
|
|
138
|
+
|
|
139
|
+
def check_robot_patterns(self, text: str) -> List[str]:
|
|
140
|
+
"""Return list of robot patterns found (for testing/monitoring)."""
|
|
141
|
+
found = []
|
|
142
|
+
for pattern, _ in ROBOT_PATTERNS:
|
|
143
|
+
if pattern.search(text):
|
|
144
|
+
found.append(pattern.pattern)
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def get_thinking_params(provider: str, thinking_budget: int = 0) -> Dict:
|
|
149
|
+
"""Get provider-specific thinking parameters."""
|
|
150
|
+
if provider == "claude" and thinking_budget > 0:
|
|
151
|
+
return {"thinking": {"type": "enabled", "budget_tokens": thinking_budget}}
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Default instance
|
|
156
|
+
voice = ConnyVoice()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""conny_voice_engine.py — ElevenLabs voice with intelligent audio triggers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("conny.voice_engine")
|
|
11
|
+
|
|
12
|
+
BOOKING_KEYWORDS = ["confirmad", "agendad", "reservad", "cita quedó", "quedó para"]
|
|
13
|
+
EMPATHY_KEYWORDS = ["dolor", "angustia", "urgente", "miedo", "preocup", "asust", "llor", "fiebre", "grave"]
|
|
14
|
+
FAREWELL_KEYWORDS = ["chao", "gracias", "hasta luego", "bendiciones", "bye", "adiós", "nos vemos"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConnyVoiceEngine:
|
|
18
|
+
"""
|
|
19
|
+
Smart voice engine. Only sends audio when it has high conversion impact.
|
|
20
|
+
Gracefully disabled if no ELEVENLABS_API_KEY is set.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, instance_id: str = "default"):
|
|
24
|
+
self.instance_id = instance_id
|
|
25
|
+
self.api_key = os.getenv("ELEVENLABS_API_KEY", "")
|
|
26
|
+
self.voice_id = os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
|
|
27
|
+
self.enabled = bool(self.api_key)
|
|
28
|
+
self._client = None
|
|
29
|
+
self._recent_audio_count = 0
|
|
30
|
+
|
|
31
|
+
def _get_client(self):
|
|
32
|
+
if not self._client and self.enabled:
|
|
33
|
+
try:
|
|
34
|
+
from elevenlabs.client import ElevenLabs
|
|
35
|
+
self._client = ElevenLabs(api_key=self.api_key)
|
|
36
|
+
except ImportError:
|
|
37
|
+
log.warning("[voice_engine] elevenlabs package not installed")
|
|
38
|
+
self.enabled = False
|
|
39
|
+
return self._client
|
|
40
|
+
|
|
41
|
+
async def should_send_audio(self, response: str, context: Dict, history: List) -> Tuple[bool, str]:
|
|
42
|
+
if not self.enabled:
|
|
43
|
+
return False, "disabled"
|
|
44
|
+
if context.get("user_sent_audio"):
|
|
45
|
+
return True, "reciprocity"
|
|
46
|
+
if self._recent_audio_count >= 1:
|
|
47
|
+
return False, "cooldown"
|
|
48
|
+
if len(response.strip()) < 20:
|
|
49
|
+
return False, "too_short"
|
|
50
|
+
if len(response.strip()) > 300:
|
|
51
|
+
return False, "too_long"
|
|
52
|
+
|
|
53
|
+
response_low = response.lower()
|
|
54
|
+
if any(kw in response_low for kw in BOOKING_KEYWORDS):
|
|
55
|
+
return True, "booking_confirmed"
|
|
56
|
+
|
|
57
|
+
user_msg = ""
|
|
58
|
+
for m in reversed(history[-5:]):
|
|
59
|
+
if m.get("role") == "user":
|
|
60
|
+
user_msg = m.get("content", "").lower()
|
|
61
|
+
break
|
|
62
|
+
if any(kw in user_msg for kw in EMPATHY_KEYWORDS):
|
|
63
|
+
return True, "empathy_required"
|
|
64
|
+
if any(kw in response_low for kw in FAREWELL_KEYWORDS):
|
|
65
|
+
return True, "farewell"
|
|
66
|
+
if context.get("is_first_turn") and len(history) <= 2:
|
|
67
|
+
return True, "welcome_new_user"
|
|
68
|
+
|
|
69
|
+
return False, "no_trigger"
|
|
70
|
+
|
|
71
|
+
async def text_to_audio(self, text: str) -> Optional[str]:
|
|
72
|
+
if not self.enabled:
|
|
73
|
+
return None
|
|
74
|
+
client = self._get_client()
|
|
75
|
+
if not client:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
from elevenlabs import VoiceSettings
|
|
79
|
+
audio = client.text_to_speech.convert(
|
|
80
|
+
text=text,
|
|
81
|
+
voice_id=self.voice_id,
|
|
82
|
+
model_id="eleven_flash_v2_5",
|
|
83
|
+
output_format="mp3_44100_128",
|
|
84
|
+
voice_settings=VoiceSettings(
|
|
85
|
+
stability=0.45,
|
|
86
|
+
similarity_boost=0.80,
|
|
87
|
+
style=0.35,
|
|
88
|
+
use_speaker_boost=True,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
|
92
|
+
for chunk in audio:
|
|
93
|
+
tmp.write(chunk)
|
|
94
|
+
tmp.close()
|
|
95
|
+
self._recent_audio_count += 1
|
|
96
|
+
return tmp.name
|
|
97
|
+
except Exception as e:
|
|
98
|
+
log.error(f"[voice_engine] TTS error: {e}")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
async def process_response(self, response: str, context: Dict, history: List,
|
|
102
|
+
send_text_fn=None, send_audio_fn=None):
|
|
103
|
+
if not self.enabled or not send_audio_fn:
|
|
104
|
+
if send_text_fn:
|
|
105
|
+
await send_text_fn(response)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
send_audio, reason = await self.should_send_audio(response, context, history)
|
|
109
|
+
if send_audio:
|
|
110
|
+
log.info(f"[voice_engine] sending audio [{reason}]: {response[:50]}...")
|
|
111
|
+
audio_path = await self.text_to_audio(response)
|
|
112
|
+
if audio_path:
|
|
113
|
+
await send_audio_fn(audio_path)
|
|
114
|
+
os.unlink(audio_path)
|
|
115
|
+
if send_text_fn:
|
|
116
|
+
await asyncio.sleep(0.8)
|
|
117
|
+
await send_text_fn(f"_{response}_")
|
|
118
|
+
elif send_text_fn:
|
|
119
|
+
await send_text_fn(response)
|
|
120
|
+
elif send_text_fn:
|
|
121
|
+
await send_text_fn(response)
|
|
122
|
+
|
|
123
|
+
def reset_cooldown(self):
|
|
124
|
+
self._recent_audio_count = 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""conny_web_search.py — Web search via Brave Search API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import List, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("conny.web_search")
|
|
11
|
+
|
|
12
|
+
BRAVE_API_KEY = os.getenv("BRAVE_API_KEY", "")
|
|
13
|
+
BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def search_web(query: str, num_results: int = 5) -> List[Dict[str, str]]:
|
|
17
|
+
"""Search the web using Brave Search API. Returns list of {title, snippet, url}."""
|
|
18
|
+
if not BRAVE_API_KEY:
|
|
19
|
+
log.debug("[web_search] no BRAVE_API_KEY configured")
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
24
|
+
r = await client.get(
|
|
25
|
+
BRAVE_URL,
|
|
26
|
+
params={"q": query, "count": num_results},
|
|
27
|
+
headers={"X-Subscription-Token": BRAVE_API_KEY, "Accept": "application/json"},
|
|
28
|
+
)
|
|
29
|
+
if r.status_code != 200:
|
|
30
|
+
log.warning(f"[web_search] Brave returned {r.status_code}")
|
|
31
|
+
return []
|
|
32
|
+
data = r.json()
|
|
33
|
+
results = []
|
|
34
|
+
for item in data.get("web", {}).get("results", [])[:num_results]:
|
|
35
|
+
results.append({
|
|
36
|
+
"title": item.get("title", ""),
|
|
37
|
+
"snippet": item.get("description", ""),
|
|
38
|
+
"url": item.get("url", ""),
|
|
39
|
+
})
|
|
40
|
+
return results
|
|
41
|
+
except Exception as e:
|
|
42
|
+
log.error(f"[web_search] error: {e}")
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def search_business(business_name: str, city: str = "Medellín") -> str:
|
|
47
|
+
"""Search for a business and return a summary string for LLM context."""
|
|
48
|
+
query = f"{business_name} {city} servicios precios horario"
|
|
49
|
+
results = await search_web(query, num_results=5)
|
|
50
|
+
if not results:
|
|
51
|
+
return ""
|
|
52
|
+
lines = []
|
|
53
|
+
for r in results:
|
|
54
|
+
lines.append(f"- {r['title']}: {r['snippet'][:150]}")
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def search_topic(topic: str) -> str:
|
|
59
|
+
"""General topic search, returns formatted results."""
|
|
60
|
+
results = await search_web(topic, num_results=3)
|
|
61
|
+
if not results:
|
|
62
|
+
return ""
|
|
63
|
+
lines = []
|
|
64
|
+
for r in results:
|
|
65
|
+
lines.append(f"- {r['title']}: {r['snippet'][:200]}")
|
|
66
|
+
return "\n".join(lines)
|