dsh-plugin-xujie-memory 0.5.0 → 0.6.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/CHANGELOG.md +28 -1
- package/LICENSE +21 -21
- package/README.md +33 -8
- package/README_EN.md +27 -4
- package/cordis.patch.yml +49 -49
- package/lib/index.js +121 -0
- package/package.json +1 -1
- package/python/specs/characters/aloof.json +14 -14
- package/python/specs/characters/gentle.json +14 -14
- package/python/specs/characters/tsundere.json +14 -14
- package/python/specs/debt.v1.json +31 -31
- package/python/specs/nickname.v1.json +16 -16
- package/python/specs/preference.v1.json +16 -16
- package/python/xujie_memory/admission.py +211 -211
- package/python/xujie_memory/audit.py +377 -377
- package/python/xujie_memory/benchmark.py +281 -281
- package/python/xujie_memory/character.py +199 -199
- package/python/xujie_memory/console.py +41 -41
- package/python/xujie_memory/consolidate.py +356 -356
- package/python/xujie_memory/due.py +121 -121
- package/python/xujie_memory/emotion.py +573 -573
- package/python/xujie_memory/errors.py +98 -98
- package/python/xujie_memory/importance.py +104 -104
- package/python/xujie_memory/judge.py +350 -350
- package/python/xujie_memory/memory.py +55 -0
- package/python/xujie_memory/orchestrator.py +690 -666
- package/python/xujie_memory/phrasing.py +292 -292
- package/python/xujie_memory/server.py +24 -0
- package/python/xujie_memory/specs.py +170 -170
- package/python/xujie_memory/summary.py +238 -238
|
@@ -1,356 +1,356 @@
|
|
|
1
|
-
"""整合周期:把碎片收起来(方案 5.7,实现顺序第六步)。
|
|
2
|
-
|
|
3
|
-
方案原话:
|
|
4
|
-
|
|
5
|
-
> 以上六步不必实时执行。连续的编码会产生碎片。碎片需要周期性整合:
|
|
6
|
-
> 合并同类摘要、重算权重、归档、重建线索。
|
|
7
|
-
> 这个周期是机制的一部分,不是优化。
|
|
8
|
-
> 没有它,记忆只会越存越碎,最终无法回答「现在什么重要」。
|
|
9
|
-
|
|
10
|
-
方案列了四件事,**本实现只做两件半**。这不是没做完,是刻意:
|
|
11
|
-
|
|
12
|
-
| 方案写的 | 本实现 | 为什么 |
|
|
13
|
-
|---|---|---|
|
|
14
|
-
| 合并同类摘要 | 做,但只按**结构**合并 | 按语义合并要模型判断,破坏 I2 |
|
|
15
|
-
| 归档 | 做,归档前验证可恢复 | I4 |
|
|
16
|
-
| 重算权重 | **不做** | 权重不存库,是 `decay.priority()` 每次算出来的 |
|
|
17
|
-
| 重建线索 | **不做** | 线索就是 SQL 索引,没有独立的线索表 |
|
|
18
|
-
|
|
19
|
-
后两件如果不做会出事,那必须做。做了才是问题:把派生值存下来,
|
|
20
|
-
就要维护一致性;一旦有两条写路径,迟早不一致。而 `priority()` 是纯函数,
|
|
21
|
-
它就是唯一的真相——**这是比"存一份"更强的保证,不是偷懒**。
|
|
22
|
-
|
|
23
|
-
## 合并的边界:只按结构,不按语义
|
|
24
|
-
|
|
25
|
-
「这两段摘要意思相近,合了吧」——这句话本身需要模型判断,
|
|
26
|
-
而模型判断不满足 I2(同样输入同样输出)。所以合并只能靠**结构证据**:
|
|
27
|
-
|
|
28
|
-
同一批输入痕迹来源相同(source_ref 前缀一致,例如 c06-1 / c06-2 / c06-3)
|
|
29
|
-
且都是短碎片(≤ fragment_max_chars)
|
|
30
|
-
且都没被合并过(coverage 里没有 merged_from)
|
|
31
|
-
|
|
32
|
-
代价是:跨会话的同类碎片不会合并。接受——**宁可碎,不可错**。
|
|
33
|
-
碎只是不好看,错是把两件不同的事揉成一件。
|
|
34
|
-
|
|
35
|
-
## 幂等
|
|
36
|
-
|
|
37
|
-
跑两遍必须得到同样的结果,否则这个周期不能定时跑(I2)。
|
|
38
|
-
靠三条保证:
|
|
39
|
-
|
|
40
|
-
合并后旧摘要 lifecycle_state='merged',第二遍的 active 里没有它们
|
|
41
|
-
归档后 level='archived',第二遍的 sweep 只看 level='hot'
|
|
42
|
-
新产物的 coverage 里有 merged_from,第二遍不再参与合并
|
|
43
|
-
|
|
44
|
-
第二条尤其重要:`sweep()` 本来就只扫 hot,所以"归档过的不会再归档"
|
|
45
|
-
不是靠额外判断,是靠状态机本身。
|
|
46
|
-
"""
|
|
47
|
-
|
|
48
|
-
from __future__ import annotations
|
|
49
|
-
|
|
50
|
-
import json
|
|
51
|
-
from dataclasses import dataclass, field
|
|
52
|
-
from typing import Any
|
|
53
|
-
|
|
54
|
-
from .decay import Lifecycle
|
|
55
|
-
from .summary import SummaryStore, extractive
|
|
56
|
-
|
|
57
|
-
# 合并时会话归属的判定:source_ref 形如 "c06_flour-3",取最后一段的前缀。
|
|
58
|
-
# 没有 "-" 的就整条当分组键——那说明这个 source_ref 本身已经是一件事了。
|
|
59
|
-
_REF_SEP = "-"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
@dataclass
|
|
63
|
-
class ConsolidationReport:
|
|
64
|
-
"""一次整合做了什么、**没做什么**。
|
|
65
|
-
|
|
66
|
-
`kept` 和 `skipped_steps` 不是调试信息,是 I5 的一部分:
|
|
67
|
-
用户问「为什么这段记忆还在热区」,系统得答得出来。
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
scope_id: str
|
|
71
|
-
started_at: str
|
|
72
|
-
finished_at: str = ""
|
|
73
|
-
dry_run: bool = False
|
|
74
|
-
|
|
75
|
-
traces_examined: int = 0
|
|
76
|
-
summaries_before: int = 0
|
|
77
|
-
|
|
78
|
-
# 给"还没有摘要的痕迹"生成的摘要。它和 merged 是两步:
|
|
79
|
-
# digested 产出碎片,merged 收拾碎片。
|
|
80
|
-
digested: list[dict[str, Any]] = field(default_factory=list)
|
|
81
|
-
summaries_merged: list[dict[str, Any]] = field(default_factory=list)
|
|
82
|
-
archived: list[str] = field(default_factory=list)
|
|
83
|
-
kept: list[dict[str, Any]] = field(default_factory=list)
|
|
84
|
-
skipped_steps: list[str] = field(default_factory=list)
|
|
85
|
-
errors: list[str] = field(default_factory=list)
|
|
86
|
-
|
|
87
|
-
def to_dict(self) -> dict[str, Any]:
|
|
88
|
-
return {
|
|
89
|
-
"scope_id": self.scope_id,
|
|
90
|
-
"started_at": self.started_at,
|
|
91
|
-
"finished_at": self.finished_at,
|
|
92
|
-
"dry_run": self.dry_run,
|
|
93
|
-
"traces_examined": self.traces_examined,
|
|
94
|
-
"summaries_before": self.summaries_before,
|
|
95
|
-
"digested": self.digested,
|
|
96
|
-
"summaries_merged": self.summaries_merged,
|
|
97
|
-
"archived": self.archived,
|
|
98
|
-
"kept": self.kept,
|
|
99
|
-
"skipped_steps": self.skipped_steps,
|
|
100
|
-
"errors": self.errors,
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
@property
|
|
104
|
-
def touched(self) -> int:
|
|
105
|
-
return len(self.digested) + len(self.summaries_merged) + len(self.archived)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# ---------------------------------------------------------------- 入口
|
|
109
|
-
def consolidate(
|
|
110
|
-
engine,
|
|
111
|
-
scope_id: str,
|
|
112
|
-
*,
|
|
113
|
-
min_age_days: float = 30.0,
|
|
114
|
-
threshold: float = 0.05,
|
|
115
|
-
fragment_max_chars: int = 120,
|
|
116
|
-
limit: int = 200,
|
|
117
|
-
dry_run: bool = False,
|
|
118
|
-
) -> ConsolidationReport:
|
|
119
|
-
"""跑一次整合。可以重复跑,第二次通常是空的。"""
|
|
120
|
-
report = ConsolidationReport(
|
|
121
|
-
scope_id=scope_id,
|
|
122
|
-
started_at=engine.now(),
|
|
123
|
-
dry_run=dry_run,
|
|
124
|
-
skipped_steps=[
|
|
125
|
-
"recompute_weights: 权重是 decay.priority() 算出来的,库里没有可重算的值",
|
|
126
|
-
"rebuild_cues: 线索由 SQL 索引承担,本实现没有独立线索表",
|
|
127
|
-
],
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
try:
|
|
131
|
-
# 顺序不能反:先有摘要,才有"可合并的碎片"。
|
|
132
|
-
_digest_uncovered(engine, scope_id, report, limit=limit, dry_run=dry_run)
|
|
133
|
-
_merge_fragments(engine, scope_id, report,
|
|
134
|
-
fragment_max_chars=fragment_max_chars, limit=limit,
|
|
135
|
-
dry_run=dry_run)
|
|
136
|
-
_archive_low_weight(engine, scope_id, report,
|
|
137
|
-
min_age_days=min_age_days, threshold=threshold,
|
|
138
|
-
limit=limit, dry_run=dry_run)
|
|
139
|
-
except Exception as exc: # noqa: BLE001
|
|
140
|
-
# 周期任务不能因为一条脏数据整轮不跑,但也不能假装没发生。
|
|
141
|
-
report.errors.append(f"{type(exc).__name__}: {exc}")
|
|
142
|
-
|
|
143
|
-
report.finished_at = engine.now()
|
|
144
|
-
return report
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
# ---------------------------------------------------------------- 第一步
|
|
148
|
-
def _group_key(engine, summary: dict) -> str | None:
|
|
149
|
-
"""这条摘要属于哪一堆。读不出来就返回 None(不参与合并)。
|
|
150
|
-
|
|
151
|
-
分组键来自**输入痕迹的来源**,不来自摘要正文——
|
|
152
|
-
正文要靠"看得懂"才能分组,而"看得懂"是不确定的。
|
|
153
|
-
"""
|
|
154
|
-
try:
|
|
155
|
-
refs = json.loads(summary["input_refs_json"])
|
|
156
|
-
except (TypeError, ValueError):
|
|
157
|
-
return None
|
|
158
|
-
if not refs:
|
|
159
|
-
return None
|
|
160
|
-
trace = engine.get_trace(refs[0])
|
|
161
|
-
if trace is None:
|
|
162
|
-
return None
|
|
163
|
-
return _ref_group(trace["source_ref"])
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _digest_uncovered(
|
|
167
|
-
engine, scope_id: str, report: ConsolidationReport,
|
|
168
|
-
*, limit: int, dry_run: bool,
|
|
169
|
-
) -> None:
|
|
170
|
-
"""把**还没有摘要**的痕迹按来源分批摘要成一条。
|
|
171
|
-
|
|
172
|
-
为什么这一步必须在合并之前:合并的对象是"已有的短摘要",
|
|
173
|
-
而写路径从来只写痕迹、不写摘要。所以一个真实库里的摘要数
|
|
174
|
-
长期是 0——**合并永远没有对象**。
|
|
175
|
-
|
|
176
|
-
这和 `lifecycle` 那条是同一类问题:机制写好了,但没人给它喂数据。
|
|
177
|
-
v0.34 的端到端验收两次撞到它(第一次归档 0 条,第二次合并 0 组)。
|
|
178
|
-
|
|
179
|
-
分批的依据是 `source_ref` 前缀:`c01_rice-1..10` 属于同一段对话,
|
|
180
|
-
合起来读是连贯的;跨段合并会把"米"和"酒"揉成一句话。
|
|
181
|
-
"""
|
|
182
|
-
store = SummaryStore(engine)
|
|
183
|
-
covered: set[str] = set()
|
|
184
|
-
actives = store.active(scope_id)[:limit]
|
|
185
|
-
for s in actives:
|
|
186
|
-
try:
|
|
187
|
-
covered.update(json.loads(s["input_refs_json"]))
|
|
188
|
-
except (TypeError, ValueError):
|
|
189
|
-
continue
|
|
190
|
-
|
|
191
|
-
rows = engine._conn().execute(
|
|
192
|
-
"SELECT trace_id, source_ref FROM traces"
|
|
193
|
-
" WHERE scope_id=? AND erased_at IS NULL ORDER BY recorded_at, trace_id LIMIT ?",
|
|
194
|
-
(scope_id, limit * 10),
|
|
195
|
-
).fetchall()
|
|
196
|
-
|
|
197
|
-
buckets: dict[str, list[str]] = {}
|
|
198
|
-
for row in rows:
|
|
199
|
-
if row["trace_id"] in covered:
|
|
200
|
-
continue
|
|
201
|
-
key = _ref_group(row["source_ref"])
|
|
202
|
-
if key:
|
|
203
|
-
buckets.setdefault(key, []).append(row["trace_id"])
|
|
204
|
-
|
|
205
|
-
for key, tids in buckets.items():
|
|
206
|
-
# 一条也摘要。单条不是"合并",但它是碎片的最小单位;
|
|
207
|
-
# 只处理 >=2 条的话,一段只聊了一句的对话永远进不了摘要。
|
|
208
|
-
if dry_run:
|
|
209
|
-
report.digested.append({"group": key, "traces": len(tids), "summary_id": ""})
|
|
210
|
-
continue
|
|
211
|
-
try:
|
|
212
|
-
result = store.create(scope_id, tids, summarizer=extractive)
|
|
213
|
-
except Exception as exc: # noqa: BLE001
|
|
214
|
-
report.errors.append(f"digest {key}: {type(exc).__name__}: {exc}")
|
|
215
|
-
continue
|
|
216
|
-
report.digested.append({
|
|
217
|
-
"group": key, "traces": len(tids), "summary_id": result.summary_id,
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _ref_group(source_ref: str) -> str | None:
|
|
222
|
-
"""`c01_rice-3` → `c01_rice`;没有分隔符就整条当一组。"""
|
|
223
|
-
if not source_ref:
|
|
224
|
-
return None
|
|
225
|
-
if _REF_SEP in source_ref:
|
|
226
|
-
return source_ref.rsplit(_REF_SEP, 1)[0] or None
|
|
227
|
-
return source_ref
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def _merge_fragments(
|
|
231
|
-
engine, scope_id: str, report: ConsolidationReport,
|
|
232
|
-
*, fragment_max_chars: int, limit: int, dry_run: bool,
|
|
233
|
-
) -> None:
|
|
234
|
-
store = SummaryStore(engine)
|
|
235
|
-
actives = store.active(scope_id)[:limit]
|
|
236
|
-
report.summaries_before = len(actives)
|
|
237
|
-
|
|
238
|
-
buckets: dict[str, list[dict]] = {}
|
|
239
|
-
for s in actives:
|
|
240
|
-
# 空摘要不参与合并。它不是"短",它是"什么都没提出来"——
|
|
241
|
-
# 两条空摘要合起来还是空,但会消耗一次 merge 的校验和一次写入。
|
|
242
|
-
# (`extractive` 遇上超过 max_chars 的单句就会产出空串。)
|
|
243
|
-
if not s["text"].strip():
|
|
244
|
-
report.kept.append({"summary_id": s["summary_id"], "reason": "empty"})
|
|
245
|
-
continue
|
|
246
|
-
if len(s["text"]) > fragment_max_chars:
|
|
247
|
-
continue
|
|
248
|
-
# 已经合并过的产物不再参与合并。
|
|
249
|
-
#
|
|
250
|
-
# 不加这条会无限合并:A+B 产生 C,下一轮 C+D 产生 E,再下一轮 E+F……
|
|
251
|
-
# 每轮都对(都满足"两条以上短碎片"),但摘要会越来越像一个
|
|
252
|
-
# 覆盖全文的巨型句子,最后连"这是一件事"都读不出来。
|
|
253
|
-
coverage = json.loads(s["coverage_json"] or "{}")
|
|
254
|
-
if coverage.get("merged_from"):
|
|
255
|
-
continue
|
|
256
|
-
# 校验失败的摘要不参与合并:它自己就已经丢了东西,
|
|
257
|
-
# 再把两条丢过东西的拼起来,等于把损失叠一层。
|
|
258
|
-
if s["validation_status"] == "failed":
|
|
259
|
-
report.kept.append({
|
|
260
|
-
"summary_id": s["summary_id"],
|
|
261
|
-
"reason": "validation_failed",
|
|
262
|
-
})
|
|
263
|
-
continue
|
|
264
|
-
key = _group_key(engine, s)
|
|
265
|
-
if key:
|
|
266
|
-
buckets.setdefault(key, []).append(s)
|
|
267
|
-
|
|
268
|
-
for key, group in buckets.items():
|
|
269
|
-
if len(group) < 2:
|
|
270
|
-
continue
|
|
271
|
-
ids = [s["summary_id"] for s in group]
|
|
272
|
-
if dry_run:
|
|
273
|
-
report.summaries_merged.append({"group": key, "from": ids, "to": ""})
|
|
274
|
-
continue
|
|
275
|
-
try:
|
|
276
|
-
result = store.merge(scope_id, ids, summarizer=extractive)
|
|
277
|
-
except Exception as exc: # noqa: BLE001
|
|
278
|
-
report.errors.append(f"merge {key}: {type(exc).__name__}: {exc}")
|
|
279
|
-
continue
|
|
280
|
-
if result.summary_id:
|
|
281
|
-
report.summaries_merged.append({
|
|
282
|
-
"group": key, "from": ids, "to": result.summary_id,
|
|
283
|
-
})
|
|
284
|
-
else:
|
|
285
|
-
# merge 内部校验失败会保留旧产物(返回空 id)。
|
|
286
|
-
# 这不是错误,是拒绝——但要记下来,否则"为什么没合"无从查。
|
|
287
|
-
report.kept.append({
|
|
288
|
-
"group": key, "summary_ids": ids,
|
|
289
|
-
"reason": "omission_check_failed",
|
|
290
|
-
"warnings": result.warnings,
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
# ---------------------------------------------------------------- 第二步
|
|
295
|
-
def _referenced_by_known_state(engine, trace_ids: list[str]) -> set[str]:
|
|
296
|
-
"""哪些痕迹还在给「当前有效的状态」作证。
|
|
297
|
-
|
|
298
|
-
这些不能归档——归档本身是可逆的(I4),但一条仍在支撑余额的痕迹
|
|
299
|
-
被挪到冷区,下次追查来源就要多跳一次。代价不大,但没必要。
|
|
300
|
-
这条边界来自 XM-07。
|
|
301
|
-
"""
|
|
302
|
-
if not trace_ids:
|
|
303
|
-
return set()
|
|
304
|
-
conn = engine._conn()
|
|
305
|
-
rows = conn.execute(
|
|
306
|
-
f"""
|
|
307
|
-
SELECT DISTINCT e.trace_id
|
|
308
|
-
FROM event_evidence e
|
|
309
|
-
JOIN events v ON v.event_id = e.event_id
|
|
310
|
-
JOIN states s ON s.state_key = v.state_key
|
|
311
|
-
WHERE s.status IN ('known', 'disputed')
|
|
312
|
-
AND v.void = 0
|
|
313
|
-
AND e.trace_id IN ({','.join('?' * len(trace_ids))})
|
|
314
|
-
""",
|
|
315
|
-
tuple(trace_ids),
|
|
316
|
-
).fetchall()
|
|
317
|
-
return {r["trace_id"] for r in rows}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
def _archive_low_weight(
|
|
321
|
-
engine, scope_id: str, report: ConsolidationReport,
|
|
322
|
-
*, min_age_days: float, threshold: float, limit: int, dry_run: bool,
|
|
323
|
-
) -> None:
|
|
324
|
-
lc = Lifecycle(engine)
|
|
325
|
-
candidates = lc.sweep(
|
|
326
|
-
scope_id, threshold=threshold, min_age_days=min_age_days,
|
|
327
|
-
limit=limit, dry_run=True,
|
|
328
|
-
)
|
|
329
|
-
report.traces_examined = len(candidates)
|
|
330
|
-
if not candidates:
|
|
331
|
-
return
|
|
332
|
-
|
|
333
|
-
# 先剔掉"还在给有效状态作证"的那批,再动手。
|
|
334
|
-
# 顺序不能反:先归档再补救,中间那段时间里来源链路是断的。
|
|
335
|
-
protected = _referenced_by_known_state(engine, candidates)
|
|
336
|
-
to_archive = [t for t in candidates if t not in protected]
|
|
337
|
-
for tid in candidates:
|
|
338
|
-
if tid in protected:
|
|
339
|
-
report.kept.append({"trace_id": tid, "reason": "supports_live_state"})
|
|
340
|
-
|
|
341
|
-
if dry_run:
|
|
342
|
-
report.archived.extend(to_archive)
|
|
343
|
-
return
|
|
344
|
-
|
|
345
|
-
# 逐条走 archive(),**不再回头调一次 sweep**。
|
|
346
|
-
#
|
|
347
|
-
# 先算出 to_archive 再调 sweep 是个陷阱:sweep 只看"低权重 + 够老",
|
|
348
|
-
# 它不知道哪些痕迹还在给有效状态作证,于是会把刚剔掉的那批一起扫走。
|
|
349
|
-
# dry_run 报告和真实结果就此分叉,而这种"报的和做的不一样"最难查。
|
|
350
|
-
for tid in to_archive:
|
|
351
|
-
if lc.archive(tid):
|
|
352
|
-
report.archived.append(tid)
|
|
353
|
-
else:
|
|
354
|
-
# archive() 返回 False 只有两种:痕迹不存在,或已被擦除。
|
|
355
|
-
# 后者是用户行使了删除权,不是失败,记下来即可。
|
|
356
|
-
report.kept.append({"trace_id": tid, "reason": "not_recoverable"})
|
|
1
|
+
"""整合周期:把碎片收起来(方案 5.7,实现顺序第六步)。
|
|
2
|
+
|
|
3
|
+
方案原话:
|
|
4
|
+
|
|
5
|
+
> 以上六步不必实时执行。连续的编码会产生碎片。碎片需要周期性整合:
|
|
6
|
+
> 合并同类摘要、重算权重、归档、重建线索。
|
|
7
|
+
> 这个周期是机制的一部分,不是优化。
|
|
8
|
+
> 没有它,记忆只会越存越碎,最终无法回答「现在什么重要」。
|
|
9
|
+
|
|
10
|
+
方案列了四件事,**本实现只做两件半**。这不是没做完,是刻意:
|
|
11
|
+
|
|
12
|
+
| 方案写的 | 本实现 | 为什么 |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| 合并同类摘要 | 做,但只按**结构**合并 | 按语义合并要模型判断,破坏 I2 |
|
|
15
|
+
| 归档 | 做,归档前验证可恢复 | I4 |
|
|
16
|
+
| 重算权重 | **不做** | 权重不存库,是 `decay.priority()` 每次算出来的 |
|
|
17
|
+
| 重建线索 | **不做** | 线索就是 SQL 索引,没有独立的线索表 |
|
|
18
|
+
|
|
19
|
+
后两件如果不做会出事,那必须做。做了才是问题:把派生值存下来,
|
|
20
|
+
就要维护一致性;一旦有两条写路径,迟早不一致。而 `priority()` 是纯函数,
|
|
21
|
+
它就是唯一的真相——**这是比"存一份"更强的保证,不是偷懒**。
|
|
22
|
+
|
|
23
|
+
## 合并的边界:只按结构,不按语义
|
|
24
|
+
|
|
25
|
+
「这两段摘要意思相近,合了吧」——这句话本身需要模型判断,
|
|
26
|
+
而模型判断不满足 I2(同样输入同样输出)。所以合并只能靠**结构证据**:
|
|
27
|
+
|
|
28
|
+
同一批输入痕迹来源相同(source_ref 前缀一致,例如 c06-1 / c06-2 / c06-3)
|
|
29
|
+
且都是短碎片(≤ fragment_max_chars)
|
|
30
|
+
且都没被合并过(coverage 里没有 merged_from)
|
|
31
|
+
|
|
32
|
+
代价是:跨会话的同类碎片不会合并。接受——**宁可碎,不可错**。
|
|
33
|
+
碎只是不好看,错是把两件不同的事揉成一件。
|
|
34
|
+
|
|
35
|
+
## 幂等
|
|
36
|
+
|
|
37
|
+
跑两遍必须得到同样的结果,否则这个周期不能定时跑(I2)。
|
|
38
|
+
靠三条保证:
|
|
39
|
+
|
|
40
|
+
合并后旧摘要 lifecycle_state='merged',第二遍的 active 里没有它们
|
|
41
|
+
归档后 level='archived',第二遍的 sweep 只看 level='hot'
|
|
42
|
+
新产物的 coverage 里有 merged_from,第二遍不再参与合并
|
|
43
|
+
|
|
44
|
+
第二条尤其重要:`sweep()` 本来就只扫 hot,所以"归档过的不会再归档"
|
|
45
|
+
不是靠额外判断,是靠状态机本身。
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
from .decay import Lifecycle
|
|
55
|
+
from .summary import SummaryStore, extractive
|
|
56
|
+
|
|
57
|
+
# 合并时会话归属的判定:source_ref 形如 "c06_flour-3",取最后一段的前缀。
|
|
58
|
+
# 没有 "-" 的就整条当分组键——那说明这个 source_ref 本身已经是一件事了。
|
|
59
|
+
_REF_SEP = "-"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ConsolidationReport:
|
|
64
|
+
"""一次整合做了什么、**没做什么**。
|
|
65
|
+
|
|
66
|
+
`kept` 和 `skipped_steps` 不是调试信息,是 I5 的一部分:
|
|
67
|
+
用户问「为什么这段记忆还在热区」,系统得答得出来。
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
scope_id: str
|
|
71
|
+
started_at: str
|
|
72
|
+
finished_at: str = ""
|
|
73
|
+
dry_run: bool = False
|
|
74
|
+
|
|
75
|
+
traces_examined: int = 0
|
|
76
|
+
summaries_before: int = 0
|
|
77
|
+
|
|
78
|
+
# 给"还没有摘要的痕迹"生成的摘要。它和 merged 是两步:
|
|
79
|
+
# digested 产出碎片,merged 收拾碎片。
|
|
80
|
+
digested: list[dict[str, Any]] = field(default_factory=list)
|
|
81
|
+
summaries_merged: list[dict[str, Any]] = field(default_factory=list)
|
|
82
|
+
archived: list[str] = field(default_factory=list)
|
|
83
|
+
kept: list[dict[str, Any]] = field(default_factory=list)
|
|
84
|
+
skipped_steps: list[str] = field(default_factory=list)
|
|
85
|
+
errors: list[str] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"scope_id": self.scope_id,
|
|
90
|
+
"started_at": self.started_at,
|
|
91
|
+
"finished_at": self.finished_at,
|
|
92
|
+
"dry_run": self.dry_run,
|
|
93
|
+
"traces_examined": self.traces_examined,
|
|
94
|
+
"summaries_before": self.summaries_before,
|
|
95
|
+
"digested": self.digested,
|
|
96
|
+
"summaries_merged": self.summaries_merged,
|
|
97
|
+
"archived": self.archived,
|
|
98
|
+
"kept": self.kept,
|
|
99
|
+
"skipped_steps": self.skipped_steps,
|
|
100
|
+
"errors": self.errors,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def touched(self) -> int:
|
|
105
|
+
return len(self.digested) + len(self.summaries_merged) + len(self.archived)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------- 入口
|
|
109
|
+
def consolidate(
|
|
110
|
+
engine,
|
|
111
|
+
scope_id: str,
|
|
112
|
+
*,
|
|
113
|
+
min_age_days: float = 30.0,
|
|
114
|
+
threshold: float = 0.05,
|
|
115
|
+
fragment_max_chars: int = 120,
|
|
116
|
+
limit: int = 200,
|
|
117
|
+
dry_run: bool = False,
|
|
118
|
+
) -> ConsolidationReport:
|
|
119
|
+
"""跑一次整合。可以重复跑,第二次通常是空的。"""
|
|
120
|
+
report = ConsolidationReport(
|
|
121
|
+
scope_id=scope_id,
|
|
122
|
+
started_at=engine.now(),
|
|
123
|
+
dry_run=dry_run,
|
|
124
|
+
skipped_steps=[
|
|
125
|
+
"recompute_weights: 权重是 decay.priority() 算出来的,库里没有可重算的值",
|
|
126
|
+
"rebuild_cues: 线索由 SQL 索引承担,本实现没有独立线索表",
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
# 顺序不能反:先有摘要,才有"可合并的碎片"。
|
|
132
|
+
_digest_uncovered(engine, scope_id, report, limit=limit, dry_run=dry_run)
|
|
133
|
+
_merge_fragments(engine, scope_id, report,
|
|
134
|
+
fragment_max_chars=fragment_max_chars, limit=limit,
|
|
135
|
+
dry_run=dry_run)
|
|
136
|
+
_archive_low_weight(engine, scope_id, report,
|
|
137
|
+
min_age_days=min_age_days, threshold=threshold,
|
|
138
|
+
limit=limit, dry_run=dry_run)
|
|
139
|
+
except Exception as exc: # noqa: BLE001
|
|
140
|
+
# 周期任务不能因为一条脏数据整轮不跑,但也不能假装没发生。
|
|
141
|
+
report.errors.append(f"{type(exc).__name__}: {exc}")
|
|
142
|
+
|
|
143
|
+
report.finished_at = engine.now()
|
|
144
|
+
return report
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------- 第一步
|
|
148
|
+
def _group_key(engine, summary: dict) -> str | None:
|
|
149
|
+
"""这条摘要属于哪一堆。读不出来就返回 None(不参与合并)。
|
|
150
|
+
|
|
151
|
+
分组键来自**输入痕迹的来源**,不来自摘要正文——
|
|
152
|
+
正文要靠"看得懂"才能分组,而"看得懂"是不确定的。
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
refs = json.loads(summary["input_refs_json"])
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
return None
|
|
158
|
+
if not refs:
|
|
159
|
+
return None
|
|
160
|
+
trace = engine.get_trace(refs[0])
|
|
161
|
+
if trace is None:
|
|
162
|
+
return None
|
|
163
|
+
return _ref_group(trace["source_ref"])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _digest_uncovered(
|
|
167
|
+
engine, scope_id: str, report: ConsolidationReport,
|
|
168
|
+
*, limit: int, dry_run: bool,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""把**还没有摘要**的痕迹按来源分批摘要成一条。
|
|
171
|
+
|
|
172
|
+
为什么这一步必须在合并之前:合并的对象是"已有的短摘要",
|
|
173
|
+
而写路径从来只写痕迹、不写摘要。所以一个真实库里的摘要数
|
|
174
|
+
长期是 0——**合并永远没有对象**。
|
|
175
|
+
|
|
176
|
+
这和 `lifecycle` 那条是同一类问题:机制写好了,但没人给它喂数据。
|
|
177
|
+
v0.34 的端到端验收两次撞到它(第一次归档 0 条,第二次合并 0 组)。
|
|
178
|
+
|
|
179
|
+
分批的依据是 `source_ref` 前缀:`c01_rice-1..10` 属于同一段对话,
|
|
180
|
+
合起来读是连贯的;跨段合并会把"米"和"酒"揉成一句话。
|
|
181
|
+
"""
|
|
182
|
+
store = SummaryStore(engine)
|
|
183
|
+
covered: set[str] = set()
|
|
184
|
+
actives = store.active(scope_id)[:limit]
|
|
185
|
+
for s in actives:
|
|
186
|
+
try:
|
|
187
|
+
covered.update(json.loads(s["input_refs_json"]))
|
|
188
|
+
except (TypeError, ValueError):
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
rows = engine._conn().execute(
|
|
192
|
+
"SELECT trace_id, source_ref FROM traces"
|
|
193
|
+
" WHERE scope_id=? AND erased_at IS NULL ORDER BY recorded_at, trace_id LIMIT ?",
|
|
194
|
+
(scope_id, limit * 10),
|
|
195
|
+
).fetchall()
|
|
196
|
+
|
|
197
|
+
buckets: dict[str, list[str]] = {}
|
|
198
|
+
for row in rows:
|
|
199
|
+
if row["trace_id"] in covered:
|
|
200
|
+
continue
|
|
201
|
+
key = _ref_group(row["source_ref"])
|
|
202
|
+
if key:
|
|
203
|
+
buckets.setdefault(key, []).append(row["trace_id"])
|
|
204
|
+
|
|
205
|
+
for key, tids in buckets.items():
|
|
206
|
+
# 一条也摘要。单条不是"合并",但它是碎片的最小单位;
|
|
207
|
+
# 只处理 >=2 条的话,一段只聊了一句的对话永远进不了摘要。
|
|
208
|
+
if dry_run:
|
|
209
|
+
report.digested.append({"group": key, "traces": len(tids), "summary_id": ""})
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
result = store.create(scope_id, tids, summarizer=extractive)
|
|
213
|
+
except Exception as exc: # noqa: BLE001
|
|
214
|
+
report.errors.append(f"digest {key}: {type(exc).__name__}: {exc}")
|
|
215
|
+
continue
|
|
216
|
+
report.digested.append({
|
|
217
|
+
"group": key, "traces": len(tids), "summary_id": result.summary_id,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _ref_group(source_ref: str) -> str | None:
|
|
222
|
+
"""`c01_rice-3` → `c01_rice`;没有分隔符就整条当一组。"""
|
|
223
|
+
if not source_ref:
|
|
224
|
+
return None
|
|
225
|
+
if _REF_SEP in source_ref:
|
|
226
|
+
return source_ref.rsplit(_REF_SEP, 1)[0] or None
|
|
227
|
+
return source_ref
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _merge_fragments(
|
|
231
|
+
engine, scope_id: str, report: ConsolidationReport,
|
|
232
|
+
*, fragment_max_chars: int, limit: int, dry_run: bool,
|
|
233
|
+
) -> None:
|
|
234
|
+
store = SummaryStore(engine)
|
|
235
|
+
actives = store.active(scope_id)[:limit]
|
|
236
|
+
report.summaries_before = len(actives)
|
|
237
|
+
|
|
238
|
+
buckets: dict[str, list[dict]] = {}
|
|
239
|
+
for s in actives:
|
|
240
|
+
# 空摘要不参与合并。它不是"短",它是"什么都没提出来"——
|
|
241
|
+
# 两条空摘要合起来还是空,但会消耗一次 merge 的校验和一次写入。
|
|
242
|
+
# (`extractive` 遇上超过 max_chars 的单句就会产出空串。)
|
|
243
|
+
if not s["text"].strip():
|
|
244
|
+
report.kept.append({"summary_id": s["summary_id"], "reason": "empty"})
|
|
245
|
+
continue
|
|
246
|
+
if len(s["text"]) > fragment_max_chars:
|
|
247
|
+
continue
|
|
248
|
+
# 已经合并过的产物不再参与合并。
|
|
249
|
+
#
|
|
250
|
+
# 不加这条会无限合并:A+B 产生 C,下一轮 C+D 产生 E,再下一轮 E+F……
|
|
251
|
+
# 每轮都对(都满足"两条以上短碎片"),但摘要会越来越像一个
|
|
252
|
+
# 覆盖全文的巨型句子,最后连"这是一件事"都读不出来。
|
|
253
|
+
coverage = json.loads(s["coverage_json"] or "{}")
|
|
254
|
+
if coverage.get("merged_from"):
|
|
255
|
+
continue
|
|
256
|
+
# 校验失败的摘要不参与合并:它自己就已经丢了东西,
|
|
257
|
+
# 再把两条丢过东西的拼起来,等于把损失叠一层。
|
|
258
|
+
if s["validation_status"] == "failed":
|
|
259
|
+
report.kept.append({
|
|
260
|
+
"summary_id": s["summary_id"],
|
|
261
|
+
"reason": "validation_failed",
|
|
262
|
+
})
|
|
263
|
+
continue
|
|
264
|
+
key = _group_key(engine, s)
|
|
265
|
+
if key:
|
|
266
|
+
buckets.setdefault(key, []).append(s)
|
|
267
|
+
|
|
268
|
+
for key, group in buckets.items():
|
|
269
|
+
if len(group) < 2:
|
|
270
|
+
continue
|
|
271
|
+
ids = [s["summary_id"] for s in group]
|
|
272
|
+
if dry_run:
|
|
273
|
+
report.summaries_merged.append({"group": key, "from": ids, "to": ""})
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
result = store.merge(scope_id, ids, summarizer=extractive)
|
|
277
|
+
except Exception as exc: # noqa: BLE001
|
|
278
|
+
report.errors.append(f"merge {key}: {type(exc).__name__}: {exc}")
|
|
279
|
+
continue
|
|
280
|
+
if result.summary_id:
|
|
281
|
+
report.summaries_merged.append({
|
|
282
|
+
"group": key, "from": ids, "to": result.summary_id,
|
|
283
|
+
})
|
|
284
|
+
else:
|
|
285
|
+
# merge 内部校验失败会保留旧产物(返回空 id)。
|
|
286
|
+
# 这不是错误,是拒绝——但要记下来,否则"为什么没合"无从查。
|
|
287
|
+
report.kept.append({
|
|
288
|
+
"group": key, "summary_ids": ids,
|
|
289
|
+
"reason": "omission_check_failed",
|
|
290
|
+
"warnings": result.warnings,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------- 第二步
|
|
295
|
+
def _referenced_by_known_state(engine, trace_ids: list[str]) -> set[str]:
|
|
296
|
+
"""哪些痕迹还在给「当前有效的状态」作证。
|
|
297
|
+
|
|
298
|
+
这些不能归档——归档本身是可逆的(I4),但一条仍在支撑余额的痕迹
|
|
299
|
+
被挪到冷区,下次追查来源就要多跳一次。代价不大,但没必要。
|
|
300
|
+
这条边界来自 XM-07。
|
|
301
|
+
"""
|
|
302
|
+
if not trace_ids:
|
|
303
|
+
return set()
|
|
304
|
+
conn = engine._conn()
|
|
305
|
+
rows = conn.execute(
|
|
306
|
+
f"""
|
|
307
|
+
SELECT DISTINCT e.trace_id
|
|
308
|
+
FROM event_evidence e
|
|
309
|
+
JOIN events v ON v.event_id = e.event_id
|
|
310
|
+
JOIN states s ON s.state_key = v.state_key
|
|
311
|
+
WHERE s.status IN ('known', 'disputed')
|
|
312
|
+
AND v.void = 0
|
|
313
|
+
AND e.trace_id IN ({','.join('?' * len(trace_ids))})
|
|
314
|
+
""",
|
|
315
|
+
tuple(trace_ids),
|
|
316
|
+
).fetchall()
|
|
317
|
+
return {r["trace_id"] for r in rows}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _archive_low_weight(
|
|
321
|
+
engine, scope_id: str, report: ConsolidationReport,
|
|
322
|
+
*, min_age_days: float, threshold: float, limit: int, dry_run: bool,
|
|
323
|
+
) -> None:
|
|
324
|
+
lc = Lifecycle(engine)
|
|
325
|
+
candidates = lc.sweep(
|
|
326
|
+
scope_id, threshold=threshold, min_age_days=min_age_days,
|
|
327
|
+
limit=limit, dry_run=True,
|
|
328
|
+
)
|
|
329
|
+
report.traces_examined = len(candidates)
|
|
330
|
+
if not candidates:
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
# 先剔掉"还在给有效状态作证"的那批,再动手。
|
|
334
|
+
# 顺序不能反:先归档再补救,中间那段时间里来源链路是断的。
|
|
335
|
+
protected = _referenced_by_known_state(engine, candidates)
|
|
336
|
+
to_archive = [t for t in candidates if t not in protected]
|
|
337
|
+
for tid in candidates:
|
|
338
|
+
if tid in protected:
|
|
339
|
+
report.kept.append({"trace_id": tid, "reason": "supports_live_state"})
|
|
340
|
+
|
|
341
|
+
if dry_run:
|
|
342
|
+
report.archived.extend(to_archive)
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
# 逐条走 archive(),**不再回头调一次 sweep**。
|
|
346
|
+
#
|
|
347
|
+
# 先算出 to_archive 再调 sweep 是个陷阱:sweep 只看"低权重 + 够老",
|
|
348
|
+
# 它不知道哪些痕迹还在给有效状态作证,于是会把刚剔掉的那批一起扫走。
|
|
349
|
+
# dry_run 报告和真实结果就此分叉,而这种"报的和做的不一样"最难查。
|
|
350
|
+
for tid in to_archive:
|
|
351
|
+
if lc.archive(tid):
|
|
352
|
+
report.archived.append(tid)
|
|
353
|
+
else:
|
|
354
|
+
# archive() 返回 False 只有两种:痕迹不存在,或已被擦除。
|
|
355
|
+
# 后者是用户行使了删除权,不是失败,记下来即可。
|
|
356
|
+
report.kept.append({"trace_id": tid, "reason": "not_recoverable"})
|