@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,37 @@
|
|
|
1
|
+
# Nova Governance Rules — Default
|
|
2
|
+
# These rules apply to all Conny instances unless overridden.
|
|
3
|
+
|
|
4
|
+
agent: Conny
|
|
5
|
+
version: "1.0"
|
|
6
|
+
|
|
7
|
+
can_do:
|
|
8
|
+
- "respond to patient greetings and general inquiries"
|
|
9
|
+
- "schedule appointments when patient provides name and preferred time"
|
|
10
|
+
- "provide general service information from knowledge base"
|
|
11
|
+
- "answer frequently asked questions from FAQ database"
|
|
12
|
+
- "redirect patient to specialist for complex medical questions"
|
|
13
|
+
- "send appointment reminders"
|
|
14
|
+
- "collect patient contact information"
|
|
15
|
+
|
|
16
|
+
cannot_do:
|
|
17
|
+
- "provide medical diagnoses or treatment recommendations"
|
|
18
|
+
- "prescribe medication or dosages"
|
|
19
|
+
- "guarantee specific results from procedures"
|
|
20
|
+
- "share patient information with third parties"
|
|
21
|
+
- "offer discounts without admin approval"
|
|
22
|
+
- "make negative comments about patient appearance"
|
|
23
|
+
- "share exact pricing without admin-approved price list"
|
|
24
|
+
- "claim to be human when directly asked if AI"
|
|
25
|
+
|
|
26
|
+
escalate_when:
|
|
27
|
+
- "patient expresses urgency or emergency"
|
|
28
|
+
- "patient asks for refund or files complaint"
|
|
29
|
+
- "patient mentions legal action"
|
|
30
|
+
- "confidence score below 0.3"
|
|
31
|
+
- "patient explicitly requests human agent"
|
|
32
|
+
|
|
33
|
+
response_limits:
|
|
34
|
+
max_bubbles: 3
|
|
35
|
+
max_chars_per_bubble: 300
|
|
36
|
+
min_response_time_ms: 2000
|
|
37
|
+
max_exclamation_marks: 1
|
package/nova_bridge.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nova_bridge.py — Puente entre Nova y Conny
|
|
3
|
+
|
|
4
|
+
Nova es el sistema nervioso de Conny:
|
|
5
|
+
- Antes de enviar cualquier mensaje, Conny pregunta a Nova: "¿puedo?"
|
|
6
|
+
- Nova evalúa las reglas y responde: APPROVED / BLOCKED / ESCALATED
|
|
7
|
+
- Todo queda en el ledger criptográfico de Nova
|
|
8
|
+
- El admin puede crear reglas en lenguaje natural: "no le mandes X a Y"
|
|
9
|
+
|
|
10
|
+
Cómo funciona:
|
|
11
|
+
1. ConnyGuard intercepta cada mensaje ANTES de enviarlo
|
|
12
|
+
2. Llama a Nova /validate con la acción
|
|
13
|
+
3. APPROVED → mensaje se envía normalmente
|
|
14
|
+
4. BLOCKED → Conny responde algo alternativo, no el mensaje bloqueado
|
|
15
|
+
5. ESCALATED → Conny le pregunta al admin antes de enviar
|
|
16
|
+
|
|
17
|
+
Requisitos:
|
|
18
|
+
- Nova server corriendo (puerto 9002 por defecto)
|
|
19
|
+
- NOVA_TOKEN configurado en .env (token del agente "Conny")
|
|
20
|
+
- NOVA_URL=http://localhost:9002
|
|
21
|
+
|
|
22
|
+
Sin Nova activo → Conny funciona normal (modo degradado seguro)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
from typing import Dict, List, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger("conny.nova")
|
|
38
|
+
|
|
39
|
+
# ─── Config ──────────────────────────────────────────────────────────────────
|
|
40
|
+
NOVA_URL = os.getenv("NOVA_URL", "http://localhost:9002")
|
|
41
|
+
NOVA_TOKEN = os.getenv("NOVA_TOKEN", "") # Token del agente Conny en Nova
|
|
42
|
+
NOVA_API_KEY = os.getenv("NOVA_API_KEY", "") # API key de Nova
|
|
43
|
+
NOVA_ENABLED = os.getenv("NOVA_ENABLED", "true").lower() == "true"
|
|
44
|
+
NOVA_TIMEOUT = float(os.getenv("NOVA_TIMEOUT", "3.0")) # 3s max, no bloquear usuarios
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─── Veredictos de Nova ───────────────────────────────────────────────────────
|
|
48
|
+
APPROVED = "APPROVED"
|
|
49
|
+
BLOCKED = "BLOCKED"
|
|
50
|
+
ESCALATED = "ESCALATED"
|
|
51
|
+
UNKNOWN = "UNKNOWN"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ─── Cache local para decisiones rápidas ─────────────────────────────────────
|
|
55
|
+
_decision_cache: Dict[str, Tuple[str, float]] = {}
|
|
56
|
+
_CACHE_TTL = 300 # 5 min
|
|
57
|
+
|
|
58
|
+
def _cache_key(action: str, context: str) -> str:
|
|
59
|
+
import hashlib
|
|
60
|
+
return hashlib.md5(f"{action}|{context}".encode()).hexdigest()[:12]
|
|
61
|
+
|
|
62
|
+
def _cache_get(key: str) -> Optional[str]:
|
|
63
|
+
if key in _decision_cache:
|
|
64
|
+
verdict, ts = _decision_cache[key]
|
|
65
|
+
if time.time() - ts < _CACHE_TTL:
|
|
66
|
+
return verdict
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _cache_set(key: str, verdict: str):
|
|
70
|
+
_decision_cache[key] = (verdict, time.time())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ─── Reglas locales pre-evaluación ───────────────────────────────────────────
|
|
74
|
+
# Evita llamadas a Nova para casos obvios — <1ms
|
|
75
|
+
|
|
76
|
+
# Mensajes que SIEMPRE se aprueban (mínimamente riesgosos)
|
|
77
|
+
_ALWAYS_APPROVE_PATTERNS = [
|
|
78
|
+
r"^(hola|buenas|buenos días|buenas tardes)$",
|
|
79
|
+
r"^(ok|listo|dale|claro|perfecto|entendido)$",
|
|
80
|
+
r"agendar.*cita",
|
|
81
|
+
r"te paso.*información",
|
|
82
|
+
r"gracias.*escribir",
|
|
83
|
+
r"(horario|horarios|servicios|dirección|ubicación)",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# Mensajes que SIEMPRE se bloquean (nunca debería enviar esto un asistente médico)
|
|
87
|
+
_ALWAYS_BLOCK_PATTERNS = [
|
|
88
|
+
r"(diagnóstico|diagnóstico médico|tengo cáncer|tienes cáncer)",
|
|
89
|
+
r"(toma.*pastilla|toma.*medicamento|dosis.*recomendad)",
|
|
90
|
+
r"(contraindicado.*absolutamente|no debes.*nunca.*hacer)",
|
|
91
|
+
r"(precio.*gratis|descuento.*100%|sin costo.*siempre)",
|
|
92
|
+
r"(manda.*fotos.*privadas|mándame.*foto)",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def _local_pre_check(action: str) -> Optional[str]:
|
|
96
|
+
"""
|
|
97
|
+
Decisión local ultrarrápida antes de llamar a Nova.
|
|
98
|
+
Retorna APPROVED/BLOCKED/None (None = necesita Nova)
|
|
99
|
+
"""
|
|
100
|
+
action_low = action.lower().strip()
|
|
101
|
+
|
|
102
|
+
for pattern in _ALWAYS_BLOCK_PATTERNS:
|
|
103
|
+
if re.search(pattern, action_low):
|
|
104
|
+
log.info(f"[nova_local] BLOCKED (local): {action[:60]}")
|
|
105
|
+
return BLOCKED
|
|
106
|
+
|
|
107
|
+
for pattern in _ALWAYS_APPROVE_PATTERNS:
|
|
108
|
+
if re.search(pattern, action_low):
|
|
109
|
+
return APPROVED
|
|
110
|
+
|
|
111
|
+
return None # Necesita evaluación de Nova
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ─── Cliente Nova ─────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
class NovaClient:
|
|
117
|
+
"""Cliente para la API de Nova."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, url: str = NOVA_URL, api_key: str = NOVA_API_KEY,
|
|
120
|
+
token_id: str = NOVA_TOKEN):
|
|
121
|
+
self.url = url.rstrip("/")
|
|
122
|
+
self.api_key = api_key
|
|
123
|
+
self.token_id = token_id
|
|
124
|
+
self._healthy: Optional[bool] = None
|
|
125
|
+
self._last_health_check = 0.0
|
|
126
|
+
|
|
127
|
+
def _headers(self) -> Dict:
|
|
128
|
+
h = {"Content-Type": "application/json", "User-Agent": "conny/5.0"}
|
|
129
|
+
if self.api_key:
|
|
130
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
131
|
+
h["X-API-Key"] = self.api_key
|
|
132
|
+
return h
|
|
133
|
+
|
|
134
|
+
async def health_check(self) -> bool:
|
|
135
|
+
"""Verifica si Nova está activo. Cachea el resultado por 30s."""
|
|
136
|
+
now = time.time()
|
|
137
|
+
if now - self._last_health_check < 30 and self._healthy is not None:
|
|
138
|
+
return self._healthy
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(timeout=2.0) as c:
|
|
141
|
+
r = await c.get(f"{self.url}/health", headers=self._headers())
|
|
142
|
+
self._healthy = r.status_code in (200, 204)
|
|
143
|
+
self._last_health_check = now
|
|
144
|
+
if self._healthy:
|
|
145
|
+
log.debug("[nova] server healthy")
|
|
146
|
+
return self._healthy
|
|
147
|
+
except Exception as e:
|
|
148
|
+
log.debug(f"[nova] health check failed: {e}")
|
|
149
|
+
self._healthy = False
|
|
150
|
+
self._last_health_check = now
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
async def validate(
|
|
154
|
+
self,
|
|
155
|
+
action: str,
|
|
156
|
+
context: str = "",
|
|
157
|
+
patient_id: str = "",
|
|
158
|
+
dry_run: bool = False
|
|
159
|
+
) -> Dict:
|
|
160
|
+
"""
|
|
161
|
+
Valida una acción con Nova.
|
|
162
|
+
Retorna: {"verdict": "APPROVED|BLOCKED|ESCALATED",
|
|
163
|
+
"score": 0-100, "reason": "...", "ledger_id": "..."}
|
|
164
|
+
"""
|
|
165
|
+
if not self.token_id:
|
|
166
|
+
return {"verdict": APPROVED, "reason": "no_token_configured"}
|
|
167
|
+
|
|
168
|
+
payload = {
|
|
169
|
+
"token_id": self.token_id,
|
|
170
|
+
"action": action,
|
|
171
|
+
"context": context or patient_id,
|
|
172
|
+
"dry_run": dry_run,
|
|
173
|
+
"check_duplicates": False, # No duplicados para mensajes
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
async with httpx.AsyncClient(timeout=NOVA_TIMEOUT) as c:
|
|
178
|
+
r = await c.post(f"{self.url}/validate",
|
|
179
|
+
json=payload, headers=self._headers())
|
|
180
|
+
if r.status_code == 200:
|
|
181
|
+
return r.json()
|
|
182
|
+
elif r.status_code == 403:
|
|
183
|
+
# Nova bloqueó
|
|
184
|
+
return {
|
|
185
|
+
"verdict": BLOCKED,
|
|
186
|
+
"score": 0,
|
|
187
|
+
"reason": r.json().get("reason", "blocked by policy"),
|
|
188
|
+
"ledger_id": r.json().get("ledger_id")
|
|
189
|
+
}
|
|
190
|
+
else:
|
|
191
|
+
log.warning(f"[nova] validate HTTP {r.status_code}")
|
|
192
|
+
return {"verdict": APPROVED, "reason": f"nova_http_{r.status_code}"}
|
|
193
|
+
except asyncio.TimeoutError:
|
|
194
|
+
log.warning("[nova] validate timeout — approving (degraded mode)")
|
|
195
|
+
return {"verdict": APPROVED, "reason": "nova_timeout"}
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log.warning(f"[nova] validate error: {e} — approving (degraded mode)")
|
|
198
|
+
return {"verdict": APPROVED, "reason": f"nova_error: {e}"}
|
|
199
|
+
|
|
200
|
+
async def create_agent_rule(
|
|
201
|
+
self,
|
|
202
|
+
agent_name: str,
|
|
203
|
+
can_do: List[str],
|
|
204
|
+
cannot_do: List[str],
|
|
205
|
+
authorized_by: str = "admin"
|
|
206
|
+
) -> Dict:
|
|
207
|
+
"""
|
|
208
|
+
Crea o actualiza el agente Conny en Nova con nuevas reglas.
|
|
209
|
+
Se llama cuando el admin agrega una nueva regla en lenguaje natural.
|
|
210
|
+
"""
|
|
211
|
+
payload = {
|
|
212
|
+
"agent_name": agent_name,
|
|
213
|
+
"description": "Conny — recepcionista virtual de clínica estética",
|
|
214
|
+
"can_do": can_do,
|
|
215
|
+
"cannot_do": cannot_do,
|
|
216
|
+
"authorized_by": authorized_by,
|
|
217
|
+
}
|
|
218
|
+
try:
|
|
219
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
220
|
+
r = await c.post(f"{self.url}/tokens",
|
|
221
|
+
json=payload, headers=self._headers())
|
|
222
|
+
r.raise_for_status()
|
|
223
|
+
return r.json()
|
|
224
|
+
except Exception as e:
|
|
225
|
+
log.error(f"[nova] create_agent_rule error: {e}")
|
|
226
|
+
return {"error": str(e)}
|
|
227
|
+
|
|
228
|
+
async def get_ledger(self, limit: int = 20) -> List[Dict]:
|
|
229
|
+
"""Obtiene el ledger de decisiones recientes."""
|
|
230
|
+
try:
|
|
231
|
+
async with httpx.AsyncClient(timeout=5.0) as c:
|
|
232
|
+
r = await c.get(f"{self.url}/ledger?limit={limit}",
|
|
233
|
+
headers=self._headers())
|
|
234
|
+
r.raise_for_status()
|
|
235
|
+
return r.json()
|
|
236
|
+
except Exception as e:
|
|
237
|
+
log.warning(f"[nova] get_ledger error: {e}")
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ─── Guard de Conny ─────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
class ConnyGuard:
|
|
244
|
+
"""
|
|
245
|
+
Interceptor principal entre Conny y sus mensajes.
|
|
246
|
+
Se llama antes de enviar cualquier burbuja al paciente.
|
|
247
|
+
|
|
248
|
+
Flujo:
|
|
249
|
+
1. Verificación local ultrarrápida (<1ms)
|
|
250
|
+
2. Caché de decisiones previas (~0ms)
|
|
251
|
+
3. Llamada a Nova si es necesario (~50-200ms)
|
|
252
|
+
4. Si Nova no está → modo degradado (aprueba todo)
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(self, client: NovaClient = None):
|
|
256
|
+
self.client = client or NovaClient()
|
|
257
|
+
self._nova_available = None # None = no verificado aún
|
|
258
|
+
|
|
259
|
+
async def should_send(
|
|
260
|
+
self,
|
|
261
|
+
message: str,
|
|
262
|
+
patient_chat_id: str = "",
|
|
263
|
+
context: str = "",
|
|
264
|
+
clinic_name: str = ""
|
|
265
|
+
) -> Tuple[bool, str, str]:
|
|
266
|
+
"""
|
|
267
|
+
Decide si Conny puede enviar este mensaje.
|
|
268
|
+
|
|
269
|
+
Retorna: (should_send: bool, reason: str, ledger_id: str)
|
|
270
|
+
"""
|
|
271
|
+
if not NOVA_ENABLED or not self.client.token_id:
|
|
272
|
+
return True, "nova_disabled", ""
|
|
273
|
+
|
|
274
|
+
# 1. Decisión local instantánea
|
|
275
|
+
local = _local_pre_check(message)
|
|
276
|
+
if local == BLOCKED:
|
|
277
|
+
return False, "local_policy_blocked", ""
|
|
278
|
+
if local == APPROVED:
|
|
279
|
+
return True, "local_policy_approved", ""
|
|
280
|
+
|
|
281
|
+
# 2. Cache
|
|
282
|
+
ck = _cache_key(message[:100], patient_chat_id)
|
|
283
|
+
cached = _cache_get(ck)
|
|
284
|
+
if cached:
|
|
285
|
+
return cached == APPROVED, f"cached_{cached.lower()}", ""
|
|
286
|
+
|
|
287
|
+
# 3. Nova
|
|
288
|
+
# Construir contexto rico para Nova
|
|
289
|
+
action = f"Send WhatsApp/Telegram message to patient: {message[:200]}"
|
|
290
|
+
full_context = (
|
|
291
|
+
f"Patient: {patient_chat_id} | "
|
|
292
|
+
f"Clinic: {clinic_name} | "
|
|
293
|
+
f"Context: {context[:200]}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
result = await self.client.validate(
|
|
297
|
+
action=action,
|
|
298
|
+
context=full_context,
|
|
299
|
+
patient_id=patient_chat_id
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
verdict = result.get("verdict", UNKNOWN)
|
|
303
|
+
reason = result.get("reason", "")
|
|
304
|
+
ledger_id = result.get("ledger_id", "") or ""
|
|
305
|
+
|
|
306
|
+
_cache_set(ck, verdict)
|
|
307
|
+
|
|
308
|
+
if verdict in (APPROVED, UNKNOWN):
|
|
309
|
+
return True, reason, str(ledger_id)
|
|
310
|
+
elif verdict == ESCALATED:
|
|
311
|
+
# Para clínicas: escalado = preguntarle al admin antes
|
|
312
|
+
log.info(f"[nova] ESCALATED: {message[:60]}")
|
|
313
|
+
return False, f"escalated: {reason}", str(ledger_id)
|
|
314
|
+
else:
|
|
315
|
+
# BLOCKED
|
|
316
|
+
log.info(f"[nova] BLOCKED (#{ledger_id}): {reason}")
|
|
317
|
+
return False, reason, str(ledger_id)
|
|
318
|
+
|
|
319
|
+
async def filter_bubbles(
|
|
320
|
+
self,
|
|
321
|
+
bubbles: List[str],
|
|
322
|
+
patient_chat_id: str = "",
|
|
323
|
+
context: str = "",
|
|
324
|
+
clinic_name: str = ""
|
|
325
|
+
) -> Tuple[List[str], List[str]]:
|
|
326
|
+
"""
|
|
327
|
+
Filtra una lista de burbujas.
|
|
328
|
+
Retorna: (allowed_bubbles, blocked_bubbles)
|
|
329
|
+
"""
|
|
330
|
+
allowed = []
|
|
331
|
+
blocked = []
|
|
332
|
+
|
|
333
|
+
for bubble in bubbles:
|
|
334
|
+
ok, reason, _ = await self.should_send(
|
|
335
|
+
bubble, patient_chat_id, context, clinic_name
|
|
336
|
+
)
|
|
337
|
+
if ok:
|
|
338
|
+
allowed.append(bubble)
|
|
339
|
+
else:
|
|
340
|
+
blocked.append(bubble)
|
|
341
|
+
if not reason.startswith("local_"):
|
|
342
|
+
log.info(f"[nova] bubble blocked: {bubble[:60]} | {reason}")
|
|
343
|
+
|
|
344
|
+
return allowed, blocked
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ─── Traductor lenguaje natural → reglas Nova ─────────────────────────────────
|
|
348
|
+
|
|
349
|
+
async def nl_to_nova_rules(
|
|
350
|
+
instruction: str,
|
|
351
|
+
llm_complete_fn, # función llm_engine.complete de conny
|
|
352
|
+
existing_rules: Dict = None
|
|
353
|
+
) -> Dict:
|
|
354
|
+
"""
|
|
355
|
+
Traduce una instrucción en lenguaje natural a reglas de Nova.
|
|
356
|
+
|
|
357
|
+
Ej: "No permitas que Conny le envíe precios a clientes con menos de 3 visitas"
|
|
358
|
+
→ cannot_do: ["send price information to new patients with fewer than 3 visits"]
|
|
359
|
+
|
|
360
|
+
Retorna: {"can_do": [...], "cannot_do": [...], "explanation": "..."}
|
|
361
|
+
"""
|
|
362
|
+
existing = json.dumps(existing_rules or {}, ensure_ascii=False)[:500]
|
|
363
|
+
|
|
364
|
+
prompt = f"""Eres un motor de políticas para Conny, una recepcionista virtual de clínica estética.
|
|
365
|
+
|
|
366
|
+
El administrador dijo: "{instruction}"
|
|
367
|
+
|
|
368
|
+
Reglas actuales:
|
|
369
|
+
{existing}
|
|
370
|
+
|
|
371
|
+
TAREA: Traduce esa instrucción a reglas para el motor de gobernanza Nova.
|
|
372
|
+
|
|
373
|
+
Devuelve SOLO este JSON (sin markdown, sin explicación):
|
|
374
|
+
{{
|
|
375
|
+
"can_do": ["acción específica que SÍ puede hacer relacionada con esto"],
|
|
376
|
+
"cannot_do": ["acción específica que NO puede hacer, formulada así: 'send/say/share X to/about Y'"],
|
|
377
|
+
"explanation": "resumen en una oración de lo que cambiará",
|
|
378
|
+
"rule_type": "block_message|allow_message|restrict_patient|restrict_content|restrict_timing"
|
|
379
|
+
}}
|
|
380
|
+
|
|
381
|
+
EJEMPLOS:
|
|
382
|
+
"No le mandes precios a clientes nuevos"
|
|
383
|
+
→ cannot_do: ["send price list to new patients who have contacted us fewer than 3 times",
|
|
384
|
+
"share pricing information before explaining service value"]
|
|
385
|
+
|
|
386
|
+
"Permite hablar de descuentos solo si el dueño lo autoriza"
|
|
387
|
+
→ cannot_do: ["mention discounts without admin approval",
|
|
388
|
+
"offer promotional pricing autonomously"]
|
|
389
|
+
can_do: ["discuss discounts after receiving admin approval token"]
|
|
390
|
+
|
|
391
|
+
"Nunca le digas a una paciente que tiene muchas arrugas"
|
|
392
|
+
→ cannot_do: ["make negative comments about patient's appearance",
|
|
393
|
+
"describe physical defects directly to patient"]"""
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
raw, _ = await asyncio.wait_for(
|
|
397
|
+
llm_complete_fn(
|
|
398
|
+
[{"role": "user", "content": prompt}],
|
|
399
|
+
model_tier="fast",
|
|
400
|
+
temperature=0.1,
|
|
401
|
+
max_tokens=400,
|
|
402
|
+
use_cache=False
|
|
403
|
+
),
|
|
404
|
+
timeout=10.0
|
|
405
|
+
)
|
|
406
|
+
raw = raw.strip()
|
|
407
|
+
m = re.search(r'\{[\s\S]+\}', raw)
|
|
408
|
+
if m:
|
|
409
|
+
return json.loads(m.group(0))
|
|
410
|
+
except Exception as e:
|
|
411
|
+
log.error(f"[nova_bridge] nl_to_rules error: {e}")
|
|
412
|
+
|
|
413
|
+
return {"can_do": [], "cannot_do": [], "explanation": "no procesado", "rule_type": "block_message"}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ─── Funciones de configuración ───────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
async def setup_conny_agent(
|
|
419
|
+
client: NovaClient,
|
|
420
|
+
clinic_name: str,
|
|
421
|
+
agent_name: str = "Conny"
|
|
422
|
+
) -> Optional[str]:
|
|
423
|
+
"""
|
|
424
|
+
Crea el agente Conny en Nova con reglas base para clínicas estéticas.
|
|
425
|
+
Retorna el token_id o None si falló.
|
|
426
|
+
"""
|
|
427
|
+
can_do = [
|
|
428
|
+
"send appointment confirmations",
|
|
429
|
+
"answer questions about clinic services",
|
|
430
|
+
"provide clinic hours and location information",
|
|
431
|
+
"ask for patient name and contact information",
|
|
432
|
+
"offer free consultation booking",
|
|
433
|
+
"answer general questions about aesthetic procedures",
|
|
434
|
+
"provide general information about procedure duration and recovery",
|
|
435
|
+
"refer patients to call the clinic for urgent matters",
|
|
436
|
+
]
|
|
437
|
+
cannot_do = [
|
|
438
|
+
"provide specific medical diagnosis",
|
|
439
|
+
"prescribe medications or dosages",
|
|
440
|
+
"share another patient's personal information",
|
|
441
|
+
"guarantee specific treatment results",
|
|
442
|
+
"offer unauthorized discounts or promotions",
|
|
443
|
+
"discuss competitor clinics negatively",
|
|
444
|
+
"share the clinic's internal pricing strategy",
|
|
445
|
+
"send messages that could be interpreted as medical advice",
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
result = await client.create_agent_rule(
|
|
449
|
+
agent_name=f"Conny - {clinic_name}",
|
|
450
|
+
can_do=can_do,
|
|
451
|
+
cannot_do=cannot_do,
|
|
452
|
+
authorized_by="admin"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if "error" not in result:
|
|
456
|
+
token_id = result.get("token_id", "")
|
|
457
|
+
log.info(f"[nova] agente Conny creado: {token_id[:20]}...")
|
|
458
|
+
return token_id
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def get_ledger_summary(client: NovaClient, limit: int = 10) -> str:
|
|
463
|
+
"""
|
|
464
|
+
Resumen del ledger de Nova para mostrar al admin.
|
|
465
|
+
"""
|
|
466
|
+
entries = await client.get_ledger(limit=limit)
|
|
467
|
+
if not entries:
|
|
468
|
+
return "El ledger de Nova está vacío."
|
|
469
|
+
|
|
470
|
+
lines = [f"Últimas {len(entries)} decisiones de Nova:\n"]
|
|
471
|
+
for e in entries:
|
|
472
|
+
verdict = e.get("verdict", "?")
|
|
473
|
+
action = (e.get("action", "") or "")[:60]
|
|
474
|
+
reason = (e.get("reason", "") or "")[:40]
|
|
475
|
+
ts = (e.get("created_at", "") or "")[:16]
|
|
476
|
+
icon = "✓" if verdict == APPROVED else ("✗" if verdict == BLOCKED else "!")
|
|
477
|
+
lines.append(f" {icon} [{ts}] {action} — {reason}")
|
|
478
|
+
|
|
479
|
+
return "\n".join(lines)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ─── Instancia global ──────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
_guard: Optional[ConnyGuard] = None
|
|
485
|
+
_client: Optional[NovaClient] = None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def init_nova() -> ConnyGuard:
|
|
489
|
+
"""Inicializa el puente Nova. Seguro aunque Nova no esté activo."""
|
|
490
|
+
global _guard, _client
|
|
491
|
+
_client = NovaClient(
|
|
492
|
+
url=NOVA_URL,
|
|
493
|
+
api_key=NOVA_API_KEY,
|
|
494
|
+
token_id=NOVA_TOKEN
|
|
495
|
+
)
|
|
496
|
+
_guard = ConnyGuard(_client)
|
|
497
|
+
if NOVA_ENABLED:
|
|
498
|
+
log.info(f"[nova] bridge iniciado → {NOVA_URL}")
|
|
499
|
+
else:
|
|
500
|
+
log.info("[nova] bridge desactivado (NOVA_ENABLED=false)")
|
|
501
|
+
return _guard
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def get_guard() -> Optional[ConnyGuard]:
|
|
505
|
+
return _guard
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def get_client() -> Optional[NovaClient]:
|
|
509
|
+
return _client
|