@innvisor/conny-ai 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
|
@@ -0,0 +1,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()
|
package/conny_memory.py
ADDED
|
@@ -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()
|