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,238 +1,238 @@
|
|
|
1
|
-
"""摘要:合并、校验、版本化。
|
|
2
|
-
|
|
3
|
-
设计文档 5.8 / 12.2:
|
|
4
|
-
合并创建新产物;旧摘要可归档,但依赖关系要保留。
|
|
5
|
-
摘要不能成为精确数量计算、权限裁决和冲突解决的唯一依据。
|
|
6
|
-
|
|
7
|
-
这里的默认实现是**抽取式**(取每条痕迹的首句拼接),不调用模型。
|
|
8
|
-
接模型只需替换 `Summarizer`,其余逻辑不变。
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import json
|
|
14
|
-
from dataclasses import dataclass
|
|
15
|
-
from typing import Callable, Protocol, Sequence
|
|
16
|
-
|
|
17
|
-
from .engine import canonical_json, digest
|
|
18
|
-
|
|
19
|
-
# 省略校验用的词表。宁可误报,不可漏报——摘要丢一个"不"字,语义就反了。
|
|
20
|
-
# 但"别"歧义太大("别忘""别人""特别"都不是否定),所以不收。
|
|
21
|
-
NEGATION = ("不", "没", "未", "甭")
|
|
22
|
-
|
|
23
|
-
# 这些搭配里虽然有否定字,但不构成否定命题,先剔除再检测。
|
|
24
|
-
SAFE_PHRASES = ("不过", "不管", "不仅", "不如", "不少", "无论", "没事", "没关系")
|
|
25
|
-
CONDITION = ("要是", "如果", "假如", "倘若", "万一", "除非")
|
|
26
|
-
PLAN = ("明天", "下次", "回头", "待会", "以后", "等我", "将来")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class Summarizer(Protocol):
|
|
30
|
-
def __call__(self, texts: Sequence[str]) -> str:
|
|
31
|
-
...
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def extractive(texts: Sequence[str], max_chars: int = 200) -> str:
|
|
35
|
-
"""默认实现:抽取式。不生成新内容,只挑原文片段。
|
|
36
|
-
|
|
37
|
-
好处:永远不会引入原文没有的说法。
|
|
38
|
-
代价:长对话里信息覆盖差。接模型时替换这个函数即可。
|
|
39
|
-
"""
|
|
40
|
-
parts: list[str] = []
|
|
41
|
-
used = 0
|
|
42
|
-
for t in texts:
|
|
43
|
-
head = t.strip().split("。")[0]
|
|
44
|
-
if not head:
|
|
45
|
-
continue
|
|
46
|
-
if used + len(head) > max_chars:
|
|
47
|
-
break
|
|
48
|
-
parts.append(head)
|
|
49
|
-
used += len(head)
|
|
50
|
-
return ";".join(parts)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@dataclass
|
|
54
|
-
class SummaryResult:
|
|
55
|
-
summary_id: str
|
|
56
|
-
text: str
|
|
57
|
-
input_refs: list[str]
|
|
58
|
-
validation_status: str # ok / failed
|
|
59
|
-
warnings: list[str]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def validate_omissions(texts: Sequence[str], summary: str) -> list[str]:
|
|
63
|
-
"""检查摘要是否丢掉了会改变语义的成分(12.2)。
|
|
64
|
-
|
|
65
|
-
这是启发式,不是证明。它只能发现「原文有、摘要没有」的显性丢失,
|
|
66
|
-
发现不了「换了说法但意思变了」。
|
|
67
|
-
"""
|
|
68
|
-
warnings: list[str] = []
|
|
69
|
-
source = "".join(texts)
|
|
70
|
-
for phrase in SAFE_PHRASES:
|
|
71
|
-
source = source.replace(phrase, " ")
|
|
72
|
-
|
|
73
|
-
for label, words in (("negation", NEGATION), ("condition", CONDITION), ("plan", PLAN)):
|
|
74
|
-
src_hits = {w for w in words if w in source}
|
|
75
|
-
if not src_hits:
|
|
76
|
-
continue
|
|
77
|
-
miss = [w for w in src_hits if w not in summary]
|
|
78
|
-
if miss:
|
|
79
|
-
warnings.append(f"{label}_lost:{','.join(sorted(miss))}")
|
|
80
|
-
|
|
81
|
-
# 数字:摘要里出现的数字必须能在原文里找到
|
|
82
|
-
import re
|
|
83
|
-
src_nums = set(re.findall(r"\d+", source))
|
|
84
|
-
sum_nums = set(re.findall(r"\d+", summary))
|
|
85
|
-
extra = sum_nums - src_nums
|
|
86
|
-
if extra:
|
|
87
|
-
warnings.append(f"numbers_not_in_source:{','.join(sorted(extra))}")
|
|
88
|
-
|
|
89
|
-
return warnings
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
class SummaryStore:
|
|
93
|
-
def __init__(self, engine):
|
|
94
|
-
self.engine = engine
|
|
95
|
-
|
|
96
|
-
# ------------------------------------------------------------- 生成
|
|
97
|
-
def create(
|
|
98
|
-
self,
|
|
99
|
-
scope_id: str,
|
|
100
|
-
trace_ids: Sequence[str],
|
|
101
|
-
summarizer: Summarizer = extractive,
|
|
102
|
-
generator_version: str = "extractive-v1",
|
|
103
|
-
coverage: dict | None = None,
|
|
104
|
-
) -> SummaryResult:
|
|
105
|
-
texts: list[str] = []
|
|
106
|
-
for tid in trace_ids:
|
|
107
|
-
t = self.engine.get_trace(tid)
|
|
108
|
-
if t is None or t["erased_at"]:
|
|
109
|
-
continue
|
|
110
|
-
texts.append(t["content"])
|
|
111
|
-
|
|
112
|
-
text = summarizer(texts)
|
|
113
|
-
warnings = validate_omissions(texts, text)
|
|
114
|
-
status = "failed" if any(w.startswith("negation_lost") for w in warnings) else "ok"
|
|
115
|
-
|
|
116
|
-
summary_id = "sm_" + digest(scope_id, canonical_json(list(trace_ids)), self.engine.now())[:24]
|
|
117
|
-
self.engine._conn().execute(
|
|
118
|
-
"INSERT INTO summaries(summary_id, scope_id, input_refs_json, text,"
|
|
119
|
-
" generator_version, coverage_json, omissions_json, validation_status,"
|
|
120
|
-
" lifecycle_state, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
121
|
-
(
|
|
122
|
-
summary_id, scope_id, canonical_json(list(trace_ids)), text,
|
|
123
|
-
generator_version, canonical_json(coverage or {}),
|
|
124
|
-
canonical_json(warnings), status, "active", self.engine.now(),
|
|
125
|
-
),
|
|
126
|
-
)
|
|
127
|
-
return SummaryResult(summary_id, text, list(trace_ids), status, warnings)
|
|
128
|
-
|
|
129
|
-
# ------------------------------------------------------------- 合并
|
|
130
|
-
def merge(
|
|
131
|
-
self,
|
|
132
|
-
scope_id: str,
|
|
133
|
-
summary_ids: Sequence[str],
|
|
134
|
-
summarizer: Summarizer = extractive,
|
|
135
|
-
generator_version: str = "extractive-v1",
|
|
136
|
-
) -> SummaryResult:
|
|
137
|
-
"""合并多个摘要:生成新产物,旧产物标 merged,不覆盖(12.2)。"""
|
|
138
|
-
conn = self.engine._conn()
|
|
139
|
-
# 顺序按时间正序,不按 IN 列表顺序,也不按数据库返回顺序。
|
|
140
|
-
#
|
|
141
|
-
# 摘要是给人读的:「还你三斤;再还两斤;免一斤吧」和
|
|
142
|
-
# 「免一斤吧;再还两斤;还你三斤」是两段不同的话。
|
|
143
|
-
# 而 IN 查询的返回顺序**不保证**(这里实测是倒的),
|
|
144
|
-
# 所以必须显式 ORDER BY,并用 summary_id 兜底保证可复现(I2)。
|
|
145
|
-
rows = conn.execute(
|
|
146
|
-
f"SELECT * FROM summaries WHERE summary_id IN ({','.join('?' * len(summary_ids))})"
|
|
147
|
-
f" ORDER BY created_at ASC, summary_id ASC",
|
|
148
|
-
tuple(summary_ids),
|
|
149
|
-
).fetchall()
|
|
150
|
-
if not rows:
|
|
151
|
-
raise ValueError("no summaries to merge")
|
|
152
|
-
|
|
153
|
-
texts = [r["text"] for r in rows]
|
|
154
|
-
inputs: list[str] = []
|
|
155
|
-
for r in rows:
|
|
156
|
-
inputs.extend(json.loads(r["input_refs_json"]))
|
|
157
|
-
|
|
158
|
-
text = summarizer(texts)
|
|
159
|
-
warnings = validate_omissions(texts, text)
|
|
160
|
-
if warnings:
|
|
161
|
-
# 校验失败保留旧产物,不能因为「更短」就替换(12.2)
|
|
162
|
-
return SummaryResult("", text, inputs, "failed", warnings)
|
|
163
|
-
|
|
164
|
-
result = self.create(scope_id, inputs, summarizer, generator_version,
|
|
165
|
-
coverage={"merged_from": list(summary_ids)})
|
|
166
|
-
conn.execute(
|
|
167
|
-
f"UPDATE summaries SET lifecycle_state='merged'"
|
|
168
|
-
f" WHERE summary_id IN ({','.join('?' * len(summary_ids))})",
|
|
169
|
-
tuple(summary_ids),
|
|
170
|
-
)
|
|
171
|
-
return result
|
|
172
|
-
|
|
173
|
-
# ------------------------------------------------------------- 读
|
|
174
|
-
def active(self, scope_id: str) -> list[dict]:
|
|
175
|
-
# summary_id 是兜底排序键:同一秒内产生的多条摘要,光按 created_at
|
|
176
|
-
# 排出来的顺序由存储引擎决定——这次是 A、下次可能是 B。
|
|
177
|
-
# 而整合周期要按这个顺序分组合并,顺序不稳就等于**不幂等**(I2)。
|
|
178
|
-
rows = self.engine._conn().execute(
|
|
179
|
-
"SELECT * FROM summaries WHERE scope_id=? AND lifecycle_state='active'"
|
|
180
|
-
" ORDER BY created_at DESC, summary_id ASC",
|
|
181
|
-
(scope_id,),
|
|
182
|
-
).fetchall()
|
|
183
|
-
return [dict(r) for r in rows]
|
|
184
|
-
|
|
185
|
-
def get(self, summary_id: str) -> dict | None:
|
|
186
|
-
row = self.engine._conn().execute(
|
|
187
|
-
"SELECT * FROM summaries WHERE summary_id=?", (summary_id,)
|
|
188
|
-
).fetchone()
|
|
189
|
-
return dict(row) if row else None
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def expire_states(engine, scope_id: str, now: str | None = None) -> list[str]:
|
|
193
|
-
"""按规格的 validity 把过期状态转 stale(12.3)。
|
|
194
|
-
|
|
195
|
-
注意:这只改 status,不改 value。历史上仍可查,只是不再充当「现在」。
|
|
196
|
-
"""
|
|
197
|
-
now = now or engine.now()
|
|
198
|
-
stale: list[str] = []
|
|
199
|
-
conn = engine._conn()
|
|
200
|
-
|
|
201
|
-
for spec in engine.registry.all():
|
|
202
|
-
if spec.validity != "ttl" or not spec.ttl_seconds:
|
|
203
|
-
continue
|
|
204
|
-
rows = conn.execute(
|
|
205
|
-
"SELECT state_key, status FROM states WHERE scope_id=? AND status IN ('known','stale')",
|
|
206
|
-
(scope_id,),
|
|
207
|
-
).fetchall()
|
|
208
|
-
for row in rows:
|
|
209
|
-
if relation_of_key(row["state_key"]) != spec.relation:
|
|
210
|
-
continue
|
|
211
|
-
last = conn.execute(
|
|
212
|
-
"SELECT MAX(effective_at) FROM events WHERE state_key=? AND void=0",
|
|
213
|
-
(row["state_key"],),
|
|
214
|
-
).fetchone()[0]
|
|
215
|
-
if last is None:
|
|
216
|
-
continue
|
|
217
|
-
from datetime import datetime, timezone
|
|
218
|
-
try:
|
|
219
|
-
ts = datetime.fromisoformat(last)
|
|
220
|
-
except ValueError:
|
|
221
|
-
continue
|
|
222
|
-
if ts.tzinfo is None:
|
|
223
|
-
ts = ts.replace(tzinfo=timezone.utc)
|
|
224
|
-
cur = datetime.fromisoformat(now)
|
|
225
|
-
if cur.tzinfo is None:
|
|
226
|
-
cur = cur.replace(tzinfo=timezone.utc)
|
|
227
|
-
if (cur - ts).total_seconds() > spec.ttl_seconds:
|
|
228
|
-
conn.execute(
|
|
229
|
-
"UPDATE states SET status='stale' WHERE state_key=?",
|
|
230
|
-
(row["state_key"],),
|
|
231
|
-
)
|
|
232
|
-
stale.append(row["state_key"])
|
|
233
|
-
return stale
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def relation_of_key(state_key: str) -> str:
|
|
237
|
-
parts = state_key.split("|")
|
|
238
|
-
return parts[2] if len(parts) >= 3 else ""
|
|
1
|
+
"""摘要:合并、校验、版本化。
|
|
2
|
+
|
|
3
|
+
设计文档 5.8 / 12.2:
|
|
4
|
+
合并创建新产物;旧摘要可归档,但依赖关系要保留。
|
|
5
|
+
摘要不能成为精确数量计算、权限裁决和冲突解决的唯一依据。
|
|
6
|
+
|
|
7
|
+
这里的默认实现是**抽取式**(取每条痕迹的首句拼接),不调用模型。
|
|
8
|
+
接模型只需替换 `Summarizer`,其余逻辑不变。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Protocol, Sequence
|
|
16
|
+
|
|
17
|
+
from .engine import canonical_json, digest
|
|
18
|
+
|
|
19
|
+
# 省略校验用的词表。宁可误报,不可漏报——摘要丢一个"不"字,语义就反了。
|
|
20
|
+
# 但"别"歧义太大("别忘""别人""特别"都不是否定),所以不收。
|
|
21
|
+
NEGATION = ("不", "没", "未", "甭")
|
|
22
|
+
|
|
23
|
+
# 这些搭配里虽然有否定字,但不构成否定命题,先剔除再检测。
|
|
24
|
+
SAFE_PHRASES = ("不过", "不管", "不仅", "不如", "不少", "无论", "没事", "没关系")
|
|
25
|
+
CONDITION = ("要是", "如果", "假如", "倘若", "万一", "除非")
|
|
26
|
+
PLAN = ("明天", "下次", "回头", "待会", "以后", "等我", "将来")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Summarizer(Protocol):
|
|
30
|
+
def __call__(self, texts: Sequence[str]) -> str:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extractive(texts: Sequence[str], max_chars: int = 200) -> str:
|
|
35
|
+
"""默认实现:抽取式。不生成新内容,只挑原文片段。
|
|
36
|
+
|
|
37
|
+
好处:永远不会引入原文没有的说法。
|
|
38
|
+
代价:长对话里信息覆盖差。接模型时替换这个函数即可。
|
|
39
|
+
"""
|
|
40
|
+
parts: list[str] = []
|
|
41
|
+
used = 0
|
|
42
|
+
for t in texts:
|
|
43
|
+
head = t.strip().split("。")[0]
|
|
44
|
+
if not head:
|
|
45
|
+
continue
|
|
46
|
+
if used + len(head) > max_chars:
|
|
47
|
+
break
|
|
48
|
+
parts.append(head)
|
|
49
|
+
used += len(head)
|
|
50
|
+
return ";".join(parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SummaryResult:
|
|
55
|
+
summary_id: str
|
|
56
|
+
text: str
|
|
57
|
+
input_refs: list[str]
|
|
58
|
+
validation_status: str # ok / failed
|
|
59
|
+
warnings: list[str]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_omissions(texts: Sequence[str], summary: str) -> list[str]:
|
|
63
|
+
"""检查摘要是否丢掉了会改变语义的成分(12.2)。
|
|
64
|
+
|
|
65
|
+
这是启发式,不是证明。它只能发现「原文有、摘要没有」的显性丢失,
|
|
66
|
+
发现不了「换了说法但意思变了」。
|
|
67
|
+
"""
|
|
68
|
+
warnings: list[str] = []
|
|
69
|
+
source = "".join(texts)
|
|
70
|
+
for phrase in SAFE_PHRASES:
|
|
71
|
+
source = source.replace(phrase, " ")
|
|
72
|
+
|
|
73
|
+
for label, words in (("negation", NEGATION), ("condition", CONDITION), ("plan", PLAN)):
|
|
74
|
+
src_hits = {w for w in words if w in source}
|
|
75
|
+
if not src_hits:
|
|
76
|
+
continue
|
|
77
|
+
miss = [w for w in src_hits if w not in summary]
|
|
78
|
+
if miss:
|
|
79
|
+
warnings.append(f"{label}_lost:{','.join(sorted(miss))}")
|
|
80
|
+
|
|
81
|
+
# 数字:摘要里出现的数字必须能在原文里找到
|
|
82
|
+
import re
|
|
83
|
+
src_nums = set(re.findall(r"\d+", source))
|
|
84
|
+
sum_nums = set(re.findall(r"\d+", summary))
|
|
85
|
+
extra = sum_nums - src_nums
|
|
86
|
+
if extra:
|
|
87
|
+
warnings.append(f"numbers_not_in_source:{','.join(sorted(extra))}")
|
|
88
|
+
|
|
89
|
+
return warnings
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SummaryStore:
|
|
93
|
+
def __init__(self, engine):
|
|
94
|
+
self.engine = engine
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------- 生成
|
|
97
|
+
def create(
|
|
98
|
+
self,
|
|
99
|
+
scope_id: str,
|
|
100
|
+
trace_ids: Sequence[str],
|
|
101
|
+
summarizer: Summarizer = extractive,
|
|
102
|
+
generator_version: str = "extractive-v1",
|
|
103
|
+
coverage: dict | None = None,
|
|
104
|
+
) -> SummaryResult:
|
|
105
|
+
texts: list[str] = []
|
|
106
|
+
for tid in trace_ids:
|
|
107
|
+
t = self.engine.get_trace(tid)
|
|
108
|
+
if t is None or t["erased_at"]:
|
|
109
|
+
continue
|
|
110
|
+
texts.append(t["content"])
|
|
111
|
+
|
|
112
|
+
text = summarizer(texts)
|
|
113
|
+
warnings = validate_omissions(texts, text)
|
|
114
|
+
status = "failed" if any(w.startswith("negation_lost") for w in warnings) else "ok"
|
|
115
|
+
|
|
116
|
+
summary_id = "sm_" + digest(scope_id, canonical_json(list(trace_ids)), self.engine.now())[:24]
|
|
117
|
+
self.engine._conn().execute(
|
|
118
|
+
"INSERT INTO summaries(summary_id, scope_id, input_refs_json, text,"
|
|
119
|
+
" generator_version, coverage_json, omissions_json, validation_status,"
|
|
120
|
+
" lifecycle_state, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
121
|
+
(
|
|
122
|
+
summary_id, scope_id, canonical_json(list(trace_ids)), text,
|
|
123
|
+
generator_version, canonical_json(coverage or {}),
|
|
124
|
+
canonical_json(warnings), status, "active", self.engine.now(),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
return SummaryResult(summary_id, text, list(trace_ids), status, warnings)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------- 合并
|
|
130
|
+
def merge(
|
|
131
|
+
self,
|
|
132
|
+
scope_id: str,
|
|
133
|
+
summary_ids: Sequence[str],
|
|
134
|
+
summarizer: Summarizer = extractive,
|
|
135
|
+
generator_version: str = "extractive-v1",
|
|
136
|
+
) -> SummaryResult:
|
|
137
|
+
"""合并多个摘要:生成新产物,旧产物标 merged,不覆盖(12.2)。"""
|
|
138
|
+
conn = self.engine._conn()
|
|
139
|
+
# 顺序按时间正序,不按 IN 列表顺序,也不按数据库返回顺序。
|
|
140
|
+
#
|
|
141
|
+
# 摘要是给人读的:「还你三斤;再还两斤;免一斤吧」和
|
|
142
|
+
# 「免一斤吧;再还两斤;还你三斤」是两段不同的话。
|
|
143
|
+
# 而 IN 查询的返回顺序**不保证**(这里实测是倒的),
|
|
144
|
+
# 所以必须显式 ORDER BY,并用 summary_id 兜底保证可复现(I2)。
|
|
145
|
+
rows = conn.execute(
|
|
146
|
+
f"SELECT * FROM summaries WHERE summary_id IN ({','.join('?' * len(summary_ids))})"
|
|
147
|
+
f" ORDER BY created_at ASC, summary_id ASC",
|
|
148
|
+
tuple(summary_ids),
|
|
149
|
+
).fetchall()
|
|
150
|
+
if not rows:
|
|
151
|
+
raise ValueError("no summaries to merge")
|
|
152
|
+
|
|
153
|
+
texts = [r["text"] for r in rows]
|
|
154
|
+
inputs: list[str] = []
|
|
155
|
+
for r in rows:
|
|
156
|
+
inputs.extend(json.loads(r["input_refs_json"]))
|
|
157
|
+
|
|
158
|
+
text = summarizer(texts)
|
|
159
|
+
warnings = validate_omissions(texts, text)
|
|
160
|
+
if warnings:
|
|
161
|
+
# 校验失败保留旧产物,不能因为「更短」就替换(12.2)
|
|
162
|
+
return SummaryResult("", text, inputs, "failed", warnings)
|
|
163
|
+
|
|
164
|
+
result = self.create(scope_id, inputs, summarizer, generator_version,
|
|
165
|
+
coverage={"merged_from": list(summary_ids)})
|
|
166
|
+
conn.execute(
|
|
167
|
+
f"UPDATE summaries SET lifecycle_state='merged'"
|
|
168
|
+
f" WHERE summary_id IN ({','.join('?' * len(summary_ids))})",
|
|
169
|
+
tuple(summary_ids),
|
|
170
|
+
)
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------- 读
|
|
174
|
+
def active(self, scope_id: str) -> list[dict]:
|
|
175
|
+
# summary_id 是兜底排序键:同一秒内产生的多条摘要,光按 created_at
|
|
176
|
+
# 排出来的顺序由存储引擎决定——这次是 A、下次可能是 B。
|
|
177
|
+
# 而整合周期要按这个顺序分组合并,顺序不稳就等于**不幂等**(I2)。
|
|
178
|
+
rows = self.engine._conn().execute(
|
|
179
|
+
"SELECT * FROM summaries WHERE scope_id=? AND lifecycle_state='active'"
|
|
180
|
+
" ORDER BY created_at DESC, summary_id ASC",
|
|
181
|
+
(scope_id,),
|
|
182
|
+
).fetchall()
|
|
183
|
+
return [dict(r) for r in rows]
|
|
184
|
+
|
|
185
|
+
def get(self, summary_id: str) -> dict | None:
|
|
186
|
+
row = self.engine._conn().execute(
|
|
187
|
+
"SELECT * FROM summaries WHERE summary_id=?", (summary_id,)
|
|
188
|
+
).fetchone()
|
|
189
|
+
return dict(row) if row else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def expire_states(engine, scope_id: str, now: str | None = None) -> list[str]:
|
|
193
|
+
"""按规格的 validity 把过期状态转 stale(12.3)。
|
|
194
|
+
|
|
195
|
+
注意:这只改 status,不改 value。历史上仍可查,只是不再充当「现在」。
|
|
196
|
+
"""
|
|
197
|
+
now = now or engine.now()
|
|
198
|
+
stale: list[str] = []
|
|
199
|
+
conn = engine._conn()
|
|
200
|
+
|
|
201
|
+
for spec in engine.registry.all():
|
|
202
|
+
if spec.validity != "ttl" or not spec.ttl_seconds:
|
|
203
|
+
continue
|
|
204
|
+
rows = conn.execute(
|
|
205
|
+
"SELECT state_key, status FROM states WHERE scope_id=? AND status IN ('known','stale')",
|
|
206
|
+
(scope_id,),
|
|
207
|
+
).fetchall()
|
|
208
|
+
for row in rows:
|
|
209
|
+
if relation_of_key(row["state_key"]) != spec.relation:
|
|
210
|
+
continue
|
|
211
|
+
last = conn.execute(
|
|
212
|
+
"SELECT MAX(effective_at) FROM events WHERE state_key=? AND void=0",
|
|
213
|
+
(row["state_key"],),
|
|
214
|
+
).fetchone()[0]
|
|
215
|
+
if last is None:
|
|
216
|
+
continue
|
|
217
|
+
from datetime import datetime, timezone
|
|
218
|
+
try:
|
|
219
|
+
ts = datetime.fromisoformat(last)
|
|
220
|
+
except ValueError:
|
|
221
|
+
continue
|
|
222
|
+
if ts.tzinfo is None:
|
|
223
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
224
|
+
cur = datetime.fromisoformat(now)
|
|
225
|
+
if cur.tzinfo is None:
|
|
226
|
+
cur = cur.replace(tzinfo=timezone.utc)
|
|
227
|
+
if (cur - ts).total_seconds() > spec.ttl_seconds:
|
|
228
|
+
conn.execute(
|
|
229
|
+
"UPDATE states SET status='stale' WHERE state_key=?",
|
|
230
|
+
(row["state_key"],),
|
|
231
|
+
)
|
|
232
|
+
stale.append(row["state_key"])
|
|
233
|
+
return stale
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def relation_of_key(state_key: str) -> str:
|
|
237
|
+
parts = state_key.split("|")
|
|
238
|
+
return parts[2] if len(parts) >= 3 else ""
|