mindforge-memory 6.4.1__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.
- MindForge.py +20 -0
- mindforge/__init__.py +111 -0
- mindforge/adapters/__init__.py +18 -0
- mindforge/adapters/claude_adapter.py +84 -0
- mindforge/adapters/crewai_memory.py +76 -0
- mindforge/adapters/generic_api.py +183 -0
- mindforge/adapters/langgraph_store.py +463 -0
- mindforge/adapters/openclaw_adapter.py +91 -0
- mindforge/api/__init__.py +1 -0
- mindforge/api/server.py +1567 -0
- mindforge/cli/__init__.py +1 -0
- mindforge/cli/main.py +13607 -0
- mindforge/core/__init__.py +37 -0
- mindforge/core/embedding.py +847 -0
- mindforge/core/encryption.py +597 -0
- mindforge/core/indexer.py +363 -0
- mindforge/core/mindforge.py +6298 -0
- mindforge/core/paths.py +43 -0
- mindforge/core/query.py +501 -0
- mindforge/core/storage.py +18656 -0
- mindforge/core/types.py +314 -0
- mindforge/core/version.py +56 -0
- mindforge/daemon/__init__.py +0 -0
- mindforge/daemon/server.py +277 -0
- mindforge/mcp/__init__.py +4 -0
- mindforge/mcp/server.py +2559 -0
- mindforge/modules/__init__.py +138 -0
- mindforge/modules/categorizer.py +151 -0
- mindforge/modules/conflict_detector.py +356 -0
- mindforge/modules/connectors.py +331 -0
- mindforge/modules/context_window.py +337 -0
- mindforge/modules/event_bus.py +572 -0
- mindforge/modules/evolution.py +210 -0
- mindforge/modules/experience.py +402 -0
- mindforge/modules/federated.py +722 -0
- mindforge/modules/federated_acl.py +393 -0
- mindforge/modules/follow_up.py +315 -0
- mindforge/modules/hybrid_search.py +565 -0
- mindforge/modules/importer.py +374 -0
- mindforge/modules/integrator.py +196 -0
- mindforge/modules/intent_router.py +359 -0
- mindforge/modules/knowledge_graph.py +395 -0
- mindforge/modules/memory_compressor.py +592 -0
- mindforge/modules/memory_decay.py +474 -0
- mindforge/modules/memory_passport.py +250 -0
- mindforge/modules/multi_agent.py +534 -0
- mindforge/modules/multimodal.py +304 -0
- mindforge/modules/outcome_learning.py +186 -0
- mindforge/modules/personality.py +275 -0
- mindforge/modules/principled_forgetting.py +258 -0
- mindforge/modules/privacy.py +736 -0
- mindforge/modules/recall.py +263 -0
- mindforge/modules/session_focus.py +305 -0
- mindforge/modules/share_conflict.py +521 -0
- mindforge/modules/skill_extractor.py +340 -0
- mindforge/modules/skill_pack.py +205 -0
- mindforge/modules/timeline_viz.py +206 -0
- mindforge/modules/v6.py +931 -0
- mindforge_memory-6.4.1.dist-info/METADATA +281 -0
- mindforge_memory-6.4.1.dist-info/RECORD +64 -0
- mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
- mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
- mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
- mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge v5.0 召回引擎
|
|
3
|
+
语义检索 + 知识图谱增强 + 上下文优化
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import List, Optional, Any
|
|
10
|
+
|
|
11
|
+
from mindforge.core.storage import StorageEngine, MemoryEntry
|
|
12
|
+
from mindforge.core.indexer import IndexEngine
|
|
13
|
+
from mindforge.core.query import QueryEngine, MemoryChunk, RecallResult
|
|
14
|
+
from mindforge.core.types import Importance, MemoryLayer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RecallConfig:
|
|
19
|
+
"""召回配置"""
|
|
20
|
+
max_results: int = 10
|
|
21
|
+
min_relevance: float = 0.3
|
|
22
|
+
include_categories: Optional[List[str]] = None
|
|
23
|
+
exclude_categories: Optional[List[str]] = None
|
|
24
|
+
include_layers: Optional[List[MemoryLayer]] = None
|
|
25
|
+
min_importance: Optional[Any] = None
|
|
26
|
+
max_tokens: int = 4000
|
|
27
|
+
use_knowledge_graph: bool = True
|
|
28
|
+
use_reranking: bool = True
|
|
29
|
+
diversity_weight: float = 0.2
|
|
30
|
+
temporal_bias: float = 0.1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RecallEngine:
|
|
34
|
+
"""召回引擎"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, storage: StorageEngine, index: IndexEngine,
|
|
37
|
+
knowledge_graph=None):
|
|
38
|
+
self.storage = storage
|
|
39
|
+
self.index = index
|
|
40
|
+
self.query_engine = QueryEngine(storage, index)
|
|
41
|
+
self.knowledge_graph = knowledge_graph
|
|
42
|
+
|
|
43
|
+
def recall(self,
|
|
44
|
+
query: str,
|
|
45
|
+
agent_id: str = "",
|
|
46
|
+
session_id: str = "",
|
|
47
|
+
config: Optional[RecallConfig] = None) -> RecallResult:
|
|
48
|
+
"""召回记忆"""
|
|
49
|
+
start = time.time()
|
|
50
|
+
config = config or RecallConfig()
|
|
51
|
+
|
|
52
|
+
base_results = self.query_engine.search(
|
|
53
|
+
query=query,
|
|
54
|
+
max_results=config.max_results * 3,
|
|
55
|
+
min_relevance=config.min_relevance * 0.5,
|
|
56
|
+
categories=config.include_categories,
|
|
57
|
+
layers=config.include_layers,
|
|
58
|
+
agent_id=agent_id,
|
|
59
|
+
session_id=session_id,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
chunks = base_results.chunks
|
|
63
|
+
|
|
64
|
+
if config.use_knowledge_graph and self.knowledge_graph:
|
|
65
|
+
kg_chunks = self._kg_enhanced_search(query, config)
|
|
66
|
+
chunks = self._merge_results(chunks, kg_chunks)
|
|
67
|
+
|
|
68
|
+
if config.exclude_categories or config.min_importance is not None:
|
|
69
|
+
chunks = self._apply_config_filters(chunks, config)
|
|
70
|
+
|
|
71
|
+
if config.use_reranking:
|
|
72
|
+
chunks = self._rerank(chunks, query, config)
|
|
73
|
+
|
|
74
|
+
chunks = chunks[:config.max_results]
|
|
75
|
+
|
|
76
|
+
token_estimate = sum(len(c.content) for c in chunks) // 4
|
|
77
|
+
if config.max_tokens > 0 and token_estimate > config.max_tokens:
|
|
78
|
+
chunks = self._optimize_context_window(chunks, config.max_tokens)
|
|
79
|
+
|
|
80
|
+
elapsed = (time.time() - start) * 1000
|
|
81
|
+
layers_used = list(set(c.layer.value for c in chunks))
|
|
82
|
+
|
|
83
|
+
return RecallResult(
|
|
84
|
+
chunks=chunks,
|
|
85
|
+
total_found=len(chunks),
|
|
86
|
+
query_time_ms=round(elapsed, 2),
|
|
87
|
+
strategy_used="hybrid_semantic_kg",
|
|
88
|
+
token_estimate=sum(len(c.content) for c in chunks) // 4,
|
|
89
|
+
layers_used=layers_used,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _apply_config_filters(self, chunks: List[MemoryChunk],
|
|
93
|
+
config: RecallConfig) -> List[MemoryChunk]:
|
|
94
|
+
"""应用 RecallConfig 中声明的排除分类和最低重要度。"""
|
|
95
|
+
minimum = config.min_importance
|
|
96
|
+
if minimum is not None:
|
|
97
|
+
if isinstance(minimum, str):
|
|
98
|
+
try:
|
|
99
|
+
minimum = Importance[minimum.strip().upper()]
|
|
100
|
+
except KeyError as exc:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"min_importance must be LOW/MEDIUM/HIGH/CRITICAL"
|
|
103
|
+
) from exc
|
|
104
|
+
elif not isinstance(minimum, Importance):
|
|
105
|
+
if (isinstance(minimum, int) and not isinstance(minimum, bool)
|
|
106
|
+
and 0 <= minimum <= 3):
|
|
107
|
+
minimum = minimum
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"min_importance must be an Importance, valid name, or rank 0-3"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
kept = []
|
|
114
|
+
excluded = set(config.exclude_categories or [])
|
|
115
|
+
for chunk in chunks:
|
|
116
|
+
entry = self.storage.get_memory(chunk.memory_id)
|
|
117
|
+
if entry is None or entry.category in excluded:
|
|
118
|
+
continue
|
|
119
|
+
if minimum is not None:
|
|
120
|
+
rank = minimum if isinstance(minimum, int) else minimum.to_int()
|
|
121
|
+
if entry.importance.to_int() < rank:
|
|
122
|
+
continue
|
|
123
|
+
kept.append(chunk)
|
|
124
|
+
return kept
|
|
125
|
+
|
|
126
|
+
def _kg_enhanced_search(self, query: str, config: RecallConfig) -> List[MemoryChunk]:
|
|
127
|
+
"""知识图谱增强搜索"""
|
|
128
|
+
chunks = []
|
|
129
|
+
entities = self.knowledge_graph.extract_entities(query)
|
|
130
|
+
|
|
131
|
+
for entity_name, _ in entities:
|
|
132
|
+
related = self.knowledge_graph.get_related_entities(
|
|
133
|
+
entity_name, depth=1, max_results=10
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for related_name, rel_type, weight in related:
|
|
137
|
+
expanded_query = f"{query} {related_name}"
|
|
138
|
+
result = self.query_engine.search(
|
|
139
|
+
query=expanded_query,
|
|
140
|
+
max_results=3,
|
|
141
|
+
min_relevance=config.min_relevance,
|
|
142
|
+
categories=config.include_categories,
|
|
143
|
+
)
|
|
144
|
+
for chunk in result.chunks:
|
|
145
|
+
chunk.relevance_score *= weight * 0.8
|
|
146
|
+
chunks.append(chunk)
|
|
147
|
+
|
|
148
|
+
return chunks
|
|
149
|
+
|
|
150
|
+
def _merge_results(self, base: List[MemoryChunk],
|
|
151
|
+
enhanced: List[MemoryChunk]) -> List[MemoryChunk]:
|
|
152
|
+
"""合并搜索结果"""
|
|
153
|
+
seen = set()
|
|
154
|
+
merged = []
|
|
155
|
+
|
|
156
|
+
for chunk in base:
|
|
157
|
+
if chunk.memory_id not in seen:
|
|
158
|
+
seen.add(chunk.memory_id)
|
|
159
|
+
merged.append(chunk)
|
|
160
|
+
|
|
161
|
+
for chunk in enhanced:
|
|
162
|
+
if chunk.memory_id not in seen:
|
|
163
|
+
seen.add(chunk.memory_id)
|
|
164
|
+
merged.append(chunk)
|
|
165
|
+
else:
|
|
166
|
+
for existing in merged:
|
|
167
|
+
if existing.memory_id == chunk.memory_id:
|
|
168
|
+
existing.relevance_score = max(
|
|
169
|
+
existing.relevance_score, chunk.relevance_score
|
|
170
|
+
)
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
merged.sort(key=lambda x: x.relevance_score, reverse=True)
|
|
174
|
+
return merged
|
|
175
|
+
|
|
176
|
+
def _rerank(self, chunks: List[MemoryChunk], query: str,
|
|
177
|
+
config: RecallConfig) -> List[MemoryChunk]:
|
|
178
|
+
"""重排序"""
|
|
179
|
+
if not chunks:
|
|
180
|
+
return chunks
|
|
181
|
+
|
|
182
|
+
max_score = max(c.relevance_score for c in chunks) if chunks else 1.0
|
|
183
|
+
|
|
184
|
+
# v5.4.7 修复 M-6:批量获取记忆,避免 N+1 查询
|
|
185
|
+
memory_ids = [c.memory_id for c in chunks]
|
|
186
|
+
entries_map = {}
|
|
187
|
+
for mid in memory_ids:
|
|
188
|
+
entry = self.storage.get_memory(mid)
|
|
189
|
+
if entry:
|
|
190
|
+
entries_map[mid] = entry
|
|
191
|
+
|
|
192
|
+
for chunk in chunks:
|
|
193
|
+
score = chunk.relevance_score / max_score if max_score > 0 else 0
|
|
194
|
+
|
|
195
|
+
entry = entries_map.get(chunk.memory_id)
|
|
196
|
+
if entry:
|
|
197
|
+
recency = 1.0
|
|
198
|
+
if entry.last_accessed_at:
|
|
199
|
+
import time as _time
|
|
200
|
+
elapsed_hours = (_time.time() - entry.last_accessed_at) / 3600
|
|
201
|
+
recency = 1.0 / (1.0 + elapsed_hours * 0.01)
|
|
202
|
+
|
|
203
|
+
importance_bonus = entry.importance.to_int() / 3.0 * 0.2
|
|
204
|
+
|
|
205
|
+
final_score = (
|
|
206
|
+
score * 0.6
|
|
207
|
+
+ recency * config.temporal_bias
|
|
208
|
+
+ importance_bonus
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
chunk.relevance_score = final_score
|
|
212
|
+
|
|
213
|
+
chunks.sort(key=lambda x: x.relevance_score, reverse=True)
|
|
214
|
+
return chunks
|
|
215
|
+
|
|
216
|
+
def _optimize_context_window(self, chunks: List[MemoryChunk],
|
|
217
|
+
max_tokens: int) -> List[MemoryChunk]:
|
|
218
|
+
"""优化上下文窗口"""
|
|
219
|
+
selected = []
|
|
220
|
+
total_tokens = 0
|
|
221
|
+
|
|
222
|
+
for chunk in chunks:
|
|
223
|
+
chunk_tokens = len(chunk.content) // 4
|
|
224
|
+
if total_tokens + chunk_tokens <= max_tokens:
|
|
225
|
+
selected.append(chunk)
|
|
226
|
+
total_tokens += chunk_tokens
|
|
227
|
+
else:
|
|
228
|
+
remaining = max_tokens - total_tokens
|
|
229
|
+
if remaining > 50:
|
|
230
|
+
truncated = copy.copy(chunk)
|
|
231
|
+
truncated.content = chunk.content[:remaining * 4]
|
|
232
|
+
selected.append(truncated)
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
return selected
|
|
236
|
+
|
|
237
|
+
def get_session_relevant(self, session_id: str,
|
|
238
|
+
current_query: str,
|
|
239
|
+
limit: int = 10) -> List[MemoryEntry]:
|
|
240
|
+
"""获取会话相关记忆"""
|
|
241
|
+
session_memories = self.query_engine.get_session_context(session_id, limit * 2)
|
|
242
|
+
|
|
243
|
+
result = self.recall(
|
|
244
|
+
query=current_query,
|
|
245
|
+
session_id=session_id,
|
|
246
|
+
config=RecallConfig(max_results=limit),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
entries = []
|
|
250
|
+
seen_ids = set()
|
|
251
|
+
for c in result.chunks:
|
|
252
|
+
if c.memory_id not in seen_ids:
|
|
253
|
+
entry = self.storage.get_memory(c.memory_id)
|
|
254
|
+
if entry and entry.source_session in ("", session_id):
|
|
255
|
+
entries.append(entry)
|
|
256
|
+
seen_ids.add(c.memory_id)
|
|
257
|
+
for entry in session_memories:
|
|
258
|
+
if entry.id not in seen_ids:
|
|
259
|
+
entries.append(entry)
|
|
260
|
+
seen_ids.add(entry.id)
|
|
261
|
+
if len(entries) >= limit:
|
|
262
|
+
break
|
|
263
|
+
return entries[:limit]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge 会话焦点增强引擎
|
|
3
|
+
================================
|
|
4
|
+
输入:一个会话消息序列(每条 {id, role, content, timestamp})
|
|
5
|
+
输出:
|
|
6
|
+
- 按时间滚动的主题簇(Topic Cluster)
|
|
7
|
+
- 会话焦点摘要(当前 top-K 主题 + 代表词)
|
|
8
|
+
- 焦点漂移检测(与上一窗口相比主题变化率)
|
|
9
|
+
- 焦点增强查询(把当前主题关键词拼接到原始搜索 query,提升召回)
|
|
10
|
+
|
|
11
|
+
实现:纯文本特征,滑动窗口 + token / 2-gram 并集频率做简单 k-means(k 自动)
|
|
12
|
+
无外部依赖。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# 停用词(中英常见功能词 / 代词)
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
STOPWORDS = set("""
|
|
29
|
+
的 了 和 是 就 都 而 及 与 或 一个 一些 这 那 这个 那个 这些 那些 我 你 他 她 它
|
|
30
|
+
我们 你们 他们 自己 什么 怎么 如何 为什么 可以 已经 将 要 会 能 有 在 于 对 从
|
|
31
|
+
到 给 把 被 让 向 比 又 还 只 也 很 更 最 不 没 没有 不是 吧 吗 呢 啊 哦 嗯
|
|
32
|
+
the a an and or but if then of in on at to for with from by is are was were be been
|
|
33
|
+
being have has had do does did i you he she it we they them my your our their this that
|
|
34
|
+
these those what which who how why when where so not no just only also can will would should
|
|
35
|
+
could may might about into out up down over under again further then once here there all any
|
|
36
|
+
each few more most other some such than too very
|
|
37
|
+
""".split())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# 数据类
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class TopicCluster:
|
|
46
|
+
cluster_id: int
|
|
47
|
+
keywords: List[Tuple[str, float]] # [(词, 权重)]
|
|
48
|
+
message_ids: List[str] = field(default_factory=list)
|
|
49
|
+
representative: str = "" # 代表消息的正文(最长、关键词密度最高)
|
|
50
|
+
size: int = 0
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"cluster_id": self.cluster_id,
|
|
55
|
+
"size": self.size,
|
|
56
|
+
"top_keywords": self.keywords[:12],
|
|
57
|
+
"message_ids": self.message_ids[:200],
|
|
58
|
+
"representative": self.representative[:160],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class FocusSummary:
|
|
64
|
+
window_start_ts: float
|
|
65
|
+
window_end_ts: float
|
|
66
|
+
clusters: List[TopicCluster] = field(default_factory=list)
|
|
67
|
+
drift_score: float = 0.0 # 0.0 ~ 1.0
|
|
68
|
+
drift_indicators: List[str] = field(default_factory=list)
|
|
69
|
+
focus_keywords: List[Tuple[str, float]] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"window": {
|
|
74
|
+
"start": self.window_start_ts,
|
|
75
|
+
"end": self.window_end_ts,
|
|
76
|
+
"duration": round(self.window_end_ts - self.window_start_ts, 2),
|
|
77
|
+
},
|
|
78
|
+
"clusters": [c.to_dict() for c in self.clusters],
|
|
79
|
+
"drift_score": round(self.drift_score, 3),
|
|
80
|
+
"drift_indicators": self.drift_indicators,
|
|
81
|
+
"focus_keywords": self.focus_keywords[:20],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def enhance_query(self, query: str, top_k: int = 5,
|
|
85
|
+
boost_mode: str = "append") -> str:
|
|
86
|
+
"""把焦点关键词拼接到查询。boost_mode: append / replace_empty / weighted_string"""
|
|
87
|
+
if not query:
|
|
88
|
+
return " ".join(w for w, _ in self.focus_keywords[:top_k])
|
|
89
|
+
kws = [w for w, s in self.focus_keywords[:top_k] if w and w not in query.lower()]
|
|
90
|
+
if not kws:
|
|
91
|
+
return query
|
|
92
|
+
if boost_mode == "replace_empty":
|
|
93
|
+
return query
|
|
94
|
+
# 默认 append:保留原 query 并在后面加「关键词」
|
|
95
|
+
extra = " ".join(kws)
|
|
96
|
+
return f"{query} {extra}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# 引擎
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
class SessionFocus:
|
|
104
|
+
"""无外部依赖的会话焦点聚类 + 漂移检测 + 查询增强"""
|
|
105
|
+
|
|
106
|
+
def __init__(self,
|
|
107
|
+
max_messages_per_window: int = 50,
|
|
108
|
+
min_cluster_size: int = 2,
|
|
109
|
+
max_clusters: int = 8,
|
|
110
|
+
similarity_threshold: float = 0.25,
|
|
111
|
+
top_n_keywords: int = 10):
|
|
112
|
+
self.max_w = max_messages_per_window
|
|
113
|
+
self.min_cluster = min_cluster_size
|
|
114
|
+
self.max_clusters = max_clusters
|
|
115
|
+
self.sim_thresh = similarity_threshold
|
|
116
|
+
self.top_n = top_n_keywords
|
|
117
|
+
self._prev_keywords: Counter = Counter()
|
|
118
|
+
|
|
119
|
+
# -- feature extraction ------------------------------------------------
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _text(msg: Dict[str, Any]) -> str:
|
|
123
|
+
role = str(msg.get("role", "user"))
|
|
124
|
+
content = str(msg.get("content", ""))
|
|
125
|
+
return f"{role}\n{content}"
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def _tokens(cls, text: str) -> List[str]:
|
|
129
|
+
cleaned = re.sub(r"[\W_]+", " ", text.lower()).split()
|
|
130
|
+
bag: List[str] = []
|
|
131
|
+
for t in cleaned:
|
|
132
|
+
if not t or t in STOPWORDS or len(t) <= 1:
|
|
133
|
+
continue
|
|
134
|
+
bag.append(t)
|
|
135
|
+
# 中文 2-gram(字符级)
|
|
136
|
+
cn_seg = "".join(cleaned)
|
|
137
|
+
if re.search(r"[\u4e00-\u9fa5]", cn_seg):
|
|
138
|
+
for n in (2, 3):
|
|
139
|
+
for i in range(len(cn_seg) - n + 1):
|
|
140
|
+
chunk = cn_seg[i:i + n]
|
|
141
|
+
if re.search(r"[\u4e00-\u9fa5]", chunk) and chunk not in STOPWORDS:
|
|
142
|
+
bag.append(chunk)
|
|
143
|
+
return bag
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def _tfidf_features(cls, texts: Sequence[str]) -> Tuple[List[Counter], Counter]:
|
|
147
|
+
tfs: List[Counter] = []
|
|
148
|
+
df: Counter = Counter()
|
|
149
|
+
for txt in texts:
|
|
150
|
+
tokens = cls._tokens(txt)
|
|
151
|
+
tf = Counter(tokens)
|
|
152
|
+
tfs.append(tf)
|
|
153
|
+
for w in set(tf):
|
|
154
|
+
df[w] += 1
|
|
155
|
+
return tfs, df
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _cosine(a: Counter, b: Counter) -> float:
|
|
159
|
+
if not a or not b:
|
|
160
|
+
return 0.0
|
|
161
|
+
shared = set(a) & set(b)
|
|
162
|
+
dot = sum(a[w] * b[w] for w in shared)
|
|
163
|
+
if dot == 0:
|
|
164
|
+
return 0.0
|
|
165
|
+
na = math.sqrt(sum(v * v for v in a.values()))
|
|
166
|
+
nb = math.sqrt(sum(v * v for v in b.values()))
|
|
167
|
+
return dot / max(1e-9, na * nb)
|
|
168
|
+
|
|
169
|
+
# -- simple greedy clustering -----------------------------------------
|
|
170
|
+
|
|
171
|
+
def _cluster(self, tfs: Sequence[Counter],
|
|
172
|
+
ids: Sequence[str], texts: Sequence[str]) -> List[TopicCluster]:
|
|
173
|
+
n = len(tfs)
|
|
174
|
+
centers: List[int] = [] # 中心消息的索引
|
|
175
|
+
center_to_cluster: Dict[int, int] = {}
|
|
176
|
+
assignments = [-1] * n
|
|
177
|
+
|
|
178
|
+
for i in range(n):
|
|
179
|
+
best_idx, best_sim = -1, 0.0
|
|
180
|
+
for c_idx, cent in enumerate(centers):
|
|
181
|
+
sim = self._cosine(tfs[i], tfs[cent])
|
|
182
|
+
if sim > best_sim:
|
|
183
|
+
best_sim, best_idx = sim, c_idx
|
|
184
|
+
if best_idx >= 0 and best_sim >= self.sim_thresh:
|
|
185
|
+
assignments[i] = best_idx
|
|
186
|
+
elif len(centers) < self.max_clusters:
|
|
187
|
+
assignments[i] = len(centers)
|
|
188
|
+
center_to_cluster[i] = assignments[i]
|
|
189
|
+
centers.append(i)
|
|
190
|
+
else:
|
|
191
|
+
# 兜底归给最相似的
|
|
192
|
+
assignments[i] = best_idx if best_idx >= 0 else 0
|
|
193
|
+
|
|
194
|
+
# 构建簇
|
|
195
|
+
clusters_by_id: Dict[int, TopicCluster] = {}
|
|
196
|
+
for i, cid in enumerate(assignments):
|
|
197
|
+
if cid < 0:
|
|
198
|
+
continue
|
|
199
|
+
cl = clusters_by_id.setdefault(cid, TopicCluster(
|
|
200
|
+
cluster_id=cid, keywords=[], message_ids=[], representative="", size=0,
|
|
201
|
+
))
|
|
202
|
+
cl.message_ids.append(ids[i])
|
|
203
|
+
cl.size += 1
|
|
204
|
+
if len(texts[i]) > len(cl.representative):
|
|
205
|
+
cl.representative = texts[i]
|
|
206
|
+
|
|
207
|
+
# 计算每个簇的关键词(簇内tf-idf之和)
|
|
208
|
+
_, df = self._tfidf_features(texts)
|
|
209
|
+
N = max(1, len(texts))
|
|
210
|
+
clusters: List[TopicCluster] = []
|
|
211
|
+
for cid, cl in clusters_by_id.items():
|
|
212
|
+
if cl.size < self.min_cluster:
|
|
213
|
+
continue
|
|
214
|
+
cluster_tf: Counter = Counter()
|
|
215
|
+
for i in range(n):
|
|
216
|
+
if assignments[i] == cid:
|
|
217
|
+
cluster_tf.update(tfs[i])
|
|
218
|
+
scored = []
|
|
219
|
+
for w, f in cluster_tf.items():
|
|
220
|
+
idf = math.log((1 + N) / (1 + df.get(w, 0)) + 1.0)
|
|
221
|
+
scored.append((w, float(f) * idf))
|
|
222
|
+
scored.sort(key=lambda x: -x[1])
|
|
223
|
+
cl.keywords = scored[: self.top_n]
|
|
224
|
+
clusters.append(cl)
|
|
225
|
+
clusters.sort(key=lambda c: -c.size)
|
|
226
|
+
return clusters
|
|
227
|
+
|
|
228
|
+
# -- drift detection ---------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def _detect_drift(self, current_keywords: Counter,
|
|
231
|
+
prev_keywords: Counter) -> Tuple[float, List[str]]:
|
|
232
|
+
if not prev_keywords or not current_keywords:
|
|
233
|
+
return 0.0, []
|
|
234
|
+
top_curr = set(w for w, _ in current_keywords.most_common(15))
|
|
235
|
+
top_prev = set(w for w, _ in prev_keywords.most_common(15))
|
|
236
|
+
new_keys = top_curr - top_prev
|
|
237
|
+
gone_keys = top_prev - top_curr
|
|
238
|
+
shared = top_curr & top_prev
|
|
239
|
+
union = top_curr | top_prev
|
|
240
|
+
ratio = 1.0 - (len(shared) / max(1.0, len(union)))
|
|
241
|
+
indicators: List[str] = []
|
|
242
|
+
if new_keys:
|
|
243
|
+
indicators.append("新增主题词: " + ", ".join(sorted(new_keys)[:6]))
|
|
244
|
+
if gone_keys:
|
|
245
|
+
indicators.append("淡出主题词: " + ", ".join(sorted(gone_keys)[:6]))
|
|
246
|
+
return ratio, indicators
|
|
247
|
+
|
|
248
|
+
# -- main API ----------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def summarize(self, messages: Sequence[Dict[str, Any]],
|
|
251
|
+
window_size: Optional[int] = None) -> FocusSummary:
|
|
252
|
+
window_size = window_size or self.max_w
|
|
253
|
+
msgs = list(messages)[-window_size:]
|
|
254
|
+
if not msgs:
|
|
255
|
+
return FocusSummary(
|
|
256
|
+
window_start_ts=0.0, window_end_ts=0.0,
|
|
257
|
+
clusters=[], drift_score=0.0, drift_indicators=[], focus_keywords=[],
|
|
258
|
+
)
|
|
259
|
+
texts = [self._text(m) for m in msgs]
|
|
260
|
+
ids = [str(m.get("id", i)) for i, m in enumerate(msgs)]
|
|
261
|
+
|
|
262
|
+
def _ts(m: Dict[str, Any]) -> float:
|
|
263
|
+
t = m.get("timestamp") or m.get("ts") or m.get("time")
|
|
264
|
+
if isinstance(t, (int, float)):
|
|
265
|
+
return float(t)
|
|
266
|
+
return 0.0
|
|
267
|
+
|
|
268
|
+
start_ts = min((_ts(m) for m in msgs), default=0.0)
|
|
269
|
+
end_ts = max((_ts(m) for m in msgs), default=time.time())
|
|
270
|
+
tfs, _ = self._tfidf_features(texts)
|
|
271
|
+
clusters = self._cluster(tfs, ids, texts)
|
|
272
|
+
|
|
273
|
+
# 当前窗口焦点关键词(tf-idf)
|
|
274
|
+
global_tf = Counter()
|
|
275
|
+
for tf in tfs:
|
|
276
|
+
global_tf.update(tf)
|
|
277
|
+
_, df_all = self._tfidf_features(texts)
|
|
278
|
+
N = len(texts)
|
|
279
|
+
scored_all = []
|
|
280
|
+
for w, f in global_tf.items():
|
|
281
|
+
idf = math.log((1 + N) / (1 + df_all.get(w, 0)) + 1.0)
|
|
282
|
+
scored_all.append((w, float(f) * idf))
|
|
283
|
+
scored_all.sort(key=lambda x: -x[1])
|
|
284
|
+
focus_keywords = scored_all[: self.top_n]
|
|
285
|
+
|
|
286
|
+
drift, indicators = self._detect_drift(global_tf, self._prev_keywords)
|
|
287
|
+
summary = FocusSummary(
|
|
288
|
+
window_start_ts=start_ts,
|
|
289
|
+
window_end_ts=end_ts,
|
|
290
|
+
clusters=clusters,
|
|
291
|
+
drift_score=drift,
|
|
292
|
+
drift_indicators=indicators,
|
|
293
|
+
focus_keywords=focus_keywords,
|
|
294
|
+
)
|
|
295
|
+
# 保存供下次 drift 比较
|
|
296
|
+
self._prev_keywords = global_tf
|
|
297
|
+
return summary
|
|
298
|
+
|
|
299
|
+
# -- streaming helpers -------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def reset(self) -> None:
|
|
302
|
+
self._prev_keywords = Counter()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__all__ = ["SessionFocus", "TopicCluster", "FocusSummary"]
|