neobot-memory 1.0.0__tar.gz
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.
- neobot_memory-1.0.0/PKG-INFO +10 -0
- neobot_memory-1.0.0/README.md +0 -0
- neobot_memory-1.0.0/pyproject.toml +22 -0
- neobot_memory-1.0.0/pyproject.toml.orig +18 -0
- neobot_memory-1.0.0/src/neobot_memory/__init__.py +25 -0
- neobot_memory-1.0.0/src/neobot_memory/archive_service.py +718 -0
- neobot_memory-1.0.0/src/neobot_memory/defaults.py +264 -0
- neobot_memory-1.0.0/src/neobot_memory/image_service.py +92 -0
- neobot_memory-1.0.0/src/neobot_memory/py.typed +0 -0
- neobot_memory-1.0.0/src/neobot_memory/reader.py +13 -0
- neobot_memory-1.0.0/src/neobot_memory/service.py +46 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: neobot-memory
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: wsrsq, tangtian
|
|
6
|
+
Author-email: wsrsq <wsrsq001@163.com>, tangtian <a14b@126.com>
|
|
7
|
+
Requires-Dist: neobot-contracts
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "neobot-memory"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = ["neobot-contracts"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "wsrsq"
|
|
11
|
+
email = "wsrsq001@163.com"
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "tangtian"
|
|
15
|
+
email = "a14b@126.com"
|
|
16
|
+
|
|
17
|
+
[tool.uv.sources.neobot-contracts]
|
|
18
|
+
workspace = true
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.9.27,<0.10.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "neobot-memory"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "wsrsq", email = "wsrsq001@163.com" },
|
|
8
|
+
{ name = "tangtian", email = "a14b@126.com" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = ["neobot-contracts"]
|
|
12
|
+
|
|
13
|
+
[tool.uv.sources]
|
|
14
|
+
neobot-contracts = { workspace = true }
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.9.27,<0.10.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""neobot_memory — 记忆包公共 API"""
|
|
2
|
+
|
|
3
|
+
from neobot_memory.archive_service import ArchiveMemoryService
|
|
4
|
+
from neobot_memory.defaults import (
|
|
5
|
+
InMemoryArchiveMemoryAccess,
|
|
6
|
+
InMemoryImageAnalysisAccess,
|
|
7
|
+
InMemoryMemoryRepository,
|
|
8
|
+
NullLogger,
|
|
9
|
+
SystemClock,
|
|
10
|
+
)
|
|
11
|
+
from neobot_memory.image_service import ImageAnalysisService
|
|
12
|
+
from neobot_memory.reader import MemoryReader
|
|
13
|
+
from neobot_memory.service import MemoryService
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ArchiveMemoryService",
|
|
17
|
+
"ImageAnalysisService",
|
|
18
|
+
"MemoryService",
|
|
19
|
+
"MemoryReader",
|
|
20
|
+
"InMemoryMemoryRepository",
|
|
21
|
+
"InMemoryArchiveMemoryAccess",
|
|
22
|
+
"InMemoryImageAnalysisAccess",
|
|
23
|
+
"NullLogger",
|
|
24
|
+
"SystemClock",
|
|
25
|
+
]
|
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"""归档记忆服务。
|
|
2
|
+
|
|
3
|
+
本模块同时是「档案容量治理」的唯一收口点:save_archive、patch_archive 与自动总结
|
|
4
|
+
计数器三条写入路径最终都走 ArchiveMemoryService.set(),超限判定放在这里才能保证
|
|
5
|
+
绕过 skill 的写入同样受保护(D1-B)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from neobot_contracts.errors import NeoBotError
|
|
16
|
+
from neobot_contracts.models.memory import ArchiveMemory
|
|
17
|
+
from neobot_contracts.ports.logging import Logger
|
|
18
|
+
from neobot_contracts.ports.unit_of_work import UnitOfWorkFactory
|
|
19
|
+
|
|
20
|
+
#: 超限动作默认值:写库成功后异步压缩。
|
|
21
|
+
DEFAULT_OVERFLOW_ACTION = "summarize"
|
|
22
|
+
#: 同一 (table_name, key) 两次自动压缩之间的默认最小间隔(秒)。
|
|
23
|
+
DEFAULT_OVERFLOW_COOLDOWN_SECONDS = 600.0
|
|
24
|
+
#: 超限清单一次最多返回多少条(防止把大表整块塞进面板响应体)。
|
|
25
|
+
_MAX_OVER_LIMIT_PAGE_SIZE = 500
|
|
26
|
+
#: 超限清单里的内容预览字符数。
|
|
27
|
+
_OVER_LIMIT_PREVIEW_CHARS = 200
|
|
28
|
+
#: 同一 (table_name, key) 只保留最近多少份「压缩前快照」(spec(4) §4.10.4 / D15)。
|
|
29
|
+
MAX_ARCHIVE_SNAPSHOTS_PER_KEY = 10
|
|
30
|
+
#: 压缩前快照的全局兜底上限(防止大量不同 key 无限累积)。
|
|
31
|
+
MAX_ARCHIVE_SNAPSHOTS = 2000
|
|
32
|
+
|
|
33
|
+
#: 不参与容量治理的内部表(默认豁免名单)。
|
|
34
|
+
#: ⚠️ 与 agent.memory.archive.allowed_tables 语义完全不同:那个是「限制模型能访问
|
|
35
|
+
#: 哪些表」,这里是「哪些表不做长度上限拦截」,两者绝不可复用。
|
|
36
|
+
#: memory_counter 存的是「待总结消息 JSON」,group_interval=500 时天然超过任何上限:
|
|
37
|
+
#: - 若对它 reject:自动总结连计数都写不进去,整条管线直接断掉;
|
|
38
|
+
#: - 若对它 summarize:压缩器会去「压缩」待总结消息队列,把尚未总结的消息吃掉。
|
|
39
|
+
#: 因此它必须无条件豁免;一旦误伤,表现是「档案自动总结整体失效」,比长度超限严重得多。
|
|
40
|
+
DEFAULT_EXEMPT_TABLES: frozenset[str] = frozenset({"memory_counter"})
|
|
41
|
+
|
|
42
|
+
#: 超限压缩的调度回调签名:(table_name, key) -> 是否已调度。
|
|
43
|
+
#: 必须立即返回(同步):写路径要求「先落库、再异步压缩」,不能等一次完整的 AI 调用。
|
|
44
|
+
OverflowSummaryTrigger = Callable[[str, str], bool]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ArchiveOverflowRejected(NeoBotError, ValueError):
|
|
48
|
+
"""overflow_action="reject" 时拒绝写入(交由模型自行压缩后重试)。"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
table_name: str = "",
|
|
55
|
+
key: str = "",
|
|
56
|
+
chars: int = 0,
|
|
57
|
+
max_total_chars: int = 0,
|
|
58
|
+
hint: str = "",
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.table_name = table_name
|
|
62
|
+
self.key = key
|
|
63
|
+
self.chars = chars
|
|
64
|
+
self.max_total_chars = max_total_chars
|
|
65
|
+
self.hint = hint
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ArchiveVersionConflictError(NeoBotError, ValueError):
|
|
69
|
+
"""档案乐观锁版本冲突(服务层公开异常,面板据此返回 409)。
|
|
70
|
+
|
|
71
|
+
仓库层(neobot_storage)在版本不一致时抛出它自己的同名异常,
|
|
72
|
+
服务层统一翻译成本类,面板只需要依赖 neobot_memory。
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
message: str,
|
|
78
|
+
*,
|
|
79
|
+
table_name: str = "",
|
|
80
|
+
key: str = "",
|
|
81
|
+
expected_version: int = 0,
|
|
82
|
+
actual_version: int = 0,
|
|
83
|
+
) -> None:
|
|
84
|
+
super().__init__(message)
|
|
85
|
+
self.table_name = table_name
|
|
86
|
+
self.key = key
|
|
87
|
+
self.expected_version = expected_version
|
|
88
|
+
self.actual_version = actual_version
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ArchiveWriteOutcome:
|
|
93
|
+
"""一次档案写入的结果,附带容量治理信息。
|
|
94
|
+
|
|
95
|
+
item 为 None 表示写入被拒绝(只在 overflow_action="reject" 时出现)。
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
item: Optional[ArchiveMemory] = None
|
|
99
|
+
over_limit: bool = False
|
|
100
|
+
action: str = "none" # none | summarize | reject
|
|
101
|
+
scheduled: bool = False # 是否已把后台压缩交给触发方
|
|
102
|
+
chars: int = 0
|
|
103
|
+
max_total_chars: int = 0
|
|
104
|
+
hint: str = ""
|
|
105
|
+
error: str = ""
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def ok(self) -> bool:
|
|
109
|
+
"""写入是否真的落库。"""
|
|
110
|
+
return self.item is not None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalize_max_total_chars(value: Any) -> int:
|
|
114
|
+
"""把配置读成非负整数上限;None/非法值/负数一律视为 0(禁用)。"""
|
|
115
|
+
try:
|
|
116
|
+
parsed = int(value)
|
|
117
|
+
except (TypeError, ValueError):
|
|
118
|
+
return 0
|
|
119
|
+
return parsed if parsed > 0 else 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _normalize_overflow_action(value: Any) -> str:
|
|
123
|
+
"""把配置读成 summarize / reject;未知值回落到 summarize。"""
|
|
124
|
+
action = str(value or "").strip().lower()
|
|
125
|
+
return action if action in {"summarize", "reject"} else DEFAULT_OVERFLOW_ACTION
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _normalize_cooldown(value: Any) -> float:
|
|
129
|
+
"""把配置读成非负冷却秒数;None/非法值回落默认值。"""
|
|
130
|
+
try:
|
|
131
|
+
parsed = float(value)
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
return DEFAULT_OVERFLOW_COOLDOWN_SECONDS
|
|
134
|
+
return parsed if parsed > 0 else 0.0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ArchiveMemoryService:
|
|
138
|
+
"""归档记忆的增删改查与查询操作服务。"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
uow_factory: UnitOfWorkFactory,
|
|
143
|
+
logger: Logger,
|
|
144
|
+
*,
|
|
145
|
+
max_total_chars: Any = 0,
|
|
146
|
+
overflow_action: Any = DEFAULT_OVERFLOW_ACTION,
|
|
147
|
+
overflow_summary_cooldown_seconds: Any = DEFAULT_OVERFLOW_COOLDOWN_SECONDS,
|
|
148
|
+
exempt_tables: Iterable[str] | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
self._uow_factory = uow_factory
|
|
151
|
+
self._logger = logger
|
|
152
|
+
# 容量治理默认关闭:只有外部(bootstrap / 自动总结服务)显式配置后才会生效,
|
|
153
|
+
# 从而保证「不接线 = 与现状完全一致」。
|
|
154
|
+
self._max_total_chars = _normalize_max_total_chars(max_total_chars)
|
|
155
|
+
self._overflow_action = _normalize_overflow_action(overflow_action)
|
|
156
|
+
self._overflow_cooldown_seconds = _normalize_cooldown(overflow_summary_cooldown_seconds)
|
|
157
|
+
self._exempt_tables: frozenset[str] = (
|
|
158
|
+
frozenset(str(name) for name in exempt_tables)
|
|
159
|
+
if exempt_tables is not None
|
|
160
|
+
else DEFAULT_EXEMPT_TABLES
|
|
161
|
+
)
|
|
162
|
+
self._overflow_trigger: Optional[OverflowSummaryTrigger] = None
|
|
163
|
+
#: (table_name, key) -> 冷却到期时刻(time.monotonic())
|
|
164
|
+
self._overflow_cooldown_until: dict[tuple[str, str], float] = {}
|
|
165
|
+
|
|
166
|
+
# ── 容量治理配置 ────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def configure_overflow_policy(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
max_total_chars: Any = None,
|
|
172
|
+
overflow_action: Any = None,
|
|
173
|
+
cooldown_seconds: Any = None,
|
|
174
|
+
exempt_tables: Iterable[str] | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""配置单条档案的存储上限与超限动作;参数为 None 表示保持当前值。
|
|
177
|
+
|
|
178
|
+
max_total_chars=0 表示禁用容量治理(行为回落到现状)。
|
|
179
|
+
"""
|
|
180
|
+
if max_total_chars is not None:
|
|
181
|
+
self._max_total_chars = _normalize_max_total_chars(max_total_chars)
|
|
182
|
+
if overflow_action is not None:
|
|
183
|
+
self._overflow_action = _normalize_overflow_action(overflow_action)
|
|
184
|
+
if cooldown_seconds is not None:
|
|
185
|
+
self._overflow_cooldown_seconds = _normalize_cooldown(cooldown_seconds)
|
|
186
|
+
if exempt_tables is not None:
|
|
187
|
+
self._exempt_tables = frozenset(str(name) for name in exempt_tables)
|
|
188
|
+
|
|
189
|
+
def set_overflow_trigger(self, trigger: Optional[OverflowSummaryTrigger]) -> None:
|
|
190
|
+
"""注入「档案超限 → 调度一次自动压缩」的回调(None 表示卸载)。"""
|
|
191
|
+
self._overflow_trigger = trigger
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def max_total_chars(self) -> int:
|
|
195
|
+
"""单条档案的存储硬上限;0 表示禁用。"""
|
|
196
|
+
return self._max_total_chars
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def overflow_action(self) -> str:
|
|
200
|
+
"""超限动作:summarize / reject。"""
|
|
201
|
+
return self._overflow_action
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def exempt_tables(self) -> frozenset[str]:
|
|
205
|
+
"""不参与容量治理的内部表集合。"""
|
|
206
|
+
return self._exempt_tables
|
|
207
|
+
|
|
208
|
+
def clear_overflow_cooldown(self, table_name: str, key: str) -> None:
|
|
209
|
+
"""清除某个条目的压缩冷却。
|
|
210
|
+
|
|
211
|
+
压缩失败时由触发方调用:失败不应该被冷却窗口掩盖成 600 秒的静默,
|
|
212
|
+
清掉冷却后「下次写入再试」才成立(是否真的重试仍由触发方的失败退避决定)。
|
|
213
|
+
"""
|
|
214
|
+
self._overflow_cooldown_until.pop((table_name, key), None)
|
|
215
|
+
|
|
216
|
+
# ── 基本读写(签名语义保持不变,面板直接转调) ──────────────────
|
|
217
|
+
|
|
218
|
+
async def get(self, table_name: str, key: str) -> Optional[ArchiveMemory]:
|
|
219
|
+
async with self._uow_factory() as uow:
|
|
220
|
+
item = await uow.archive.get(table_name, key)
|
|
221
|
+
self._logger.debug("存档记忆已获取", table_name=table_name, key=key, found=item is not None)
|
|
222
|
+
return item
|
|
223
|
+
|
|
224
|
+
async def exists(self, table_name: str, key: str) -> bool:
|
|
225
|
+
async with self._uow_factory() as uow:
|
|
226
|
+
exists = await uow.archive.exists(table_name, key)
|
|
227
|
+
self._logger.debug("存档记忆存在检查", table_name=table_name, key=key, exists=exists)
|
|
228
|
+
return exists
|
|
229
|
+
|
|
230
|
+
async def list(
|
|
231
|
+
self,
|
|
232
|
+
table_name: str,
|
|
233
|
+
*,
|
|
234
|
+
tags: Optional[list[str]] = None,
|
|
235
|
+
key_query: Optional[str] = None,
|
|
236
|
+
value_query: Optional[str] = None,
|
|
237
|
+
limit: int = 50,
|
|
238
|
+
offset: int = 0,
|
|
239
|
+
) -> list[ArchiveMemory]:
|
|
240
|
+
async with self._uow_factory() as uow:
|
|
241
|
+
items = await uow.archive.list(
|
|
242
|
+
table_name,
|
|
243
|
+
tags=tags,
|
|
244
|
+
key_query=key_query,
|
|
245
|
+
value_query=value_query,
|
|
246
|
+
limit=limit,
|
|
247
|
+
offset=offset,
|
|
248
|
+
)
|
|
249
|
+
self._logger.debug(
|
|
250
|
+
"存档记忆列表已获取",
|
|
251
|
+
table_name=table_name,
|
|
252
|
+
count=len(items),
|
|
253
|
+
limit=limit,
|
|
254
|
+
offset=offset,
|
|
255
|
+
)
|
|
256
|
+
return items
|
|
257
|
+
|
|
258
|
+
async def set(self, table_name: str, key: str, value: str, tags: list[str]) -> ArchiveMemory:
|
|
259
|
+
"""创建或更新归档记忆条目(容量治理的唯一收口点)。
|
|
260
|
+
|
|
261
|
+
超过 max_total_chars 时:
|
|
262
|
+
- overflow_action="summarize":先落库(不丢信息),再触发异步压缩,立即返回;
|
|
263
|
+
- overflow_action="reject":不写库,抛 ArchiveOverflowRejected。
|
|
264
|
+
"""
|
|
265
|
+
outcome = await self.set_with_outcome(table_name, key, value, tags)
|
|
266
|
+
if outcome.item is None:
|
|
267
|
+
raise ArchiveOverflowRejected(
|
|
268
|
+
outcome.error or f"档案超过存储上限: {table_name}:{key}",
|
|
269
|
+
table_name=table_name,
|
|
270
|
+
key=key,
|
|
271
|
+
chars=outcome.chars,
|
|
272
|
+
max_total_chars=outcome.max_total_chars,
|
|
273
|
+
hint=outcome.hint,
|
|
274
|
+
)
|
|
275
|
+
return outcome.item
|
|
276
|
+
|
|
277
|
+
async def set_with_outcome(
|
|
278
|
+
self, table_name: str, key: str, value: str, tags: list[str]
|
|
279
|
+
) -> ArchiveWriteOutcome:
|
|
280
|
+
"""与 set 相同,但把超限信息(over_limit / 是否已调度压缩 / hint)一并返回。
|
|
281
|
+
|
|
282
|
+
工具层(archive_crud)用它给模型回 over_limit=True + hint;面板/内部写路径
|
|
283
|
+
可以直接用 set()。
|
|
284
|
+
"""
|
|
285
|
+
limit = self._max_total_chars
|
|
286
|
+
chars = len(value or "")
|
|
287
|
+
governed = table_name not in self._exempt_tables
|
|
288
|
+
over_limit = governed and limit > 0 and chars > limit
|
|
289
|
+
if over_limit and self._overflow_action == "reject":
|
|
290
|
+
hint = (
|
|
291
|
+
f"档案超过存储上限 {limit} 字(当前 {chars} 字),已拒绝写入"
|
|
292
|
+
"(agent.memory.archive.overflow_action=reject)。"
|
|
293
|
+
"请先用 read_archive 的 outline/offset 分页阅读,再用 patch_archive "
|
|
294
|
+
"压缩或删除冗余内容后重试。"
|
|
295
|
+
)
|
|
296
|
+
self._logger.warning(
|
|
297
|
+
"档案超过存储上限,按配置拒绝写入",
|
|
298
|
+
table_name=table_name,
|
|
299
|
+
key=key,
|
|
300
|
+
chars=chars,
|
|
301
|
+
max_total_chars=limit,
|
|
302
|
+
)
|
|
303
|
+
return ArchiveWriteOutcome(
|
|
304
|
+
item=None,
|
|
305
|
+
over_limit=True,
|
|
306
|
+
action="reject",
|
|
307
|
+
chars=chars,
|
|
308
|
+
max_total_chars=limit,
|
|
309
|
+
hint=hint,
|
|
310
|
+
error=f"档案超过存储上限 {limit} 字(当前 {chars} 字),已拒绝写入",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async with self._uow_factory() as uow:
|
|
314
|
+
item = await uow.archive.set(table_name, key, value, tags)
|
|
315
|
+
await uow.commit()
|
|
316
|
+
self._logger.debug(
|
|
317
|
+
"存档记忆已保存",
|
|
318
|
+
table_name=table_name,
|
|
319
|
+
key=key,
|
|
320
|
+
version=item.version,
|
|
321
|
+
)
|
|
322
|
+
if not over_limit:
|
|
323
|
+
return ArchiveWriteOutcome(item=item, chars=chars, max_total_chars=limit)
|
|
324
|
+
|
|
325
|
+
# 先写后压缩:原文已经落库,压缩只是在后台把体积收回来(不丢信息)。
|
|
326
|
+
scheduled = self._schedule_overflow_compression(table_name, key, chars=chars, limit=limit)
|
|
327
|
+
if scheduled:
|
|
328
|
+
hint = (
|
|
329
|
+
f"档案已超过存储上限 {limit} 字(当前 {chars} 字),已触发后台自动压缩。"
|
|
330
|
+
"压缩期间请勿整条重写该档案;稍后用 read_archive 查看压缩结果。"
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
hint = (
|
|
334
|
+
f"档案已超过存储上限 {limit} 字(当前 {chars} 字),后台自动压缩暂未调度"
|
|
335
|
+
"(AI 不可用或处于冷却中)。原文已完整保留,下次写入会自动重试。"
|
|
336
|
+
)
|
|
337
|
+
self._logger.warning(
|
|
338
|
+
"档案超过存储上限,已保留原内容并等待压缩",
|
|
339
|
+
table_name=table_name,
|
|
340
|
+
key=key,
|
|
341
|
+
chars=chars,
|
|
342
|
+
max_total_chars=limit,
|
|
343
|
+
compression_scheduled=scheduled,
|
|
344
|
+
)
|
|
345
|
+
return ArchiveWriteOutcome(
|
|
346
|
+
item=item,
|
|
347
|
+
over_limit=True,
|
|
348
|
+
action="summarize",
|
|
349
|
+
scheduled=scheduled,
|
|
350
|
+
chars=chars,
|
|
351
|
+
max_total_chars=limit,
|
|
352
|
+
hint=hint,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
async def delete(self, table_name: str, key: str) -> bool:
|
|
356
|
+
async with self._uow_factory() as uow:
|
|
357
|
+
deleted = await uow.archive.delete(table_name, key)
|
|
358
|
+
if deleted:
|
|
359
|
+
await uow.commit()
|
|
360
|
+
self._logger.debug("存档记忆已删除", table_name=table_name, key=key, deleted=deleted)
|
|
361
|
+
return deleted
|
|
362
|
+
|
|
363
|
+
# ── 乐观锁与观测能力(面板) ────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
async def set_if_version(
|
|
366
|
+
self,
|
|
367
|
+
table_name: str,
|
|
368
|
+
key: str,
|
|
369
|
+
value: str,
|
|
370
|
+
tags: list[str],
|
|
371
|
+
expected_version: int,
|
|
372
|
+
) -> ArchiveMemory:
|
|
373
|
+
"""带乐观锁的写入:version 不一致时抛 ArchiveVersionConflictError(面板 409)。
|
|
374
|
+
|
|
375
|
+
注意这里不做容量治理判定:面板是运维兜底入口,管理员必须能写入并看到
|
|
376
|
+
超限提示,而不是被上限拦在门外(真正超限的档案应通过压缩回收)。
|
|
377
|
+
"""
|
|
378
|
+
limit = self._max_total_chars
|
|
379
|
+
chars = len(value or "")
|
|
380
|
+
if limit > 0 and chars > limit and table_name not in self._exempt_tables:
|
|
381
|
+
# 面板是运维兜底入口:必须能写入并看到超限,而不是被上限拦在门外;
|
|
382
|
+
# 这里只记录 WARNING,让超限档案仍能被 list_over_limit 枚举出来。
|
|
383
|
+
self._logger.warning(
|
|
384
|
+
"面板写入的档案超过存储上限(面板路径不拦截,仅记录)",
|
|
385
|
+
table_name=table_name,
|
|
386
|
+
key=key,
|
|
387
|
+
chars=chars,
|
|
388
|
+
max_total_chars=limit,
|
|
389
|
+
)
|
|
390
|
+
async with self._uow_factory() as uow:
|
|
391
|
+
setter = getattr(uow.archive, "set_if_version", None)
|
|
392
|
+
if setter is None:
|
|
393
|
+
# 默认内存实现(neobot_memory.defaults)没有乐观锁:退化成先查后写。
|
|
394
|
+
# 仅在单进程开发/测试路径上使用,真正的并发保护在 SqlAlchemy 仓库里。
|
|
395
|
+
item = await self._set_if_version_fallback(
|
|
396
|
+
uow, table_name, key, value, tags, expected_version
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
try:
|
|
400
|
+
item = await setter(table_name, key, value, tags, expected_version)
|
|
401
|
+
except NeoBotError as exc:
|
|
402
|
+
# 仓库层的冲突异常带 table/key/expected/actual,翻译时原样保留,
|
|
403
|
+
# 面板据此能直接渲染「已被他人修改(当前 version=N)」。
|
|
404
|
+
raise ArchiveVersionConflictError(
|
|
405
|
+
str(exc),
|
|
406
|
+
table_name=str(getattr(exc, "table_name", "") or table_name),
|
|
407
|
+
key=str(getattr(exc, "key", "") or key),
|
|
408
|
+
expected_version=int(getattr(exc, "expected_version", 0) or 0),
|
|
409
|
+
actual_version=int(getattr(exc, "actual_version", 0) or 0),
|
|
410
|
+
) from exc
|
|
411
|
+
await uow.commit()
|
|
412
|
+
self._logger.debug(
|
|
413
|
+
"存档记忆乐观锁写入",
|
|
414
|
+
table_name=table_name,
|
|
415
|
+
key=key,
|
|
416
|
+
version=item.version,
|
|
417
|
+
)
|
|
418
|
+
return item
|
|
419
|
+
|
|
420
|
+
async def _set_if_version_fallback(
|
|
421
|
+
self,
|
|
422
|
+
uow: Any,
|
|
423
|
+
table_name: str,
|
|
424
|
+
key: str,
|
|
425
|
+
value: str,
|
|
426
|
+
tags: list[str],
|
|
427
|
+
expected_version: int,
|
|
428
|
+
) -> ArchiveMemory:
|
|
429
|
+
"""无原生乐观锁的存储实现:先查后写(有 TOCTOU 窗口,仅开发路径)。"""
|
|
430
|
+
current = await uow.archive.get(table_name, key)
|
|
431
|
+
actual = int(getattr(current, "version", 0) or 0) if current is not None else 0
|
|
432
|
+
try:
|
|
433
|
+
expected = int(expected_version)
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
expected = 0
|
|
436
|
+
if actual != expected:
|
|
437
|
+
raise ArchiveVersionConflictError(
|
|
438
|
+
f"档案已被其他写入修改: {table_name}:{key}",
|
|
439
|
+
table_name=table_name,
|
|
440
|
+
key=key,
|
|
441
|
+
expected_version=expected,
|
|
442
|
+
actual_version=actual,
|
|
443
|
+
)
|
|
444
|
+
return await uow.archive.set(table_name, key, value, tags)
|
|
445
|
+
|
|
446
|
+
async def list_table_names(self) -> list[str]:
|
|
447
|
+
"""库内实际存在哪些档案表(面板表清单;不依赖静态常量)。"""
|
|
448
|
+
async with self._uow_factory() as uow:
|
|
449
|
+
lister = getattr(uow.archive, "list_table_names", None)
|
|
450
|
+
if lister is None:
|
|
451
|
+
self._logger.warning("当前档案存储实现不支持枚举表名,返回空清单")
|
|
452
|
+
return []
|
|
453
|
+
names = await lister()
|
|
454
|
+
return [str(name) for name in names]
|
|
455
|
+
|
|
456
|
+
async def table_stats(self) -> list[dict[str, Any]]:
|
|
457
|
+
"""每张档案表的条目数与最大 value 字符数。"""
|
|
458
|
+
async with self._uow_factory() as uow:
|
|
459
|
+
stats = getattr(uow.archive, "table_stats", None)
|
|
460
|
+
if stats is None:
|
|
461
|
+
self._logger.warning("当前档案存储实现不支持表统计,返回空清单")
|
|
462
|
+
return []
|
|
463
|
+
rows = await stats()
|
|
464
|
+
return [dict(row) for row in rows]
|
|
465
|
+
|
|
466
|
+
async def count_over_limit(self, table_name: Optional[str] = None) -> int:
|
|
467
|
+
"""当前超过存储上限的档案条目数(上限禁用时为 0)。"""
|
|
468
|
+
limit = self._max_total_chars
|
|
469
|
+
if limit <= 0:
|
|
470
|
+
return 0
|
|
471
|
+
async with self._uow_factory() as uow:
|
|
472
|
+
counter = getattr(uow.archive, "count_over_limit", None)
|
|
473
|
+
if counter is None:
|
|
474
|
+
return 0
|
|
475
|
+
total = await counter(
|
|
476
|
+
limit,
|
|
477
|
+
table_name=table_name,
|
|
478
|
+
exclude_tables=tuple(sorted(self._exempt_tables)),
|
|
479
|
+
)
|
|
480
|
+
return int(total or 0)
|
|
481
|
+
|
|
482
|
+
async def list_over_limit(
|
|
483
|
+
self,
|
|
484
|
+
table_name: Optional[str] = None,
|
|
485
|
+
*,
|
|
486
|
+
limit: int = 100,
|
|
487
|
+
offset: int = 0,
|
|
488
|
+
) -> list[dict[str, Any]]:
|
|
489
|
+
"""枚举「当前超过存储上限」的档案(A7 可观测,供面板直接返回 JSON)。
|
|
490
|
+
|
|
491
|
+
返回字段:table_name / key / chars / version / updated_at(ISO8601)/ preview。
|
|
492
|
+
内部豁免表(memory_counter 等)永远不出现在这里——它们天然超限,列出来只会
|
|
493
|
+
把真正的超标档案淹没。
|
|
494
|
+
"""
|
|
495
|
+
max_chars = self._max_total_chars
|
|
496
|
+
if max_chars <= 0:
|
|
497
|
+
return []
|
|
498
|
+
page_size = max(0, min(int(limit), _MAX_OVER_LIMIT_PAGE_SIZE))
|
|
499
|
+
async with self._uow_factory() as uow:
|
|
500
|
+
lister = getattr(uow.archive, "list_over_limit", None)
|
|
501
|
+
if lister is None:
|
|
502
|
+
self._logger.warning("当前档案存储实现不支持超限清单,返回空清单")
|
|
503
|
+
return []
|
|
504
|
+
items = await lister(
|
|
505
|
+
max_chars,
|
|
506
|
+
table_name=table_name,
|
|
507
|
+
exclude_tables=tuple(sorted(self._exempt_tables)),
|
|
508
|
+
limit=page_size,
|
|
509
|
+
offset=max(offset, 0),
|
|
510
|
+
)
|
|
511
|
+
return [self._over_limit_entry(item, max_chars=max_chars) for item in items]
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
def _over_limit_entry(item: ArchiveMemory, *, max_chars: int) -> dict[str, Any]:
|
|
515
|
+
value = item.value or ""
|
|
516
|
+
updated_at = item.updated_at
|
|
517
|
+
return {
|
|
518
|
+
"table_name": item.table_name,
|
|
519
|
+
"key": item.key,
|
|
520
|
+
"chars": len(value),
|
|
521
|
+
"version": item.version,
|
|
522
|
+
"updated_at": updated_at.isoformat() if updated_at is not None else None,
|
|
523
|
+
"max_total_chars": max_chars,
|
|
524
|
+
"preview": value[:_OVER_LIMIT_PREVIEW_CHARS],
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
# ── 压缩前快照(spec(4) Part C / D15) ──────────────────────────
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _snapshot_access(uow: Any) -> Any:
|
|
531
|
+
"""取快照访问层;旧实现 / 测试替身没有它时返回 None(快照降级为不落盘)。"""
|
|
532
|
+
return getattr(uow, "archive_snapshots", None)
|
|
533
|
+
|
|
534
|
+
async def save_snapshot(
|
|
535
|
+
self,
|
|
536
|
+
table_name: str,
|
|
537
|
+
key: str,
|
|
538
|
+
value: str,
|
|
539
|
+
*,
|
|
540
|
+
total_chars: Optional[int] = None,
|
|
541
|
+
version: int = 0,
|
|
542
|
+
reason: str = "manual",
|
|
543
|
+
operator_ip: Optional[str] = None,
|
|
544
|
+
) -> Optional[int]:
|
|
545
|
+
"""写入一份压缩前快照并执行保留策略,返回快照 id(存储不支持时为 None)。
|
|
546
|
+
|
|
547
|
+
写入时机由调用方保证:**压缩开始前**、拿到 chars_before 之后。
|
|
548
|
+
失败 / 未达标时调用方必须用 ``delete_snapshot`` 删掉本次快照。
|
|
549
|
+
"""
|
|
550
|
+
text = str(value or "")
|
|
551
|
+
chars = int(total_chars) if total_chars is not None else len(text)
|
|
552
|
+
async with self._uow_factory() as uow:
|
|
553
|
+
access = self._snapshot_access(uow)
|
|
554
|
+
if access is None:
|
|
555
|
+
self._logger.warning(
|
|
556
|
+
"当前档案存储实现不支持压缩快照,本次压缩不留痕",
|
|
557
|
+
table_name=table_name,
|
|
558
|
+
key=key,
|
|
559
|
+
)
|
|
560
|
+
return None
|
|
561
|
+
row = await access.add(
|
|
562
|
+
table_name,
|
|
563
|
+
key,
|
|
564
|
+
text,
|
|
565
|
+
total_chars=chars,
|
|
566
|
+
version=int(version or 0),
|
|
567
|
+
reason=str(reason or "manual"),
|
|
568
|
+
operator_ip=operator_ip,
|
|
569
|
+
)
|
|
570
|
+
pruned = await access.prune(
|
|
571
|
+
table_name,
|
|
572
|
+
key,
|
|
573
|
+
keep=MAX_ARCHIVE_SNAPSHOTS_PER_KEY,
|
|
574
|
+
max_total=MAX_ARCHIVE_SNAPSHOTS,
|
|
575
|
+
)
|
|
576
|
+
await uow.commit()
|
|
577
|
+
snapshot_id = int(row.get("id") or 0)
|
|
578
|
+
self._logger.debug(
|
|
579
|
+
"压缩前快照已保存",
|
|
580
|
+
table_name=table_name,
|
|
581
|
+
key=key,
|
|
582
|
+
total_chars=chars,
|
|
583
|
+
reason=str(reason or "manual"),
|
|
584
|
+
snapshot_id=snapshot_id,
|
|
585
|
+
pruned=int(pruned or 0),
|
|
586
|
+
)
|
|
587
|
+
return snapshot_id or None
|
|
588
|
+
|
|
589
|
+
async def list_snapshots(
|
|
590
|
+
self,
|
|
591
|
+
table_name: str,
|
|
592
|
+
key: str,
|
|
593
|
+
*,
|
|
594
|
+
limit: int = MAX_ARCHIVE_SNAPSHOTS_PER_KEY,
|
|
595
|
+
include_value: bool = False,
|
|
596
|
+
) -> list[dict[str, Any]]:
|
|
597
|
+
"""列出某条档案最近若干份快照(默认不含全文,避免大档案塞爆响应)。"""
|
|
598
|
+
page = max(0, min(int(limit), MAX_ARCHIVE_SNAPSHOTS_PER_KEY))
|
|
599
|
+
async with self._uow_factory() as uow:
|
|
600
|
+
access = self._snapshot_access(uow)
|
|
601
|
+
if access is None:
|
|
602
|
+
return []
|
|
603
|
+
rows = await access.list(
|
|
604
|
+
table_name, key, limit=page, include_value=include_value
|
|
605
|
+
)
|
|
606
|
+
return [self._snapshot_entry(row) for row in rows]
|
|
607
|
+
|
|
608
|
+
async def get_snapshot(self, snapshot_id: int) -> Optional[dict[str, Any]]:
|
|
609
|
+
"""读取某一份快照全文(面板「查看某份快照」)。"""
|
|
610
|
+
async with self._uow_factory() as uow:
|
|
611
|
+
access = self._snapshot_access(uow)
|
|
612
|
+
if access is None:
|
|
613
|
+
return None
|
|
614
|
+
row = await access.get(int(snapshot_id))
|
|
615
|
+
if row is None:
|
|
616
|
+
return None
|
|
617
|
+
entry = self._snapshot_entry(row, include_value=True)
|
|
618
|
+
entry["value"] = str(row.get("value") or "")
|
|
619
|
+
return entry
|
|
620
|
+
|
|
621
|
+
async def delete_snapshot(self, snapshot_id: Optional[int]) -> bool:
|
|
622
|
+
"""删除一份快照(压缩失败 / 未达标时回滚本次留痕)。"""
|
|
623
|
+
if not snapshot_id:
|
|
624
|
+
return False
|
|
625
|
+
async with self._uow_factory() as uow:
|
|
626
|
+
access = self._snapshot_access(uow)
|
|
627
|
+
if access is None:
|
|
628
|
+
return False
|
|
629
|
+
deleted = await access.delete(int(snapshot_id))
|
|
630
|
+
if deleted:
|
|
631
|
+
await uow.commit()
|
|
632
|
+
return bool(deleted)
|
|
633
|
+
|
|
634
|
+
async def prune_snapshots(
|
|
635
|
+
self, table_name: Optional[str] = None, key: Optional[str] = None
|
|
636
|
+
) -> int:
|
|
637
|
+
"""按保留策略清理快照:给了 (table_name, key) 时同时做单键与全局两级。"""
|
|
638
|
+
async with self._uow_factory() as uow:
|
|
639
|
+
access = self._snapshot_access(uow)
|
|
640
|
+
if access is None:
|
|
641
|
+
return 0
|
|
642
|
+
removed = await access.prune(
|
|
643
|
+
table_name,
|
|
644
|
+
key,
|
|
645
|
+
keep=MAX_ARCHIVE_SNAPSHOTS_PER_KEY,
|
|
646
|
+
max_total=MAX_ARCHIVE_SNAPSHOTS,
|
|
647
|
+
)
|
|
648
|
+
if removed:
|
|
649
|
+
await uow.commit()
|
|
650
|
+
return int(removed or 0)
|
|
651
|
+
|
|
652
|
+
@staticmethod
|
|
653
|
+
def _snapshot_entry(row: dict[str, Any], *, include_value: bool = False) -> dict[str, Any]:
|
|
654
|
+
"""把仓库行投影成面板 / 工具可序列化的字典(时间转 ISO8601)。"""
|
|
655
|
+
created_at = row.get("created_at")
|
|
656
|
+
isoformat = getattr(created_at, "isoformat", None)
|
|
657
|
+
entry: dict[str, Any] = {
|
|
658
|
+
"id": int(row.get("id") or 0),
|
|
659
|
+
"table_name": str(row.get("table_name") or ""),
|
|
660
|
+
"key": str(row.get("key") or ""),
|
|
661
|
+
"total_chars": int(row.get("total_chars") or 0),
|
|
662
|
+
"version": int(row.get("version") or 0),
|
|
663
|
+
"reason": str(row.get("reason") or ""),
|
|
664
|
+
"operator_ip": row.get("operator_ip"),
|
|
665
|
+
"created_at": str(isoformat()) if callable(isoformat) else "",
|
|
666
|
+
}
|
|
667
|
+
if include_value:
|
|
668
|
+
entry["value"] = str(row.get("value") or "")
|
|
669
|
+
return entry
|
|
670
|
+
|
|
671
|
+
# ── 超限压缩调度 ────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
def _in_overflow_cooldown(self, table_name: str, key: str) -> bool:
|
|
674
|
+
until = self._overflow_cooldown_until.get((table_name, key), 0.0)
|
|
675
|
+
return until > 0.0 and time.monotonic() < until
|
|
676
|
+
|
|
677
|
+
def _schedule_overflow_compression(
|
|
678
|
+
self, table_name: str, key: str, *, chars: int, limit: int
|
|
679
|
+
) -> bool:
|
|
680
|
+
"""触发一次异步压缩;返回是否真的调度成功。
|
|
681
|
+
|
|
682
|
+
冷却与单飞:冷却在这里判(同一 key 在 overflow_summary_cooldown_seconds
|
|
683
|
+
内不重复触发),单飞由触发方持有(同一 (table, key) 同时只跑一个压缩任务)。
|
|
684
|
+
触发方返回 False(AI 不可用 / 正在压缩 / 处于失败退避)时不写冷却,
|
|
685
|
+
这样「下次写入再试」才不会被一次失败锁死 600 秒。
|
|
686
|
+
"""
|
|
687
|
+
if self._in_overflow_cooldown(table_name, key):
|
|
688
|
+
self._logger.debug(
|
|
689
|
+
"档案超过存储上限,但仍在压缩冷却期内,跳过本次触发",
|
|
690
|
+
table_name=table_name,
|
|
691
|
+
key=key,
|
|
692
|
+
)
|
|
693
|
+
return False
|
|
694
|
+
trigger = self._overflow_trigger
|
|
695
|
+
if trigger is None:
|
|
696
|
+
self._logger.warning(
|
|
697
|
+
"档案超过存储上限但没有可用的自动压缩入口,仅保留原内容",
|
|
698
|
+
table_name=table_name,
|
|
699
|
+
key=key,
|
|
700
|
+
chars=chars,
|
|
701
|
+
max_total_chars=limit,
|
|
702
|
+
)
|
|
703
|
+
return False
|
|
704
|
+
try:
|
|
705
|
+
scheduled = bool(trigger(table_name, key))
|
|
706
|
+
except Exception as exc:
|
|
707
|
+
self._logger.warning(
|
|
708
|
+
"档案超过存储上限,触发自动压缩失败",
|
|
709
|
+
table_name=table_name,
|
|
710
|
+
key=key,
|
|
711
|
+
error=str(exc) or type(exc).__name__,
|
|
712
|
+
)
|
|
713
|
+
return False
|
|
714
|
+
if scheduled:
|
|
715
|
+
self._overflow_cooldown_until[(table_name, key)] = (
|
|
716
|
+
time.monotonic() + self._overflow_cooldown_seconds
|
|
717
|
+
)
|
|
718
|
+
return scheduled
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Defaults — 开箱即用的默认实现"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from neobot_contracts.models import ConversationRef, MemoryRecord
|
|
8
|
+
from neobot_contracts.models.memory import ArchiveMemory, ImageAnalysis
|
|
9
|
+
from neobot_contracts.ports.archive_memory_access import ArchiveMemoryAccess
|
|
10
|
+
from neobot_contracts.time_context import now_utc
|
|
11
|
+
from neobot_contracts.ports.image_analysis_access import ImageAnalysisAccess
|
|
12
|
+
from neobot_contracts.ports.clock import SystemClock as SystemClock # re-export
|
|
13
|
+
from neobot_contracts.ports.logging import NullLogger as NullLogger # re-export
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InMemoryMemoryRepository:
|
|
17
|
+
"""纯内存记忆存储,用于测试或独立运行"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._records: list[MemoryRecord] = []
|
|
21
|
+
|
|
22
|
+
async def save(self, record: MemoryRecord) -> None:
|
|
23
|
+
self._records.append(record)
|
|
24
|
+
|
|
25
|
+
async def search(
|
|
26
|
+
self, conversation: ConversationRef, query: str, limit: int = 5
|
|
27
|
+
) -> list[MemoryRecord]:
|
|
28
|
+
matches = [
|
|
29
|
+
r for r in self._records
|
|
30
|
+
if r.conversation == conversation and query.lower() in r.content.lower()
|
|
31
|
+
]
|
|
32
|
+
return matches[-limit:]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InMemoryArchiveMemoryAccess:
|
|
36
|
+
"""内存档案式记忆存储,用于测试或独立运行"""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._storage: dict[tuple[str, str], ArchiveMemory] = {}
|
|
40
|
+
self._id_counter = 0
|
|
41
|
+
|
|
42
|
+
async def get(self, table_name: str, key: str) -> Optional[ArchiveMemory]:
|
|
43
|
+
"""根据表名和键名获取档案式记忆条目"""
|
|
44
|
+
entry = self._storage.get((table_name, key))
|
|
45
|
+
if entry is None:
|
|
46
|
+
return None
|
|
47
|
+
return self._clone(entry)
|
|
48
|
+
|
|
49
|
+
async def set(
|
|
50
|
+
self,
|
|
51
|
+
table_name: str,
|
|
52
|
+
key: str,
|
|
53
|
+
value: str,
|
|
54
|
+
tags: list[str],
|
|
55
|
+
) -> ArchiveMemory:
|
|
56
|
+
"""创建或更新档案式记忆条目"""
|
|
57
|
+
entry_key = (table_name, key)
|
|
58
|
+
|
|
59
|
+
if entry_key in self._storage:
|
|
60
|
+
# 更新现有条目
|
|
61
|
+
existing = self._storage[entry_key]
|
|
62
|
+
updated = ArchiveMemory(
|
|
63
|
+
id=existing.id,
|
|
64
|
+
table_name=table_name,
|
|
65
|
+
key=key,
|
|
66
|
+
value=value,
|
|
67
|
+
tags=tags.copy(), # 创建副本避免外部修改
|
|
68
|
+
created_at=existing.created_at, # 保留原始创建时间
|
|
69
|
+
updated_at=now_utc(),
|
|
70
|
+
version=existing.version + 1,
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
# 创建新条目
|
|
74
|
+
self._id_counter += 1
|
|
75
|
+
now = now_utc()
|
|
76
|
+
updated = ArchiveMemory(
|
|
77
|
+
id=self._id_counter,
|
|
78
|
+
table_name=table_name,
|
|
79
|
+
key=key,
|
|
80
|
+
value=value,
|
|
81
|
+
tags=tags.copy(),
|
|
82
|
+
created_at=now,
|
|
83
|
+
updated_at=now,
|
|
84
|
+
version=1,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self._storage[entry_key] = self._clone(updated)
|
|
88
|
+
return self._clone(updated)
|
|
89
|
+
|
|
90
|
+
async def delete(self, table_name: str, key: str) -> bool:
|
|
91
|
+
"""删除档案式记忆条目"""
|
|
92
|
+
entry_key = (table_name, key)
|
|
93
|
+
if entry_key in self._storage:
|
|
94
|
+
del self._storage[entry_key]
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
async def exists(self, table_name: str, key: str) -> bool:
|
|
99
|
+
"""检查档案式记忆条目是否存在"""
|
|
100
|
+
return (table_name, key) in self._storage
|
|
101
|
+
|
|
102
|
+
async def list(
|
|
103
|
+
self,
|
|
104
|
+
table_name: str,
|
|
105
|
+
*,
|
|
106
|
+
tags: Optional[list[str]] = None,
|
|
107
|
+
key_query: Optional[str] = None,
|
|
108
|
+
value_query: Optional[str] = None,
|
|
109
|
+
limit: int = 50,
|
|
110
|
+
offset: int = 0,
|
|
111
|
+
) -> list[ArchiveMemory]:
|
|
112
|
+
"""列出符合条件的档案式记忆条目"""
|
|
113
|
+
rows = [
|
|
114
|
+
self._clone(entry)
|
|
115
|
+
for (stored_table_name, _), entry in self._storage.items()
|
|
116
|
+
if stored_table_name == table_name
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
if tags:
|
|
120
|
+
rows = [row for row in rows if all(tag in row.tags for tag in tags)]
|
|
121
|
+
if key_query:
|
|
122
|
+
lowered_key_query = key_query.lower()
|
|
123
|
+
rows = [row for row in rows if lowered_key_query in row.key.lower()]
|
|
124
|
+
if value_query:
|
|
125
|
+
lowered_value_query = value_query.lower()
|
|
126
|
+
rows = [row for row in rows if lowered_value_query in row.value.lower()]
|
|
127
|
+
|
|
128
|
+
rows.sort(key=lambda row: (row.updated_at, row.id), reverse=True)
|
|
129
|
+
return rows[offset : offset + limit]
|
|
130
|
+
|
|
131
|
+
# Type check: ensure class implements the protocol
|
|
132
|
+
def __init_subclass__(cls) -> None:
|
|
133
|
+
super().__init_subclass__()
|
|
134
|
+
# Verify that this class implements the ArchiveMemoryAccess protocol
|
|
135
|
+
if not issubclass(cls, ArchiveMemoryAccess): # type: ignore
|
|
136
|
+
raise TypeError(f"{cls.__name__} does not implement ArchiveMemoryAccess protocol")
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _clone(entry: ArchiveMemory) -> ArchiveMemory:
|
|
140
|
+
return ArchiveMemory(
|
|
141
|
+
id=entry.id,
|
|
142
|
+
table_name=entry.table_name,
|
|
143
|
+
key=entry.key,
|
|
144
|
+
value=entry.value,
|
|
145
|
+
tags=entry.tags.copy(),
|
|
146
|
+
created_at=entry.created_at,
|
|
147
|
+
updated_at=entry.updated_at,
|
|
148
|
+
version=entry.version,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class InMemoryImageAnalysisAccess:
|
|
153
|
+
"""In-memory image analysis cache for tests and standalone runs."""
|
|
154
|
+
|
|
155
|
+
def __init__(self) -> None:
|
|
156
|
+
self._storage: dict[str, ImageAnalysis] = {}
|
|
157
|
+
self._id_counter = 0
|
|
158
|
+
|
|
159
|
+
async def get(self, file_hash: str) -> Optional[ImageAnalysis]:
|
|
160
|
+
entry = self._storage.get(file_hash)
|
|
161
|
+
if entry is None:
|
|
162
|
+
return None
|
|
163
|
+
return self._clone(entry)
|
|
164
|
+
|
|
165
|
+
async def set(
|
|
166
|
+
self,
|
|
167
|
+
file_hash: str,
|
|
168
|
+
*,
|
|
169
|
+
source: Optional[str] = None,
|
|
170
|
+
mime_type: Optional[str] = None,
|
|
171
|
+
original_width: Optional[int] = None,
|
|
172
|
+
original_height: Optional[int] = None,
|
|
173
|
+
processed_width: Optional[int] = None,
|
|
174
|
+
processed_height: Optional[int] = None,
|
|
175
|
+
analysis_text: Optional[str] = None,
|
|
176
|
+
) -> ImageAnalysis:
|
|
177
|
+
if file_hash in self._storage:
|
|
178
|
+
existing = self._storage[file_hash]
|
|
179
|
+
updated = ImageAnalysis(
|
|
180
|
+
id=existing.id,
|
|
181
|
+
file_hash=file_hash,
|
|
182
|
+
source=source if source is not None else existing.source,
|
|
183
|
+
mime_type=mime_type if mime_type is not None else existing.mime_type,
|
|
184
|
+
original_width=original_width if original_width is not None else existing.original_width,
|
|
185
|
+
original_height=original_height if original_height is not None else existing.original_height,
|
|
186
|
+
processed_width=processed_width if processed_width is not None else existing.processed_width,
|
|
187
|
+
processed_height=processed_height if processed_height is not None else existing.processed_height,
|
|
188
|
+
analysis_text=analysis_text if analysis_text is not None else existing.analysis_text,
|
|
189
|
+
created_at=existing.created_at,
|
|
190
|
+
updated_at=now_utc(),
|
|
191
|
+
version=existing.version + 1,
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
self._id_counter += 1
|
|
195
|
+
now = now_utc()
|
|
196
|
+
updated = ImageAnalysis(
|
|
197
|
+
id=self._id_counter,
|
|
198
|
+
file_hash=file_hash,
|
|
199
|
+
source=source,
|
|
200
|
+
mime_type=mime_type,
|
|
201
|
+
original_width=original_width,
|
|
202
|
+
original_height=original_height,
|
|
203
|
+
processed_width=processed_width,
|
|
204
|
+
processed_height=processed_height,
|
|
205
|
+
analysis_text=analysis_text,
|
|
206
|
+
created_at=now,
|
|
207
|
+
updated_at=now,
|
|
208
|
+
version=1,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
self._storage[file_hash] = self._clone(updated)
|
|
212
|
+
return self._clone(updated)
|
|
213
|
+
|
|
214
|
+
async def delete(self, file_hash: str) -> bool:
|
|
215
|
+
if file_hash in self._storage:
|
|
216
|
+
del self._storage[file_hash]
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
async def exists(self, file_hash: str) -> bool:
|
|
221
|
+
return file_hash in self._storage
|
|
222
|
+
|
|
223
|
+
async def list(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
source_query: Optional[str] = None,
|
|
227
|
+
has_analysis_text: Optional[bool] = None,
|
|
228
|
+
limit: int = 50,
|
|
229
|
+
offset: int = 0,
|
|
230
|
+
) -> list[ImageAnalysis]:
|
|
231
|
+
rows = [self._clone(entry) for entry in self._storage.values()]
|
|
232
|
+
|
|
233
|
+
if source_query:
|
|
234
|
+
lowered = source_query.lower()
|
|
235
|
+
rows = [row for row in rows if row.source and lowered in row.source.lower()]
|
|
236
|
+
if has_analysis_text is True:
|
|
237
|
+
rows = [row for row in rows if row.analysis_text]
|
|
238
|
+
elif has_analysis_text is False:
|
|
239
|
+
rows = [row for row in rows if not row.analysis_text]
|
|
240
|
+
|
|
241
|
+
rows.sort(key=lambda row: (row.updated_at, row.id), reverse=True)
|
|
242
|
+
return rows[offset : offset + limit]
|
|
243
|
+
|
|
244
|
+
def __init_subclass__(cls) -> None:
|
|
245
|
+
super().__init_subclass__()
|
|
246
|
+
if not issubclass(cls, ImageAnalysisAccess): # type: ignore[arg-type]
|
|
247
|
+
raise TypeError(f"{cls.__name__} does not implement ImageAnalysisAccess protocol")
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _clone(entry: ImageAnalysis) -> ImageAnalysis:
|
|
251
|
+
return ImageAnalysis(
|
|
252
|
+
id=entry.id,
|
|
253
|
+
file_hash=entry.file_hash,
|
|
254
|
+
source=entry.source,
|
|
255
|
+
mime_type=entry.mime_type,
|
|
256
|
+
original_width=entry.original_width,
|
|
257
|
+
original_height=entry.original_height,
|
|
258
|
+
processed_width=entry.processed_width,
|
|
259
|
+
processed_height=entry.processed_height,
|
|
260
|
+
analysis_text=entry.analysis_text,
|
|
261
|
+
created_at=entry.created_at,
|
|
262
|
+
updated_at=entry.updated_at,
|
|
263
|
+
version=entry.version,
|
|
264
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""图片分析缓存服务。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from neobot_contracts.models.memory import ImageAnalysis
|
|
8
|
+
from neobot_contracts.ports.logging import Logger
|
|
9
|
+
from neobot_contracts.ports.unit_of_work import UnitOfWorkFactory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImageAnalysisService:
|
|
13
|
+
"""图片分析结果的缓存 CRUD 与查询服务。"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, uow_factory: UnitOfWorkFactory, logger: Logger) -> None:
|
|
16
|
+
self._uow_factory = uow_factory
|
|
17
|
+
self._logger = logger
|
|
18
|
+
|
|
19
|
+
async def get(self, file_hash: str) -> Optional[ImageAnalysis]:
|
|
20
|
+
async with self._uow_factory() as uow:
|
|
21
|
+
item = await uow.images.get(file_hash)
|
|
22
|
+
self._logger.debug("图像分析已获取", file_hash=file_hash, found=item is not None)
|
|
23
|
+
return item
|
|
24
|
+
|
|
25
|
+
async def exists(self, file_hash: str) -> bool:
|
|
26
|
+
async with self._uow_factory() as uow:
|
|
27
|
+
exists = await uow.images.exists(file_hash)
|
|
28
|
+
self._logger.debug("图像分析存在检查", file_hash=file_hash, exists=exists)
|
|
29
|
+
return exists
|
|
30
|
+
|
|
31
|
+
async def list(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
source_query: Optional[str] = None,
|
|
35
|
+
has_analysis_text: Optional[bool] = None,
|
|
36
|
+
limit: int = 50,
|
|
37
|
+
offset: int = 0,
|
|
38
|
+
) -> list[ImageAnalysis]:
|
|
39
|
+
async with self._uow_factory() as uow:
|
|
40
|
+
items = await uow.images.list(
|
|
41
|
+
source_query=source_query,
|
|
42
|
+
has_analysis_text=has_analysis_text,
|
|
43
|
+
limit=limit,
|
|
44
|
+
offset=offset,
|
|
45
|
+
)
|
|
46
|
+
self._logger.debug(
|
|
47
|
+
"图像分析列表已获取",
|
|
48
|
+
count=len(items),
|
|
49
|
+
limit=limit,
|
|
50
|
+
offset=offset,
|
|
51
|
+
has_analysis_text=has_analysis_text,
|
|
52
|
+
)
|
|
53
|
+
return items
|
|
54
|
+
|
|
55
|
+
async def set(
|
|
56
|
+
self,
|
|
57
|
+
file_hash: str,
|
|
58
|
+
*,
|
|
59
|
+
source: Optional[str] = None,
|
|
60
|
+
mime_type: Optional[str] = None,
|
|
61
|
+
original_width: Optional[int] = None,
|
|
62
|
+
original_height: Optional[int] = None,
|
|
63
|
+
processed_width: Optional[int] = None,
|
|
64
|
+
processed_height: Optional[int] = None,
|
|
65
|
+
analysis_text: Optional[str] = None,
|
|
66
|
+
) -> ImageAnalysis:
|
|
67
|
+
async with self._uow_factory() as uow:
|
|
68
|
+
item = await uow.images.set(
|
|
69
|
+
file_hash,
|
|
70
|
+
source=source,
|
|
71
|
+
mime_type=mime_type,
|
|
72
|
+
original_width=original_width,
|
|
73
|
+
original_height=original_height,
|
|
74
|
+
processed_width=processed_width,
|
|
75
|
+
processed_height=processed_height,
|
|
76
|
+
analysis_text=analysis_text,
|
|
77
|
+
)
|
|
78
|
+
await uow.commit()
|
|
79
|
+
self._logger.debug(
|
|
80
|
+
"图像分析已保存",
|
|
81
|
+
file_hash=file_hash,
|
|
82
|
+
version=item.version,
|
|
83
|
+
)
|
|
84
|
+
return item
|
|
85
|
+
|
|
86
|
+
async def delete(self, file_hash: str) -> bool:
|
|
87
|
+
async with self._uow_factory() as uow:
|
|
88
|
+
deleted = await uow.images.delete(file_hash)
|
|
89
|
+
if deleted:
|
|
90
|
+
await uow.commit()
|
|
91
|
+
self._logger.debug("图像分析已删除", file_hash=file_hash, deleted=deleted)
|
|
92
|
+
return deleted
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MemoryReader Protocol — 记忆读取抽象"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MemoryReader(Protocol):
|
|
9
|
+
"""记忆读取接口,MemoryService 结构性满足此接口"""
|
|
10
|
+
|
|
11
|
+
async def recall(
|
|
12
|
+
self, conversation_id: str, query: str, limit: int = 5
|
|
13
|
+
) -> list[str]: ...
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""MemoryService — 记忆读写服务"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from neobot_contracts.models import ConversationRef, MemoryRecord
|
|
6
|
+
from neobot_contracts.ports.clock import Clock
|
|
7
|
+
from neobot_contracts.ports.logging import Logger
|
|
8
|
+
from neobot_contracts.ports.repository import MemoryRepository
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemoryService:
|
|
12
|
+
"""记忆服务,负责存储和检索记忆条目"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
repository: MemoryRepository,
|
|
17
|
+
logger: Logger,
|
|
18
|
+
clock: Clock,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._repository = repository
|
|
21
|
+
self._logger = logger
|
|
22
|
+
self._clock = clock
|
|
23
|
+
|
|
24
|
+
async def remember(
|
|
25
|
+
self, conversation_id: str, speaker_id: str, content: str
|
|
26
|
+
) -> None:
|
|
27
|
+
record = MemoryRecord(
|
|
28
|
+
conversation=ConversationRef(kind="private", id=conversation_id),
|
|
29
|
+
speaker_id=speaker_id,
|
|
30
|
+
content=content,
|
|
31
|
+
created_at=self._clock.now(),
|
|
32
|
+
)
|
|
33
|
+
await self._repository.save(record)
|
|
34
|
+
self._logger.debug("短期记忆已保存", conversation_id=conversation_id, speaker_id=speaker_id)
|
|
35
|
+
|
|
36
|
+
async def recall(
|
|
37
|
+
self, conversation_id: str, query: str, limit: int = 5
|
|
38
|
+
) -> list[str]:
|
|
39
|
+
ref = ConversationRef(kind="private", id=conversation_id)
|
|
40
|
+
records = await self._repository.search(ref, query, limit)
|
|
41
|
+
self._logger.debug(
|
|
42
|
+
"记忆已检索",
|
|
43
|
+
conversation_id=conversation_id,
|
|
44
|
+
count=len(records),
|
|
45
|
+
)
|
|
46
|
+
return [r.content for r in records]
|