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.
Files changed (64) hide show
  1. MindForge.py +20 -0
  2. mindforge/__init__.py +111 -0
  3. mindforge/adapters/__init__.py +18 -0
  4. mindforge/adapters/claude_adapter.py +84 -0
  5. mindforge/adapters/crewai_memory.py +76 -0
  6. mindforge/adapters/generic_api.py +183 -0
  7. mindforge/adapters/langgraph_store.py +463 -0
  8. mindforge/adapters/openclaw_adapter.py +91 -0
  9. mindforge/api/__init__.py +1 -0
  10. mindforge/api/server.py +1567 -0
  11. mindforge/cli/__init__.py +1 -0
  12. mindforge/cli/main.py +13607 -0
  13. mindforge/core/__init__.py +37 -0
  14. mindforge/core/embedding.py +847 -0
  15. mindforge/core/encryption.py +597 -0
  16. mindforge/core/indexer.py +363 -0
  17. mindforge/core/mindforge.py +6298 -0
  18. mindforge/core/paths.py +43 -0
  19. mindforge/core/query.py +501 -0
  20. mindforge/core/storage.py +18656 -0
  21. mindforge/core/types.py +314 -0
  22. mindforge/core/version.py +56 -0
  23. mindforge/daemon/__init__.py +0 -0
  24. mindforge/daemon/server.py +277 -0
  25. mindforge/mcp/__init__.py +4 -0
  26. mindforge/mcp/server.py +2559 -0
  27. mindforge/modules/__init__.py +138 -0
  28. mindforge/modules/categorizer.py +151 -0
  29. mindforge/modules/conflict_detector.py +356 -0
  30. mindforge/modules/connectors.py +331 -0
  31. mindforge/modules/context_window.py +337 -0
  32. mindforge/modules/event_bus.py +572 -0
  33. mindforge/modules/evolution.py +210 -0
  34. mindforge/modules/experience.py +402 -0
  35. mindforge/modules/federated.py +722 -0
  36. mindforge/modules/federated_acl.py +393 -0
  37. mindforge/modules/follow_up.py +315 -0
  38. mindforge/modules/hybrid_search.py +565 -0
  39. mindforge/modules/importer.py +374 -0
  40. mindforge/modules/integrator.py +196 -0
  41. mindforge/modules/intent_router.py +359 -0
  42. mindforge/modules/knowledge_graph.py +395 -0
  43. mindforge/modules/memory_compressor.py +592 -0
  44. mindforge/modules/memory_decay.py +474 -0
  45. mindforge/modules/memory_passport.py +250 -0
  46. mindforge/modules/multi_agent.py +534 -0
  47. mindforge/modules/multimodal.py +304 -0
  48. mindforge/modules/outcome_learning.py +186 -0
  49. mindforge/modules/personality.py +275 -0
  50. mindforge/modules/principled_forgetting.py +258 -0
  51. mindforge/modules/privacy.py +736 -0
  52. mindforge/modules/recall.py +263 -0
  53. mindforge/modules/session_focus.py +305 -0
  54. mindforge/modules/share_conflict.py +521 -0
  55. mindforge/modules/skill_extractor.py +340 -0
  56. mindforge/modules/skill_pack.py +205 -0
  57. mindforge/modules/timeline_viz.py +206 -0
  58. mindforge/modules/v6.py +931 -0
  59. mindforge_memory-6.4.1.dist-info/METADATA +281 -0
  60. mindforge_memory-6.4.1.dist-info/RECORD +64 -0
  61. mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
  62. mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
  63. mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
  64. mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,565 @@
1
+ """
2
+ MindForge 混合检索增强管线
3
+ ================================
4
+ 补上原检索缺的两块:
5
+ A. 查询扩展 Query Expansion:
6
+ - 同义词扩展(内置中文常见技术/生活同义词 + 用户自定义词典)
7
+ - 上位词泛化(如「MySQL 报错」→「数据库报错」)
8
+ - 缩写还原(如「k8s」→「Kubernetes」)
9
+ - 纠错回退(编辑距离 1 的常见错拼)
10
+
11
+ B. Cross-Encoder 风格重排(Reranking):
12
+ - 纯 CPU 的多特征打分(query-token overlap / 关键短语命中 / n-gram overlap
13
+ / 词距惩罚 / 类别与重要性先验),模拟 cross-encoder 的相关性判断
14
+ - 与原 relevance_score 做加权融合,最终输出新排序
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import math
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # 内置查询扩展词典(可通过 add_synonym / add_abbr 扩充)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ DEFAULT_SYNONYMS: Dict[str, List[str]] = {
29
+ "部署": ["上线", "发布", "发版"],
30
+ "报错": ["错误", "异常", "失败", "崩溃"],
31
+ "数据库": ["mysql", "postgresql", "pgsql", "db", "sqlite", "redis", "mongo", "mongodb"],
32
+ "mysql": ["数据库", "mysql", "mysqldb"],
33
+ "k8s": ["kubernetes", "kubectl", "容器", "容器化"],
34
+ "容器": ["docker", "k8s", "kubernetes", "镜像"],
35
+ "配置": ["conf", "config", "配置文件", "settings", "参数"],
36
+ "启动": ["运行", "拉起", "start", "开机", "执行"],
37
+ "停止": ["shutdown", "stop", "停掉", "终止", "结束"],
38
+ "搜索": ["查找", "查询", "检索", "search", "find"],
39
+ "缓存": ["cache", "redis", "memcached"],
40
+ "接口": ["api", "接口调用", "端点", "endpoint"],
41
+ "用户": ["user", "客户", "账号"],
42
+ "密码": ["password", "口令", "pwd", "秘钥"],
43
+ "日志": ["log", "日志文件", "访问日志"],
44
+ "性能": ["速度", "慢", "耗时", "吞吐", "qps"],
45
+ }
46
+
47
+ DEFAULT_ABBR: Dict[str, str] = {
48
+ "k8s": "Kubernetes",
49
+ "db": "数据库",
50
+ "api": "接口",
51
+ "ai": "人工智能",
52
+ "llm": "大语言模型",
53
+ "kg": "知识图谱",
54
+ "qa": "问答",
55
+ "ui": "界面",
56
+ "ux": "用户体验",
57
+ "ci": "持续集成",
58
+ "cd": "持续部署",
59
+ "devops": "开发运维",
60
+ "sdk": "开发工具包",
61
+ "sql": "结构化查询语言",
62
+ "cpu": "中央处理器",
63
+ "gpu": "图形处理器",
64
+ "nlp": "自然语言处理",
65
+ }
66
+
67
+ DEFAULT_HYPONYMS: Dict[str, List[str]] = { # 具体 → 泛化
68
+ "mysql": ["数据库", "关系型数据库"],
69
+ "postgresql": ["数据库"],
70
+ "redis": ["数据库", "缓存"],
71
+ "mongodb": ["数据库", "文档数据库"],
72
+ "k8s": ["容器编排", "云原生"],
73
+ "docker": ["容器", "虚拟化"],
74
+ "kubernetes": ["容器编排", "云原生"],
75
+ "nginx": ["反向代理", "web服务器"],
76
+ "fastapi": ["api框架", "web框架"],
77
+ "flask": ["api框架", "web框架"],
78
+ "django": ["api框架", "web框架"],
79
+ }
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # 数据类
84
+ # ---------------------------------------------------------------------------
85
+
86
+ @dataclass
87
+ class ExpansionResult:
88
+ original: str
89
+ expanded_terms: List[str] = field(default_factory=list) # 扩展出的并列词
90
+ rewrites: List[str] = field(default_factory=list) # 重写后的查询(可直接拿去多路检索)
91
+ note: str = ""
92
+
93
+ def to_dict(self) -> Dict[str, Any]:
94
+ return {"original": self.original,
95
+ "expanded_terms": self.expanded_terms[:40],
96
+ "rewrites": self.rewrites[:10],
97
+ "note": self.note}
98
+
99
+
100
+ @dataclass
101
+ class RerankResult:
102
+ memory_id: str
103
+ original_score: float
104
+ rerank_score: float
105
+ fused_score: float
106
+ rank_before: int
107
+ rank_after: int
108
+ features: Dict[str, float] = field(default_factory=dict)
109
+ content: str = ""
110
+ # v5.5.6 fix(P2-2): 保留候选元数据,供下游过滤/去重/展示
111
+ importance: str = ""
112
+ category: str = ""
113
+ layer: str = ""
114
+ tags: List[str] = field(default_factory=list)
115
+
116
+ def to_dict(self) -> Dict[str, Any]:
117
+ return {
118
+ "memory_id": self.memory_id,
119
+ "original_score": round(self.original_score, 4),
120
+ "rerank_score": round(self.rerank_score, 4),
121
+ "fused_score": round(self.fused_score, 4),
122
+ "rank_before": self.rank_before,
123
+ "rank_after": self.rank_after,
124
+ "delta_rank": self.rank_before - self.rank_after,
125
+ "features": {k: round(v, 4) for k, v in self.features.items()},
126
+ "content": self.content[:120],
127
+ "importance": self.importance,
128
+ "category": self.category,
129
+ "layer": self.layer,
130
+ "tags": self.tags,
131
+ }
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Query Expansion
136
+ # ---------------------------------------------------------------------------
137
+
138
+ class QueryExpander:
139
+ """同义词 + 缩写 + 上位词 + 可选编辑距离纠错"""
140
+
141
+ def __init__(self,
142
+ synonyms: Optional[Dict[str, List[str]]] = None,
143
+ abbr: Optional[Dict[str, str]] = None,
144
+ hyponyms: Optional[Dict[str, List[str]]] = None,
145
+ max_rewrites: int = 6,
146
+ enable_typo: bool = True):
147
+ self.synonyms: Dict[str, List[str]] = {}
148
+ for k, vs in (synonyms or DEFAULT_SYNONYMS).items():
149
+ self._add_multi(self.synonyms, k, vs)
150
+ self.abbr = dict(abbr or DEFAULT_ABBR)
151
+ self.hyponyms = dict(hyponyms or DEFAULT_HYPONYMS)
152
+ self.max_rewrites = max_rewrites
153
+ self.enable_typo = enable_typo
154
+
155
+ @staticmethod
156
+ def _add_multi(d: Dict[str, List[str]], k: str, vs: Iterable[str]) -> None:
157
+ lc = k.lower()
158
+ bucket = d.setdefault(lc, [])
159
+ for v in vs:
160
+ v_lc = v.lower()
161
+ if v_lc not in bucket and v_lc != lc:
162
+ bucket.append(v_lc)
163
+ # 双向:把每个同义词也作为 key,反向扩展回原词
164
+ for v in vs:
165
+ v_lc = v.lower()
166
+ reverse = d.setdefault(v_lc, [])
167
+ if lc not in reverse and lc != v_lc:
168
+ reverse.append(lc)
169
+
170
+ def add_synonym(self, term: str, synonyms: Sequence[str]) -> None:
171
+ self._add_multi(self.synonyms, term, synonyms)
172
+
173
+ def add_abbr(self, abbr: str, full: str) -> None:
174
+ self.abbr[abbr.lower()] = full
175
+
176
+ @staticmethod
177
+ def _tokenize(text: str) -> List[str]:
178
+ raw = re.sub(r"[\W_]+", " ", text.lower()).split()
179
+ # 中文逐字不做,这里仅按空白+符号分词;中文再切 2-gram
180
+ bag: List[str] = []
181
+ for token in raw:
182
+ if not token:
183
+ continue
184
+ bag.append(token)
185
+ if re.search(r"[\u4e00-\u9fa5]", token) and len(token) >= 2:
186
+ for n in (2, 3):
187
+ for i in range(len(token) - n + 1):
188
+ bag.append(token[i:i + n])
189
+ return bag
190
+
191
+ # -- typo tolerance (edit distance 1) ----------------------------------
192
+
193
+ @staticmethod
194
+ def _edit_dist1(a: str, b: str) -> bool:
195
+ """Check if two strings have edit distance <= 1. O(min(len(a), len(b)))"""
196
+ la, lb = len(a), len(b)
197
+ if abs(la - lb) > 1:
198
+ return False
199
+ # Ensure a is the shorter one
200
+ if la > lb:
201
+ a, b = b, a
202
+ la, lb = lb, la
203
+ # Same length: check for 0 or 1 substitution
204
+ if la == lb:
205
+ diff = 0
206
+ for ca, cb in zip(a, b):
207
+ if ca != cb:
208
+ diff += 1
209
+ if diff > 1:
210
+ return False
211
+ return True
212
+ # Length diff 1: check for 1 insertion/deletion
213
+ i = j = 0
214
+ diff = 0
215
+ while i < la and j < lb:
216
+ if a[i] == b[j]:
217
+ i += 1
218
+ j += 1
219
+ else:
220
+ diff += 1
221
+ if diff > 1:
222
+ return False
223
+ j += 1 # skip one char in longer string
224
+ return True
225
+
226
+ def _typo_candidates(self, tokens: Sequence[str], max_tokens: int = 5) -> Dict[str, List[str]]:
227
+ """把已知同义词/缩写词典作为 vocabulary,寻找编辑距离 1 的替换。
228
+
229
+ v5.4.8 P1-001 修复:改用反向查找(遍历 vocab 检查编辑距离),
230
+ 而不是生成所有 edits(O(L*A) per word)。
231
+ 添加 max_tokens 限制防止长查询导致性能崩溃。
232
+ """
233
+ vocab: Set[str] = set(self.synonyms) | set(self.abbr) | set(self.hyponyms)
234
+ if not vocab:
235
+ return {}
236
+
237
+ out: Dict[str, List[str]] = {}
238
+ checked = 0
239
+ for t in tokens:
240
+ if checked >= max_tokens:
241
+ break
242
+ if len(t) < 3 or not re.fullmatch(r"[a-z0-9_]+", t):
243
+ continue
244
+ if t in vocab:
245
+ continue
246
+ # 反向查找:遍历 vocab 检查编辑距离
247
+ candidates = [v for v in vocab if self._edit_dist1(t, v)]
248
+ if candidates:
249
+ out[t] = candidates[:3]
250
+ checked += 1
251
+ return out
252
+
253
+ # -- main expand -------------------------------------------------------
254
+
255
+ def expand(self, query: str, hyponym: bool = True,
256
+ typo: Optional[bool] = None) -> ExpansionResult:
257
+ q = (query or "").strip()
258
+ tokens = self._tokenize(q)
259
+ seen_terms: Set[str] = set(tokens)
260
+ expanded: List[str] = []
261
+
262
+ def _add(t: str) -> None:
263
+ if not t:
264
+ return
265
+ if t not in seen_terms:
266
+ seen_terms.add(t)
267
+ expanded.append(t)
268
+
269
+ # 1) 缩写还原
270
+ for t in tokens:
271
+ if t in self.abbr:
272
+ _add(self.abbr[t].lower())
273
+
274
+ # 2) 同义词
275
+ for t in tokens:
276
+ if t in self.synonyms:
277
+ for s in self.synonyms[t]:
278
+ _add(s)
279
+
280
+ # 3) 上位词泛化
281
+ if hyponym:
282
+ for t in tokens:
283
+ if t in self.hyponyms:
284
+ for g in self.hyponyms[t]:
285
+ _add(g.lower())
286
+
287
+ # 4) 纠错(把 query 中可能的 typo token 指向候选词)
288
+ typo_note = ""
289
+ if (typo if typo is not None else self.enable_typo):
290
+ suggestions = self._typo_candidates(tokens)
291
+ if suggestions:
292
+ for bad, goods in suggestions.items():
293
+ for g in goods:
294
+ _add(g)
295
+ typo_parts = [f"{b}→{'/'.join(g)}" for b, g in suggestions.items()]
296
+ typo_note = "typo 候选: " + ", ".join(typo_parts)
297
+
298
+ # 生成 rewrite:原句 + 同义词替换版本(逐词替换一次)
299
+ rewrites: List[str] = [q]
300
+ lowered_tokens = re.split(r"(\s+)", q)
301
+ for idx, chunk in enumerate(lowered_tokens):
302
+ lc = chunk.lower()
303
+ if lc in self.synonyms:
304
+ for s in self.synonyms[lc][:2]:
305
+ new_parts = lowered_tokens.copy()
306
+ # v5.6.8 修复:原写法 `s if chunk.isupper() else s` 两个分支相同,
307
+ # 大小写保留逻辑形同虚设(全大写原词会被替换成小写同义词)。
308
+ new_parts[idx] = s.upper() if chunk.isupper() else s
309
+ rewrites.append("".join(new_parts))
310
+ if lc in self.abbr:
311
+ new_parts = lowered_tokens.copy()
312
+ new_parts[idx] = self.abbr[lc]
313
+ rewrites.append("".join(new_parts))
314
+ # 去重保序 + 截断
315
+ dedup: List[str] = []
316
+ for r in rewrites:
317
+ if r not in dedup:
318
+ dedup.append(r)
319
+ if len(dedup) >= self.max_rewrites:
320
+ break
321
+ return ExpansionResult(
322
+ original=q,
323
+ expanded_terms=expanded,
324
+ rewrites=dedup,
325
+ note=typo_note,
326
+ )
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Cross-Encoder Style Reranker (纯 CPU, 多特征融合)
331
+ # ---------------------------------------------------------------------------
332
+
333
+ @dataclass
334
+ class RerankWeights:
335
+ original: float = 0.35
336
+ token_overlap: float = 0.22
337
+ phrase_hit: float = 0.20
338
+ ngram_overlap: float = 0.13
339
+ proximity: float = 0.05
340
+ importance_bonus: float = 0.03
341
+ category_bonus: float = 0.02
342
+ layer_bonus: float = 0.01
343
+ length_norm: float = 0.10
344
+
345
+ def total(self) -> float:
346
+ return (self.original + self.token_overlap + self.phrase_hit +
347
+ self.ngram_overlap + self.proximity +
348
+ self.importance_bonus + self.category_bonus +
349
+ self.layer_bonus)
350
+
351
+
352
+ class CrossEncoderReranker:
353
+ """无外部依赖的相关性重排:融合 query 感知的多特征打分"""
354
+
355
+ def __init__(self, weights: Optional[RerankWeights] = None):
356
+ self.weights = weights or RerankWeights()
357
+
358
+ @staticmethod
359
+ def _norm(text: str) -> str:
360
+ return re.sub(r"[\W_]+", " ", (text or "").lower())
361
+
362
+ @staticmethod
363
+ def _tokens(text: str) -> List[str]:
364
+ s = re.sub(r"[\W_]+", " ", text.lower())
365
+ return [t for t in s.split() if t]
366
+
367
+ @staticmethod
368
+ def _char_ngrams(text: str, n: int = 2) -> List[str]:
369
+ s = re.sub(r"[\s]+", "", text.lower())
370
+ if len(s) < n:
371
+ return [s] if s else []
372
+ return [s[i:i + n] for i in range(len(s) - n + 1)]
373
+
374
+ def _score_token_overlap(self, q_tokens: Sequence[str], d_tokens: Sequence[str]) -> float:
375
+ if not q_tokens or not d_tokens:
376
+ return 0.0
377
+ qs, ds = set(q_tokens), set(d_tokens)
378
+ inter = len(qs & ds)
379
+ union = len(qs | ds)
380
+ jaccard = inter / max(1, union)
381
+ # recall 部分:query 词命中比例
382
+ recall = sum(1 for t in q_tokens if t in ds) / max(1, len(q_tokens))
383
+ return 0.6 * recall + 0.4 * jaccard
384
+
385
+ def _score_phrase_hit(self, query: str, doc: str) -> float:
386
+ if not query or not doc:
387
+ return 0.0
388
+ q = self._norm(query)
389
+ d = self._norm(doc)
390
+ if not q:
391
+ return 0.0
392
+ # 3+ 长度的 token:若在文档里出现算一个强命中
393
+ long_tokens = [t for t in self._tokens(q) if len(t) >= 3]
394
+ hits = 0
395
+ for t in long_tokens:
396
+ if t in d:
397
+ hits += 1
398
+ # 完整子串命中(2-gram以上)
399
+ substring_hits = 0
400
+ for n in (3, 4):
401
+ for i in range(len(q) - n + 1):
402
+ chunk = q[i:i + n]
403
+ if chunk.isspace():
404
+ continue
405
+ if chunk in d:
406
+ substring_hits += 1
407
+ ratio1 = hits / max(1, len(long_tokens))
408
+ ratio2 = min(1.0, substring_hits / max(1, len(q) / 3.0))
409
+ return 0.5 * ratio1 + 0.5 * ratio2
410
+
411
+ def _score_ngram_overlap(self, query: str, doc: str) -> float:
412
+ q = set(self._char_ngrams(query, 2)) | set(self._char_ngrams(query, 3))
413
+ d = set(self._char_ngrams(doc, 2)) | set(self._char_ngrams(doc, 3))
414
+ if not q or not d:
415
+ return 0.0
416
+ return len(q & d) / max(1, len(q | d))
417
+
418
+ def _score_proximity(self, q_tokens: Sequence[str], doc: str) -> float:
419
+ """query 词在文档中首次出现位置的跨度越小分越高。"""
420
+ if not q_tokens or not doc:
421
+ return 0.0
422
+ d_lower = doc.lower()
423
+ positions: List[int] = []
424
+ for t in q_tokens:
425
+ idx = d_lower.find(t)
426
+ if idx >= 0:
427
+ positions.append(idx)
428
+ if len(positions) < 2:
429
+ return 0.0 if len(positions) == 0 else 0.5
430
+ span = max(positions) - min(positions)
431
+ # span 越小越好;按 doc 长度归一
432
+ norm = 1.0 - min(1.0, span / max(1.0, len(doc)))
433
+ return norm
434
+
435
+ def _score_metadata(self, doc_meta: Dict[str, Any], query_meta: Dict[str, Any]) -> Tuple[float, float, float]:
436
+ """(importance_bonus, category_bonus, layer_bonus)"""
437
+ importance_score = 0.0
438
+ imp = str(doc_meta.get("importance", "")).upper()
439
+ if imp == "CRITICAL":
440
+ importance_score = 1.0
441
+ elif imp == "HIGH":
442
+ importance_score = 0.75
443
+ elif imp == "MEDIUM":
444
+ importance_score = 0.45
445
+ elif imp == "LOW":
446
+ importance_score = 0.15
447
+ category_score = 0.0
448
+ cat = str(doc_meta.get("category", "")).lower()
449
+ if cat and cat == str(query_meta.get("category", "")).lower():
450
+ category_score = 1.0
451
+ layer_score = 0.0
452
+ layer = str(doc_meta.get("layer", "")).lower()
453
+ if layer == "permanent":
454
+ layer_score = 1.0
455
+ elif layer == "long_term":
456
+ layer_score = 0.8
457
+ elif layer == "short_term":
458
+ layer_score = 0.4
459
+ return importance_score, category_score, layer_score
460
+
461
+ # -- main rerank -------------------------------------------------------
462
+
463
+ def rerank(self,
464
+ query: str,
465
+ candidates: Sequence, # v5.4.7 修复 H-5:接受 dict 或 dataclass
466
+ query_category: Optional[str] = None,
467
+ top_k: int = 20) -> List[RerankResult]:
468
+ """candidates 每项需含: memory_id, content, original_score;
469
+ 可含 importance/category/layer 元数据。
470
+ v5.4.7 修复 H-5:支持 dict 和 dataclass (MemoryChunk) 两种输入。"""
471
+ q_norm = self._norm(query)
472
+ q_tokens = self._tokens(query)
473
+
474
+ def _get_field(c, field, default=""):
475
+ """v5.4.7 修复 H-5:兼容 dict 和 dataclass 访问"""
476
+ if isinstance(c, dict):
477
+ return c.get(field, default)
478
+ return getattr(c, field, default)
479
+
480
+ scored: List[RerankResult] = []
481
+ for rank_before, c in enumerate(candidates, 1):
482
+ content = str(_get_field(c, "content", ""))
483
+ d_tokens = self._tokens(content)
484
+ meta = {
485
+ "importance": _get_field(c, "importance"),
486
+ "category": _get_field(c, "category"),
487
+ "layer": _get_field(c, "layer"),
488
+ }
489
+ q_meta = {"category": query_category or ""}
490
+
491
+ f_token = self._score_token_overlap(q_tokens, d_tokens)
492
+ f_phrase = self._score_phrase_hit(q_norm, self._norm(content))
493
+ f_ngram = self._score_ngram_overlap(q_norm, content)
494
+ f_prox = self._score_proximity(q_tokens, content)
495
+ f_imp, f_cat, f_layer = self._score_metadata(meta, q_meta)
496
+
497
+ original_score = float(_get_field(c, "original_score", _get_field(c, "score", 0.0)))
498
+ # 归一化 original_score 到 0-1(若原分 >1 先压缩)
499
+ orig_norm = 1.0 - math.exp(-max(0.0, original_score))
500
+ rerank_raw = (
501
+ self.weights.token_overlap * f_token +
502
+ self.weights.phrase_hit * f_phrase +
503
+ self.weights.ngram_overlap * f_ngram +
504
+ self.weights.proximity * f_prox +
505
+ self.weights.importance_bonus * f_imp +
506
+ self.weights.category_bonus * f_cat +
507
+ self.weights.layer_bonus * f_layer
508
+ )
509
+ rerank_denom = (
510
+ self.weights.token_overlap + self.weights.phrase_hit +
511
+ self.weights.ngram_overlap + self.weights.proximity +
512
+ self.weights.importance_bonus + self.weights.category_bonus +
513
+ self.weights.layer_bonus
514
+ ) or 1.0
515
+ rerank_score = rerank_raw / rerank_denom
516
+ # 长度惩罚:内容过短(<10 字)给 0.9 折扣
517
+ length_penalty = 1.0
518
+ if len(content.strip()) < 10:
519
+ length_penalty = 0.9
520
+ fused_score = (
521
+ self.weights.original * orig_norm +
522
+ (1.0 - self.weights.original) * rerank_score
523
+ ) * length_penalty
524
+
525
+ scored.append(RerankResult(
526
+ # v6.1.2 修复:本函数其余字段一律经 `_get_field` 读取(v5.4.7 H-5
527
+ # 声明「支持 dict 和 dataclass」),只有这一行直接 `c.get(...)`。
528
+ # 传 MemoryChunk(query.py 的 dataclass,没有 .get)即在首个候选上
529
+ # 抛 AttributeError——对外承诺的 dataclass 支持一直是失效的,内部
530
+ # 调用方 core/mindforge.py 早先把 chunks 转成 dict 才绕开。
531
+ memory_id=str(_get_field(c, "memory_id", "")
532
+ or _get_field(c, "id", "")),
533
+ original_score=original_score,
534
+ rerank_score=rerank_score,
535
+ fused_score=fused_score,
536
+ rank_before=rank_before,
537
+ rank_after=0,
538
+ features={
539
+ "token_overlap": f_token,
540
+ "phrase_hit": f_phrase,
541
+ "ngram_overlap": f_ngram,
542
+ "proximity": f_prox,
543
+ "importance": f_imp,
544
+ "category_match": f_cat,
545
+ "layer_bonus": f_layer,
546
+ "length_penalty": length_penalty,
547
+ },
548
+ content=content,
549
+ # v5.5.6 fix(P2-2): 透传候选元数据
550
+ importance=str(_get_field(c, "importance", "") or ""),
551
+ category=str(_get_field(c, "category", "") or ""),
552
+ layer=str(_get_field(c, "layer", "") or ""),
553
+ tags=list(_get_field(c, "tags", []) or []),
554
+ ))
555
+ # 按 fused_score 排序
556
+ scored.sort(key=lambda x: -x.fused_score)
557
+ for rank_after, r in enumerate(scored, 1):
558
+ r.rank_after = rank_after
559
+ return scored[: max(1, int(top_k))]
560
+
561
+
562
+ __all__ = [
563
+ "QueryExpander", "ExpansionResult",
564
+ "CrossEncoderReranker", "RerankResult", "RerankWeights",
565
+ ]