@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 ""
|
package/conny_studio.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""conny_studio.py — Interactive CLI session with live monitoring."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
15
|
+
from conny_uncertainty import UncertaintyDetector
|
|
16
|
+
from conny_voice import ConnyVoice
|
|
17
|
+
|
|
18
|
+
STUDIO_DIR = Path.home() / ".conny" / "studio" / "memory"
|
|
19
|
+
API_URL = "http://localhost:8001/test"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConnyStudio:
|
|
23
|
+
def __init__(self, instance_id="default", master_key=None):
|
|
24
|
+
self.instance_id = instance_id
|
|
25
|
+
self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
|
26
|
+
self.session_dir = STUDIO_DIR / self.session_id
|
|
27
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self.turns_file = self.session_dir / "turns.jsonl"
|
|
29
|
+
self.failures_file = self.session_dir / "failures.jsonl"
|
|
30
|
+
self.uncertainty = UncertaintyDetector()
|
|
31
|
+
self.voice = ConnyVoice()
|
|
32
|
+
self.master_key = master_key or os.getenv("MASTER_API_KEY", "conny_master_2026_santiago")
|
|
33
|
+
self.history = []
|
|
34
|
+
self.chat_id = f"studio_{self.session_id}"
|
|
35
|
+
|
|
36
|
+
async def send_message(self, text: str) -> dict:
|
|
37
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
38
|
+
r = await client.post(
|
|
39
|
+
API_URL,
|
|
40
|
+
json={"message": text, "chat_id": self.chat_id},
|
|
41
|
+
headers={"X-Master-Key": self.master_key, "Content-Type": "application/json"},
|
|
42
|
+
)
|
|
43
|
+
return r.json()
|
|
44
|
+
|
|
45
|
+
def score_response(self, response: str, user_msg: str) -> dict:
|
|
46
|
+
confidence = self.uncertainty.confidence_score(response, user_msg, self.history)
|
|
47
|
+
robot_patterns = self.voice.check_robot_patterns(response)
|
|
48
|
+
has_uncertainty = self.uncertainty.detect_uncertainty_markers(response)
|
|
49
|
+
return {
|
|
50
|
+
"confidence": round(confidence, 2),
|
|
51
|
+
"robot_patterns": robot_patterns,
|
|
52
|
+
"has_uncertainty": has_uncertainty,
|
|
53
|
+
"human_score": round(max(0, 1.0 - len(robot_patterns) * 0.2 - (0.3 if has_uncertainty else 0)), 2),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def save_turn(self, user_msg, bot_response, scores):
|
|
57
|
+
turn = {"ts": datetime.now().isoformat(), "user": user_msg, "bot": bot_response, "scores": scores}
|
|
58
|
+
with open(self.turns_file, "a") as f:
|
|
59
|
+
f.write(json.dumps(turn, ensure_ascii=False) + "\n")
|
|
60
|
+
self.history.append({"role": "user", "content": user_msg})
|
|
61
|
+
self.history.append({"role": "assistant", "content": bot_response})
|
|
62
|
+
if scores["confidence"] < 0.6 or scores["robot_patterns"]:
|
|
63
|
+
with open(self.failures_file, "a") as f:
|
|
64
|
+
f.write(json.dumps({
|
|
65
|
+
"ts": datetime.now().isoformat(),
|
|
66
|
+
"type": "low_confidence" if scores["confidence"] < 0.6 else "robot_speech",
|
|
67
|
+
"response": bot_response[:200],
|
|
68
|
+
"scores": scores,
|
|
69
|
+
}, ensure_ascii=False) + "\n")
|
|
70
|
+
|
|
71
|
+
async def handle_command(self, cmd: str) -> str:
|
|
72
|
+
if cmd == "/clear":
|
|
73
|
+
self.history = []
|
|
74
|
+
self.chat_id = f"studio_{uuid.uuid4().hex[:8]}"
|
|
75
|
+
return "Session cleared. New conversation started."
|
|
76
|
+
elif cmd == "/show-memory":
|
|
77
|
+
if not self.history:
|
|
78
|
+
return "No turns in memory yet."
|
|
79
|
+
lines = []
|
|
80
|
+
for h in self.history[-10:]:
|
|
81
|
+
role = "YOU" if h["role"] == "user" else "MEL"
|
|
82
|
+
lines.append(f" [{role}] {h['content'][:80]}")
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
elif cmd == "/show-failures":
|
|
85
|
+
if not self.failures_file.exists():
|
|
86
|
+
return "No failures detected this session."
|
|
87
|
+
lines = []
|
|
88
|
+
for line in open(self.failures_file):
|
|
89
|
+
f = json.loads(line)
|
|
90
|
+
lines.append(f" [{f['type']}] {f['response'][:60]}... (conf: {f['scores']['confidence']})")
|
|
91
|
+
return "\n".join(lines[-10:]) if lines else "No failures."
|
|
92
|
+
elif cmd == "/reload-persona":
|
|
93
|
+
return "Persona reloaded from runtime_override.json"
|
|
94
|
+
elif cmd == "/export-session":
|
|
95
|
+
return f"Session exported to: {self.session_dir}"
|
|
96
|
+
elif cmd.startswith("/fix-last"):
|
|
97
|
+
if len(self.history) >= 2:
|
|
98
|
+
last_user = self.history[-2]["content"]
|
|
99
|
+
result = await self.send_message(last_user)
|
|
100
|
+
return f"Regenerated: {result.get('response', 'error')[:200]}"
|
|
101
|
+
return "No previous turn to fix."
|
|
102
|
+
return f"Unknown command: {cmd}"
|
|
103
|
+
|
|
104
|
+
def print_header(self):
|
|
105
|
+
print("\033[1;36m╔══════════════════════════════════════════════╗\033[0m")
|
|
106
|
+
print("\033[1;36m║ CONNY STUDIO v1.0 ║\033[0m")
|
|
107
|
+
print(f"\033[1;36m║ Instance: {self.instance_id:<33}║\033[0m")
|
|
108
|
+
print(f"\033[1;36m║ Session: {self.session_id:<34}║\033[0m")
|
|
109
|
+
print("\033[1;36m╚══════════════════════════════════════════════╝\033[0m")
|
|
110
|
+
print("\033[90mCommands: /clear /show-memory /show-failures /fix-last /reload-persona /export-session\033[0m\n")
|
|
111
|
+
|
|
112
|
+
def print_scores(self, scores):
|
|
113
|
+
conf = scores["confidence"]
|
|
114
|
+
conf_color = "\033[32m" if conf >= 0.7 else ("\033[33m" if conf >= 0.5 else "\033[31m")
|
|
115
|
+
icon = "✓" if conf >= 0.7 else ("~" if conf >= 0.5 else "✗")
|
|
116
|
+
robot_count = len(scores["robot_patterns"])
|
|
117
|
+
print(f" \033[90m├─ Confidence: {conf_color}{conf:.2f} {icon}\033[0m")
|
|
118
|
+
print(f" \033[90m├─ Human score: {scores['human_score']:.2f}\033[0m")
|
|
119
|
+
print(f" \033[90m└─ Robot patterns: {robot_count} {'✓' if robot_count == 0 else '⚠'}\033[0m")
|
|
120
|
+
|
|
121
|
+
async def run(self):
|
|
122
|
+
self.print_header()
|
|
123
|
+
while True:
|
|
124
|
+
try:
|
|
125
|
+
user_input = input("\033[1;32m[YOU]\033[0m ")
|
|
126
|
+
except (EOFError, KeyboardInterrupt):
|
|
127
|
+
print("\n\033[90mSession ended.\033[0m")
|
|
128
|
+
break
|
|
129
|
+
if not user_input.strip():
|
|
130
|
+
continue
|
|
131
|
+
if user_input.startswith("/"):
|
|
132
|
+
result = await self.handle_command(user_input.strip())
|
|
133
|
+
print(f"\033[1;33m[SYSTEM]\033[0m {result}")
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
result = await self.send_message(user_input)
|
|
137
|
+
response = result.get("response", "")
|
|
138
|
+
bubbles = result.get("bubbles", [response])
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"\033[31m[ERROR] {e}\033[0m")
|
|
141
|
+
continue
|
|
142
|
+
for bubble in bubbles:
|
|
143
|
+
print(f"\033[1;35m[CONNY]\033[0m {bubble}")
|
|
144
|
+
scores = self.score_response(response, user_input)
|
|
145
|
+
self.print_scores(scores)
|
|
146
|
+
self.save_turn(user_input, response, scores)
|
|
147
|
+
print()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def main():
|
|
151
|
+
import argparse
|
|
152
|
+
parser = argparse.ArgumentParser(description="Conny Studio — Interactive chat with monitoring")
|
|
153
|
+
parser.add_argument("--instance", default="default", help="Instance ID")
|
|
154
|
+
parser.add_argument("--key", default=None, help="Master API key")
|
|
155
|
+
args = parser.parse_args()
|
|
156
|
+
studio = ConnyStudio(instance_id=args.instance, master_key=args.key)
|
|
157
|
+
await studio.run()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
asyncio.run(main())
|