w1x-cli 3.5.0__py3-none-any.whl

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.
w1x/memory.py ADDED
@@ -0,0 +1,272 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Долговременная память фактов W1x."""
3
+ import json
4
+ import re
5
+ import secrets
6
+ from datetime import datetime
7
+
8
+ from .config import (
9
+ CHAT_DIR,
10
+ FACTS_PATH,
11
+ )
12
+
13
+ _MEMORY_STOPWORDS = {
14
+ "а", "в", "вы", "где", "для", "его", "ее", "и", "как", "какая",
15
+ "какой", "кто", "мне", "мой", "моя", "мое", "моё", "на", "называется",
16
+ "о", "он", "она", "по", "про", "с", "скажи", "ты", "у", "что", "это", "я",
17
+ "ли", "же", "бы", "хоть", "был", "была", "было", "звали", "зовут",
18
+ }
19
+
20
+ _GENERAL_MEMORY_PATTERNS = (
21
+ r"обо?\s+мне",
22
+ r"что\s+(?:ты\s+)?(?:знаешь|помнишь)",
23
+ r"покажи\s+память",
24
+ r"все\s+факты",
25
+ r"список\s+фактов",
26
+ r"что\s+в\s+памяти",
27
+ )
28
+
29
+
30
+ def load_facts():
31
+ """Загрузить отдельную постоянную память фактов."""
32
+ if not FACTS_PATH.exists():
33
+ return []
34
+ try:
35
+ payload = json.loads(FACTS_PATH.read_text(encoding="utf-8"))
36
+ except (OSError, ValueError, TypeError):
37
+ return []
38
+ if not isinstance(payload, list):
39
+ return []
40
+ return [
41
+ item
42
+ for item in payload
43
+ if isinstance(item, dict)
44
+ and isinstance(item.get("fact"), str)
45
+ and item["fact"].strip()
46
+ ]
47
+
48
+
49
+ def save_facts(facts):
50
+ CHAT_DIR.mkdir(parents=True, exist_ok=True)
51
+ temporary_path = FACTS_PATH.with_suffix(".tmp")
52
+ temporary_path.write_text(
53
+ json.dumps(facts, ensure_ascii=False, indent=2),
54
+ encoding="utf-8",
55
+ )
56
+ temporary_path.replace(FACTS_PATH)
57
+
58
+
59
+ def remember_fact(fact: str) -> str:
60
+ """Запомнить пользовательский факт между диалогами.
61
+
62
+ Args:
63
+ fact: Короткий факт, который пользователь явно попросил запомнить.
64
+
65
+ Returns:
66
+ Подтверждение сохранения факта.
67
+ """
68
+ fact = " ".join(str(fact).split()).strip(" .")
69
+ if not fact:
70
+ return "Ошибка: нечего запоминать."
71
+ if len(fact) > 500:
72
+ return "Ошибка: один факт не должен быть длиннее 500 символов."
73
+
74
+ facts = load_facts()
75
+ normalized = fact.casefold()
76
+ for item in facts:
77
+ if item["fact"].casefold() == normalized:
78
+ return f"Уже помню: {item['fact']}."
79
+ facts.append(
80
+ {
81
+ "id": secrets.token_hex(4),
82
+ "fact": fact,
83
+ "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
84
+ }
85
+ )
86
+ save_facts(facts[-200:])
87
+ return f"Запомнил: {fact}."
88
+
89
+
90
+ def _stem_word(word: str) -> str:
91
+ """Удалить падежные окончания для точного поиска в русском языке."""
92
+ w = word.casefold()
93
+ if len(w) <= 3:
94
+ return w
95
+ for suffix in (
96
+ "ами", "ями", "ого", "его", "ому", "ему", "ыми", "ими", "ах", "ях",
97
+ "ов", "ев", "ей", "ом", "ем", "ам", "ям", "ой", "ей", "ий", "ый",
98
+ "ая", "яя", "ое", "ее", "ые", "ие", "ут", "ют", "ат", "ят", "ил",
99
+ "ила", "или", "ит", "ет", "у", "ю", "а", "я", "е", "ы", "и", "о",
100
+ ):
101
+ if w.endswith(suffix) and len(w) - len(suffix) >= 3:
102
+ return w[:-len(suffix)]
103
+ return w
104
+
105
+
106
+ def _memory_words(text: str) -> set[str]:
107
+ words = set()
108
+ for raw in re.findall(r"[a-zа-яё0-9_-]{2,}", text.casefold()):
109
+ if raw not in _MEMORY_STOPWORDS:
110
+ words.add(raw)
111
+ stem = _stem_word(raw)
112
+ if stem:
113
+ words.add(stem)
114
+ return words
115
+
116
+
117
+ def _is_general_query(query: str) -> bool:
118
+ q = query.casefold().strip()
119
+ if not q:
120
+ return True
121
+ return any(re.search(pat, q) for pat in _GENERAL_MEMORY_PATTERNS)
122
+
123
+
124
+ def recall_facts(query: str = "") -> str:
125
+ """Найти факты в постоянной памяти W1x.
126
+
127
+ Args:
128
+ query: Что именно нужно вспомнить. Пустая строка показывает все сохранённые факты.
129
+
130
+ Returns:
131
+ Найденные факты или сообщение, что совпадений нет.
132
+ """
133
+ facts = load_facts()
134
+ if not facts:
135
+ return "Постоянная память фактов пуста."
136
+
137
+ if _is_general_query(query):
138
+ return "Факты из постоянной памяти:\n" + "\n".join(
139
+ f"- {item['fact']}" for item in facts[-20:]
140
+ )
141
+
142
+ query_words = _memory_words(query)
143
+ ranked = []
144
+ for index, item in enumerate(facts):
145
+ fact_words = _memory_words(item["fact"])
146
+ overlap = len(query_words & fact_words)
147
+ # Также проверяем частичные подстроки
148
+ if not overlap:
149
+ for qw in query_words:
150
+ if len(qw) >= 3 and any(qw in fw or fw in qw for fw in fact_words):
151
+ overlap += 1
152
+ if overlap:
153
+ ranked.append((overlap, index, item["fact"]))
154
+
155
+ if not ranked:
156
+ # Если точных совпадений нет, но фактов мало (до 5) — возвращаем их
157
+ if len(facts) <= 5:
158
+ return "Факты из постоянной памяти:\n" + "\n".join(
159
+ f"- {item['fact']}" for item in facts
160
+ )
161
+ return "В постоянной памяти нет подходящего факта."
162
+
163
+ ranked.sort(reverse=True)
164
+ selected = [fact for _, _, fact in ranked[:8]]
165
+ return "Факты из постоянной памяти:\n" + "\n".join(
166
+ f"- {fact}" for fact in selected
167
+ )
168
+
169
+
170
+ def forget_facts(query: str) -> str:
171
+ """Удалить подходящие факты из постоянной памяти.
172
+
173
+ Args:
174
+ query: Слова из факта, который пользователь попросил забыть.
175
+
176
+ Returns:
177
+ Количество удалённых фактов.
178
+ """
179
+ query_words = _memory_words(query)
180
+ if not query_words:
181
+ return "Ошибка: уточни, какой факт забыть."
182
+ facts = load_facts()
183
+ kept = []
184
+ removed = []
185
+ for item in facts:
186
+ fact_words = _memory_words(item["fact"])
187
+ match = any(qw in fact_words for qw in query_words)
188
+ if match:
189
+ removed.append(item["fact"])
190
+ else:
191
+ kept.append(item)
192
+ if not removed:
193
+ return "Подходящий факт в памяти не найден."
194
+ save_facts(kept)
195
+ return "Забыл: " + "; ".join(removed) + "."
196
+
197
+
198
+ def memory(action: str, text: str = "") -> str:
199
+ """Управление постоянной памятью фактов О ПОЛЬЗОВАТЕЛЕ (имя, питомцы, предпочтения).
200
+ НЕ вызывай этот инструмент для общих вопросов о мире, географии, физике, истории или воде в море.
201
+
202
+ Args:
203
+ action: remember — сохранить факт о пользователе, recall — вспомнить факт о пользователе, forget — забыть факт.
204
+ text: Личный факт о пользователе (для remember) или слова поиска (для recall/forget).
205
+
206
+ Returns:
207
+ Результат операции с постоянной памятью пользователя.
208
+ """
209
+ action = action.strip().casefold()
210
+ if action == "remember":
211
+ return remember_fact(text)
212
+ if action == "recall":
213
+ return recall_facts(text)
214
+ if action == "forget":
215
+ return forget_facts(text)
216
+ return "Ошибка: action должен быть remember, recall или forget."
217
+
218
+
219
+ def relevant_memory_block(query=""):
220
+ facts = load_facts()
221
+ if not facts:
222
+ return ""
223
+ # Если сохранено до 30 фактов — подмешиваем их ВСЕ в контекст системного промпта!
224
+ if len(facts) <= 30:
225
+ return "Факты из постоянной памяти о пользователе:\n" + "\n".join(
226
+ f"- {item['fact']}" for item in facts
227
+ )
228
+ recalled = recall_facts(query)
229
+ return "" if recalled.endswith("пуста.") or "нет подходящего" in recalled else recalled
230
+
231
+
232
+ def extract_remember_request(text):
233
+ match = re.match(
234
+ r"^\s*(?:запомни|запиши\s+в\s+память|сохрани\s+в\s+память)"
235
+ r"\s*[:,-]?\s*(.+?)\s*$",
236
+ text,
237
+ re.IGNORECASE | re.DOTALL,
238
+ )
239
+ return match.group(1).strip() if match else ""
240
+
241
+
242
+ def extract_forget_request(text):
243
+ match = re.match(
244
+ r"^\s*(?:забудь|удали\s+из\s+памяти)\s*[:,-]?\s*(.+?)\s*$",
245
+ text,
246
+ re.IGNORECASE | re.DOTALL,
247
+ )
248
+ return match.group(1).strip() if match else ""
249
+
250
+
251
+ def looks_like_memory_question(text):
252
+ normalized = text.casefold()
253
+ return bool(
254
+ re.search(r"\b(?:помнишь|вспомни|забыл\w*)\b", normalized)
255
+ or re.search(r"\bкак\s+называется\s+(?:мой|моя|мое|моё)\b", normalized)
256
+ or re.search(r"\bчто\s+ты\s+(?:знаешь|помнишь)\s+обо?\s+мне\b", normalized)
257
+ )
258
+
259
+
260
+ def migrate_explicit_memory(messages):
261
+ """Один раз подобрать старые фразы «запомни» из сохранённого диалога."""
262
+ migrated = 0
263
+ for message in messages:
264
+ if message.get("role") != "user":
265
+ continue
266
+ fact = extract_remember_request(message.get("content", ""))
267
+ if not fact:
268
+ continue
269
+ before = len(load_facts())
270
+ remember_fact(fact)
271
+ migrated += len(load_facts()) > before
272
+ return migrated