neobot-memory 1.0.0a6__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.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: neobot-memory
3
+ Version: 1.0.0a6
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,18 @@
1
+ [project]
2
+ name = "neobot-memory"
3
+ version = "1.0.0-alpha.6"
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,77 @@
1
+ """Archive memory service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from neobot_contracts.models.memory import ArchiveMemory
8
+ from neobot_contracts.ports.logging import Logger
9
+ from neobot_contracts.ports.unit_of_work import UnitOfWorkFactory
10
+
11
+
12
+ class ArchiveMemoryService:
13
+ """Service for archive memory CRUD and query operations."""
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, table_name: str, key: str) -> Optional[ArchiveMemory]:
20
+ async with self._uow_factory() as uow:
21
+ item = await uow.archive.get(table_name, key)
22
+ self._logger.debug("存档记忆已获取", table_name=table_name, key=key, found=item is not None)
23
+ return item
24
+
25
+ async def exists(self, table_name: str, key: str) -> bool:
26
+ async with self._uow_factory() as uow:
27
+ exists = await uow.archive.exists(table_name, key)
28
+ self._logger.debug("存档记忆存在检查", table_name=table_name, key=key, exists=exists)
29
+ return exists
30
+
31
+ async def list(
32
+ self,
33
+ table_name: str,
34
+ *,
35
+ tags: Optional[list[str]] = None,
36
+ key_query: Optional[str] = None,
37
+ value_query: Optional[str] = None,
38
+ limit: int = 50,
39
+ offset: int = 0,
40
+ ) -> list[ArchiveMemory]:
41
+ async with self._uow_factory() as uow:
42
+ items = await uow.archive.list(
43
+ table_name,
44
+ tags=tags,
45
+ key_query=key_query,
46
+ value_query=value_query,
47
+ limit=limit,
48
+ offset=offset,
49
+ )
50
+ self._logger.debug(
51
+ "存档记忆列表已获取",
52
+ table_name=table_name,
53
+ count=len(items),
54
+ limit=limit,
55
+ offset=offset,
56
+ )
57
+ return items
58
+
59
+ async def set(self, table_name: str, key: str, value: str, tags: list[str]) -> ArchiveMemory:
60
+ async with self._uow_factory() as uow:
61
+ item = await uow.archive.set(table_name, key, value, tags)
62
+ await uow.commit()
63
+ self._logger.debug(
64
+ "存档记忆已保存",
65
+ table_name=table_name,
66
+ key=key,
67
+ version=item.version,
68
+ )
69
+ return item
70
+
71
+ async def delete(self, table_name: str, key: str) -> bool:
72
+ async with self._uow_factory() as uow:
73
+ deleted = await uow.archive.delete(table_name, key)
74
+ if deleted:
75
+ await uow.commit()
76
+ self._logger.debug("存档记忆已删除", table_name=table_name, key=key, deleted=deleted)
77
+ return deleted
@@ -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
+ """Image analysis cache service."""
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
+ """Service for cached image analysis CRUD and lookup operations."""
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
+ "memory recalled",
43
+ conversation_id=conversation_id,
44
+ count=len(records),
45
+ )
46
+ return [r.content for r in records]