amrita_plugin_memory 0.1.0__py3-none-any.whl

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,434 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from asyncio import to_thread
5
+ from collections import defaultdict
6
+ from collections.abc import Awaitable, Callable, Sequence
7
+ from datetime import datetime
8
+ from functools import wraps
9
+ from inspect import iscoroutinefunction
10
+ from types import CoroutineType
11
+ from typing import Any, Literal, TypeVar, overload
12
+ from uuid import UUID
13
+
14
+ import aiologic
15
+ import chromadb
16
+ from amrita_core.libchat import call_embedding
17
+ from amrita_core.types import EmbeddingChunk
18
+ from amrita_core.weakcache import WeakValueLRUCache
19
+ from chromadb.api import ClientAPI
20
+ from chromadb.api.collection_configuration import (
21
+ CreateCollectionConfiguration,
22
+ )
23
+ from chromadb.api.models.Collection import Collection
24
+ from chromadb.api.types import (
25
+ CollectionMetadata,
26
+ DataLoader,
27
+ DefaultEmbeddingFunction,
28
+ Embeddable,
29
+ EmbeddingFunction,
30
+ Loadable,
31
+ Schema,
32
+ )
33
+ from chromadb.config import DEFAULT_DATABASE
34
+ from nonebot import get_driver, logger
35
+ from pydantic import BaseModel, Field
36
+ from pytz import utc
37
+
38
+ from .config import VECTOR_DB_PATH, build_preset, env_config
39
+
40
+ T = TypeVar("T")
41
+ _collection_lock_pool: defaultdict[str, WeakValueLRUCache[str, aiologic.Lock]] = (
42
+ defaultdict(lambda: WeakValueLRUCache(capacity=1024, loose_mode=True))
43
+ )
44
+
45
+
46
+ def get_lock(collection: str, uid: str) -> aiologic.Lock:
47
+ if (lock := _collection_lock_pool[collection].get(uid)) is None:
48
+ lock = aiologic.Lock()
49
+ _collection_lock_pool[collection][uid] = lock
50
+ return lock
51
+
52
+
53
+ @overload
54
+ def any_to_thread(
55
+ func: Callable[..., Awaitable[T]], /, *args, **kwargs
56
+ ) -> CoroutineType[Any, Any, T]: ...
57
+ @overload
58
+ def any_to_thread(
59
+ func: Callable[..., T], /, *args, **kwargs
60
+ ) -> CoroutineType[Any, Any, T]: ...
61
+
62
+
63
+ def any_to_thread(func: Callable[..., Awaitable[T] | T], /, *args, **kwargs):
64
+ if not callable(func):
65
+ raise TypeError("func must be callable")
66
+ if iscoroutinefunction(func):
67
+ return func(*args, **kwargs)
68
+ return to_thread(func, *args, **kwargs)
69
+
70
+
71
+ def get_db_conn() -> ClientAPI:
72
+ match env_config.vector_db_type:
73
+ case "local":
74
+ db = chromadb.PersistentClient(path=VECTOR_DB_PATH)
75
+ case "remote":
76
+ db = chromadb.HttpClient(
77
+ host=env_config.vector_db_server,
78
+ port=env_config.vector_db_port,
79
+ tenant=env_config.vector_db_tenant,
80
+ database=env_config.vector_db_database,
81
+ headers=env_config.vector_db_remote_headers,
82
+ ssl=env_config.vector_db_server_ssl,
83
+ )
84
+ case _:
85
+ raise ValueError("无效的向量数据库类型")
86
+ return db
87
+
88
+
89
+ @get_driver().on_startup
90
+ async def _setup():
91
+ logger.info("正在尝试向量数据库连接...")
92
+ logger.info(
93
+ f"当前向量数据库类型: {env_config.vector_db_type}",
94
+ )
95
+ db = get_db_conn()
96
+ logger.info("创建了向量数据库客户端!")
97
+ logger.info(f"向量数据库版本:{db.get_version()}")
98
+ logger.info(f"所有可用集合:{db.list_collections()}")
99
+ logger.info("完成。")
100
+
101
+
102
+ class WrappedClientAPI:
103
+ __client: ClientAPI
104
+ tenant: str
105
+ database: str
106
+
107
+ def __init__(self, client: ClientAPI):
108
+ self.__client = client
109
+
110
+ async def list_collections(
111
+ self,
112
+ limit: int | None = None,
113
+ offset: int | None = None,
114
+ ) -> Sequence[Collection]:
115
+ """List all collections.
116
+ Args:
117
+ limit: The maximum number of entries to return. Defaults to None.
118
+ offset: The number of entries to skip before returning. Defaults to None.
119
+
120
+ Returns:
121
+ Sequence[Collection]: A list of collections.
122
+
123
+ Examples:
124
+ ```python
125
+ await client.list_collections()
126
+ # [collection(name="my_collection", metadata={})]
127
+ ```
128
+ """
129
+ return await to_thread(self.__client.list_collections, limit, offset)
130
+
131
+ async def create_collection(
132
+ self,
133
+ name: str,
134
+ schema: Schema | None = None,
135
+ configuration: CreateCollectionConfiguration | None = None,
136
+ metadata: CollectionMetadata | None = None,
137
+ embedding_function: chromadb.EmbeddingFunction[Embeddable]
138
+ | None = DefaultEmbeddingFunction(), # type: ignore
139
+ data_loader: DataLoader[Loadable] | None = None,
140
+ get_or_create: bool = False,
141
+ ) -> Collection:
142
+ """Create a new collection with the given name and metadata.
143
+ Args:
144
+ name: The name of the collection to create.
145
+ metadata: Optional metadata to associate with the collection.
146
+ embedding_function: Optional function to use to embed documents.
147
+ Uses the default embedding function if not provided.
148
+ get_or_create: If True, return the existing collection if it exists.
149
+ data_loader: Optional function to use to load records (documents, images, etc.)
150
+
151
+ Returns:
152
+ Collection: The newly created collection.
153
+
154
+ Raises:
155
+ ValueError: If the collection already exists and get_or_create is False.
156
+ ValueError: If the collection name is invalid.
157
+
158
+ Examples:
159
+ ```python
160
+ await client.create_collection("my_collection")
161
+ # collection(name="my_collection", metadata={})
162
+
163
+ await client.create_collection("my_collection", metadata={"foo": "bar"})
164
+ # collection(name="my_collection", metadata={"foo": "bar"})
165
+ ```
166
+ """
167
+ return await to_thread(
168
+ self.__client.create_collection,
169
+ name,
170
+ schema,
171
+ configuration,
172
+ metadata,
173
+ embedding_function,
174
+ data_loader,
175
+ get_or_create,
176
+ )
177
+
178
+ async def get_collection(
179
+ self,
180
+ name: str,
181
+ embedding_function: EmbeddingFunction[Embeddable]
182
+ | None = DefaultEmbeddingFunction(), # pyright: ignore[reportArgumentType]
183
+ data_loader: DataLoader[Loadable] | None = None,
184
+ ) -> Collection:
185
+ """Get a collection with the given name.
186
+ Args:
187
+ name: The name of the collection to get
188
+ embedding_function: Optional function to use to embed documents.
189
+ Uses the default embedding function if not provided.
190
+ data_loader: Optional function to use to load records (documents, images, etc.)
191
+
192
+ Returns:
193
+ Collection: The collection
194
+
195
+ Raises:
196
+ ValueError: If the collection does not exist
197
+
198
+ Examples:
199
+ ```python
200
+ await client.get_collection("my_collection")
201
+ # collection(name="my_collection", metadata={})
202
+ ```
203
+ """
204
+ return await to_thread(
205
+ self.__client.get_collection,
206
+ name,
207
+ embedding_function,
208
+ data_loader,
209
+ )
210
+
211
+ async def get_collection_by_id(
212
+ self,
213
+ id: UUID,
214
+ embedding_function: EmbeddingFunction[Embeddable]
215
+ | None = DefaultEmbeddingFunction(), # type: ignore
216
+ data_loader: DataLoader[Loadable] | None = None,
217
+ ) -> Collection:
218
+ """Get a collection by its ID.
219
+
220
+ Args:
221
+ id: The UUID of the collection to get.
222
+ embedding_function: Optional function to use to embed documents.
223
+ Uses the default embedding function if not provided.
224
+ data_loader: Optional function to use to load records (documents, images, etc.)
225
+
226
+ Returns:
227
+ Collection: The collection
228
+
229
+ Raises:
230
+ NotFoundError: If no collection with the given ID exists.
231
+
232
+ Examples:
233
+ ```python
234
+ await client.get_collection_by_id(uuid.UUID("..."))
235
+ # collection(name="my_collection", metadata={})
236
+ ```
237
+ """
238
+ return await to_thread(
239
+ self.__client.get_collection_by_id,
240
+ id,
241
+ embedding_function,
242
+ data_loader,
243
+ )
244
+
245
+ async def get_or_create_collection(
246
+ self,
247
+ name: str,
248
+ schema: Schema | None = None,
249
+ configuration: CreateCollectionConfiguration | None = None,
250
+ metadata: CollectionMetadata | None = None,
251
+ embedding_function: EmbeddingFunction[Embeddable]
252
+ | None = DefaultEmbeddingFunction(), # type: ignore
253
+ data_loader: DataLoader[Loadable] | None = None,
254
+ ) -> Collection:
255
+ """Get or create a collection with the given name and metadata.
256
+ Args:
257
+ name: The name of the collection to get or create
258
+ metadata: Optional metadata to associate with the collection. If
259
+ the collection already exists, the metadata provided is ignored.
260
+ If the collection does not exist, the new collection will be created
261
+ with the provided metadata.
262
+ embedding_function: Optional function to use to embed documents
263
+ data_loader: Optional function to use to load records (documents, images, etc.)
264
+
265
+ Returns:
266
+ The collection
267
+
268
+ Examples:
269
+ ```python
270
+ await client.get_or_create_collection("my_collection")
271
+ # collection(name="my_collection", metadata={})
272
+ ```
273
+ """
274
+ return await to_thread(
275
+ self.__client.get_or_create_collection,
276
+ name,
277
+ schema,
278
+ configuration,
279
+ metadata,
280
+ embedding_function,
281
+ data_loader,
282
+ )
283
+
284
+ async def set_tenant(self, tenant: str, database: str = DEFAULT_DATABASE) -> None:
285
+ """Set the tenant and database for the client. Raises an error if the tenant or
286
+ database does not exist.
287
+
288
+ Args:
289
+ tenant: The tenant to set.
290
+ database: The database to set.
291
+
292
+ """
293
+ return await to_thread(self.__client.set_tenant, tenant, database)
294
+
295
+ async def set_database(self, database: str) -> None:
296
+ """Set the database for the client. Raises an error if the database does not exist.
297
+
298
+ Args:
299
+ database: The database to set.
300
+
301
+ """
302
+ return await to_thread(self.__client.set_database, database)
303
+
304
+ @staticmethod
305
+ def clear_system_cache() -> None:
306
+ TypeError("Not implemented")
307
+
308
+
309
+ class MemoryMetadata(BaseModel):
310
+ memory_id: str = Field(
311
+ description="记忆ID", default_factory=lambda: uuid.uuid4().hex
312
+ )
313
+ tags: str = Field(description="标签")
314
+ importance: Literal["low", "medium", "high"] = Field(description="重要程度")
315
+ created_at: datetime = Field(
316
+ default_factory=lambda: datetime.now(utc), description="创建时间"
317
+ )
318
+
319
+
320
+ class AsyncUserMemory:
321
+ api: WrappedClientAPI
322
+ _collection_name: str = "amrita_user_memory"
323
+ _collection: Collection
324
+
325
+ def __init__(
326
+ self,
327
+ client: ClientAPI,
328
+ collection_name: str = "amrita_user_memory",
329
+ ) -> None:
330
+ self.api = WrappedClientAPI(client)
331
+ self._collection_name = collection_name
332
+
333
+ async def init(self):
334
+ if not hasattr(self, "_collection"):
335
+ self._collection = await self.api.get_or_create_collection(
336
+ self._collection_name,
337
+ )
338
+
339
+ @staticmethod
340
+ def require_init(
341
+ func,
342
+ ):
343
+ @wraps(func)
344
+ async def wrapper(*args, **kwargs):
345
+ self: AsyncUserMemory = args[0]
346
+ await self.init()
347
+ return await func(*args, **kwargs)
348
+
349
+ return wrapper
350
+
351
+ @require_init
352
+ async def add_note(self, user_id: str, note_text: str, metadata: MemoryMetadata):
353
+ async with get_lock(self._collection_name, user_id):
354
+ vector: Sequence[EmbeddingChunk] = await call_embedding(
355
+ note_text, build_preset()
356
+ )
357
+ if len(vector) == 0:
358
+ raise RuntimeError("No embedding returned")
359
+ await any_to_thread(
360
+ self._collection.add,
361
+ ids=[metadata.memory_id],
362
+ metadatas=[
363
+ {
364
+ "user_id": user_id,
365
+ "tags": metadata.tags,
366
+ "importance": metadata.importance,
367
+ "created_at": metadata.created_at.isoformat(),
368
+ }
369
+ ],
370
+ embeddings=[vector[0].embedding],
371
+ documents=[note_text],
372
+ )
373
+
374
+ @require_init
375
+ async def query_notes(
376
+ self,
377
+ user_id: str,
378
+ query_text: str,
379
+ importance: Literal["low", "medium", "high"] | None = None,
380
+ top_k: int = 5,
381
+ include: chromadb.Include = ["metadatas", "documents"],
382
+ ) -> chromadb.QueryResult:
383
+ async with get_lock(self._collection_name, user_id):
384
+ queue_embedding = await call_embedding(query_text, build_preset())
385
+ assert len(queue_embedding) == 1, "Invalid embedding vector length"
386
+ return await any_to_thread(
387
+ self._collection.query,
388
+ queue_embedding[0].embedding,
389
+ [
390
+ query_text,
391
+ ],
392
+ include=include,
393
+ n_results=top_k,
394
+ where={
395
+ "user_id": user_id,
396
+ **({"importance": importance} if importance else {}),
397
+ },
398
+ )
399
+
400
+ @require_init
401
+ async def get_all_notes(
402
+ self, user_id: str, include: chromadb.Include = ["metadatas", "documents"]
403
+ ) -> chromadb.GetResult:
404
+ async with get_lock(self._collection_name, user_id):
405
+ return await any_to_thread(
406
+ self._collection.get,
407
+ include=include,
408
+ where={"user_id": user_id},
409
+ )
410
+
411
+ @require_init
412
+ async def delete_note(self, user_id: str, doc_id: str):
413
+ async with get_lock(self._collection_name, user_id):
414
+ await any_to_thread(
415
+ self._collection.delete,
416
+ ids=[doc_id],
417
+ where={"user_id": user_id},
418
+ )
419
+
420
+ @require_init
421
+ async def delete_user_all_notes(self, user_id: str):
422
+ async with get_lock(self._collection_name, user_id):
423
+ await any_to_thread(
424
+ self._collection.delete,
425
+ where={"user_id": user_id},
426
+ )
427
+
428
+ @require_init
429
+ async def count_user_notes(self, user_id: str) -> int:
430
+ async with get_lock(self._collection_name, user_id):
431
+ result = await any_to_thread(
432
+ self._collection.get, where={"user_id": user_id}, include=[]
433
+ )
434
+ return len(result["ids"])
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: amrita_plugin_memory
3
+ Version: 0.1.0
4
+ Summary: Amrita长期记忆插件
5
+ Requires-Python: <3.14,>=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: aiohttp>=3.13.3
9
+ Requires-Dist: aiologic>=0.16.0
10
+ Requires-Dist: amrita-core>=0.8.5
11
+ Requires-Dist: amrita[full]>=1.1.9
12
+ Requires-Dist: chromadb>=1.5.8
13
+ Requires-Dist: nonebot-plugin-localstore>=0.7.4
14
+ Dynamic: license-file
15
+
16
+ # amrita_plugin_memory
17
+
18
+ Amrita 长期记忆插件 - 一个智能记忆管理系统,用于帮助 AI 助手记住关于用户的长期信息。
19
+
20
+ ## 技术栈
21
+
22
+ - **后端**: Python 3.x 插件架构
23
+ - **向量数据库**: ChromaDB (支持本地和远程模式)
24
+ - **嵌入模型**: 支持 OpenAI Embedding API 和 Ollama Embedding API
25
+ - **框架**: NoneBot2 + Amrita Core + AmritaBot
26
+ - **配置管理**: Pydantic + TOML
27
+ - **异步处理**: asyncio + aiologic + aiohttp
28
+
29
+ ## 功能特点
30
+
31
+ - **长期记忆存储**:持久化存储关于用户的长期重要信息
32
+ - **智能标签系统**:支持使用标签对记忆进行分类管理
33
+ - **重要性分级**:支持设置记忆的重要程度(low/medium/high)
34
+ - **灵活检索**:支持按关键词检索记忆内容
35
+ - **完整管理**:支持记忆的增删改查和列表查看
36
+
37
+ ## 工具函数
38
+
39
+ 插件提供了五个主要工具函数:
40
+
41
+ ### 1. write_memory
42
+
43
+ 将重要信息存入长期记忆
44
+
45
+ 参数:
46
+
47
+ - content: 记忆内容,简洁明了(必需)
48
+ - tags: 分类标签,如 `"preference"` 或 `"project"`(必需)
49
+ - importance: 重要性等级(必需,可选值:`"low"`, `"medium"`, `"high"`)
50
+
51
+ ### 2. read_memory
52
+
53
+ 从记忆库检索用户相关信息
54
+
55
+ 参数:
56
+
57
+ - query: 关键词字符串,用空格分割(必需)
58
+ - top_k: 返回数量,默认5条(可选)
59
+ - importance: 重要性等级过滤(可选,可选值:`"low"`, `"medium"`, `"high"`)
60
+
61
+ ### 3. update_memory
62
+
63
+ 更新指定ID的记忆内容
64
+
65
+ 参数:
66
+
67
+ - id: 要更新的记忆ID(必需)
68
+ - content: 新的记忆内容(可选)
69
+ - tags: 新的标签(可选)
70
+ - importance: 新的重要性等级(可选,可选值:`"low"`, `"medium"`, `"high"`)
71
+
72
+ ### 4. delete_memory
73
+
74
+ 删除指定ID的记忆
75
+
76
+ 参数:
77
+
78
+ - id: 要删除的记忆ID(必需)
79
+
80
+ ### 5. list_memory
81
+
82
+ 列出当前用户的所有记忆
83
+
84
+ 参数:
85
+
86
+ - limit: 返回数量限制,默认5条(可选)
87
+
88
+ ## 配置选项
89
+
90
+ - `short_term_expiry_days`: 短期记忆的过期天数,默认为3天
91
+ - `long_term_expiry_days`: 长期记忆的过期天数,默认为30天
92
+ - `permanent_expiry_days`: 永久记忆的过期天数,默认为1年
93
+ - `per_session_memory_limit`: 每个会话的记忆数量限制,默认为50条
94
+
95
+ ## 安装和使用
96
+
97
+ 1. 安装插件:`amrita plugin install amrita_plugin_memory`
98
+ 2. 配置插件:插件会自动生成配置文件,或者您可以在WebUI中配置。
@@ -0,0 +1,10 @@
1
+ amrita_plugin_memory/__init__.py,sha256=80xmojHjF8p9TaBZAn8N_XEz-KgBFs4HIaNA0ecvDH0,190
2
+ amrita_plugin_memory/config.py,sha256=VsW6M9k8GCbtVKTzzCbVnfcJ29KTL6bZGbYvOo6hI30,2943
3
+ amrita_plugin_memory/embed.py,sha256=8UvAan8VP4JiMfaj03E9WNmLPvElV3z9rIIpzT8OTPE,2269
4
+ amrita_plugin_memory/tools.py,sha256=7kzW7amQkvhTEKFXJCBurPFMEkry9GOLlDqZqScretE,12468
5
+ amrita_plugin_memory/vector.py,sha256=3NMSxauNR9vPeGPFYRN6diFuAVFTUL8MVifYvBSGSB8,14664
6
+ amrita_plugin_memory-0.1.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
7
+ amrita_plugin_memory-0.1.0.dist-info/METADATA,sha256=tXp7nR9L-BfWYWuzcEIRPakExlGP6-ZxzOdxAxr7SkY,2838
8
+ amrita_plugin_memory-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ amrita_plugin_memory-0.1.0.dist-info/top_level.txt,sha256=UuTBA8aUBsuum6OZfPwaynLyDR8fRcVbCPvoBOEs63g,21
10
+ amrita_plugin_memory-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+