@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,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,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())