@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,154 @@
1
+ """conny_learning.py — Real-time 3-layer learning engine."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import time
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ log = logging.getLogger("conny.learning")
13
+
14
+ POSITIVE_SIGNALS = [
15
+ "gracias", "perfecto", "listo", "genial", "excelente", "dale", "ok perfecto",
16
+ "thanks", "great", "perfect", "awesome",
17
+ "te agradezco", "muy amable", "me queda claro", "entendido",
18
+ ]
19
+ NEGATIVE_SIGNALS = [
20
+ "no entiendo", "eso no es", "no me sirve", "otra vez", "repite",
21
+ "eso ya lo dije", "ya te dije", "no es eso", "equivocad",
22
+ ]
23
+
24
+
25
+ class RealTimeLearningEngine:
26
+ """3-layer learning: per-turn, per-session, admin-corrected."""
27
+
28
+ def __init__(self, base_dir: str = "memory_store"):
29
+ self._base = Path(base_dir)
30
+ self._teachings_dir = Path("teachings")
31
+ self._teachings_dir.mkdir(exist_ok=True)
32
+
33
+ def _instance_dir(self, instance_id: str) -> Path:
34
+ d = self._base / instance_id / "learning"
35
+ d.mkdir(parents=True, exist_ok=True)
36
+ return d
37
+
38
+ async def learn_from_turn(self, instance_id: str, user_msg: str,
39
+ bot_response: str, user_reply: str = ""):
40
+ idir = self._instance_dir(instance_id)
41
+ if user_reply and any(s in user_reply.lower() for s in POSITIVE_SIGNALS):
42
+ await self._reinforce_pattern(idir, bot_response, user_msg)
43
+ if user_reply and any(s in user_reply.lower() for s in NEGATIVE_SIGNALS):
44
+ await self._flag_failed_response(idir, bot_response, user_msg, user_reply)
45
+
46
+ async def _reinforce_pattern(self, idir: Path, response: str, trigger: str):
47
+ file = idir / "reinforced.jsonl"
48
+ entry = {"ts": datetime.now().isoformat(), "trigger": trigger[:200], "response": response[:300]}
49
+ with open(file, "a") as f:
50
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
51
+
52
+ async def _flag_failed_response(self, idir: Path, response: str,
53
+ user_msg: str, user_reply: str):
54
+ file = idir / "failures.jsonl"
55
+ entry = {
56
+ "ts": datetime.now().isoformat(),
57
+ "user_msg": user_msg[:200],
58
+ "bot_response": response[:300],
59
+ "user_complaint": user_reply[:200],
60
+ }
61
+ with open(file, "a") as f:
62
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
63
+ log.info(f"[learning] flagged failed response for {idir.parent.name}")
64
+
65
+ async def learn_from_session(self, instance_id: str, messages: List[Dict],
66
+ outcome: str = "unknown"):
67
+ idir = self._instance_dir(instance_id)
68
+ file = idir / "sessions.jsonl"
69
+ entry = {
70
+ "ts": datetime.now().isoformat(),
71
+ "outcome": outcome,
72
+ "turns": len(messages),
73
+ "summary": self._summarize_session(messages),
74
+ }
75
+ with open(file, "a") as f:
76
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
77
+ if outcome == "booked":
78
+ await self._save_successful_flow(idir, messages)
79
+ elif outcome == "abandoned":
80
+ await self._save_dropout_point(idir, messages)
81
+
82
+ async def _save_successful_flow(self, idir: Path, messages: List[Dict]):
83
+ file = idir / "successful_flows.jsonl"
84
+ flow = [{"role": m["role"], "content": m["content"][:150]} for m in messages[-8:]]
85
+ with open(file, "a") as f:
86
+ f.write(json.dumps({"ts": datetime.now().isoformat(), "flow": flow}, ensure_ascii=False) + "\n")
87
+
88
+ async def _save_dropout_point(self, idir: Path, messages: List[Dict]):
89
+ file = idir / "dropouts.jsonl"
90
+ last_bot = ""
91
+ for m in reversed(messages):
92
+ if m.get("role") == "assistant":
93
+ last_bot = m["content"][:200]
94
+ break
95
+ with open(file, "a") as f:
96
+ f.write(json.dumps({"ts": datetime.now().isoformat(), "last_bot_msg": last_bot}, ensure_ascii=False) + "\n")
97
+
98
+ def _summarize_session(self, messages: List[Dict]) -> str:
99
+ user_msgs = [m["content"] for m in messages if m.get("role") == "user"]
100
+ return " | ".join(msg[:50] for msg in user_msgs[:5]) if user_msgs else "empty"
101
+
102
+ async def learn_from_admin(self, instance_id: str, question: str, answer: str,
103
+ admin_id: str = "") -> str:
104
+ teachings_file = self._teachings_dir / f"{instance_id}.jsonl"
105
+ entry = {
106
+ "ts": datetime.now().isoformat(),
107
+ "question": question,
108
+ "answer": answer,
109
+ "taught_by": admin_id,
110
+ "question_hash": hashlib.md5(question.lower().strip().encode()).hexdigest()[:12],
111
+ }
112
+ with open(teachings_file, "a") as f:
113
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
114
+
115
+ try:
116
+ faq_file = self._base / instance_id / "semantic" / "faqs.json"
117
+ faq_file.parent.mkdir(parents=True, exist_ok=True)
118
+ faqs = json.loads(faq_file.read_text()) if faq_file.exists() else {}
119
+ faqs[entry["question_hash"]] = {
120
+ "question": question,
121
+ "answer": answer,
122
+ "frequency": faqs.get(entry["question_hash"], {}).get("frequency", 0) + 1,
123
+ "source": "admin_taught",
124
+ "last_asked": datetime.now().isoformat(),
125
+ }
126
+ faq_file.write_text(json.dumps(faqs, ensure_ascii=False, indent=2))
127
+ except Exception as e:
128
+ log.warning(f"[learning] FAQ update failed: {e}")
129
+
130
+ log.info(f"[learning] admin taught: '{question[:50]}' → '{answer[:50]}'")
131
+ return f"✅ Aprendido. Ya sé responder: '{question[:50]}...'"
132
+
133
+ async def get_teachings(self, instance_id: str, limit: int = 50) -> List[Dict]:
134
+ teachings_file = self._teachings_dir / f"{instance_id}.jsonl"
135
+ if not teachings_file.exists():
136
+ return []
137
+ teachings = []
138
+ for line in open(teachings_file):
139
+ try:
140
+ teachings.append(json.loads(line))
141
+ except Exception:
142
+ continue
143
+ return teachings[-limit:]
144
+
145
+ def build_teachings_prompt(self, teachings: List[Dict]) -> str:
146
+ if not teachings:
147
+ return ""
148
+ lines = ["INFORMACIÓN APRENDIDA (verificada por admin):"]
149
+ for t in teachings:
150
+ lines.append(f"- Pregunta: {t['question']}\n Respuesta: {t['answer']}")
151
+ return "\n".join(lines)
152
+
153
+
154
+ learning_engine = RealTimeLearningEngine()
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ conny_memory.py — Persistent memory system for Conny multi-tenant instances.
4
+ Inspired by OpenClaw memory architecture, built for business use.
5
+
6
+ File structure per instance:
7
+ instances/{instance_id}/
8
+ knowledge/
9
+ MEMORY.md ← master memory (loaded on every conversation)
10
+ servicios.md ← what the business offers
11
+ precios.md ← pricing
12
+ faqs.md ← frequently asked questions
13
+ objeciones.md ← objections heard + resolutions
14
+ sector.md ← industry context and tone rules
15
+ learned/
16
+ escalaciones/
17
+ {YYYY-MM-DD}/
18
+ {timestamp}.md ← each escalation
19
+ patrones/
20
+ {YYYY-MM}.md ← monthly patterns
21
+ persona/
22
+ tono.md ← tone rules
23
+ personalidad.md ← persona config
24
+ session_cache/
25
+ {chat_id}.json ← last 20 messages per contact
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ from datetime import datetime
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ log = logging.getLogger("conny.memory")
37
+
38
+
39
+ class ConnyMemory:
40
+
41
+ def __init__(self, instance_id: str = "default"):
42
+ self.instance_id = instance_id
43
+
44
+ # Check standard paths in order of preference
45
+ paths_to_try = [
46
+ Path(f"/home/ubuntu/conny-instances/{instance_id}"),
47
+ Path(f"/home/ubuntu/conny/instances/{instance_id}"),
48
+ Path(f"instances/{instance_id}"),
49
+ ]
50
+
51
+ selected_base = paths_to_try[-1] # fallback
52
+ for p in paths_to_try:
53
+ if p.exists() and p.is_dir():
54
+ selected_base = p
55
+ break
56
+
57
+ self.base = selected_base
58
+ self.memory_file = self.base / "knowledge" / "MEMORY.md"
59
+ self.knowledge_dir = self.base / "knowledge"
60
+ self.learned_dir = self.base / "learned"
61
+ self.persona_dir = self.base / "persona"
62
+ self.cache_dir = self.base / "session_cache"
63
+
64
+ def load_context(self) -> str:
65
+ """Load everything Conny needs at the start of every conversation."""
66
+ sections = []
67
+
68
+ if self.memory_file.exists():
69
+ sections.append(self.memory_file.read_text())
70
+
71
+ if self.knowledge_dir.exists():
72
+ for f in sorted(self.knowledge_dir.glob("*.md")):
73
+ if f.name == "MEMORY.md":
74
+ continue
75
+ try:
76
+ content = f.read_text().strip()
77
+ if content:
78
+ sections.append(f"## {f.stem.replace('_', ' ').title()}\n{content}")
79
+ except Exception as e:
80
+ log.warning(f"[memory] read error {f}: {e}")
81
+
82
+ escalations = sorted(
83
+ (self.learned_dir / "escalaciones").rglob("*.md"),
84
+ key=lambda p: p.stat().st_mtime,
85
+ reverse=True,
86
+ )[:10]
87
+ if escalations:
88
+ parts = []
89
+ for e in escalations:
90
+ try:
91
+ parts.append(e.read_text().strip())
92
+ except Exception:
93
+ pass
94
+ if parts:
95
+ sections.append("## Respuestas aprendidas\n" + "\n\n---\n\n".join(parts))
96
+
97
+ return "\n\n---\n\n".join(sections) if sections else ""
98
+
99
+ def save_escalation(
100
+ self,
101
+ client_msg: str,
102
+ admin_response: str,
103
+ context: Optional[Dict[str, Any]] = None,
104
+ ) -> None:
105
+ """Save an admin escalation so Conny learns it."""
106
+ now = datetime.now()
107
+ folder = self.learned_dir / "escalaciones" / now.strftime("%Y-%m-%d")
108
+ folder.mkdir(parents=True, exist_ok=True)
109
+ ts = now.strftime("%H%M%S")
110
+ content = (
111
+ f"# Escalación {ts}\n"
112
+ f"Pregunta del cliente: {client_msg}\n"
113
+ f"Respuesta del admin: {admin_response}\n"
114
+ f"Fecha: {now.isoformat()}\n"
115
+ )
116
+ if context:
117
+ content += f"\nContexto:\n{json.dumps(context, ensure_ascii=False, indent=2)}\n"
118
+ (folder / f"{ts}.md").write_text(content)
119
+ self._append_to_memory(
120
+ f"- Aprendido {now.strftime('%Y-%m-%d')}: "
121
+ f"'{client_msg[:60]}' → '{admin_response[:120]}'"
122
+ )
123
+ log.info(f"[memory] escalation saved: {client_msg[:40]}")
124
+
125
+ def update_knowledge(self, file: str, content: str) -> None:
126
+ """Admin updates a knowledge file."""
127
+ safe = file.replace("..", "").replace("/", "").strip()
128
+ target = self.knowledge_dir / f"{safe}.md"
129
+ target.parent.mkdir(parents=True, exist_ok=True)
130
+ target.write_text(content)
131
+ log.info(f"[memory] knowledge updated: {safe}")
132
+
133
+ def read_knowledge(self, file: str) -> str:
134
+ """Read a knowledge file."""
135
+ safe = file.replace("..", "").replace("/", "").strip()
136
+ target = self.knowledge_dir / f"{safe}.md"
137
+ if target.exists():
138
+ return target.read_text()
139
+ return ""
140
+
141
+ def get_session_cache(self, chat_id: str) -> List[Dict[str, str]]:
142
+ """Get last 20 messages for a chat."""
143
+ cache_file = self.cache_dir / f"{chat_id}.json"
144
+ if cache_file.exists():
145
+ try:
146
+ return json.loads(cache_file.read_text())
147
+ except Exception:
148
+ pass
149
+ return []
150
+
151
+ def save_session_cache(self, chat_id: str, messages: List[Dict[str, str]]) -> None:
152
+ """Save session cache, keeping last 20."""
153
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
154
+ cache_file = self.cache_dir / f"{chat_id}.json"
155
+ cache_file.write_text(json.dumps(messages[-20:], ensure_ascii=False))
156
+
157
+ def append_to_cache(self, chat_id: str, role: str, content: str) -> None:
158
+ """Append one message to session cache."""
159
+ cache = self.get_session_cache(chat_id)
160
+ cache.append({"role": role, "content": content})
161
+ self.save_session_cache(chat_id, cache)
162
+
163
+ def delete_session_cache(self, chat_id: str) -> None:
164
+ """Borra la caché de sesión y archivos de aprendizaje temporal para un chat_id en modo demo."""
165
+ try:
166
+ cache_file = self.cache_dir / f"{chat_id}.json"
167
+ if cache_file.exists():
168
+ cache_file.unlink()
169
+ except Exception as e:
170
+ log.warning(f"Error deleting session cache for {chat_id}: {e}")
171
+
172
+ def get_persona_tone(self) -> str:
173
+ """Load persona tone file."""
174
+ tone = self.persona_dir / "tono.md"
175
+ if tone.exists():
176
+ return tone.read_text()
177
+ return ""
178
+
179
+ def save_persona_tone(self, content: str) -> None:
180
+ """Save persona tone."""
181
+ self.persona_dir.mkdir(parents=True, exist_ok=True)
182
+ (self.persona_dir / "tono.md").write_text(content)
183
+
184
+ def save_pattern(self, pattern_type: str, content: str) -> None:
185
+ """Save monthly pattern."""
186
+ now = datetime.now()
187
+ folder = self.learned_dir / "patrones"
188
+ folder.mkdir(parents=True, exist_ok=True)
189
+ month_file = folder / f"{now.strftime('%Y-%m')}.md"
190
+ with open(month_file, "a") as f:
191
+ f.write(f"\n## {now.strftime('%Y-%m-%d')} [{pattern_type}]\n{content}")
192
+
193
+ def get_patterns(self, months: int = 2) -> str:
194
+ """Get recent patterns."""
195
+ folder = self.learned_dir / "patrones"
196
+ if not folder.exists():
197
+ return ""
198
+ parts = []
199
+ now = datetime.now()
200
+ for i in range(months):
201
+ dt = now.replace(day=1)
202
+ m = now.month - i
203
+ while m <= 0:
204
+ m += 12
205
+ dt = dt.replace(month=((m - 1) % 12) + 1)
206
+ if m > now.month:
207
+ dt = dt.replace(year=dt.year - 1)
208
+ f = folder / f"{dt.strftime('%Y-%m')}.md"
209
+ if f.exists():
210
+ parts.append(f.read_text())
211
+ return "\n\n".join(parts)
212
+
213
+ def _append_to_memory(self, line: str) -> None:
214
+ """Append line to MEMORY.md."""
215
+ self.knowledge_dir.mkdir(parents=True, exist_ok=True)
216
+ with open(self.memory_file, "a") as f:
217
+ f.write(f"\n{line}")
218
+
219
+ def init_instance(self) -> None:
220
+ """Create all directories for a new instance."""
221
+ for d in (
222
+ self.knowledge_dir,
223
+ self.learned_dir / "escalaciones",
224
+ self.learned_dir / "patrones",
225
+ self.persona_dir,
226
+ self.cache_dir,
227
+ ):
228
+ d.mkdir(parents=True, exist_ok=True)
229
+ if not self.memory_file.exists():
230
+ self.memory_file.write_text(
231
+ f"# MEMORY.md — {self.instance_id}\n"
232
+ f"# Inicializado: {datetime.now().isoformat()}\n\n"
233
+ "## Lo que sé de este negocio:\n(vacío)\n"
234
+ )
235
+
236
+
237
+ _memory_cache: Dict[str, "ConnyMemory"] = {}
238
+
239
+
240
+ def get_memory(instance_id: str = "default") -> "ConnyMemory":
241
+ if instance_id not in _memory_cache:
242
+ _memory_cache[instance_id] = ConnyMemory(instance_id)
243
+ return _memory_cache[instance_id]
@@ -0,0 +1,292 @@
1
+ """
2
+ conny_memory_engine.py — Self-learning episodic + semantic memory.
3
+ Inspired by OpenClaw's memory pattern.
4
+ Per-instance memory with TF-IDF recall, entity extraction, FAQ consolidation.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import re
12
+ import time
13
+ from datetime import datetime, timedelta
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ log = logging.getLogger("conny.memory")
18
+
19
+
20
+ class ConnyMemoryEngine:
21
+ """Per-instance memory with episodic recall + semantic extraction + procedural learning."""
22
+
23
+ def __init__(self, base_dir: str = "memory_store"):
24
+ self._base = Path(base_dir)
25
+ self._base.mkdir(exist_ok=True)
26
+ self._tfidf_cache: Dict[str, Any] = {}
27
+
28
+ def _instance_dir(self, instance_id: str) -> Path:
29
+ d = self._base / instance_id
30
+ for sub in ("episodic", "semantic", "procedural", "working"):
31
+ (d / sub).mkdir(parents=True, exist_ok=True)
32
+ return d
33
+
34
+ async def ingest_conversation(self, instance_id: str, chat_id: str, messages: List[Dict[str, str]]):
35
+ """After every conversation: store episodic + extract entities + update FAQ frequency."""
36
+ idir = self._instance_dir(instance_id)
37
+ today = datetime.now().strftime("%Y-%m-%d")
38
+
39
+ # 1. Store episodic
40
+ ep_file = idir / "episodic" / f"{today}.jsonl"
41
+ entry = {
42
+ "ts": datetime.now().isoformat(),
43
+ "chat_id": chat_id,
44
+ "messages": messages[-20:],
45
+ }
46
+ with open(ep_file, "a") as f:
47
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
48
+
49
+ # 2. Extract entities
50
+ entities = self._extract_entities(messages)
51
+ if entities:
52
+ ent_file = idir / "semantic" / "entities.json"
53
+ existing = json.loads(ent_file.read_text()) if ent_file.exists() else {}
54
+ for etype, values in entities.items():
55
+ if etype not in existing:
56
+ existing[etype] = {}
57
+ for val in values:
58
+ if val in existing[etype]:
59
+ existing[etype][val]["count"] += 1
60
+ existing[etype][val]["last_seen"] = datetime.now().isoformat()
61
+ else:
62
+ existing[etype][val] = {
63
+ "count": 1,
64
+ "last_seen": datetime.now().isoformat(),
65
+ "chat_id": chat_id,
66
+ }
67
+ ent_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
68
+
69
+ # 3. Update FAQ frequency
70
+ user_questions = [
71
+ m["content"] for m in messages
72
+ if m.get("role") == "user" and "?" in m.get("content", "")
73
+ ]
74
+ if user_questions:
75
+ faq_file = idir / "semantic" / "faqs.json"
76
+ faqs = json.loads(faq_file.read_text()) if faq_file.exists() else {}
77
+ for q in user_questions:
78
+ qhash = hashlib.md5(q.lower().strip().encode()).hexdigest()[:12]
79
+ if qhash in faqs:
80
+ faqs[qhash]["frequency"] += 1
81
+ faqs[qhash]["last_asked"] = datetime.now().isoformat()
82
+ else:
83
+ answer = ""
84
+ for i, m in enumerate(messages):
85
+ if m.get("content") == q and i + 1 < len(messages):
86
+ answer = messages[i + 1].get("content", "")
87
+ break
88
+ faqs[qhash] = {
89
+ "question": q,
90
+ "answer": answer[:500],
91
+ "frequency": 1,
92
+ "first_asked": datetime.now().isoformat(),
93
+ "last_asked": datetime.now().isoformat(),
94
+ }
95
+ faq_file.write_text(json.dumps(faqs, ensure_ascii=False, indent=2))
96
+
97
+ # Invalidate TF-IDF cache
98
+ self._tfidf_cache.pop(instance_id, None)
99
+
100
+ async def recall_context(self, instance_id: str, user_message: str, top_k: int = 5) -> List[Dict]:
101
+ """Before every LLM call: retrieve relevant past exchanges using TF-IDF similarity."""
102
+ idir = self._instance_dir(instance_id)
103
+
104
+ # Load episodic entries from last 30 days
105
+ docs: List[Dict] = []
106
+ ep_dir = idir / "episodic"
107
+ cutoff = datetime.now() - timedelta(days=30)
108
+
109
+ for f in sorted(ep_dir.glob("*.jsonl"), reverse=True):
110
+ try:
111
+ file_date = datetime.strptime(f.stem, "%Y-%m-%d")
112
+ if file_date < cutoff:
113
+ break
114
+ except ValueError:
115
+ continue
116
+ with open(f) as fh:
117
+ for line in fh:
118
+ try:
119
+ entry = json.loads(line)
120
+ text = " ".join(
121
+ m.get("content", "") for m in entry.get("messages", [])
122
+ )
123
+ if text.strip():
124
+ docs.append({"text": text, "entry": entry})
125
+ except json.JSONDecodeError:
126
+ continue
127
+
128
+ if not docs or not user_message.strip():
129
+ return []
130
+
131
+ try:
132
+ from sklearn.feature_extraction.text import TfidfVectorizer
133
+ from sklearn.metrics.pairwise import cosine_similarity
134
+
135
+ corpus = [d["text"] for d in docs] + [user_message]
136
+ vectorizer = TfidfVectorizer(max_features=5000)
137
+ tfidf_matrix = vectorizer.fit_transform(corpus)
138
+
139
+ query_vec = tfidf_matrix[-1]
140
+ doc_vecs = tfidf_matrix[:-1]
141
+ similarities = cosine_similarity(query_vec, doc_vecs).flatten()
142
+
143
+ top_indices = similarities.argsort()[-top_k:][::-1]
144
+ results = []
145
+ for idx in top_indices:
146
+ if similarities[idx] > 0.05:
147
+ results.append({
148
+ "score": float(similarities[idx]),
149
+ "messages": docs[idx]["entry"].get("messages", [])[-6:],
150
+ "chat_id": docs[idx]["entry"].get("chat_id", ""),
151
+ "ts": docs[idx]["entry"].get("ts", ""),
152
+ })
153
+ return results
154
+ except ImportError:
155
+ log.warning("[memory] scikit-learn not available, skipping recall")
156
+ return []
157
+ except Exception as e:
158
+ log.warning(f"[memory] recall error: {e}")
159
+ return []
160
+
161
+ async def get_top_faqs(self, instance_id: str, limit: int = 20) -> List[Dict]:
162
+ """Get most frequently asked questions for system prompt injection."""
163
+ idir = self._instance_dir(instance_id)
164
+ faq_file = idir / "semantic" / "faqs.json"
165
+ if not faq_file.exists():
166
+ return []
167
+ faqs = json.loads(faq_file.read_text())
168
+ sorted_faqs = sorted(faqs.values(), key=lambda x: x.get("frequency", 0), reverse=True)
169
+ return sorted_faqs[:limit]
170
+
171
+ async def learn_from_success(self, instance_id: str, chat_id: str,
172
+ flow: List[Dict], outcome: str = "booking"):
173
+ """Store successful conversation patterns."""
174
+ idir = self._instance_dir(instance_id)
175
+ success_file = idir / "procedural" / "successful_flows.json"
176
+ existing = json.loads(success_file.read_text()) if success_file.exists() else []
177
+ existing.append({
178
+ "ts": datetime.now().isoformat(),
179
+ "chat_id": chat_id,
180
+ "outcome": outcome,
181
+ "flow_summary": [
182
+ {"role": m["role"], "content": m["content"][:200]}
183
+ for m in flow[-10:]
184
+ ],
185
+ })
186
+ existing = existing[-200:]
187
+ success_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
188
+
189
+ async def learn_from_failure(self, instance_id: str, chat_id: str,
190
+ flow: List[Dict], reason: str = "escalated"):
191
+ """Store failed conversation patterns for avoidance."""
192
+ idir = self._instance_dir(instance_id)
193
+ fail_file = idir / "procedural" / "failed_flows.json"
194
+ existing = json.loads(fail_file.read_text()) if fail_file.exists() else []
195
+ existing.append({
196
+ "ts": datetime.now().isoformat(),
197
+ "chat_id": chat_id,
198
+ "reason": reason,
199
+ "flow_summary": [
200
+ {"role": m["role"], "content": m["content"][:200]}
201
+ for m in flow[-10:]
202
+ ],
203
+ })
204
+ existing = existing[-100:]
205
+ fail_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
206
+
207
+ async def weekly_consolidation(self, instance_id: str):
208
+ """Merge episodic -> semantic, prune duplicates, update FAQ index. Run weekly."""
209
+ idir = self._instance_dir(instance_id)
210
+ log.info(f"[memory] starting weekly consolidation for {instance_id}")
211
+
212
+ # 1. Re-scan all episodic for FAQ extraction
213
+ ep_dir = idir / "episodic"
214
+ all_questions: List[Dict] = []
215
+ for f in ep_dir.glob("*.jsonl"):
216
+ with open(f) as fh:
217
+ for line in fh:
218
+ try:
219
+ entry = json.loads(line)
220
+ msgs = entry.get("messages", [])
221
+ for i, m in enumerate(msgs):
222
+ if m.get("role") == "user" and "?" in m.get("content", ""):
223
+ answer = ""
224
+ if i + 1 < len(msgs) and msgs[i + 1].get("role") == "assistant":
225
+ answer = msgs[i + 1]["content"][:500]
226
+ all_questions.append({"q": m["content"], "a": answer})
227
+ except Exception:
228
+ continue
229
+
230
+ # 2. Update FAQ index
231
+ faq_file = idir / "semantic" / "faqs.json"
232
+ faqs = json.loads(faq_file.read_text()) if faq_file.exists() else {}
233
+
234
+ for qa in all_questions:
235
+ qhash = hashlib.md5(qa["q"].lower().strip().encode()).hexdigest()[:12]
236
+ if qhash in faqs:
237
+ faqs[qhash]["frequency"] += 1
238
+ if qa["a"] and len(qa["a"]) > len(faqs[qhash].get("answer", "")):
239
+ faqs[qhash]["answer"] = qa["a"]
240
+ else:
241
+ faqs[qhash] = {
242
+ "question": qa["q"],
243
+ "answer": qa["a"],
244
+ "frequency": 1,
245
+ "first_asked": datetime.now().isoformat(),
246
+ "last_asked": datetime.now().isoformat(),
247
+ }
248
+
249
+ faq_file.write_text(json.dumps(faqs, ensure_ascii=False, indent=2))
250
+
251
+ # 3. Prune episodic older than 90 days
252
+ cutoff = datetime.now() - timedelta(days=90)
253
+ for f in ep_dir.glob("*.jsonl"):
254
+ try:
255
+ file_date = datetime.strptime(f.stem, "%Y-%m-%d")
256
+ if file_date < cutoff:
257
+ f.unlink()
258
+ log.info(f"[memory] pruned old episodic: {f.name}")
259
+ except Exception:
260
+ continue
261
+
262
+ log.info(f"[memory] consolidation complete for {instance_id}: {len(faqs)} FAQs")
263
+
264
+ def _extract_entities(self, messages: List[Dict]) -> Dict[str, List[str]]:
265
+ """Simple regex-based entity extraction."""
266
+ entities: Dict[str, List[str]] = {"phones": [], "emails": [], "names": []}
267
+ text = " ".join(
268
+ m.get("content", "") for m in messages if m.get("role") == "user"
269
+ )
270
+
271
+ # Colombian phone numbers
272
+ phones = re.findall(r"\b3[0-9]{9}\b", text)
273
+ entities["phones"] = list(set(phones))
274
+
275
+ # Emails
276
+ emails = re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text)
277
+ entities["emails"] = list(set(emails))
278
+
279
+ # Names after "me llamo", "soy", "mi nombre es"
280
+ name_patterns = [
281
+ r"(?:me llamo|soy|mi nombre es)\s+([A-ZÁÉÍÓÚ][a-záéíóú]+(?:\s+[A-ZÁÉÍÓÚ][a-záéíóú]+)?)",
282
+ ]
283
+ for pat in name_patterns:
284
+ matches = re.findall(pat, text)
285
+ entities["names"].extend(matches)
286
+ entities["names"] = list(set(entities["names"]))
287
+
288
+ return {k: v for k, v in entities.items() if v}
289
+
290
+
291
+ # Singleton
292
+ memory_engine = ConnyMemoryEngine()