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.
- neobot_memory-1.0.0a6/PKG-INFO +10 -0
- neobot_memory-1.0.0a6/README.md +0 -0
- neobot_memory-1.0.0a6/pyproject.toml +18 -0
- neobot_memory-1.0.0a6/src/neobot_memory/__init__.py +25 -0
- neobot_memory-1.0.0a6/src/neobot_memory/archive_service.py +77 -0
- neobot_memory-1.0.0a6/src/neobot_memory/defaults.py +264 -0
- neobot_memory-1.0.0a6/src/neobot_memory/image_service.py +92 -0
- neobot_memory-1.0.0a6/src/neobot_memory/py.typed +0 -0
- neobot_memory-1.0.0a6/src/neobot_memory/reader.py +13 -0
- neobot_memory-1.0.0a6/src/neobot_memory/service.py +46 -0
|
@@ -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]
|