@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,333 @@
|
|
|
1
|
+
"""conny_smart_features.py — 10 power features for human-like intelligence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json, logging, re, time
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger("conny.smart")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
12
|
+
# 1. CROSS-SESSION MEMORY — Remember patients by phone number
|
|
13
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
class CrossSessionMemory:
|
|
16
|
+
"""Remember patient context across multiple conversations."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, instance_id: str = "default"):
|
|
19
|
+
self._dir = Path(f"memory_store/{instance_id}/patients")
|
|
20
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def remember_patient(self, chat_id: str, data: Dict):
|
|
23
|
+
"""Store patient data (name, preferences, last topic)."""
|
|
24
|
+
file = self._dir / f"{self._safe_id(chat_id)}.json"
|
|
25
|
+
existing = json.loads(file.read_text()) if file.exists() else {}
|
|
26
|
+
existing.update(data)
|
|
27
|
+
existing["last_seen"] = datetime.now().isoformat()
|
|
28
|
+
existing["visit_count"] = existing.get("visit_count", 0) + 1
|
|
29
|
+
file.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
|
|
30
|
+
|
|
31
|
+
def recall_patient(self, chat_id: str) -> Dict:
|
|
32
|
+
"""Recall everything we know about this patient."""
|
|
33
|
+
file = self._dir / f"{self._safe_id(chat_id)}.json"
|
|
34
|
+
if file.exists():
|
|
35
|
+
return json.loads(file.read_text())
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
def get_context_for_prompt(self, chat_id: str) -> str:
|
|
39
|
+
"""Build a prompt section with patient memory."""
|
|
40
|
+
data = self.recall_patient(chat_id)
|
|
41
|
+
if not data:
|
|
42
|
+
return ""
|
|
43
|
+
parts = []
|
|
44
|
+
if data.get("name"):
|
|
45
|
+
parts.append(f"Se llama {data['name']}")
|
|
46
|
+
if data.get("last_topic"):
|
|
47
|
+
parts.append(f"La última vez habló de: {data['last_topic']}")
|
|
48
|
+
if data.get("visit_count", 0) > 1:
|
|
49
|
+
parts.append(f"Ya ha escrito {data['visit_count']} veces")
|
|
50
|
+
if data.get("preferences"):
|
|
51
|
+
parts.append(f"Preferencias: {data['preferences']}")
|
|
52
|
+
return "\n".join(parts) if parts else ""
|
|
53
|
+
|
|
54
|
+
def _safe_id(self, chat_id: str) -> str:
|
|
55
|
+
return chat_id.replace("@", "_").replace(".", "_")[:50]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
59
|
+
# 2. FOLLOW-UP ENGINE — Re-engage abandoned conversations
|
|
60
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
61
|
+
|
|
62
|
+
class FollowUpEngine:
|
|
63
|
+
"""Schedule and manage follow-up messages."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, instance_id: str = "default"):
|
|
66
|
+
self._file = Path(f"memory_store/{instance_id}/followups.jsonl")
|
|
67
|
+
self._file.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
def schedule_followup(self, chat_id: str, reason: str, delay_hours: int = 24):
|
|
70
|
+
"""Schedule a follow-up message."""
|
|
71
|
+
entry = {
|
|
72
|
+
"chat_id": chat_id,
|
|
73
|
+
"reason": reason,
|
|
74
|
+
"send_after": (datetime.now() + timedelta(hours=delay_hours)).isoformat(),
|
|
75
|
+
"sent": False,
|
|
76
|
+
}
|
|
77
|
+
with open(self._file, "a") as f:
|
|
78
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
79
|
+
|
|
80
|
+
def get_pending_followups(self) -> List[Dict]:
|
|
81
|
+
"""Get all follow-ups that are due."""
|
|
82
|
+
if not self._file.exists():
|
|
83
|
+
return []
|
|
84
|
+
now = datetime.now().isoformat()
|
|
85
|
+
pending = []
|
|
86
|
+
for line in open(self._file):
|
|
87
|
+
try:
|
|
88
|
+
entry = json.loads(line)
|
|
89
|
+
if not entry.get("sent") and entry.get("send_after", "") <= now:
|
|
90
|
+
pending.append(entry)
|
|
91
|
+
except Exception:
|
|
92
|
+
continue
|
|
93
|
+
return pending
|
|
94
|
+
|
|
95
|
+
def generate_followup_message(self, reason: str) -> str:
|
|
96
|
+
"""Generate a natural follow-up message."""
|
|
97
|
+
templates = {
|
|
98
|
+
"pricing": "hola! ayer estuvimos hablando de precios, te quedó alguna duda?",
|
|
99
|
+
"booking": "hey! me quedé pensando en tu cita, quieres que te ayude a agendar?",
|
|
100
|
+
"info": "hola de nuevo! por si te sirve, aquí estoy para lo que necesites",
|
|
101
|
+
"default": "hola! hace rato no hablamos, necesitas algo?",
|
|
102
|
+
}
|
|
103
|
+
return templates.get(reason, templates["default"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
107
|
+
# 3. SENTIMENT TRACKER — Detect emotional state per turn
|
|
108
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
class SentimentTracker:
|
|
111
|
+
"""Track patient sentiment across conversation turns."""
|
|
112
|
+
|
|
113
|
+
FRUSTRATED = ["no entiendo", "ya le dije", "otra vez", "no me sirve", "eso no",
|
|
114
|
+
"qué lento", "cuánto más", "pésimo", "malo", "horrible"]
|
|
115
|
+
HAPPY = ["gracias", "perfecto", "genial", "excelente", "increíble", "súper",
|
|
116
|
+
"te amo", "la mejor", "mil gracias", "buenísimo"]
|
|
117
|
+
URGENT = ["urgente", "emergencia", "ayuda", "ya", "rápido", "ahora mismo",
|
|
118
|
+
"sangre", "dolor", "grave", "auxilio"]
|
|
119
|
+
|
|
120
|
+
def analyze(self, text: str) -> Dict[str, float]:
|
|
121
|
+
"""Return sentiment scores."""
|
|
122
|
+
t = text.lower()
|
|
123
|
+
return {
|
|
124
|
+
"frustration": sum(1 for w in self.FRUSTRATED if w in t) / max(len(self.FRUSTRATED), 1),
|
|
125
|
+
"happiness": sum(1 for w in self.HAPPY if w in t) / max(len(self.HAPPY), 1),
|
|
126
|
+
"urgency": sum(1 for w in self.URGENT if w in t) / max(len(self.URGENT), 1),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def should_escalate(self, text: str, history: List[Dict] = None) -> Tuple[bool, str]:
|
|
130
|
+
"""Determine if conversation should escalate to human."""
|
|
131
|
+
scores = self.analyze(text)
|
|
132
|
+
if scores["urgency"] > 0.2:
|
|
133
|
+
return True, "urgency_detected"
|
|
134
|
+
if scores["frustration"] > 0.15:
|
|
135
|
+
# Check if frustrated for multiple turns
|
|
136
|
+
if history and len(history) >= 4:
|
|
137
|
+
recent_frustration = sum(
|
|
138
|
+
self.analyze(m.get("content", ""))["frustration"]
|
|
139
|
+
for m in history[-4:] if m.get("role") == "user"
|
|
140
|
+
)
|
|
141
|
+
if recent_frustration > 0.3:
|
|
142
|
+
return True, "sustained_frustration"
|
|
143
|
+
return False, ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
147
|
+
# 4. PREDICTIVE INTENT — Know what returning patients want
|
|
148
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
|
|
150
|
+
class PredictiveIntent:
|
|
151
|
+
"""Predict what a returning patient wants."""
|
|
152
|
+
|
|
153
|
+
def predict(self, patient_memory: Dict, current_msg: str) -> Optional[str]:
|
|
154
|
+
"""Predict intent based on history."""
|
|
155
|
+
last_topic = patient_memory.get("last_topic", "")
|
|
156
|
+
visit_count = patient_memory.get("visit_count", 0)
|
|
157
|
+
|
|
158
|
+
msg_low = current_msg.lower().strip()
|
|
159
|
+
|
|
160
|
+
# Simple greeting from returning patient → probably wants to continue
|
|
161
|
+
if msg_low in ("hola", "buenas", "hey", "hola buenas") and visit_count > 1:
|
|
162
|
+
if last_topic == "pricing":
|
|
163
|
+
return "returning_for_booking"
|
|
164
|
+
if last_topic == "booking":
|
|
165
|
+
return "checking_appointment"
|
|
166
|
+
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def get_proactive_opener(self, prediction: str, patient_name: str = "") -> Optional[str]:
|
|
170
|
+
"""Get a proactive opening based on prediction."""
|
|
171
|
+
name = patient_name or ""
|
|
172
|
+
openers = {
|
|
173
|
+
"returning_for_booking": f"hola{' ' + name if name else ''}! la otra vez estuvimos mirando precios, quieres que agendemos?",
|
|
174
|
+
"checking_appointment": f"hola{' ' + name if name else ''}! vienes por lo de tu cita?",
|
|
175
|
+
}
|
|
176
|
+
return openers.get(prediction)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
180
|
+
# 5. TIME AWARENESS — Greet appropriately for Colombian time
|
|
181
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
182
|
+
|
|
183
|
+
def get_time_greeting() -> str:
|
|
184
|
+
"""Return appropriate greeting for current Colombian time (UTC-5)."""
|
|
185
|
+
from datetime import timezone
|
|
186
|
+
now = datetime.now(timezone(timedelta(hours=-5)))
|
|
187
|
+
hour = now.hour
|
|
188
|
+
if 5 <= hour < 12:
|
|
189
|
+
return "buenos días"
|
|
190
|
+
elif 12 <= hour < 18:
|
|
191
|
+
return "buenas tardes"
|
|
192
|
+
else:
|
|
193
|
+
return "buenas noches"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_business_hours() -> bool:
|
|
197
|
+
"""Check if it's currently business hours in Colombia."""
|
|
198
|
+
from datetime import timezone
|
|
199
|
+
now = datetime.now(timezone(timedelta(hours=-5)))
|
|
200
|
+
return 8 <= now.hour <= 18 and now.weekday() < 6
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
204
|
+
# 6. CONVERSATION CLOSER — Detect natural end
|
|
205
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
206
|
+
|
|
207
|
+
CLOSING_SIGNALS = [
|
|
208
|
+
"gracias", "chao", "bye", "hasta luego", "nos vemos",
|
|
209
|
+
"listo", "perfecto gracias", "dale gracias", "ok gracias",
|
|
210
|
+
"bendiciones", "que estés bien", "buena tarde", "buena noche",
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
def is_conversation_ending(text: str) -> bool:
|
|
214
|
+
"""Detect if the patient is saying goodbye."""
|
|
215
|
+
t = text.lower().strip()
|
|
216
|
+
return any(signal in t for signal in CLOSING_SIGNALS)
|
|
217
|
+
|
|
218
|
+
def get_natural_closing(tone: str = "casual") -> str:
|
|
219
|
+
"""Generate a natural conversation closing."""
|
|
220
|
+
import random
|
|
221
|
+
closings = {
|
|
222
|
+
"casual": ["dale, cualquier cosa me escribes!", "listo, aquí estoy pa lo que necesites", "chao, que te vaya bien!"],
|
|
223
|
+
"luxury": ["fue un placer atenderle, que tenga un excelente día", "quedamos atentos para servirle, que esté muy bien"],
|
|
224
|
+
"formal": ["con gusto, que tenga buen día", "quedamos pendientes, hasta pronto"],
|
|
225
|
+
}
|
|
226
|
+
options = closings.get(tone, closings["casual"])
|
|
227
|
+
return random.choice(options)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
231
|
+
# 7. POST-VISIT FOLLOW-UP — Request reviews after appointment
|
|
232
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
233
|
+
|
|
234
|
+
def generate_review_request(patient_name: str = "", clinic_name: str = "", google_review_link: str = "") -> str:
|
|
235
|
+
"""Generate a natural review request message."""
|
|
236
|
+
name = patient_name or ""
|
|
237
|
+
greeting = f"hola{' ' + name if name else ''}!"
|
|
238
|
+
|
|
239
|
+
if google_review_link:
|
|
240
|
+
return f"{greeting} cómo te fue en tu cita? espero que todo bien\n\nsi te gustó la atención, nos ayudaría muchísimo una reseñita aquí: {google_review_link}\n\ngracias!"
|
|
241
|
+
return f"{greeting} cómo te fue en tu cita? espero que todo super bien"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
245
|
+
# 8. WEEKLY STATS — Count everything
|
|
246
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
247
|
+
|
|
248
|
+
class WeeklyStats:
|
|
249
|
+
"""Calculate weekly statistics for an instance."""
|
|
250
|
+
|
|
251
|
+
def calculate(self, db_path: str = "conny.db") -> Dict[str, int]:
|
|
252
|
+
"""Get stats for the last 7 days."""
|
|
253
|
+
import sqlite3
|
|
254
|
+
try:
|
|
255
|
+
conn = sqlite3.connect(db_path)
|
|
256
|
+
c = conn.cursor()
|
|
257
|
+
week_ago = (datetime.now() - timedelta(days=7)).isoformat()
|
|
258
|
+
|
|
259
|
+
c.execute("SELECT COUNT(DISTINCT chat_id) FROM conversations WHERE role='user' AND created_at > ?", (week_ago,))
|
|
260
|
+
patients = c.fetchone()[0] or 0
|
|
261
|
+
|
|
262
|
+
c.execute("SELECT COUNT(*) FROM conversations WHERE created_at > ?", (week_ago,))
|
|
263
|
+
messages = c.fetchone()[0] or 0
|
|
264
|
+
|
|
265
|
+
conn.close()
|
|
266
|
+
return {"patients": patients, "messages": messages, "period": "7d"}
|
|
267
|
+
except Exception:
|
|
268
|
+
return {"patients": 0, "messages": 0, "period": "7d"}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
272
|
+
# 9. ADMIN SHADOW MODE — Learn from admin's real responses
|
|
273
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
274
|
+
|
|
275
|
+
class AdminShadowMode:
|
|
276
|
+
"""When admin takes over a conversation, learn from their responses."""
|
|
277
|
+
|
|
278
|
+
def __init__(self, instance_id: str = "default"):
|
|
279
|
+
self._instance_id = instance_id
|
|
280
|
+
self._shadow_file = Path(f"soul/{instance_id}/shadow_learnings.jsonl")
|
|
281
|
+
self._shadow_file.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
|
|
283
|
+
def detect_admin_takeover(self, chat_id: str, admin_ids: List[str], message_sender: str) -> bool:
|
|
284
|
+
"""Detect if admin is responding to a patient's chat."""
|
|
285
|
+
# If message is outgoing (fromMe) in a non-admin chat, admin took over
|
|
286
|
+
return message_sender in admin_ids and chat_id not in admin_ids
|
|
287
|
+
|
|
288
|
+
def learn_from_admin_response(self, patient_question: str, admin_response: str):
|
|
289
|
+
"""Save admin's response as a learning example."""
|
|
290
|
+
entry = {
|
|
291
|
+
"ts": datetime.now().isoformat(),
|
|
292
|
+
"patient_asked": patient_question[:200],
|
|
293
|
+
"admin_responded": admin_response[:500],
|
|
294
|
+
"learned": True,
|
|
295
|
+
}
|
|
296
|
+
with open(self._shadow_file, "a") as f:
|
|
297
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
298
|
+
log.info(f"[shadow] learned from admin: {patient_question[:50]} → {admin_response[:50]}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
302
|
+
# 10. LANGUAGE DETECTOR — Auto-detect and respond in same language
|
|
303
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
304
|
+
|
|
305
|
+
class LanguageDetector:
|
|
306
|
+
"""Simple keyword-based language detection."""
|
|
307
|
+
|
|
308
|
+
ENGLISH_MARKERS = ["hello", "hi", "good morning", "how are you", "i want", "i need",
|
|
309
|
+
"please", "thank you", "appointment", "available", "price"]
|
|
310
|
+
PORTUGUESE_MARKERS = ["olá", "bom dia", "boa tarde", "quero", "preciso",
|
|
311
|
+
"por favor", "obrigado", "consulta", "disponível", "preço"]
|
|
312
|
+
|
|
313
|
+
def detect(self, text: str) -> str:
|
|
314
|
+
"""Detect language: es, en, or pt."""
|
|
315
|
+
t = text.lower()
|
|
316
|
+
en_score = sum(1 for m in self.ENGLISH_MARKERS if m in t)
|
|
317
|
+
pt_score = sum(1 for m in self.PORTUGUESE_MARKERS if m in t)
|
|
318
|
+
|
|
319
|
+
if en_score >= 2:
|
|
320
|
+
return "en"
|
|
321
|
+
if pt_score >= 2:
|
|
322
|
+
return "pt"
|
|
323
|
+
if en_score == 1 and not any(w in t for w in ["hola", "buenas", "quiero"]):
|
|
324
|
+
return "en"
|
|
325
|
+
return "es"
|
|
326
|
+
|
|
327
|
+
def get_language_instruction(self, lang: str) -> str:
|
|
328
|
+
"""Get system prompt instruction for detected language."""
|
|
329
|
+
if lang == "en":
|
|
330
|
+
return "The patient is writing in English. Respond entirely in English. Natural, warm tone."
|
|
331
|
+
if lang == "pt":
|
|
332
|
+
return "O paciente está escrevendo em português. Responda totalmente em português brasileiro."
|
|
333
|
+
return ""
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Uncertainty detection: identifies knowledge gaps and alerts admins."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("conny.uncertainty")
|
|
11
|
+
|
|
12
|
+
UNCERTAINTY_MARKERS_ES = [
|
|
13
|
+
"no sé",
|
|
14
|
+
"no tengo información",
|
|
15
|
+
"no estoy segura",
|
|
16
|
+
"no puedo ayudar",
|
|
17
|
+
"no cuento con",
|
|
18
|
+
"no manejo esa información",
|
|
19
|
+
"no tengo datos",
|
|
20
|
+
"desconozco",
|
|
21
|
+
"no sabría decirte",
|
|
22
|
+
"tendría que consultar",
|
|
23
|
+
"no tengo acceso",
|
|
24
|
+
"no dispongo de",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
UNCERTAINTY_MARKERS_EN = [
|
|
28
|
+
"i don't know",
|
|
29
|
+
"i'm not sure",
|
|
30
|
+
"i can't help with that",
|
|
31
|
+
"i don't have that information",
|
|
32
|
+
"let me check",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UncertaintyDetector:
|
|
37
|
+
def __init__(self, threshold: float = 0.4):
|
|
38
|
+
self.threshold = threshold
|
|
39
|
+
self._gaps_dir = Path("knowledge_gaps")
|
|
40
|
+
self._gaps_dir.mkdir(exist_ok=True)
|
|
41
|
+
|
|
42
|
+
def detect_uncertainty_markers(self, text: str) -> bool:
|
|
43
|
+
"""Return True if the text contains any uncertainty marker."""
|
|
44
|
+
text_low = text.lower()
|
|
45
|
+
return any(
|
|
46
|
+
marker in text_low
|
|
47
|
+
for marker in UNCERTAINTY_MARKERS_ES + UNCERTAINTY_MARKERS_EN
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def confidence_score(self, response: str, user_msg: str, history: list) -> float:
|
|
51
|
+
"""Score confidence 0.0-1.0. Detects evasion, not just explicit uncertainty."""
|
|
52
|
+
score = 1.0
|
|
53
|
+
text_low = response.lower()
|
|
54
|
+
user_low = user_msg.lower()
|
|
55
|
+
|
|
56
|
+
# Penalty for uncertainty markers
|
|
57
|
+
marker_count = sum(
|
|
58
|
+
1
|
|
59
|
+
for m in UNCERTAINTY_MARKERS_ES + UNCERTAINTY_MARKERS_EN
|
|
60
|
+
if m in text_low
|
|
61
|
+
)
|
|
62
|
+
score -= marker_count * 0.35
|
|
63
|
+
|
|
64
|
+
# Extra penalty if response IS the uncertainty (very short + marker)
|
|
65
|
+
if len(response.split()) < 8 and marker_count > 0:
|
|
66
|
+
score -= 0.15
|
|
67
|
+
|
|
68
|
+
# Penalty for very short responses to complex questions
|
|
69
|
+
if len(user_msg.split()) > 8 and len(response.split()) < 10:
|
|
70
|
+
score -= 0.2
|
|
71
|
+
|
|
72
|
+
# Penalty for deflection patterns
|
|
73
|
+
deflection = ["pregunta al", "consulta con", "contacta a", "llama a"]
|
|
74
|
+
if any(d in text_low for d in deflection):
|
|
75
|
+
score -= 0.15
|
|
76
|
+
|
|
77
|
+
# === Detect PRICE evasion ===
|
|
78
|
+
# User asked about price but response has no numbers/amounts
|
|
79
|
+
price_signals = ["cuanto", "cuánto", "precio", "vale", "cuesta", "cobran", "tarifa", "costo", "promo"]
|
|
80
|
+
user_asked_price = any(s in user_low for s in price_signals)
|
|
81
|
+
response_has_price = bool(re.search(r'\$?\d[\d.,]+', response)) or any(
|
|
82
|
+
w in text_low for w in ("gratis", "sin costo", "incluido")
|
|
83
|
+
)
|
|
84
|
+
if user_asked_price and not response_has_price:
|
|
85
|
+
score -= 0.45 # Heavy penalty: user asked price, we don't know it
|
|
86
|
+
|
|
87
|
+
# === Detect SERVICE evasion ===
|
|
88
|
+
# User asked about specific service but response says "we don't do that" or redirects
|
|
89
|
+
service_denial = [
|
|
90
|
+
"no manejamos", "no ofrecemos", "no realizamos", "no hacemos",
|
|
91
|
+
"no contamos con", "nuestra especialidad es", "solo manejamos",
|
|
92
|
+
"solo ofrecemos", "no tenemos ese",
|
|
93
|
+
]
|
|
94
|
+
if any(d in text_low for d in service_denial):
|
|
95
|
+
score -= 0.3 # She might be wrong — she doesn't actually know the full service list
|
|
96
|
+
|
|
97
|
+
# === Detect INFO evasion (horarios, disponibilidad, etc) ===
|
|
98
|
+
info_signals = ["horario", "atienden", "abren", "cierran", "disponibilidad", "abierto", "cuando"]
|
|
99
|
+
user_asked_info = any(s in user_low for s in info_signals)
|
|
100
|
+
response_deflects = any(d in text_low for d in ["verificar", "confirmo", "consultar", "le confirmo"])
|
|
101
|
+
if user_asked_info and response_deflects:
|
|
102
|
+
score -= 0.55 # She doesn't know the schedule — must alert admin
|
|
103
|
+
|
|
104
|
+
# === Detect "depende" evasion ===
|
|
105
|
+
vague_deflectors = [
|
|
106
|
+
"depende del servicio", "depende de la valoración", "depende del caso",
|
|
107
|
+
"te confirmo", "déjame verificar", "let me check",
|
|
108
|
+
]
|
|
109
|
+
if any(d in text_low for d in vague_deflectors) and user_asked_price:
|
|
110
|
+
score -= 0.35 # Vague + user wanted specifics = she doesn't know
|
|
111
|
+
|
|
112
|
+
# Bonus for specific information (contains numbers/concrete data)
|
|
113
|
+
if any(c.isdigit() for c in response):
|
|
114
|
+
score += 0.1
|
|
115
|
+
|
|
116
|
+
return max(0.0, min(1.0, score))
|
|
117
|
+
|
|
118
|
+
async def log_gap(
|
|
119
|
+
self,
|
|
120
|
+
instance_id: str,
|
|
121
|
+
user_msg: str,
|
|
122
|
+
bot_response: str,
|
|
123
|
+
confidence: float,
|
|
124
|
+
chat_id: str = "",
|
|
125
|
+
):
|
|
126
|
+
"""Log a knowledge gap to JSONL file."""
|
|
127
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
128
|
+
gap_file = self._gaps_dir / f"{today}.jsonl"
|
|
129
|
+
entry = {
|
|
130
|
+
"ts": datetime.now().isoformat(),
|
|
131
|
+
"instance_id": instance_id,
|
|
132
|
+
"chat_id": chat_id,
|
|
133
|
+
"user_msg": user_msg,
|
|
134
|
+
"bot_response": bot_response,
|
|
135
|
+
"confidence": round(confidence, 3),
|
|
136
|
+
}
|
|
137
|
+
with open(gap_file, "a") as f:
|
|
138
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
139
|
+
log.info(
|
|
140
|
+
f"[uncertainty] gap logged for {instance_id}: confidence={confidence:.2f}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def alert_admin(
|
|
144
|
+
self,
|
|
145
|
+
instance_id: str,
|
|
146
|
+
user_msg: str,
|
|
147
|
+
bot_response: str,
|
|
148
|
+
confidence: float,
|
|
149
|
+
send_fn=None,
|
|
150
|
+
):
|
|
151
|
+
"""Send WhatsApp alert to admin when confidence is low."""
|
|
152
|
+
alert_text = (
|
|
153
|
+
f"Hola, soy Conny de {instance_id}. "
|
|
154
|
+
f"Un paciente preguntó: '{user_msg[:200]}' "
|
|
155
|
+
f"y no supe responderle bien (confianza: {confidence:.0%}). "
|
|
156
|
+
f"¿Me puedes dar la información correcta para aprender?"
|
|
157
|
+
)
|
|
158
|
+
if send_fn:
|
|
159
|
+
try:
|
|
160
|
+
await send_fn(alert_text)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
log.error(f"[uncertainty] failed to alert admin: {e}")
|
|
163
|
+
return alert_text
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Singleton
|
|
167
|
+
uncertainty_detector = UncertaintyDetector()
|
|
File without changes
|