@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.
Files changed (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. 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