@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
package/conny_bridge.py
ADDED
|
@@ -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())
|