fragmented-memory 1.0.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.
- fragmented_memory/__init__.py +495 -0
- fragmented_memory/attention.py +146 -0
- fragmented_memory/consolidator.py +384 -0
- fragmented_memory/embedder.py +155 -0
- fragmented_memory/emotion.py +136 -0
- fragmented_memory/forgetter.py +229 -0
- fragmented_memory/splitter.py +304 -0
- fragmented_memory/storage.py +890 -0
- fragmented_memory-1.0.0.dist-info/METADATA +247 -0
- fragmented_memory-1.0.0.dist-info/RECORD +13 -0
- fragmented_memory-1.0.0.dist-info/WHEEL +5 -0
- fragmented_memory-1.0.0.dist-info/licenses/LICENSE +21 -0
- fragmented_memory-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fragmented-memory — 碎片化记忆系统 for Hermes Agent.
|
|
3
|
+
|
|
4
|
+
每次对话自动检索相关记忆碎片注入上下文,支持:
|
|
5
|
+
- ✂️ 语义切分 — 按段落/句子边界自动拆分成独立碎片
|
|
6
|
+
- 🔍 向量搜索 — RediSearch KNN 语义检索
|
|
7
|
+
- ⏳ 时间衰减 — 新碎片权重高,旧碎片逐步降权
|
|
8
|
+
- 🔄 自动写入 — memory() 操作和对话轮次自动存档
|
|
9
|
+
- 🏷️ 标签过滤 — 可选按标签范围搜索
|
|
10
|
+
|
|
11
|
+
安装: pip install fragmented-memory
|
|
12
|
+
激活: config.yaml 中设置 memory.provider: fragmented
|
|
13
|
+
|
|
14
|
+
配置优先级: 环境变量 > 配置文件 > 默认值
|
|
15
|
+
配置文件: ~/.config/fragmented-memory/config.json (或 FRAGMENTED_MEMORY_CONFIG 自定义路径)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from agent.memory_provider import MemoryProvider
|
|
27
|
+
from tools.registry import tool_error
|
|
28
|
+
|
|
29
|
+
from .embedder import create_embedder
|
|
30
|
+
from .splitter import split_text
|
|
31
|
+
from .storage import RedisStorage
|
|
32
|
+
from .consolidator import Consolidator
|
|
33
|
+
from .forgetter import Forgetter
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# 工具扇区(供 Hermes MemoryProvider 注册)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
FEEDBACK_SCHEMA = {
|
|
40
|
+
"name": "frag_memory_feedback",
|
|
41
|
+
"description": (
|
|
42
|
+
"记录用户对一条碎片的反馈 — 标记有用/没用。"
|
|
43
|
+
"正反馈让该碎片在未来搜索中排名更高,"
|
|
44
|
+
"负反馈大幅降权(标记为没用的碎片几乎不会再出现)。"
|
|
45
|
+
),
|
|
46
|
+
"parameters": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"fragment_key": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "碎片的 Redis key(如 memory:frag:abc123),从相关碎片的 key 字段获得。",
|
|
52
|
+
},
|
|
53
|
+
"is_positive": {
|
|
54
|
+
"type": "boolean",
|
|
55
|
+
"description": "True = 这条记忆有用,False = 没用",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"required": ["fragment_key", "is_positive"],
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
HOT_TOPICS_SCHEMA = {
|
|
63
|
+
"name": "frag_hot_topics",
|
|
64
|
+
"description": (
|
|
65
|
+
"查询全局热门话题统计。返回跨会话出现最频繁的话题词。"
|
|
66
|
+
"可选日榜/周榜/全局。"
|
|
67
|
+
),
|
|
68
|
+
"parameters": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"limit": {
|
|
72
|
+
"type": "integer",
|
|
73
|
+
"description": "返回条数(默认 10,最大 30)",
|
|
74
|
+
"default": 10,
|
|
75
|
+
},
|
|
76
|
+
"period": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"enum": ["all", "daily", "weekly"],
|
|
79
|
+
"description": "统计周期:all=全局, daily=日榜, weekly=周榜",
|
|
80
|
+
"default": "all",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
"required": [],
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
logger = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
_DEFAULT_CONFIG_PATH = "~/.config/fragmented-memory/config.json"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_json_config() -> dict:
|
|
94
|
+
"""从 JSON 配置文件加载配置。
|
|
95
|
+
|
|
96
|
+
路径来源(优先级高到低):
|
|
97
|
+
1. 环境变量 FRAGMENTED_MEMORY_CONFIG
|
|
98
|
+
2. ~/.config/fragmented-memory/config.json
|
|
99
|
+
文件不存在时返回空 dict。
|
|
100
|
+
"""
|
|
101
|
+
path_str = os.environ.get("FRAGMENTED_MEMORY_CONFIG") or _DEFAULT_CONFIG_PATH
|
|
102
|
+
path = Path(path_str).expanduser()
|
|
103
|
+
if not path.exists():
|
|
104
|
+
logger.debug("fragmented: config file not found at %s", path)
|
|
105
|
+
return {}
|
|
106
|
+
try:
|
|
107
|
+
with open(path) as f:
|
|
108
|
+
cfg: dict = json.load(f)
|
|
109
|
+
logger.info("fragmented: loaded config from %s", path)
|
|
110
|
+
return cfg
|
|
111
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
112
|
+
logger.warning("fragmented: failed to load config from %s: %s", path, e)
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
117
|
+
"""递归合并两个 dict,override 覆盖 base。"""
|
|
118
|
+
result = base.copy()
|
|
119
|
+
for key, val in override.items():
|
|
120
|
+
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
|
|
121
|
+
result[key] = _deep_merge(result[key], val)
|
|
122
|
+
else:
|
|
123
|
+
result[key] = val
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class FragmentedMemoryProvider(MemoryProvider):
|
|
128
|
+
"""
|
|
129
|
+
碎片化记忆提供者。
|
|
130
|
+
|
|
131
|
+
和 Hermes builtin 内存共存,不冲突。每轮对话自动检索相关碎片
|
|
132
|
+
注入上下文,并自动将用户消息切分存档。
|
|
133
|
+
|
|
134
|
+
配置优先级(高→低):
|
|
135
|
+
1. 环境变量 (FRAGMENTED_REDIS_HOST, FRAGMENTED_EMBEDDER 等)
|
|
136
|
+
2. JSON 配置文件 (~/.config/fragmented-memory/config.json)
|
|
137
|
+
3. config.yaml memory.fragmented 节(由 Hermes 传入)
|
|
138
|
+
4. 硬编码默认值
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
_initialized: bool = False
|
|
142
|
+
_storage: Optional[RedisStorage] = None
|
|
143
|
+
_tag_filter: str = ""
|
|
144
|
+
_consolidator: Optional[Consolidator] = None
|
|
145
|
+
_forgetter: Optional[Forgetter] = None
|
|
146
|
+
_last_maintenance: float = 0.0
|
|
147
|
+
_maintenance_interval: float = 7200.0 # 每 2h 跑一次维护
|
|
148
|
+
|
|
149
|
+
def __init__(self, **config):
|
|
150
|
+
"""
|
|
151
|
+
参数(通过 config.yaml memory 节传入):
|
|
152
|
+
|
|
153
|
+
memory:
|
|
154
|
+
provider: fragmented
|
|
155
|
+
fragmented:
|
|
156
|
+
redis_host: 127.0.0.1
|
|
157
|
+
redis_port: 6379
|
|
158
|
+
top_k: 5
|
|
159
|
+
candidate_k: 10
|
|
160
|
+
tag_filter: ""
|
|
161
|
+
embedder:
|
|
162
|
+
provider: openai
|
|
163
|
+
api_key: sk-xxx
|
|
164
|
+
base_url: https://api.openai.com/v1
|
|
165
|
+
model: text-embedding-3-small
|
|
166
|
+
"""
|
|
167
|
+
super().__init__()
|
|
168
|
+
self._config = config
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# 配置合并
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _resolve_config(inline_cfg: dict) -> dict:
|
|
176
|
+
"""按优先级合并配置源,返回最终配置。
|
|
177
|
+
|
|
178
|
+
合并顺序(后覆盖前): 默认值 ← JSON 文件 ← 环境变量 ← inline
|
|
179
|
+
inline = Hermes 的 config.yaml memory.fragmented 或 __init__ 传参
|
|
180
|
+
"""
|
|
181
|
+
# 1. 硬编码默认值(不含 embedder — 由配置文件/环境变量按需开启)
|
|
182
|
+
cfg: dict = {
|
|
183
|
+
"redis_host": "127.0.0.1",
|
|
184
|
+
"redis_port": 6379,
|
|
185
|
+
"top_k": 5,
|
|
186
|
+
"candidate_k": 10,
|
|
187
|
+
"tag_filter": "",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# 2. JSON 配置文件覆盖
|
|
191
|
+
json_cfg = _load_json_config()
|
|
192
|
+
cfg = _deep_merge(cfg, json_cfg)
|
|
193
|
+
|
|
194
|
+
# 3. 环境变量覆盖
|
|
195
|
+
env_overrides = {
|
|
196
|
+
"redis_host": os.environ.get("FRAGMENTED_REDIS_HOST"),
|
|
197
|
+
"redis_port": os.environ.get("FRAGMENTED_REDIS_PORT"),
|
|
198
|
+
"top_k": os.environ.get("FRAGMENTED_TOP_K"),
|
|
199
|
+
"candidate_k": os.environ.get("FRAGMENTED_CANDIDATE_K"),
|
|
200
|
+
"tag_filter": os.environ.get("FRAGMENTED_TAG_FILTER"),
|
|
201
|
+
}
|
|
202
|
+
for key, val in env_overrides.items():
|
|
203
|
+
if val is not None:
|
|
204
|
+
cfg[key] = val
|
|
205
|
+
|
|
206
|
+
# 4. inline(Hermes 传入的 config.yaml 配置)覆盖
|
|
207
|
+
cfg = _deep_merge(cfg, inline_cfg)
|
|
208
|
+
|
|
209
|
+
return cfg
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# MemoryProvider 接口
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def name(self) -> str:
|
|
217
|
+
return "fragmented"
|
|
218
|
+
|
|
219
|
+
def is_available(self) -> bool:
|
|
220
|
+
try:
|
|
221
|
+
import redis as _ # noqa: F401
|
|
222
|
+
except ImportError:
|
|
223
|
+
return False
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def initialize(self, session_id: str, **kwargs) -> None:
|
|
227
|
+
"""初始化 — 加载配置、连接 Redis、自动创建 index。"""
|
|
228
|
+
cfg = self._resolve_config(self._config)
|
|
229
|
+
|
|
230
|
+
redis_host = cfg.get("redis_host", "127.0.0.1")
|
|
231
|
+
redis_port = int(cfg.get("redis_port", 6379))
|
|
232
|
+
top_k = int(cfg.get("top_k", 5))
|
|
233
|
+
candidate_k = int(cfg.get("candidate_k", 10))
|
|
234
|
+
self._tag_filter = cfg.get("tag_filter", "")
|
|
235
|
+
|
|
236
|
+
embed_cfg = cfg.get("embedder", {})
|
|
237
|
+
embed_provider = embed_cfg.get("provider", "").strip().lower()
|
|
238
|
+
# 只有显式配置了 embedder provider 才创建,否则走 BM25-only 模式
|
|
239
|
+
if embed_provider and embed_provider not in ("", "default", "none"):
|
|
240
|
+
embedder = create_embedder(
|
|
241
|
+
provider=embed_cfg.get("provider", ""),
|
|
242
|
+
api_key=embed_cfg.get("api_key", ""),
|
|
243
|
+
base_url=embed_cfg.get("base_url", ""),
|
|
244
|
+
model=embed_cfg.get("model", ""),
|
|
245
|
+
)
|
|
246
|
+
embed_dim = embedder.dimension
|
|
247
|
+
logger.info(
|
|
248
|
+
"fragmented: embedder enabled (%s, dim=%d)",
|
|
249
|
+
embed_provider, embed_dim,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
embedder = None
|
|
253
|
+
embed_dim = 1536
|
|
254
|
+
logger.info("fragmented: BM25-only mode (no embedder configured)")
|
|
255
|
+
|
|
256
|
+
self._storage = RedisStorage(
|
|
257
|
+
embedder=embedder,
|
|
258
|
+
host=redis_host,
|
|
259
|
+
port=redis_port,
|
|
260
|
+
candidate_count=candidate_k,
|
|
261
|
+
final_limit=top_k,
|
|
262
|
+
embed_dim=embed_dim,
|
|
263
|
+
bm25_limit=int(cfg.get("bm25_limit", 10)),
|
|
264
|
+
decay_half_days=int(cfg.get("decay_half_days", 60)),
|
|
265
|
+
embed_cache_ttl=int(cfg.get("embed_cache_ttl", 3600)),
|
|
266
|
+
sentiment_boost_positive=float(cfg.get("sentiment_boost_positive", 1.5)),
|
|
267
|
+
sentiment_boost_negative=float(cfg.get("sentiment_boost_negative", 1.3)),
|
|
268
|
+
feedback_positive_boost=float(cfg.get("feedback_positive_boost", 1.3)),
|
|
269
|
+
feedback_negative_penalty=float(cfg.get("feedback_negative_penalty", 0.5)),
|
|
270
|
+
hot_topic_boost=float(cfg.get("hot_topic_boost", 1.2)),
|
|
271
|
+
hot_topic_decay_half_days=int(cfg.get("hot_topic_decay_half_days", 30)),
|
|
272
|
+
emotion_intensity_factor=float(cfg.get("emotion_intensity_factor", 0.4)),
|
|
273
|
+
attention_boost_max=float(cfg.get("attention_boost_max", 1.5)),
|
|
274
|
+
attention_base_increment=float(cfg.get("attention_base_increment", 2.0)),
|
|
275
|
+
attention_emotion_factor=float(cfg.get("attention_emotion_factor", 1.5)),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# 自动创建/验证 index
|
|
279
|
+
if not self._storage.ensure_index():
|
|
280
|
+
logger.warning(
|
|
281
|
+
"fragmented: Redis / RediSearch not ready at %s:%s",
|
|
282
|
+
redis_host, redis_port,
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
self._initialized = True
|
|
287
|
+
logger.info(
|
|
288
|
+
"fragmented: connected (session=%s, top_k=%d, tag_filter=%s)",
|
|
289
|
+
session_id, top_k, self._tag_filter or "(none)",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# 初始化 Consolidator 和 Forgetter(守护模式)
|
|
293
|
+
self._consolidator = Consolidator(
|
|
294
|
+
storage=self._storage,
|
|
295
|
+
min_group_size=int(cfg.get("consolidate_min_group", 2)),
|
|
296
|
+
max_age_hours=int(cfg.get("consolidate_max_age_hours", 72)),
|
|
297
|
+
)
|
|
298
|
+
self._forgetter = Forgetter(
|
|
299
|
+
storage=self._storage,
|
|
300
|
+
max_age_days=int(cfg.get("forget_max_age_days", 30)),
|
|
301
|
+
dry_run=bool(cfg.get("forget_dry_run", True)),
|
|
302
|
+
)
|
|
303
|
+
logger.info("fragmented: maintenance engines initialized")
|
|
304
|
+
|
|
305
|
+
def system_prompt_block(self) -> str:
|
|
306
|
+
parts = [
|
|
307
|
+
"你有碎片化记忆系统(fragmented-memory),连接在 Redis + RediSearch 上。",
|
|
308
|
+
"每次对话或 memory(action='add') 操作时,系统会自动检索或存储相关碎片。",
|
|
309
|
+
"相关碎片就在下面「相关碎片」段落里,直接使用即可。",
|
|
310
|
+
"碎片综合排序 = BM25相似度 × 时间衰减 × 情感权重 × 反馈权重 × 热门话题权重。",
|
|
311
|
+
"正反馈用 frag_memory_feedback(key, positive=True) 标记有用,",
|
|
312
|
+
"负反馈用 frag_memory_feedback(key, positive=False) 标记没用。",
|
|
313
|
+
"热门话题用 frag_hot_topics() 查询。",
|
|
314
|
+
]
|
|
315
|
+
return "\n".join(parts)
|
|
316
|
+
|
|
317
|
+
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
318
|
+
"""根据用户消息检索相关碎片,注入到上下文。"""
|
|
319
|
+
if not query or len(query.strip()) < 2 or not self._storage:
|
|
320
|
+
return ""
|
|
321
|
+
|
|
322
|
+
import time as _time
|
|
323
|
+
start = _time.time()
|
|
324
|
+
fragments = self._storage.search(
|
|
325
|
+
query.strip(),
|
|
326
|
+
tag_filter=self._tag_filter,
|
|
327
|
+
)
|
|
328
|
+
elapsed = _time.time() - start
|
|
329
|
+
|
|
330
|
+
if not fragments:
|
|
331
|
+
return ""
|
|
332
|
+
|
|
333
|
+
lines = ["<fragmented_memory>"]
|
|
334
|
+
lines.append(f"# 相关碎片 (检索耗时 {elapsed:.1f}s)")
|
|
335
|
+
lines.append("")
|
|
336
|
+
for i, frag in enumerate(fragments, 1):
|
|
337
|
+
lines.append(f"[{i}] {frag.get('content', '')}")
|
|
338
|
+
tags = frag.get("tags", "")
|
|
339
|
+
combined = frag.get("_combined_score", 0)
|
|
340
|
+
weights = frag.get("_weights", {})
|
|
341
|
+
info_parts = []
|
|
342
|
+
if tags:
|
|
343
|
+
info_parts.append(f"标签: {tags}")
|
|
344
|
+
info_parts.append(f"综合: {combined:.2f}")
|
|
345
|
+
if weights:
|
|
346
|
+
info_parts.append(f"w: sim={weights.get('sim',0):.2f} decay={weights.get('decay',0):.2f} "
|
|
347
|
+
f"emotion={weights.get('emotion',1):.1f} fb={weights.get('feedback',1):.1f} "
|
|
348
|
+
f"hot={weights.get('hot_topic',1):.1f}")
|
|
349
|
+
# 情感标签可视化
|
|
350
|
+
sent_label = frag.get("sentiment_label", "")
|
|
351
|
+
if sent_label and sent_label != "neutral":
|
|
352
|
+
sent_score = frag.get("sentiment_score", "0")
|
|
353
|
+
icon = "😊" if sent_label == "positive" else "😠"
|
|
354
|
+
info_parts.append(f"{icon} {sent_label}({sent_score})")
|
|
355
|
+
lines.append(f" ({', '.join(info_parts)})")
|
|
356
|
+
lines.append("")
|
|
357
|
+
|
|
358
|
+
lines.append("</fragmented_memory>")
|
|
359
|
+
return "\n".join(lines)
|
|
360
|
+
|
|
361
|
+
def sync_turn(
|
|
362
|
+
self,
|
|
363
|
+
user_content: str,
|
|
364
|
+
assistant_content: str,
|
|
365
|
+
*,
|
|
366
|
+
session_id: str = "",
|
|
367
|
+
messages: Optional[List[Dict[str, Any]]] = None,
|
|
368
|
+
) -> None:
|
|
369
|
+
"""对话每轮结束后,将用户消息切分存档,并触发维护。"""
|
|
370
|
+
if not self._storage or not user_content or len(user_content.strip()) < 10:
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
segments = split_text(user_content.strip())
|
|
374
|
+
sid_short = session_id[:8] if session_id else "unknown"
|
|
375
|
+
for seg in segments:
|
|
376
|
+
self._storage.store(
|
|
377
|
+
text=seg,
|
|
378
|
+
tags=f"session:{sid_short}",
|
|
379
|
+
category="conversation",
|
|
380
|
+
source="sync_turn",
|
|
381
|
+
fragment_type="conversation",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# 定期触发维护(Consolidation + Forget)
|
|
385
|
+
self._maybe_maintain()
|
|
386
|
+
|
|
387
|
+
def _maybe_maintain(self) -> None:
|
|
388
|
+
"""检查是否该执行维护,执行 Consolidation + Forget。"""
|
|
389
|
+
import time as _time
|
|
390
|
+
now = _time.time()
|
|
391
|
+
if now - self._last_maintenance < self._maintenance_interval:
|
|
392
|
+
return
|
|
393
|
+
self._last_maintenance = now
|
|
394
|
+
self.maintenance()
|
|
395
|
+
|
|
396
|
+
def maintenance(self) -> Dict[str, Any]:
|
|
397
|
+
"""执行一轮完整维护:Consolidation → Forget。
|
|
398
|
+
|
|
399
|
+
返回:
|
|
400
|
+
维护统计
|
|
401
|
+
"""
|
|
402
|
+
stats: Dict[str, Any] = {
|
|
403
|
+
"consolidator": {"status": "skipped"},
|
|
404
|
+
"forgetter": {"status": "skipped"},
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
# Step 1: Consolidation
|
|
408
|
+
if self._consolidator:
|
|
409
|
+
try:
|
|
410
|
+
result = self._consolidator.consolidate()
|
|
411
|
+
stats["consolidator"] = result
|
|
412
|
+
logger.info("fragmented: consolidation done — %s", result)
|
|
413
|
+
except Exception as e:
|
|
414
|
+
logger.warning("fragmented: consolidation error: %s", e)
|
|
415
|
+
stats["consolidator"] = {"status": "error", "reason": str(e)}
|
|
416
|
+
|
|
417
|
+
# Step 2: Selective Forgetting
|
|
418
|
+
if self._forgetter:
|
|
419
|
+
try:
|
|
420
|
+
result = self._forgetter.forget()
|
|
421
|
+
stats["forgetter"] = result
|
|
422
|
+
logger.info("fragmented: forgetting done — %s", result)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
logger.warning("fragmented: forgetting error: %s", e)
|
|
425
|
+
stats["forgetter"] = {"status": "error", "reason": str(e)}
|
|
426
|
+
|
|
427
|
+
return stats
|
|
428
|
+
|
|
429
|
+
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
430
|
+
return [FEEDBACK_SCHEMA, HOT_TOPICS_SCHEMA]
|
|
431
|
+
|
|
432
|
+
def handle_tool_call(
|
|
433
|
+
self,
|
|
434
|
+
tool_name: str,
|
|
435
|
+
args: Dict[str, Any],
|
|
436
|
+
**kwargs,
|
|
437
|
+
) -> str:
|
|
438
|
+
"""Route tool calls to the appropriate handler."""
|
|
439
|
+
import json as _json
|
|
440
|
+
|
|
441
|
+
if tool_name == "frag_memory_feedback":
|
|
442
|
+
return self._handle_feedback(args, _json)
|
|
443
|
+
elif tool_name == "frag_hot_topics":
|
|
444
|
+
return self._handle_hot_topics(args, _json)
|
|
445
|
+
return tool_error(f"Unknown fragmented memory tool: '{tool_name}'")
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
# Tool handlers
|
|
449
|
+
# ------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def _handle_feedback(self, args: Dict[str, Any], _json) -> str:
|
|
452
|
+
key = args.get("fragment_key", "")
|
|
453
|
+
is_pos = bool(args.get("is_positive", True))
|
|
454
|
+
if not key:
|
|
455
|
+
return tool_error("fragment_key is required")
|
|
456
|
+
if not self._storage:
|
|
457
|
+
return tool_error("Memory storage not initialized")
|
|
458
|
+
ok = self._storage.record_feedback(key, is_pos)
|
|
459
|
+
if ok:
|
|
460
|
+
action = "有用 👍" if is_pos else "没用 👎"
|
|
461
|
+
return _json.dumps({"success": True, "action": action, "key": key})
|
|
462
|
+
return tool_error("Failed to record feedback")
|
|
463
|
+
|
|
464
|
+
def _handle_hot_topics(self, args: Dict[str, Any], _json) -> str:
|
|
465
|
+
limit = min(int(args.get("limit", 10)), 30)
|
|
466
|
+
period = args.get("period", "all")
|
|
467
|
+
if not self._storage:
|
|
468
|
+
return tool_error("Memory storage not initialized")
|
|
469
|
+
topics = self._storage.get_hot_topics(limit=limit, period=period)
|
|
470
|
+
return _json.dumps({"topics": topics, "count": len(topics)}, ensure_ascii=False)
|
|
471
|
+
|
|
472
|
+
def shutdown(self) -> None:
|
|
473
|
+
if self._storage:
|
|
474
|
+
self._storage.close()
|
|
475
|
+
logger.info("fragmented memory provider shutdown")
|
|
476
|
+
|
|
477
|
+
def on_memory_write(
|
|
478
|
+
self,
|
|
479
|
+
action: str,
|
|
480
|
+
target: str,
|
|
481
|
+
content: str,
|
|
482
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
483
|
+
) -> None:
|
|
484
|
+
"""builtin memory 写入时同步存到碎片库。"""
|
|
485
|
+
if action != "add" or not content or not self._storage:
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
for seg in split_text(content):
|
|
489
|
+
self._storage.store(
|
|
490
|
+
text=seg,
|
|
491
|
+
tags=target,
|
|
492
|
+
category="memory_tool",
|
|
493
|
+
source="hermes_agent",
|
|
494
|
+
fragment_type="memory",
|
|
495
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""注意力追踪 — 统计用户对各个话题的关注频率。
|
|
2
|
+
|
|
3
|
+
用户反复提起某个话题 -> 该话题关注度上升 -> 相关碎片在搜索中权重更高。
|
|
4
|
+
|
|
5
|
+
存储: Redis Sorted Set `fragmented:attention`
|
|
6
|
+
- member: 话题词(由 jieba/关键词提取来)
|
|
7
|
+
- score: 关注度累计值(每次提及 +2,情绪烈度加权)
|
|
8
|
+
|
|
9
|
+
三套时间窗口(同 hot_topics 模式):
|
|
10
|
+
- 全局: fractured:attention (7天)
|
|
11
|
+
- 日榜: fractured:attention:daily (2天)
|
|
12
|
+
- 周榜: fractured:attention:weekly (14天)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import redis
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Redis key 前缀
|
|
25
|
+
ATTENTION_SET = "fragmented:attention"
|
|
26
|
+
ATTENTION_DAILY = "fragmented:attention:daily"
|
|
27
|
+
ATTENTION_WEEKLY = "fragmented:attention:weekly"
|
|
28
|
+
|
|
29
|
+
# 过期时间
|
|
30
|
+
_ATTENTION_TTL = {
|
|
31
|
+
ATTENTION_SET: 86400 * 7, # 全局:7天
|
|
32
|
+
ATTENTION_DAILY: 86400 * 2, # 日榜:2天
|
|
33
|
+
ATTENTION_WEEKLY: 86400 * 14, # 周榜:14天
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def record_attention(
|
|
38
|
+
client: redis.Redis,
|
|
39
|
+
text: str,
|
|
40
|
+
emotion_intensity: float = 0.0,
|
|
41
|
+
keywords: Optional[List[str]] = None,
|
|
42
|
+
base_increment: float = 2.0,
|
|
43
|
+
emotion_factor: float = 1.5,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""记录用户对一段文本中话题的关注。"""
|
|
46
|
+
if not client or not text:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
# 没有关键词时跳过(正常路径由 store() 传入,不会走到这里)
|
|
51
|
+
if not keywords:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
increment = base_increment + emotion_intensity * emotion_factor
|
|
55
|
+
|
|
56
|
+
for kw in keywords:
|
|
57
|
+
kw_lower = kw.lower().strip()
|
|
58
|
+
if len(kw_lower) < 2:
|
|
59
|
+
continue
|
|
60
|
+
for topic_set in (ATTENTION_SET, ATTENTION_DAILY, ATTENTION_WEEKLY):
|
|
61
|
+
client.zincrby(topic_set, increment, kw_lower)
|
|
62
|
+
client.expire(topic_set, _ATTENTION_TTL.get(topic_set, 86400))
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.debug("attention: record_attention error: %s", e)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_attention_score(client: redis.Redis, keyword: str) -> float:
|
|
69
|
+
"""查某个词在当前注意力分数中的排名分。"""
|
|
70
|
+
if not client or not keyword:
|
|
71
|
+
return 0.0
|
|
72
|
+
try:
|
|
73
|
+
score = client.zscore(ATTENTION_SET, keyword.lower().strip())
|
|
74
|
+
return score if score is not None else 0.0
|
|
75
|
+
except Exception:
|
|
76
|
+
return 0.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_top_attention(client: redis.Redis, limit: int = 10,
|
|
80
|
+
period: str = "all") -> List[Dict[str, Any]]:
|
|
81
|
+
"""获取关注度最高的词。"""
|
|
82
|
+
key = {
|
|
83
|
+
"all": ATTENTION_SET,
|
|
84
|
+
"daily": ATTENTION_DAILY,
|
|
85
|
+
"weekly": ATTENTION_WEEKLY,
|
|
86
|
+
}.get(period, ATTENTION_SET)
|
|
87
|
+
|
|
88
|
+
if not client:
|
|
89
|
+
return []
|
|
90
|
+
try:
|
|
91
|
+
raw = client.zrevrange(key, 0, limit - 1, withscores=True)
|
|
92
|
+
results = []
|
|
93
|
+
for t, s_raw in raw:
|
|
94
|
+
topic = t.decode("utf-8") if isinstance(t, bytes) else t
|
|
95
|
+
if isinstance(s_raw, bytes):
|
|
96
|
+
s_raw = s_raw.decode("utf-8")
|
|
97
|
+
results.append({"topic": topic, "score": round(float(s_raw), 1)})
|
|
98
|
+
return results
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.debug("attention: get_top_attention error: %s", e)
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def match_attention_boost(
|
|
105
|
+
client: redis.Redis,
|
|
106
|
+
content: str,
|
|
107
|
+
top_n: int = 10,
|
|
108
|
+
boost_max: float = 1.5,
|
|
109
|
+
) -> float:
|
|
110
|
+
"""检查碎片内容的注意力关注度加权值。
|
|
111
|
+
|
|
112
|
+
从全局注意力取 top N 话题,看碎片内容命中几个。
|
|
113
|
+
命中越多权重越高,最高 boost_max。
|
|
114
|
+
"""
|
|
115
|
+
if not client or not content:
|
|
116
|
+
return 1.0
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
raw = client.zrevrange(ATTENTION_SET, 0, top_n - 1, withscores=True)
|
|
120
|
+
if not raw:
|
|
121
|
+
return 1.0
|
|
122
|
+
|
|
123
|
+
content_lower = content.lower()
|
|
124
|
+
total_score = 0.0
|
|
125
|
+
max_score = 0.0
|
|
126
|
+
|
|
127
|
+
for topic_b, score_raw in raw:
|
|
128
|
+
topic = topic_b.decode("utf-8") if isinstance(topic_b, bytes) else topic_b
|
|
129
|
+
if isinstance(score_raw, bytes):
|
|
130
|
+
score_raw = score_raw.decode("utf-8")
|
|
131
|
+
sc = float(score_raw) # noqa: F841
|
|
132
|
+
if isinstance(sc, (int, float)):
|
|
133
|
+
if len(topic) >= 2 and topic in content_lower:
|
|
134
|
+
total_score += sc
|
|
135
|
+
max_score += sc
|
|
136
|
+
|
|
137
|
+
if max_score <= 0:
|
|
138
|
+
return 1.0
|
|
139
|
+
|
|
140
|
+
# 归一化到 1.0~boost_max,命中越高越接近 boost_max
|
|
141
|
+
ratio = min(total_score / max_score, 1.0)
|
|
142
|
+
return 1.0 + (boost_max - 1.0) * ratio
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.debug("attention: match_attention_boost error: %s", e)
|
|
146
|
+
return 1.0
|