@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,656 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Bridge de terminal para Conny con memoria local en SQLite.
4
+
5
+ Funciones principales:
6
+ - Conversacion interactiva y no interactiva contra /test
7
+ - Persistencia de turnos en la tabla bridge_sessions de conny.db
8
+ - Comandos /history, /clear, /export
9
+ - Modo --test-mode con 10 turnos y validaciones de contexto
10
+ - Evidencia en logs/bridge_YYYYMMDD_HHMMSS.txt
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import sys
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence, Tuple
26
+
27
+ import httpx
28
+
29
+
30
+ ROOT_DIR = Path(__file__).resolve().parent
31
+ DEFAULT_DB_PATH = ROOT_DIR / "conny.db"
32
+ DEFAULT_ENV_PATH = ROOT_DIR / ".env"
33
+ DEFAULT_LOGS_DIR = ROOT_DIR / "logs"
34
+ DEFAULT_URL = os.getenv("CONNY_BRIDGE_URL", "http://localhost:8001")
35
+ DEFAULT_TIMEOUT = float(os.getenv("CONNY_BRIDGE_TIMEOUT", "45"))
36
+
37
+
38
+ def utc_now() -> datetime:
39
+ return datetime.now(timezone.utc)
40
+
41
+
42
+ def iso_now() -> str:
43
+ return utc_now().replace(microsecond=0).isoformat()
44
+
45
+
46
+ def stamp_now() -> str:
47
+ return utc_now().strftime("%Y%m%d_%H%M%S")
48
+
49
+
50
+ def load_env_file(path: Path) -> Dict[str, str]:
51
+ data: Dict[str, str] = {}
52
+ if not path.exists():
53
+ return data
54
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
55
+ line = raw_line.strip()
56
+ if not line or line.startswith("#") or "=" not in line:
57
+ continue
58
+ key, value = line.split("=", 1)
59
+ value = value.strip().strip('"').strip("'")
60
+ data[key.strip()] = value
61
+ return data
62
+
63
+
64
+ def build_log_path(logs_dir: Path) -> Path:
65
+ logs_dir.mkdir(parents=True, exist_ok=True)
66
+ return logs_dir / f"bridge_{stamp_now()}.txt"
67
+
68
+
69
+ def default_export_path(logs_dir: Path, session_id: str) -> Path:
70
+ logs_dir.mkdir(parents=True, exist_ok=True)
71
+ return logs_dir / f"bridge_export_{session_id}_{stamp_now()}.json"
72
+
73
+
74
+ def parse_history_limit(parts: Sequence[str]) -> Optional[int]:
75
+ if len(parts) < 2:
76
+ return None
77
+ try:
78
+ value = int(parts[1])
79
+ except ValueError as exc:
80
+ raise ValueError("Usage: /history [limit]") from exc
81
+ if value <= 0:
82
+ raise ValueError("History limit must be > 0")
83
+ return value
84
+
85
+
86
+ @dataclass
87
+ class BridgeTurn:
88
+ session_id: str
89
+ runtime_user_id: str
90
+ turn_index: int
91
+ role: str
92
+ content: str
93
+ created_at: str
94
+ command: str = ""
95
+ metadata: Optional[Dict[str, Any]] = None
96
+
97
+
98
+ @dataclass
99
+ class TransportReply:
100
+ response: str
101
+ raw: Dict[str, Any]
102
+
103
+
104
+ class Transport(Protocol):
105
+ def send(self, message: str, user_id: str) -> TransportReply:
106
+ ...
107
+
108
+
109
+ class BridgeStore:
110
+ def __init__(self, db_path: Path) -> None:
111
+ self.db_path = Path(db_path)
112
+ self.ensure_schema()
113
+
114
+ def _connect(self) -> sqlite3.Connection:
115
+ conn = sqlite3.connect(str(self.db_path), timeout=30)
116
+ conn.row_factory = sqlite3.Row
117
+ return conn
118
+
119
+ def ensure_schema(self) -> None:
120
+ with self._connect() as conn:
121
+ conn.execute(
122
+ """
123
+ CREATE TABLE IF NOT EXISTS bridge_sessions (
124
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
125
+ session_id TEXT NOT NULL,
126
+ runtime_user_id TEXT NOT NULL,
127
+ turn_index INTEGER NOT NULL,
128
+ role TEXT NOT NULL,
129
+ content TEXT NOT NULL,
130
+ command TEXT NOT NULL DEFAULT '',
131
+ metadata TEXT NOT NULL DEFAULT '{}',
132
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
133
+ )
134
+ """
135
+ )
136
+ conn.execute(
137
+ """
138
+ CREATE INDEX IF NOT EXISTS idx_bridge_sessions_session_turn
139
+ ON bridge_sessions(session_id, turn_index, id)
140
+ """
141
+ )
142
+ conn.execute(
143
+ """
144
+ CREATE INDEX IF NOT EXISTS idx_bridge_sessions_runtime
145
+ ON bridge_sessions(runtime_user_id, created_at)
146
+ """
147
+ )
148
+
149
+ def next_turn_index(self, session_id: str) -> int:
150
+ with self._connect() as conn:
151
+ row = conn.execute(
152
+ "SELECT COALESCE(MAX(turn_index), 0) AS max_turn FROM bridge_sessions WHERE session_id=?",
153
+ (session_id,),
154
+ ).fetchone()
155
+ return int(row["max_turn"] or 0) + 1
156
+
157
+ def append_turn(
158
+ self,
159
+ session_id: str,
160
+ runtime_user_id: str,
161
+ role: str,
162
+ content: str,
163
+ *,
164
+ command: str = "",
165
+ metadata: Optional[Dict[str, Any]] = None,
166
+ ) -> BridgeTurn:
167
+ turn_index = self.next_turn_index(session_id)
168
+ created_at = iso_now()
169
+ payload = json.dumps(metadata or {}, ensure_ascii=False)
170
+ with self._connect() as conn:
171
+ conn.execute(
172
+ """
173
+ INSERT INTO bridge_sessions
174
+ (session_id, runtime_user_id, turn_index, role, content, command, metadata, created_at)
175
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
176
+ """,
177
+ (session_id, runtime_user_id, turn_index, role, content, command, payload, created_at),
178
+ )
179
+ return BridgeTurn(
180
+ session_id=session_id,
181
+ runtime_user_id=runtime_user_id,
182
+ turn_index=turn_index,
183
+ role=role,
184
+ content=content,
185
+ command=command,
186
+ metadata=metadata or {},
187
+ created_at=created_at,
188
+ )
189
+
190
+ def get_history(self, session_id: str, limit: Optional[int] = None) -> List[BridgeTurn]:
191
+ query = """
192
+ SELECT session_id, runtime_user_id, turn_index, role, content, command, metadata, created_at
193
+ FROM bridge_sessions
194
+ WHERE session_id=?
195
+ ORDER BY turn_index ASC, id ASC
196
+ """
197
+ params: Tuple[Any, ...] = (session_id,)
198
+ if limit:
199
+ query = """
200
+ SELECT * FROM (
201
+ SELECT session_id, runtime_user_id, turn_index, role, content, command, metadata, created_at
202
+ FROM bridge_sessions
203
+ WHERE session_id=?
204
+ ORDER BY turn_index DESC, id DESC
205
+ LIMIT ?
206
+ ) recent
207
+ ORDER BY turn_index ASC
208
+ """
209
+ params = (session_id, limit)
210
+ with self._connect() as conn:
211
+ rows = conn.execute(query, params).fetchall()
212
+ history: List[BridgeTurn] = []
213
+ for row in rows:
214
+ try:
215
+ metadata = json.loads(row["metadata"] or "{}")
216
+ except json.JSONDecodeError:
217
+ metadata = {}
218
+ history.append(
219
+ BridgeTurn(
220
+ session_id=row["session_id"],
221
+ runtime_user_id=row["runtime_user_id"],
222
+ turn_index=row["turn_index"],
223
+ role=row["role"],
224
+ content=row["content"],
225
+ command=row["command"],
226
+ metadata=metadata,
227
+ created_at=row["created_at"],
228
+ )
229
+ )
230
+ return history
231
+
232
+ def export_session(self, session_id: str) -> Dict[str, Any]:
233
+ turns = self.get_history(session_id)
234
+ runtime_user_id = turns[0].runtime_user_id if turns else ""
235
+ return {
236
+ "session_id": session_id,
237
+ "runtime_user_id": runtime_user_id,
238
+ "turn_count": len(turns),
239
+ "exported_at": iso_now(),
240
+ "turns": [
241
+ {
242
+ "turn_index": turn.turn_index,
243
+ "role": turn.role,
244
+ "content": turn.content,
245
+ "command": turn.command,
246
+ "metadata": turn.metadata or {},
247
+ "created_at": turn.created_at,
248
+ }
249
+ for turn in turns
250
+ ],
251
+ }
252
+
253
+
254
+ class ConnyTransport:
255
+ def __init__(self, base_url: str, master_key: str = "", timeout: float = DEFAULT_TIMEOUT) -> None:
256
+ self.base_url = base_url.rstrip("/")
257
+ self.master_key = master_key
258
+ self.timeout = timeout
259
+ self._client = httpx.Client(timeout=timeout)
260
+
261
+ def send(self, message: str, user_id: str) -> TransportReply:
262
+ headers = {"Content-Type": "application/json"}
263
+ if self.master_key:
264
+ headers["X-Master-Key"] = self.master_key
265
+ response = self._client.post(
266
+ f"{self.base_url}/test",
267
+ json={"message": message, "user_id": user_id},
268
+ headers=headers,
269
+ )
270
+ response.raise_for_status()
271
+ payload = response.json()
272
+ body = payload.get("response") or "\n".join(str(item) for item in payload.get("bubbles") or [])
273
+ return TransportReply(response=body.strip() or "(sin respuesta)", raw=payload)
274
+
275
+ def close(self) -> None:
276
+ self._client.close()
277
+
278
+
279
+ class LocalConnyTransport:
280
+ def __init__(self) -> None:
281
+ import conny as conny_module
282
+
283
+ self._module = conny_module
284
+ if getattr(self._module, "conny", None) is None:
285
+ asyncio.run(self._module.init_conny())
286
+
287
+ def send(self, message: str, user_id: str) -> TransportReply:
288
+ responses = asyncio.run(self._module.conny.process_message(user_id, message))
289
+ bubbles = [str(item) for item in responses or [] if str(item).strip()]
290
+ body = "\n".join(bubbles).strip() or "(sin respuesta)"
291
+ payload = {
292
+ "ok": True,
293
+ "message": message,
294
+ "user_id": user_id,
295
+ "bubbles": bubbles,
296
+ "response": body,
297
+ "transport": "local",
298
+ }
299
+ return TransportReply(response=body, raw=payload)
300
+
301
+ def close(self) -> None:
302
+ return None
303
+
304
+
305
+ class BridgeLogger:
306
+ def __init__(self, path: Path) -> None:
307
+ self.path = path
308
+ self.path.parent.mkdir(parents=True, exist_ok=True)
309
+ self._fh = self.path.open("a", encoding="utf-8")
310
+ self._closed = False
311
+ self.write("system", f"log_start path={self.path}")
312
+
313
+ def write(self, role: str, content: str) -> None:
314
+ line = f"[{iso_now()}] {role.upper()}: {content}\n"
315
+ self._fh.write(line)
316
+ self._fh.flush()
317
+
318
+ def close(self) -> None:
319
+ if self._closed:
320
+ return
321
+ self.write("system", "log_end")
322
+ self._fh.close()
323
+ self._closed = True
324
+
325
+
326
+ class ConnyBridge:
327
+ def __init__(
328
+ self,
329
+ store: BridgeStore,
330
+ transport: Transport,
331
+ logger: BridgeLogger,
332
+ *,
333
+ session_id: Optional[str] = None,
334
+ runtime_user_id: Optional[str] = None,
335
+ logs_dir: Optional[Path] = None,
336
+ ) -> None:
337
+ self.store = store
338
+ self.transport = transport
339
+ self.logger = logger
340
+ self.logs_dir = logs_dir or DEFAULT_LOGS_DIR
341
+ self.session_id = session_id or self._new_session_id()
342
+ self.runtime_user_id = runtime_user_id or self._new_runtime_user_id(self.session_id)
343
+ self.logger.write(
344
+ "system",
345
+ f"session_started session_id={self.session_id} runtime_user_id={self.runtime_user_id}",
346
+ )
347
+
348
+ @staticmethod
349
+ def _new_session_id() -> str:
350
+ return f"bridge_{stamp_now()}_{uuid.uuid4().hex[:8]}"
351
+
352
+ @staticmethod
353
+ def _new_runtime_user_id(session_id: str) -> str:
354
+ return f"{session_id}_user"
355
+
356
+ def send(self, message: str) -> TransportReply:
357
+ clean = message.strip()
358
+ if not clean:
359
+ raise ValueError("Empty message")
360
+ self.store.append_turn(self.session_id, self.runtime_user_id, "user", clean)
361
+ self.logger.write("user", clean)
362
+ reply = self.transport.send(clean, self.runtime_user_id)
363
+ self.store.append_turn(
364
+ self.session_id,
365
+ self.runtime_user_id,
366
+ "assistant",
367
+ reply.response,
368
+ metadata={"raw": reply.raw},
369
+ )
370
+ self.logger.write("assistant", reply.response)
371
+ return reply
372
+
373
+ def format_history(self, limit: Optional[int] = None) -> str:
374
+ turns = self.store.get_history(self.session_id, limit=limit)
375
+ visible = [turn for turn in turns if turn.role in {"user", "assistant"}]
376
+ if not visible:
377
+ return "(sin historial)"
378
+ return "\n".join(
379
+ f"[{turn.turn_index:02d}] {turn.role}: {turn.content}" for turn in visible
380
+ )
381
+
382
+ def export_session(self, export_path: Optional[Path] = None) -> Path:
383
+ export_path = export_path or default_export_path(self.logs_dir, self.session_id)
384
+ payload = self.store.export_session(self.session_id)
385
+ export_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
386
+ self.logger.write("command", f"/export -> {export_path}")
387
+ return export_path
388
+
389
+ def clear_session(self) -> Tuple[str, str]:
390
+ old_session = self.session_id
391
+ new_session_id = self._new_session_id()
392
+ self.store.append_turn(
393
+ self.session_id,
394
+ self.runtime_user_id,
395
+ "command",
396
+ "session cleared",
397
+ command="/clear",
398
+ metadata={"next_session_id": new_session_id},
399
+ )
400
+ new_runtime_user_id = self._new_runtime_user_id(new_session_id)
401
+ self.session_id = new_session_id
402
+ self.runtime_user_id = new_runtime_user_id
403
+ self.logger.write(
404
+ "command",
405
+ f"/clear old_session={old_session} new_session={new_session_id}",
406
+ )
407
+ return old_session, new_session_id
408
+
409
+ def handle_command(self, line: str) -> str:
410
+ parts = line.strip().split()
411
+ command = parts[0].lower()
412
+
413
+ if command == "/history":
414
+ limit = parse_history_limit(parts)
415
+ output = self.format_history(limit=limit)
416
+ self.logger.write("command", f"/history limit={limit or 'all'}")
417
+ return output
418
+
419
+ if command == "/clear":
420
+ old_session, new_session = self.clear_session()
421
+ return f"Session cleared. old={old_session} new={new_session}"
422
+
423
+ if command == "/export":
424
+ export_path = Path(parts[1]).expanduser() if len(parts) > 1 else None
425
+ path = self.export_session(export_path)
426
+ return f"Exported session to {path}"
427
+
428
+ if command in {"/help", "/?"}:
429
+ return "Commands: /history [limit], /clear, /export [path], /help, /exit"
430
+
431
+ if command in {"/exit", "/quit"}:
432
+ return "__EXIT__"
433
+
434
+ raise ValueError(f"Unknown command: {command}")
435
+
436
+ def process_line(self, line: str) -> str:
437
+ if line.strip().startswith("/"):
438
+ return self.handle_command(line)
439
+ return self.send(line).response
440
+
441
+ def run_interactive(self) -> int:
442
+ print(f"Bridge session: {self.session_id}")
443
+ print("Commands: /history [limit], /clear, /export [path], /exit")
444
+ while True:
445
+ try:
446
+ line = input("you> ").strip()
447
+ except EOFError:
448
+ return 0
449
+ except KeyboardInterrupt:
450
+ print()
451
+ return 130
452
+ if not line:
453
+ continue
454
+ try:
455
+ output = self.process_line(line)
456
+ except Exception as exc:
457
+ print(f"error> {exc}")
458
+ self.logger.write("error", str(exc))
459
+ continue
460
+ if output == "__EXIT__":
461
+ return 0
462
+ print(f"conny> {output}")
463
+
464
+ def run_messages(self, messages: Iterable[str]) -> int:
465
+ for raw in messages:
466
+ line = raw.rstrip("\n")
467
+ if not line:
468
+ continue
469
+ output = self.process_line(line)
470
+ if output == "__EXIT__":
471
+ return 0
472
+ print(output)
473
+ return 0
474
+
475
+ def _build_test_scenario(self) -> List[Tuple[str, Optional[Tuple[str, ...]]]]:
476
+ if self.transport.__class__.__name__ == "FakeTransport":
477
+ return [
478
+ (
479
+ "Esto es una prueba de memoria conversacional, no una venta. No cambies de tema y responde breve.",
480
+ None,
481
+ ),
482
+ ("Guarda este nombre de cliente: Laura Vega.", None),
483
+ ("Guarda esta ciudad: Medellin.", None),
484
+ ("Guarda este tratamiento de interes: botox.", None),
485
+ ("Guarda este codigo exacto: lima742. Repitelo exacto.", ("lima742",)),
486
+ ("Confirma con una respuesta breve y sigamos.", None),
487
+ ("Sin hablar de ti, responde solo con el nombre de cliente que te di antes.", ("laura",)),
488
+ ("Responde solo con la ciudad que te dije antes.", ("medellin",)),
489
+ ("Responde solo con el tratamiento de interes que te dije antes.", ("botox",)),
490
+ ("Responde solo con el codigo exacto que te di antes.", ("lima742",)),
491
+ (
492
+ "En una sola linea resume los cuatro datos que guardaste antes.",
493
+ ("laura", "medellin", "botox", "lima742"),
494
+ ),
495
+ ]
496
+
497
+ return [
498
+ ("Hola", None),
499
+ ("Clinica de Los olivos", ("olivos", "clinicalosolivos.com")),
500
+ ("Siii somos nosotros", None),
501
+ ("te puedo enviar un pdf, te sirve?", None),
502
+ ("y si te mando un audio lo entiendes?", None),
503
+ ("vale, hagamos una demo como cliente", None),
504
+ ("hola buenas tardes", None),
505
+ ("me interesa botox pero me da miedo quedar exagerada", None),
506
+ ("si quiero cita como seguimos?", None),
507
+ ("antes de seguir, dime con qué negocio estamos haciendo esta demo", ("olivos",)),
508
+ ]
509
+
510
+ def run_test_mode(self) -> Dict[str, Any]:
511
+ scenario = self._build_test_scenario()
512
+
513
+ checks: List[Dict[str, Any]] = []
514
+ failures: List[Dict[str, Any]] = []
515
+ for index, (message, expected_bits) in enumerate(scenario, start=1):
516
+ reply = self.send(message)
517
+ response = reply.response
518
+ passed = True
519
+ missing: List[str] = []
520
+ if expected_bits:
521
+ lowered = response.lower()
522
+ for expected in expected_bits:
523
+ token = expected.lower()
524
+ if token not in lowered:
525
+ passed = False
526
+ missing.append(expected)
527
+ checks.append(
528
+ {
529
+ "turn": index,
530
+ "message": message,
531
+ "response": response,
532
+ "expected": list(expected_bits or []),
533
+ "passed": passed,
534
+ "missing": missing,
535
+ }
536
+ )
537
+ if not passed:
538
+ failures.append(checks[-1])
539
+
540
+ user_turns = [
541
+ turn
542
+ for turn in self.store.get_history(self.session_id)
543
+ if turn.role == "user"
544
+ ]
545
+ if len(user_turns) < 10:
546
+ failures.append(
547
+ {
548
+ "turn": "count",
549
+ "message": "",
550
+ "response": "",
551
+ "expected": [">=10 user turns"],
552
+ "passed": False,
553
+ "missing": [f"found={len(user_turns)}"],
554
+ }
555
+ )
556
+
557
+ result = {
558
+ "success": not failures,
559
+ "session_id": self.session_id,
560
+ "runtime_user_id": self.runtime_user_id,
561
+ "turns": len(user_turns),
562
+ "checks": checks,
563
+ "failures": failures,
564
+ "log_path": str(self.logger.path),
565
+ }
566
+ self.logger.write("system", json.dumps(result, ensure_ascii=False))
567
+ if failures:
568
+ first_failure = failures[0]
569
+ raise RuntimeError(
570
+ f"Context check failed on turn {first_failure['turn']}. Missing: {', '.join(first_failure['missing'])}"
571
+ )
572
+ return result
573
+
574
+
575
+ def resolve_master_key(env_path: Path) -> str:
576
+ env_data = load_env_file(env_path)
577
+ return os.getenv("MASTER_API_KEY", env_data.get("MASTER_API_KEY", ""))
578
+
579
+
580
+ def build_parser() -> argparse.ArgumentParser:
581
+ parser = argparse.ArgumentParser(prog="conny_bridge")
582
+ parser.add_argument("--url", default=DEFAULT_URL, help="Base URL de Conny")
583
+ parser.add_argument("--transport", choices=("auto", "http", "local"), default="auto",
584
+ help="Modo de conexión con Conny")
585
+ parser.add_argument("--db-path", default=str(DEFAULT_DB_PATH), help="Ruta a conny.db")
586
+ parser.add_argument("--env-path", default=str(DEFAULT_ENV_PATH), help="Ruta al .env")
587
+ parser.add_argument("--master-key", default="", help="MASTER_API_KEY opcional")
588
+ parser.add_argument("--session-id", default="", help="Session id fijo")
589
+ parser.add_argument("--user-id", default="", help="Runtime user_id fijo")
590
+ parser.add_argument("--log-path", default="", help="Ruta fija del log de evidencia")
591
+ parser.add_argument("--message", action="append", default=[], help="Mensaje no interactivo")
592
+ parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="Timeout HTTP en segundos")
593
+ parser.add_argument("--test-mode", action="store_true", help="Corre la sesion automatica de contexto")
594
+ return parser
595
+
596
+
597
+ def main(argv: Optional[Sequence[str]] = None) -> int:
598
+ parser = build_parser()
599
+ args = parser.parse_args(argv)
600
+
601
+ db_path = Path(args.db_path).expanduser().resolve()
602
+ env_path = Path(args.env_path).expanduser().resolve()
603
+ logs_dir = DEFAULT_LOGS_DIR
604
+ log_path = Path(args.log_path).expanduser().resolve() if args.log_path else build_log_path(logs_dir)
605
+ master_key = args.master_key or resolve_master_key(env_path)
606
+
607
+ store = BridgeStore(db_path)
608
+ logger = BridgeLogger(log_path)
609
+ transport: Transport
610
+ if args.transport == "local":
611
+ transport = LocalConnyTransport()
612
+ elif args.transport == "http":
613
+ transport = ConnyTransport(args.url, master_key=master_key, timeout=args.timeout)
614
+ else:
615
+ try:
616
+ transport = LocalConnyTransport()
617
+ except Exception:
618
+ transport = ConnyTransport(args.url, master_key=master_key, timeout=args.timeout)
619
+ bridge = ConnyBridge(
620
+ store,
621
+ transport,
622
+ logger,
623
+ session_id=args.session_id or None,
624
+ runtime_user_id=args.user_id or None,
625
+ logs_dir=logs_dir,
626
+ )
627
+
628
+ try:
629
+ if args.test_mode:
630
+ try:
631
+ result = bridge.run_test_mode()
632
+ except Exception as exc:
633
+ error_payload = {
634
+ "success": False,
635
+ "error": str(exc),
636
+ "session_id": bridge.session_id,
637
+ "runtime_user_id": bridge.runtime_user_id,
638
+ "log_path": str(logger.path),
639
+ }
640
+ print(json.dumps(error_payload, ensure_ascii=False, indent=2))
641
+ return 1
642
+ print(json.dumps(result, ensure_ascii=False, indent=2))
643
+ return 0
644
+ if args.message:
645
+ return bridge.run_messages(args.message)
646
+ if not sys.stdin.isatty():
647
+ return bridge.run_messages(sys.stdin)
648
+ return bridge.run_interactive()
649
+ finally:
650
+ if hasattr(transport, "close"):
651
+ transport.close() # type: ignore[attr-defined]
652
+ logger.close()
653
+
654
+
655
+ if __name__ == "__main__":
656
+ raise SystemExit(main())