@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,353 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
log = logging.getLogger("conny.production")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConnyProduction:
|
|
10
|
+
"""
|
|
11
|
+
Producción: Atención a pacientes reales via LLM directo.
|
|
12
|
+
100% inteligencia artificial, sin templates hardcodeados.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, conny):
|
|
16
|
+
self.conny = conny
|
|
17
|
+
|
|
18
|
+
async def handle(self, chat_id: str, text: str, clinic: Dict,
|
|
19
|
+
history: List[Dict], conv_state: Dict) -> List[str]:
|
|
20
|
+
"""Procesa un mensaje de paciente real via LLM directo."""
|
|
21
|
+
from conny import db, llm_engine, kb, v8_process_response
|
|
22
|
+
|
|
23
|
+
start_time = time.time()
|
|
24
|
+
instance_id = getattr(self.conny, "_instance_id", "default")
|
|
25
|
+
|
|
26
|
+
# Smart features: memory, language, sentiment, time
|
|
27
|
+
patient_context = ""
|
|
28
|
+
lang_instruction = ""
|
|
29
|
+
try:
|
|
30
|
+
from conny_smart_features import (
|
|
31
|
+
CrossSessionMemory, SentimentTracker, LanguageDetector,
|
|
32
|
+
get_time_greeting, is_conversation_ending, get_natural_closing,
|
|
33
|
+
)
|
|
34
|
+
# Cross-session memory
|
|
35
|
+
mem = CrossSessionMemory(instance_id)
|
|
36
|
+
patient_data = mem.recall_patient(chat_id)
|
|
37
|
+
patient_context = mem.get_context_for_prompt(chat_id)
|
|
38
|
+
|
|
39
|
+
# Language detection
|
|
40
|
+
lang_det = LanguageDetector()
|
|
41
|
+
detected_lang = lang_det.detect(text)
|
|
42
|
+
lang_instruction = lang_det.get_language_instruction(detected_lang)
|
|
43
|
+
|
|
44
|
+
# Explicit human request detection
|
|
45
|
+
human_request_signals = ["hablar con humano", "hablar con una persona", "hablar con alguien",
|
|
46
|
+
"quiero hablar con", "pasame con", "pásame con", "un humano",
|
|
47
|
+
"una persona real", "talk to a human", "real person"]
|
|
48
|
+
wants_human = any(s in text.lower() for s in human_request_signals)
|
|
49
|
+
|
|
50
|
+
# Sentiment check → auto-escalate if frustrated
|
|
51
|
+
sentiment = SentimentTracker()
|
|
52
|
+
should_esc, esc_reason = sentiment.should_escalate(text, history)
|
|
53
|
+
if should_esc or wants_human:
|
|
54
|
+
admin_ids = clinic.get("admin_chat_ids", [])
|
|
55
|
+
if isinstance(admin_ids, str):
|
|
56
|
+
import json as _j2
|
|
57
|
+
admin_ids = _j2.loads(admin_ids) if admin_ids else []
|
|
58
|
+
if admin_ids:
|
|
59
|
+
admin_jid_esc = str(admin_ids[0])
|
|
60
|
+
reason_text = "quiere hablar con alguien" if wants_human else esc_reason.replace('_', ' ')
|
|
61
|
+
alert = f"oye, un paciente ({chat_id.split('@')[0][-4:]}) {reason_text}:\n\"{text[:150]}\""
|
|
62
|
+
try:
|
|
63
|
+
await self.conny._send_message(admin_jid_esc, alert)
|
|
64
|
+
log.info(f"[production] escalation alert sent: {reason_text}")
|
|
65
|
+
except Exception as _e:
|
|
66
|
+
log.warning(f"[production] escalation alert failed: {_e}")
|
|
67
|
+
|
|
68
|
+
# If wants human → respond directly and return
|
|
69
|
+
if wants_human:
|
|
70
|
+
human_response = "ya le aviso a alguien del equipo que te escriba ||| dame un momentito"
|
|
71
|
+
try:
|
|
72
|
+
db.save_message(chat_id, "user", text)
|
|
73
|
+
db.save_message(chat_id, "assistant", human_response.replace("|||", " "))
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
return self.conny._split_bubbles(human_response, chat_id=chat_id)
|
|
77
|
+
|
|
78
|
+
# Conversation ending detection
|
|
79
|
+
if is_conversation_ending(text):
|
|
80
|
+
tone = "casual"
|
|
81
|
+
try:
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
import json as _j3
|
|
84
|
+
ov = Path(f"personas/{instance_id}/runtime_override.json")
|
|
85
|
+
if ov.exists():
|
|
86
|
+
tone = _j3.loads(ov.read_text()).get("tone", "casual")
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
closing = get_natural_closing(tone)
|
|
90
|
+
db.save_message(chat_id, "user", text)
|
|
91
|
+
db.save_message(chat_id, "assistant", closing)
|
|
92
|
+
# Save last topic to memory
|
|
93
|
+
if history:
|
|
94
|
+
last_user_msgs = [m["content"] for m in history[-4:] if m.get("role") == "user"]
|
|
95
|
+
topic = last_user_msgs[0][:50] if last_user_msgs else ""
|
|
96
|
+
mem.remember_patient(chat_id, {"last_topic": topic})
|
|
97
|
+
return self.conny._split_bubbles(closing, chat_id=chat_id)
|
|
98
|
+
|
|
99
|
+
# Time awareness for greeting
|
|
100
|
+
time_greeting = get_time_greeting()
|
|
101
|
+
except ImportError:
|
|
102
|
+
time_greeting = "hola"
|
|
103
|
+
except Exception:
|
|
104
|
+
time_greeting = "hola"
|
|
105
|
+
|
|
106
|
+
clinic_name = clinic.get("name", "el negocio")
|
|
107
|
+
services = clinic.get("services", [])
|
|
108
|
+
if isinstance(services, str):
|
|
109
|
+
services = [s.strip() for s in services.split(",") if s.strip()]
|
|
110
|
+
services_str = ", ".join(services[:10]) if services else "consulta general"
|
|
111
|
+
schedule = clinic.get("schedule", "")
|
|
112
|
+
if isinstance(schedule, dict):
|
|
113
|
+
schedule = " | ".join(f"{k}: {v}" for k, v in schedule.items())
|
|
114
|
+
|
|
115
|
+
# Load persona override (tone changes from admin)
|
|
116
|
+
persona_tone = "colombian_warm"
|
|
117
|
+
try:
|
|
118
|
+
from pathlib import Path
|
|
119
|
+
import json as _json
|
|
120
|
+
override_path = Path(f"personas/{instance_id}/runtime_override.json")
|
|
121
|
+
if override_path.exists():
|
|
122
|
+
override = _json.loads(override_path.read_text())
|
|
123
|
+
persona_tone = override.get("tone", persona_tone)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# Load soul knowledge
|
|
128
|
+
soul_context = ""
|
|
129
|
+
try:
|
|
130
|
+
soul_file = Path(f"soul/{instance_id}/knowledge.md")
|
|
131
|
+
if soul_file.exists():
|
|
132
|
+
soul_context = soul_file.read_text()[-2000:]
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
tone_instructions = {
|
|
137
|
+
"luxury": "Tono LUXURY: sofisticada, elegante, exclusiva. Usa lenguaje premium. Nunca suenes informal ni uses jerga. Transmite exclusividad en cada palabra.",
|
|
138
|
+
"formal": "Tono FORMAL: profesional, respetuosa, precisa. Sin jerga. Usted en vez de tú.",
|
|
139
|
+
"casual": "Tono CASUAL: cercana, relajada, como amiga. Tutea. Usa expresiones naturales.",
|
|
140
|
+
"colombian_warm": "Tono COLOMBIANO CÁLIDO: cercana pero profesional, calidez natural, expresiones colombianas sutiles.",
|
|
141
|
+
"warm_energetic": "Tono ALEGRE: energética, positiva, con chispa. Emojis permitidos.",
|
|
142
|
+
}
|
|
143
|
+
tone_instruction = tone_instructions.get(persona_tone, tone_instructions["colombian_warm"])
|
|
144
|
+
|
|
145
|
+
sys_prompt = f"""Eres Conny, recepcionista virtual de {clinic_name}.
|
|
146
|
+
Servicios: {services_str}
|
|
147
|
+
Horario: {schedule or 'consultar'}
|
|
148
|
+
|
|
149
|
+
TONO: {tone_instruction}
|
|
150
|
+
|
|
151
|
+
{f"CONOCIMIENTO DEL NEGOCIO:{chr(10)}{soul_context}" if soul_context else ""}
|
|
152
|
+
|
|
153
|
+
{f"SOBRE ESTE PACIENTE:{chr(10)}{patient_context}" if patient_context else ""}
|
|
154
|
+
{f"{chr(10)}{lang_instruction}" if lang_instruction else ""}
|
|
155
|
+
|
|
156
|
+
REGLA #1 — RESPONDE CON LO QUE SABES:
|
|
157
|
+
- Si la respuesta está en la sección "RESPUESTAS QUE YA SABES" → DALA DIRECTAMENTE sin dudar
|
|
158
|
+
- Si NO encuentras la respuesta en ninguna sección → "me confirmo y te aviso"
|
|
159
|
+
- Las RESPUESTAS QUE YA SABES son información VERIFICADA por el dueño. Úsalas con total confianza.
|
|
160
|
+
|
|
161
|
+
REGLAS GENERALES:
|
|
162
|
+
- {tone_instruction.split(':')[0]} — aplica este tono en CADA respuesta
|
|
163
|
+
- Una sola pregunta por turno, enfocada en avanzar la conversación
|
|
164
|
+
- Si el paciente quiere cita: pide nombre, servicio, fecha preferida
|
|
165
|
+
- NUNCA digas "no tengo capacidad", "está fuera de mi alcance"
|
|
166
|
+
- Si preguntan "eres IA?" → responde HONESTA y breve: "sí, soy una IA 😊 pero estoy aquí pa ayudarte, dime en qué te puedo servir"
|
|
167
|
+
- NUNCA evadas la pregunta de si eres IA. Sé directa, no insistas ni te pongas a la defensiva
|
|
168
|
+
- NUNCA uses formato markdown (**, *, _, #, `)
|
|
169
|
+
- Usa máximo 2-3 burbujas separadas por |||
|
|
170
|
+
- Sé concisa (máx 40 palabras por burbuja)
|
|
171
|
+
- Escribe EXACTAMENTE como una persona de 28 años en WhatsApp: mensajes cortos, naturales
|
|
172
|
+
- Emojis: usa MÁXIMO 1 por conversación, y solo en el saludo inicial. Después de eso, 0 emojis. Nada de 😊 genérico en cada mensaje.
|
|
173
|
+
- Si ya saludaste, no vuelvas a saludar
|
|
174
|
+
- NUNCA te presentes con "Soy Conny tu recepcionista de X" — eso suena a bot
|
|
175
|
+
- Si es el primer mensaje, saluda así: "{time_greeting}! hablas con Conny 😊 ||| en qué te puedo ayudar?"
|
|
176
|
+
- Separa SIEMPRE en 2-3 burbujas cortas (|||), nunca un solo bloque largo
|
|
177
|
+
- NUNCA digas el nombre completo de la clínica en el saludo — el paciente ya sabe dónde escribió"""
|
|
178
|
+
|
|
179
|
+
messages = [{"role": "system", "content": sys_prompt}]
|
|
180
|
+
for m in history[-12:]:
|
|
181
|
+
messages.append({"role": m.get("role", "user"), "content": m.get("content", "")})
|
|
182
|
+
messages.append({"role": "user", "content": text})
|
|
183
|
+
|
|
184
|
+
# KB context
|
|
185
|
+
kb_context = ""
|
|
186
|
+
if kb:
|
|
187
|
+
try:
|
|
188
|
+
if hasattr(kb, "has_content") and kb.has_content():
|
|
189
|
+
kb_context = kb.query(text)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
if kb_context:
|
|
193
|
+
messages[0]["content"] += f"\n\nCONTEXTO DEL NEGOCIO:\n{kb_context[:1000]}"
|
|
194
|
+
|
|
195
|
+
# Teachings injection — THIS IS YOUR KNOWLEDGE, USE IT
|
|
196
|
+
try:
|
|
197
|
+
from conny_learning import learning_engine
|
|
198
|
+
teachings = await learning_engine.get_teachings(instance_id, limit=20)
|
|
199
|
+
if teachings:
|
|
200
|
+
qa_lines = []
|
|
201
|
+
for t in teachings:
|
|
202
|
+
q = t.get("question", "").replace("[admin enseñó] ", "")
|
|
203
|
+
a = t.get("answer", "")
|
|
204
|
+
if a and not q.startswith("["):
|
|
205
|
+
qa_lines.append(f"Si preguntan: \"{q}\" → Responde: \"{a}\"")
|
|
206
|
+
elif a:
|
|
207
|
+
qa_lines.append(f"Regla: {a[:150]}")
|
|
208
|
+
if qa_lines:
|
|
209
|
+
# Inject ABOVE the rules, as part of the clinic facts
|
|
210
|
+
messages[0]["content"] = messages[0]["content"].replace(
|
|
211
|
+
"REGLA #1",
|
|
212
|
+
"DATOS CONFIRMADOS POR EL DUEÑO (responde con estos sin dudar):\n" + "\n".join(qa_lines) + "\n\nREGLA #1"
|
|
213
|
+
)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# Admin rules injection (things admin said to ask first)
|
|
218
|
+
try:
|
|
219
|
+
from pathlib import Path
|
|
220
|
+
rules_file = Path(f"soul/{instance_id}/admin_rules.json")
|
|
221
|
+
if rules_file.exists():
|
|
222
|
+
import json as _j
|
|
223
|
+
rules = _j.loads(rules_file.read_text())
|
|
224
|
+
if rules:
|
|
225
|
+
rules_text = "\n".join(f"- Si preguntan sobre '{r['topic']}': {r['action']}" for r in rules[-10:])
|
|
226
|
+
messages[0]["content"] += f"\n\nINSTRUCCIONES DEL DUEÑO:\n{rules_text}"
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# LLM call
|
|
231
|
+
response = ""
|
|
232
|
+
model_used = "llm"
|
|
233
|
+
if llm_engine:
|
|
234
|
+
try:
|
|
235
|
+
response, meta = await llm_engine.complete(
|
|
236
|
+
messages, model_tier="fast", temperature=0.75,
|
|
237
|
+
max_tokens=2048, use_cache=False,
|
|
238
|
+
)
|
|
239
|
+
model_used = meta.get("model", "llm")
|
|
240
|
+
log.info(f"[production] {meta.get('provider','?')} latency={time.time()-start_time:.1f}s")
|
|
241
|
+
except Exception as e:
|
|
242
|
+
log.error(f"[production] LLM error: {e}")
|
|
243
|
+
|
|
244
|
+
if not response or not response.strip():
|
|
245
|
+
response = "cuéntame en qué te puedo ayudar"
|
|
246
|
+
|
|
247
|
+
# Strip ALL markdown — patients must get pure human text (no *, **, `, #)
|
|
248
|
+
import re as _re
|
|
249
|
+
response = _re.sub(r'\*\*(.+?)\*\*', r'\1', response)
|
|
250
|
+
response = _re.sub(r'\*(.+?)\*', r'\1', response)
|
|
251
|
+
response = _re.sub(r'`(.+?)`', r'\1', response)
|
|
252
|
+
response = _re.sub(r'^#+\s*', '', response, flags=_re.MULTILINE)
|
|
253
|
+
response = _re.sub(r'_(.+?)_', r'\1', response) # no italics either
|
|
254
|
+
|
|
255
|
+
response = v8_process_response(response, chat_id=chat_id)
|
|
256
|
+
|
|
257
|
+
# Uncertainty check + admin escalation
|
|
258
|
+
try:
|
|
259
|
+
from conny_uncertainty import uncertainty_detector
|
|
260
|
+
confidence = uncertainty_detector.confidence_score(response, text, history)
|
|
261
|
+
|
|
262
|
+
# If response contains data from teachings, trust it (don't override)
|
|
263
|
+
try:
|
|
264
|
+
from conny_learning import learning_engine as _le
|
|
265
|
+
_teachings = await _le.get_teachings(instance_id, limit=30)
|
|
266
|
+
for t in _teachings:
|
|
267
|
+
if t.get("answer", "")[:20].lower() in response.lower():
|
|
268
|
+
confidence = max(confidence, 0.8)
|
|
269
|
+
break
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
# Get admin JID for alerts
|
|
274
|
+
admin_ids = clinic.get("admin_chat_ids", [])
|
|
275
|
+
if isinstance(admin_ids, str):
|
|
276
|
+
import json as _j
|
|
277
|
+
admin_ids = _j.loads(admin_ids) if admin_ids else []
|
|
278
|
+
admin_jid = str(admin_ids[0]) if admin_ids else ""
|
|
279
|
+
|
|
280
|
+
# Skip alert if we have teachings that match the question
|
|
281
|
+
has_relevant_teaching = False
|
|
282
|
+
try:
|
|
283
|
+
from conny_learning import learning_engine as _le
|
|
284
|
+
_t = await _le.get_teachings(instance_id, limit=30)
|
|
285
|
+
user_low = text.lower()
|
|
286
|
+
resp_low = response.lower()
|
|
287
|
+
for t in _t:
|
|
288
|
+
q = t.get("question", "").lower().replace("[admin enseñó] ", "")
|
|
289
|
+
a = t.get("answer", "").lower()
|
|
290
|
+
# Fuzzy: check if root words overlap (horario/hora, atencion/atienden)
|
|
291
|
+
q_stems = set(w[:4] for w in q.split() if len(w) > 3)
|
|
292
|
+
user_stems = set(w[:4] for w in user_low.split() if len(w) > 3)
|
|
293
|
+
if q_stems & user_stems:
|
|
294
|
+
has_relevant_teaching = True
|
|
295
|
+
break
|
|
296
|
+
if a and a[:12] in resp_low:
|
|
297
|
+
has_relevant_teaching = True
|
|
298
|
+
break
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
if confidence < 0.5 and admin_jid and not has_relevant_teaching:
|
|
303
|
+
await uncertainty_detector.log_gap(instance_id, text, response, confidence, chat_id)
|
|
304
|
+
alert_msg = (
|
|
305
|
+
f"oye, me acaba de escribir un paciente preguntando: \"{text[:150]}\"\n\n"
|
|
306
|
+
f"no tengo esa info todavía, qué le digo?"
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
await self.conny._send_message(admin_jid, alert_msg)
|
|
310
|
+
log.info(f"[production] admin alerted: confidence={confidence:.2f} question='{text[:50]}'")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
log.warning(f"[production] failed to alert admin: {e}")
|
|
313
|
+
|
|
314
|
+
# Override response: tell patient we're checking (only if no teaching matches)
|
|
315
|
+
response = "dame un momento que verifico eso ||| ya te confirmo"
|
|
316
|
+
elif has_relevant_teaching and confidence < 0.5:
|
|
317
|
+
# We have the answer in teachings but LLM still deflected — don't override
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
log.error(f"[production] uncertainty check FAILED: {e}", exc_info=True)
|
|
322
|
+
|
|
323
|
+
# Save to DB
|
|
324
|
+
try:
|
|
325
|
+
db.save_message(chat_id, "user", text)
|
|
326
|
+
db.save_message(chat_id, "assistant", response.replace("|||", " "),
|
|
327
|
+
model=model_used,
|
|
328
|
+
latency=int((time.time() - start_time) * 1000))
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
# Real-time learning from turn
|
|
333
|
+
try:
|
|
334
|
+
from conny_learning import learning_engine
|
|
335
|
+
await learning_engine.learn_from_turn(instance_id, text, response)
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Save patient memory (name extraction, last topic, visit count)
|
|
340
|
+
try:
|
|
341
|
+
from conny_smart_features import CrossSessionMemory
|
|
342
|
+
mem = CrossSessionMemory(instance_id)
|
|
343
|
+
import re as _re2
|
|
344
|
+
# Extract name if patient says it
|
|
345
|
+
name_match = _re2.search(r'(?:me llamo|soy|mi nombre es)\s+([A-ZÁÉÍÓÚ][a-záéíóú]+(?:\s+[A-ZÁÉÍÓÚ][a-záéíóú]+)?)', text)
|
|
346
|
+
patient_update = {"last_topic": text[:50]}
|
|
347
|
+
if name_match:
|
|
348
|
+
patient_update["name"] = name_match.group(1)
|
|
349
|
+
mem.remember_patient(chat_id, patient_update)
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
return self.conny._split_bubbles(response, chat_id=chat_id)
|
|
@@ -0,0 +1,75 @@
|
|
|
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 hash_password(password: str) -> str:
|
|
22
|
+
"""Hash de contrasena con PBKDF2 + salt."""
|
|
23
|
+
salt = secrets.token_hex(16)
|
|
24
|
+
key = hashlib.pbkdf2_hmac(
|
|
25
|
+
"sha256",
|
|
26
|
+
password.encode("utf-8"),
|
|
27
|
+
salt.encode("utf-8"),
|
|
28
|
+
260_000
|
|
29
|
+
).hex()
|
|
30
|
+
return f"{salt}:{key}"
|
|
31
|
+
|
|
32
|
+
def verify_password(password: str, stored_hash: str) -> bool:
|
|
33
|
+
"""Verifica contrasena contra hash almacenado."""
|
|
34
|
+
try:
|
|
35
|
+
salt, key = stored_hash.split(":", 1)
|
|
36
|
+
test = hashlib.pbkdf2_hmac(
|
|
37
|
+
"sha256",
|
|
38
|
+
password.encode("utf-8"),
|
|
39
|
+
salt.encode("utf-8"),
|
|
40
|
+
260_000
|
|
41
|
+
).hex()
|
|
42
|
+
return test == key
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def _parse_admin_ids(raw) -> list:
|
|
47
|
+
"""Parsea admin_chat_ids de forma segura."""
|
|
48
|
+
if not raw: return []
|
|
49
|
+
if isinstance(raw, list): return [str(i) for i in raw]
|
|
50
|
+
if isinstance(raw, str):
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(raw)
|
|
53
|
+
if isinstance(data, list): return [str(i) for i in data]
|
|
54
|
+
return [str(data)]
|
|
55
|
+
except Exception:
|
|
56
|
+
return [i.strip() for i in raw.split(",") if i.strip()]
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
def extract_model_request_from_text(text: str) -> Optional[str]:
|
|
60
|
+
"""Extrae solicitud de cambio de modelo del lenguaje natural."""
|
|
61
|
+
t = text.lower().strip()
|
|
62
|
+
if not t.startswith("/modelo"):
|
|
63
|
+
if "cambia el modelo a" in t: return t.split("cambia el modelo a")[-1].strip()
|
|
64
|
+
if "usa el modelo" in t: return t.split("usa el modelo")[-1].strip()
|
|
65
|
+
return None
|
|
66
|
+
parts = t.split()
|
|
67
|
+
return parts[1] if len(parts) > 1 else "reset"
|
|
68
|
+
|
|
69
|
+
def normalize_model_arg(arg: str) -> str:
|
|
70
|
+
"""Normaliza el nombre del modelo solicitado."""
|
|
71
|
+
m = arg.lower().strip()
|
|
72
|
+
if m in ("flash", "gemini"): return "google/gemini-2.5-flash"
|
|
73
|
+
if m in ("pro", "sonnet"): return "anthropic/claude-3-5-sonnet"
|
|
74
|
+
if m in ("fast", "haiku"): return "anthropic/claude-3-haiku"
|
|
75
|
+
return m
|