MemoryOS 1.0.0__py3-none-any.whl → 1.1.1__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.

Potentially problematic release.


This version of MemoryOS might be problematic. Click here for more details.

Files changed (94) hide show
  1. {memoryos-1.0.0.dist-info → memoryos-1.1.1.dist-info}/METADATA +8 -2
  2. {memoryos-1.0.0.dist-info → memoryos-1.1.1.dist-info}/RECORD +92 -69
  3. {memoryos-1.0.0.dist-info → memoryos-1.1.1.dist-info}/WHEEL +1 -1
  4. memos/__init__.py +1 -1
  5. memos/api/client.py +109 -0
  6. memos/api/config.py +35 -8
  7. memos/api/context/dependencies.py +15 -66
  8. memos/api/middleware/request_context.py +63 -0
  9. memos/api/product_api.py +5 -2
  10. memos/api/product_models.py +107 -16
  11. memos/api/routers/product_router.py +62 -19
  12. memos/api/start_api.py +13 -0
  13. memos/configs/graph_db.py +4 -0
  14. memos/configs/mem_scheduler.py +38 -3
  15. memos/configs/memory.py +13 -0
  16. memos/configs/reranker.py +18 -0
  17. memos/context/context.py +255 -0
  18. memos/embedders/factory.py +2 -0
  19. memos/graph_dbs/base.py +4 -2
  20. memos/graph_dbs/nebular.py +368 -223
  21. memos/graph_dbs/neo4j.py +49 -13
  22. memos/graph_dbs/neo4j_community.py +13 -3
  23. memos/llms/factory.py +2 -0
  24. memos/llms/openai.py +74 -2
  25. memos/llms/vllm.py +2 -0
  26. memos/log.py +128 -4
  27. memos/mem_cube/general.py +3 -1
  28. memos/mem_os/core.py +89 -23
  29. memos/mem_os/main.py +3 -6
  30. memos/mem_os/product.py +418 -154
  31. memos/mem_os/utils/reference_utils.py +20 -0
  32. memos/mem_reader/factory.py +2 -0
  33. memos/mem_reader/simple_struct.py +204 -82
  34. memos/mem_scheduler/analyzer/__init__.py +0 -0
  35. memos/mem_scheduler/analyzer/mos_for_test_scheduler.py +569 -0
  36. memos/mem_scheduler/analyzer/scheduler_for_eval.py +280 -0
  37. memos/mem_scheduler/base_scheduler.py +126 -56
  38. memos/mem_scheduler/general_modules/dispatcher.py +2 -2
  39. memos/mem_scheduler/general_modules/misc.py +99 -1
  40. memos/mem_scheduler/general_modules/scheduler_logger.py +17 -11
  41. memos/mem_scheduler/general_scheduler.py +40 -88
  42. memos/mem_scheduler/memory_manage_modules/__init__.py +5 -0
  43. memos/mem_scheduler/memory_manage_modules/memory_filter.py +308 -0
  44. memos/mem_scheduler/{general_modules → memory_manage_modules}/retriever.py +34 -7
  45. memos/mem_scheduler/monitors/dispatcher_monitor.py +9 -8
  46. memos/mem_scheduler/monitors/general_monitor.py +119 -39
  47. memos/mem_scheduler/optimized_scheduler.py +124 -0
  48. memos/mem_scheduler/orm_modules/__init__.py +0 -0
  49. memos/mem_scheduler/orm_modules/base_model.py +635 -0
  50. memos/mem_scheduler/orm_modules/monitor_models.py +261 -0
  51. memos/mem_scheduler/scheduler_factory.py +2 -0
  52. memos/mem_scheduler/schemas/monitor_schemas.py +96 -29
  53. memos/mem_scheduler/utils/config_utils.py +100 -0
  54. memos/mem_scheduler/utils/db_utils.py +33 -0
  55. memos/mem_scheduler/utils/filter_utils.py +1 -1
  56. memos/mem_scheduler/webservice_modules/__init__.py +0 -0
  57. memos/mem_user/mysql_user_manager.py +4 -2
  58. memos/memories/activation/kv.py +2 -1
  59. memos/memories/textual/item.py +96 -17
  60. memos/memories/textual/naive.py +1 -1
  61. memos/memories/textual/tree.py +57 -3
  62. memos/memories/textual/tree_text_memory/organize/handler.py +4 -2
  63. memos/memories/textual/tree_text_memory/organize/manager.py +28 -14
  64. memos/memories/textual/tree_text_memory/organize/relation_reason_detector.py +1 -2
  65. memos/memories/textual/tree_text_memory/organize/reorganizer.py +75 -23
  66. memos/memories/textual/tree_text_memory/retrieve/bochasearch.py +10 -6
  67. memos/memories/textual/tree_text_memory/retrieve/internet_retriever.py +6 -2
  68. memos/memories/textual/tree_text_memory/retrieve/internet_retriever_factory.py +2 -0
  69. memos/memories/textual/tree_text_memory/retrieve/recall.py +119 -21
  70. memos/memories/textual/tree_text_memory/retrieve/searcher.py +172 -44
  71. memos/memories/textual/tree_text_memory/retrieve/utils.py +6 -4
  72. memos/memories/textual/tree_text_memory/retrieve/xinyusearch.py +5 -4
  73. memos/memos_tools/notification_utils.py +46 -0
  74. memos/memos_tools/singleton.py +174 -0
  75. memos/memos_tools/thread_safe_dict.py +22 -0
  76. memos/memos_tools/thread_safe_dict_segment.py +382 -0
  77. memos/parsers/factory.py +2 -0
  78. memos/reranker/__init__.py +4 -0
  79. memos/reranker/base.py +24 -0
  80. memos/reranker/concat.py +59 -0
  81. memos/reranker/cosine_local.py +96 -0
  82. memos/reranker/factory.py +48 -0
  83. memos/reranker/http_bge.py +312 -0
  84. memos/reranker/noop.py +16 -0
  85. memos/templates/mem_reader_prompts.py +289 -40
  86. memos/templates/mem_scheduler_prompts.py +242 -0
  87. memos/templates/mos_prompts.py +133 -60
  88. memos/types.py +4 -1
  89. memos/api/context/context.py +0 -147
  90. memos/mem_scheduler/mos_for_test_scheduler.py +0 -146
  91. {memoryos-1.0.0.dist-info → memoryos-1.1.1.dist-info}/entry_points.txt +0 -0
  92. {memoryos-1.0.0.dist-info → memoryos-1.1.1.dist-info/licenses}/LICENSE +0 -0
  93. /memos/mem_scheduler/{general_modules → webservice_modules}/rabbitmq_service.py +0 -0
  94. /memos/mem_scheduler/{general_modules → webservice_modules}/redis_service.py +0 -0
@@ -1,19 +1,20 @@
1
- import concurrent.futures
2
1
  import json
2
+ import traceback
3
3
 
4
4
  from datetime import datetime
5
5
 
6
+ from memos.context.context import ContextThreadPoolExecutor
6
7
  from memos.embedders.factory import OllamaEmbedder
7
8
  from memos.graph_dbs.factory import Neo4jGraphDB
8
9
  from memos.llms.factory import AzureLLM, OllamaLLM, OpenAILLM
9
10
  from memos.log import get_logger
10
11
  from memos.memories.textual.item import SearchedTreeNodeTextualMemoryMetadata, TextualMemoryItem
12
+ from memos.reranker.base import BaseReranker
11
13
  from memos.utils import timed
12
14
 
13
15
  from .internet_retriever_factory import InternetRetrieverFactory
14
16
  from .reasoner import MemoryReasoner
15
17
  from .recall import GraphMemoryRetriever
16
- from .reranker import MemoryReranker
17
18
  from .task_goal_parser import TaskGoalParser
18
19
 
19
20
 
@@ -26,22 +27,33 @@ class Searcher:
26
27
  dispatcher_llm: OpenAILLM | OllamaLLM | AzureLLM,
27
28
  graph_store: Neo4jGraphDB,
28
29
  embedder: OllamaEmbedder,
30
+ reranker: BaseReranker,
29
31
  internet_retriever: InternetRetrieverFactory | None = None,
32
+ moscube: bool = False,
30
33
  ):
31
34
  self.graph_store = graph_store
32
35
  self.embedder = embedder
33
36
 
34
37
  self.task_goal_parser = TaskGoalParser(dispatcher_llm)
35
38
  self.graph_retriever = GraphMemoryRetriever(self.graph_store, self.embedder)
36
- self.reranker = MemoryReranker(dispatcher_llm, self.embedder)
39
+ self.reranker = reranker
37
40
  self.reasoner = MemoryReasoner(dispatcher_llm)
38
41
 
39
42
  # Create internet retriever from config if provided
40
43
  self.internet_retriever = internet_retriever
44
+ self.moscube = moscube
45
+
46
+ self._usage_executor = ContextThreadPoolExecutor(max_workers=4, thread_name_prefix="usage")
41
47
 
42
48
  @timed
43
49
  def search(
44
- self, query: str, top_k: int, info=None, mode="fast", memory_type="All"
50
+ self,
51
+ query: str,
52
+ top_k: int,
53
+ info=None,
54
+ mode="fast",
55
+ memory_type="All",
56
+ search_filter: dict | None = None,
45
57
  ) -> list[TextualMemoryItem]:
46
58
  """
47
59
  Search for memories based on a query.
@@ -56,6 +68,7 @@ class Searcher:
56
68
  - 'fine': Uses a more detailed search process, invoking large models for higher precision, but slower performance.
57
69
  memory_type (str): Type restriction for search.
58
70
  ['All', 'WorkingMemory', 'LongTermMemory', 'UserMemory']
71
+ search_filter (dict, optional): Optional metadata filters for search results.
59
72
  Returns:
60
73
  list[TextualMemoryItem]: List of matching memories.
61
74
  """
@@ -71,19 +84,27 @@ class Searcher:
71
84
  else:
72
85
  logger.debug(f"[SEARCH] Received info dict: {info}")
73
86
 
74
- parsed_goal, query_embedding, context, query = self._parse_task(query, info, mode)
87
+ parsed_goal, query_embedding, context, query = self._parse_task(
88
+ query, info, mode, search_filter=search_filter
89
+ )
75
90
  results = self._retrieve_paths(
76
- query, parsed_goal, query_embedding, info, top_k, mode, memory_type
91
+ query, parsed_goal, query_embedding, info, top_k, mode, memory_type, search_filter
77
92
  )
78
93
  deduped = self._deduplicate_results(results)
79
94
  final_results = self._sort_and_trim(deduped, top_k)
80
95
  self._update_usage_history(final_results, info)
81
96
 
82
97
  logger.info(f"[SEARCH] Done. Total {len(final_results)} results.")
98
+ res_results = ""
99
+ for _num_i, result in enumerate(final_results):
100
+ res_results += "\n" + (
101
+ result.id + "|" + result.metadata.memory_type + "|" + result.memory
102
+ )
103
+ logger.info(f"[SEARCH] Results. {res_results}")
83
104
  return final_results
84
105
 
85
106
  @timed
86
- def _parse_task(self, query, info, mode, top_k=5):
107
+ def _parse_task(self, query, info, mode, top_k=5, search_filter: dict | None = None):
87
108
  """Parse user query, do embedding search and create context"""
88
109
  context = []
89
110
  query_embedding = None
@@ -96,14 +117,30 @@ class Searcher:
96
117
  # retrieve related nodes by embedding
97
118
  related_nodes = [
98
119
  self.graph_store.get_node(n["id"])
99
- for n in self.graph_store.search_by_embedding(query_embedding, top_k=top_k)
120
+ for n in self.graph_store.search_by_embedding(
121
+ query_embedding, top_k=top_k, search_filter=search_filter
122
+ )
100
123
  ]
101
- context = list({node["memory"] for node in related_nodes})
124
+ memories = []
125
+ for node in related_nodes:
126
+ try:
127
+ m = (
128
+ node.get("memory")
129
+ if isinstance(node, dict)
130
+ else (getattr(node, "memory", None))
131
+ )
132
+ if isinstance(m, str) and m:
133
+ memories.append(m)
134
+ except Exception:
135
+ logger.error(f"[SEARCH] Error during search: {traceback.format_exc()}")
136
+ continue
137
+ context = list(dict.fromkeys(memories))
102
138
 
103
139
  # optional: supplement context with internet knowledge
104
- if self.internet_retriever:
140
+ """if self.internet_retriever:
105
141
  extra = self.internet_retriever.retrieve_from_internet(query=query, top_k=3)
106
142
  context.extend(item.memory.partition("\nContent: ")[-1] for item in extra)
143
+ """
107
144
 
108
145
  # parse goal using LLM
109
146
  parsed_goal = self.task_goal_parser.parse(
@@ -121,10 +158,20 @@ class Searcher:
121
158
  return parsed_goal, query_embedding, context, query
122
159
 
123
160
  @timed
124
- def _retrieve_paths(self, query, parsed_goal, query_embedding, info, top_k, mode, memory_type):
161
+ def _retrieve_paths(
162
+ self,
163
+ query,
164
+ parsed_goal,
165
+ query_embedding,
166
+ info,
167
+ top_k,
168
+ mode,
169
+ memory_type,
170
+ search_filter: dict | None = None,
171
+ ):
125
172
  """Run A/B/C retrieval paths in parallel"""
126
173
  tasks = []
127
- with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
174
+ with ContextThreadPoolExecutor(max_workers=3) as executor:
128
175
  tasks.append(
129
176
  executor.submit(
130
177
  self._retrieve_from_working_memory,
@@ -133,6 +180,7 @@ class Searcher:
133
180
  query_embedding,
134
181
  top_k,
135
182
  memory_type,
183
+ search_filter,
136
184
  )
137
185
  )
138
186
  tasks.append(
@@ -143,6 +191,7 @@ class Searcher:
143
191
  query_embedding,
144
192
  top_k,
145
193
  memory_type,
194
+ search_filter,
146
195
  )
147
196
  )
148
197
  tasks.append(
@@ -157,6 +206,17 @@ class Searcher:
157
206
  memory_type,
158
207
  )
159
208
  )
209
+ if self.moscube:
210
+ tasks.append(
211
+ executor.submit(
212
+ self._retrieve_from_memcubes,
213
+ query,
214
+ parsed_goal,
215
+ query_embedding,
216
+ top_k,
217
+ "memos_cube01",
218
+ )
219
+ )
160
220
 
161
221
  results = []
162
222
  for t in tasks:
@@ -168,14 +228,24 @@ class Searcher:
168
228
  # --- Path A
169
229
  @timed
170
230
  def _retrieve_from_working_memory(
171
- self, query, parsed_goal, query_embedding, top_k, memory_type
231
+ self,
232
+ query,
233
+ parsed_goal,
234
+ query_embedding,
235
+ top_k,
236
+ memory_type,
237
+ search_filter: dict | None = None,
172
238
  ):
173
239
  """Retrieve and rerank from WorkingMemory"""
174
240
  if memory_type not in ["All", "WorkingMemory"]:
175
241
  logger.info(f"[PATH-A] '{query}'Skipped (memory_type does not match)")
176
242
  return []
177
243
  items = self.graph_retriever.retrieve(
178
- query=query, parsed_goal=parsed_goal, top_k=top_k, memory_scope="WorkingMemory"
244
+ query=query,
245
+ parsed_goal=parsed_goal,
246
+ top_k=top_k,
247
+ memory_scope="WorkingMemory",
248
+ search_filter=search_filter,
179
249
  )
180
250
  return self.reranker.rerank(
181
251
  query=query,
@@ -183,36 +253,79 @@ class Searcher:
183
253
  graph_results=items,
184
254
  top_k=top_k,
185
255
  parsed_goal=parsed_goal,
256
+ search_filter=search_filter,
186
257
  )
187
258
 
188
259
  # --- Path B
189
260
  @timed
190
261
  def _retrieve_from_long_term_and_user(
191
- self, query, parsed_goal, query_embedding, top_k, memory_type
262
+ self,
263
+ query,
264
+ parsed_goal,
265
+ query_embedding,
266
+ top_k,
267
+ memory_type,
268
+ search_filter: dict | None = None,
192
269
  ):
193
270
  """Retrieve and rerank from LongTermMemory and UserMemory"""
194
271
  results = []
195
- if memory_type in ["All", "LongTermMemory"]:
196
- results += self.graph_retriever.retrieve(
197
- query=query,
198
- parsed_goal=parsed_goal,
199
- query_embedding=query_embedding,
200
- top_k=top_k * 2,
201
- memory_scope="LongTermMemory",
202
- )
203
- if memory_type in ["All", "UserMemory"]:
204
- results += self.graph_retriever.retrieve(
205
- query=query,
206
- parsed_goal=parsed_goal,
207
- query_embedding=query_embedding,
208
- top_k=top_k * 2,
209
- memory_scope="UserMemory",
210
- )
272
+ tasks = []
273
+
274
+ with ContextThreadPoolExecutor(max_workers=2) as executor:
275
+ if memory_type in ["All", "LongTermMemory"]:
276
+ tasks.append(
277
+ executor.submit(
278
+ self.graph_retriever.retrieve,
279
+ query=query,
280
+ parsed_goal=parsed_goal,
281
+ query_embedding=query_embedding,
282
+ top_k=top_k * 2,
283
+ memory_scope="LongTermMemory",
284
+ search_filter=search_filter,
285
+ )
286
+ )
287
+ if memory_type in ["All", "UserMemory"]:
288
+ tasks.append(
289
+ executor.submit(
290
+ self.graph_retriever.retrieve,
291
+ query=query,
292
+ parsed_goal=parsed_goal,
293
+ query_embedding=query_embedding,
294
+ top_k=top_k * 2,
295
+ memory_scope="UserMemory",
296
+ search_filter=search_filter,
297
+ )
298
+ )
299
+
300
+ # Collect results from all tasks
301
+ for task in tasks:
302
+ results.extend(task.result())
303
+
211
304
  return self.reranker.rerank(
212
305
  query=query,
213
306
  query_embedding=query_embedding[0],
214
307
  graph_results=results,
308
+ top_k=top_k,
309
+ parsed_goal=parsed_goal,
310
+ search_filter=search_filter,
311
+ )
312
+
313
+ @timed
314
+ def _retrieve_from_memcubes(
315
+ self, query, parsed_goal, query_embedding, top_k, cube_name="memos_cube01"
316
+ ):
317
+ """Retrieve and rerank from LongTermMemory and UserMemory"""
318
+ results = self.graph_retriever.retrieve_from_cube(
319
+ query_embedding=query_embedding,
215
320
  top_k=top_k * 2,
321
+ memory_scope="LongTermMemory",
322
+ cube_name=cube_name,
323
+ )
324
+ return self.reranker.rerank(
325
+ query=query,
326
+ query_embedding=query_embedding[0],
327
+ graph_results=results,
328
+ top_k=top_k,
216
329
  parsed_goal=parsed_goal,
217
330
  )
218
331
 
@@ -256,8 +369,7 @@ class Searcher:
256
369
  final_items = []
257
370
  for item, score in sorted_results:
258
371
  meta_data = item.metadata.model_dump()
259
- if "relativity" not in meta_data:
260
- meta_data["relativity"] = score
372
+ meta_data["relativity"] = score
261
373
  final_items.append(
262
374
  TextualMemoryItem(
263
375
  id=item.id,
@@ -271,14 +383,30 @@ class Searcher:
271
383
  def _update_usage_history(self, items, info):
272
384
  """Update usage history in graph DB"""
273
385
  now_time = datetime.now().isoformat()
274
- info.pop("chat_history", None)
275
- # `info` should be a serializable dict or string
276
- usage_record = json.dumps({"time": now_time, "info": info})
277
- for item in items:
278
- if (
279
- hasattr(item, "id")
280
- and hasattr(item, "metadata")
281
- and hasattr(item.metadata, "usage")
282
- ):
283
- item.metadata.usage.append(usage_record)
284
- self.graph_store.update_node(item.id, {"usage": item.metadata.usage})
386
+ info_copy = dict(info or {})
387
+ info_copy.pop("chat_history", None)
388
+ usage_record = json.dumps({"time": now_time, "info": info_copy})
389
+ payload = []
390
+ for it in items:
391
+ try:
392
+ item_id = getattr(it, "id", None)
393
+ md = getattr(it, "metadata", None)
394
+ if md is None:
395
+ continue
396
+ if not hasattr(md, "usage") or md.usage is None:
397
+ md.usage = []
398
+ md.usage.append(usage_record)
399
+ if item_id:
400
+ payload.append((item_id, list(md.usage)))
401
+ except Exception:
402
+ logger.exception("[USAGE] snapshot item failed")
403
+
404
+ if payload:
405
+ self._usage_executor.submit(self._update_usage_history_worker, payload, usage_record)
406
+
407
+ def _update_usage_history_worker(self, payload, usage_record: str):
408
+ try:
409
+ for item_id, usage_list in payload:
410
+ self.graph_store.update_node(item_id, {"usage": usage_list})
411
+ except Exception:
412
+ logger.exception("[USAGE] update usage failed")
@@ -8,18 +8,20 @@ You are a task parsing expert. Given a user task instruction, optional former co
8
8
  5. Need for internet search: If the user's task instruction only involves objective facts or can be completed without introducing external knowledge, set "internet_search" to False. Otherwise, set it to True.
9
9
  6. Memories: Provide 2–5 short semantic expansions or rephrasings of the rephrased/original user task instruction. These are used for improved embedding search coverage. Each should be clear, concise, and meaningful for retrieval.
10
10
 
11
- Task description:
12
- \"\"\"$task\"\"\"
13
-
14
11
  Former conversation (if any):
15
12
  \"\"\"
16
13
  $conversation
17
14
  \"\"\"
18
15
 
16
+ Task description(User Question):
17
+ \"\"\"$task\"\"\"
18
+
19
19
  Context (if any):
20
20
  \"\"\"$context\"\"\"
21
21
 
22
- Return strictly in this JSON format:
22
+ Return strictly in this JSON format, note that the
23
+ keys/tags/rephrased_instruction/memories should use the same language as the
24
+ input query:
23
25
  {
24
26
  "keys": [...],
25
27
  "tags": [...],
@@ -3,15 +3,16 @@
3
3
  import json
4
4
  import uuid
5
5
 
6
- from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from concurrent.futures import as_completed
7
7
  from datetime import datetime
8
8
 
9
9
  import requests
10
10
 
11
+ from memos.context.context import ContextThreadPoolExecutor
11
12
  from memos.embedders.factory import OllamaEmbedder
12
13
  from memos.log import get_logger
13
14
  from memos.mem_reader.base import BaseMemReader
14
- from memos.memories.textual.item import TextualMemoryItem
15
+ from memos.memories.textual.item import SourceMessage, TextualMemoryItem
15
16
 
16
17
 
17
18
  logger = get_logger(__name__)
@@ -150,7 +151,7 @@ class XinyuSearchRetriever:
150
151
  # Convert to TextualMemoryItem format
151
152
  memory_items: list[TextualMemoryItem] = []
152
153
 
153
- with ThreadPoolExecutor(max_workers=8) as executor:
154
+ with ContextThreadPoolExecutor(max_workers=8) as executor:
154
155
  futures = [
155
156
  executor.submit(self._process_result, result, query, parsed_goal, info)
156
157
  for result in search_results
@@ -332,7 +333,7 @@ class XinyuSearchRetriever:
332
333
  )
333
334
  read_item_i.metadata.source = "web"
334
335
  read_item_i.metadata.memory_type = "OuterMemory"
335
- read_item_i.metadata.sources = [url] if url else []
336
+ read_item_i.metadata.sources = [SourceMessage(type="web", url=url)] if url else []
336
337
  read_item_i.metadata.visibility = "public"
337
338
 
338
339
  memory_items.append(read_item_i)
@@ -2,6 +2,7 @@
2
2
  Notification utilities for MemOS product.
3
3
  """
4
4
 
5
+ import asyncio
5
6
  import logging
6
7
 
7
8
  from collections.abc import Callable
@@ -51,6 +52,51 @@ def send_online_bot_notification(
51
52
  logger.warning(f"Failed to send online bot notification: {e}")
52
53
 
53
54
 
55
+ async def send_online_bot_notification_async(
56
+ online_bot: Callable | None,
57
+ header_name: str,
58
+ sub_title_name: str,
59
+ title_color: str,
60
+ other_data1: dict[str, Any],
61
+ other_data2: dict[str, Any],
62
+ emoji: dict[str, str],
63
+ ) -> None:
64
+ """
65
+ Send notification via online_bot asynchronously if available.
66
+
67
+ Args:
68
+ online_bot: The online_bot function or None
69
+ header_name: Header name for the report
70
+ sub_title_name: Subtitle for the report
71
+ title_color: Title color
72
+ other_data1: First data dict
73
+ other_data2: Second data dict
74
+ emoji: Emoji configuration dict
75
+ """
76
+ if online_bot is None:
77
+ return
78
+
79
+ try:
80
+ # Run the potentially blocking notification in a thread pool
81
+ loop = asyncio.get_event_loop()
82
+ await loop.run_in_executor(
83
+ None,
84
+ lambda: online_bot(
85
+ header_name=header_name,
86
+ sub_title_name=sub_title_name,
87
+ title_color=title_color,
88
+ other_data1=other_data1,
89
+ other_data2=other_data2,
90
+ emoji=emoji,
91
+ ),
92
+ )
93
+
94
+ logger.info(f"Online bot notification sent successfully (async): {header_name}")
95
+
96
+ except Exception as e:
97
+ logger.warning(f"Failed to send online bot notification (async): {e}")
98
+
99
+
54
100
  def send_error_bot_notification(
55
101
  error_bot: Callable | None,
56
102
  err: str,
@@ -0,0 +1,174 @@
1
+ """
2
+ Singleton decorator module for caching factory instances to avoid excessive memory usage
3
+ from repeated initialization.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+
9
+ from collections.abc import Callable
10
+ from functools import wraps
11
+ from typing import Any, TypeVar
12
+ from weakref import WeakValueDictionary
13
+
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ class FactorySingleton:
19
+ """Factory singleton manager that caches instances based on configuration parameters"""
20
+
21
+ def __init__(self):
22
+ # Use weak reference dictionary for automatic cleanup when instances are no longer referenced
23
+ self._instances: dict[str, WeakValueDictionary] = {}
24
+
25
+ def _generate_cache_key(self, config: Any, *args, **kwargs) -> str:
26
+ """Generate cache key based on configuration only (ignoring other parameters)"""
27
+
28
+ # Handle configuration objects - only use the config parameter
29
+ if hasattr(config, "model_dump"): # Pydantic model
30
+ config_data = config.model_dump()
31
+ elif hasattr(config, "dict"): # Legacy Pydantic model
32
+ config_data = config.dict()
33
+ elif isinstance(config, dict):
34
+ config_data = config
35
+ else:
36
+ # For other types, try to convert to string
37
+ config_data = str(config)
38
+
39
+ # Filter out time-related fields that shouldn't affect caching
40
+ filtered_config = self._filter_temporal_fields(config_data)
41
+
42
+ # Generate hash key based only on config
43
+ try:
44
+ cache_str = json.dumps(filtered_config, sort_keys=True, ensure_ascii=False, default=str)
45
+ except (TypeError, ValueError):
46
+ # If JSON serialization fails, convert the entire config to string
47
+ cache_str = str(filtered_config)
48
+
49
+ return hashlib.md5(cache_str.encode("utf-8")).hexdigest()
50
+
51
+ def _filter_temporal_fields(self, config_data: Any) -> Any:
52
+ """Filter out temporal fields that shouldn't affect instance caching"""
53
+ if isinstance(config_data, dict):
54
+ filtered = {}
55
+ for key, value in config_data.items():
56
+ # Skip common temporal field names
57
+ if key.lower() in {
58
+ "created_at",
59
+ "updated_at",
60
+ "timestamp",
61
+ "time",
62
+ "date",
63
+ "created_time",
64
+ "updated_time",
65
+ "last_modified",
66
+ "modified_at",
67
+ "start_time",
68
+ "end_time",
69
+ "execution_time",
70
+ "run_time",
71
+ }:
72
+ continue
73
+ # Recursively filter nested dictionaries
74
+ filtered[key] = self._filter_temporal_fields(value)
75
+ return filtered
76
+ elif isinstance(config_data, list):
77
+ # Recursively filter lists
78
+ return [self._filter_temporal_fields(item) for item in config_data]
79
+ else:
80
+ # For primitive types, return as-is
81
+ return config_data
82
+
83
+ def get_or_create(self, factory_class: type, cache_key: str, creator_func: Callable) -> Any:
84
+ """Get or create instance"""
85
+ class_name = factory_class.__name__
86
+
87
+ if class_name not in self._instances:
88
+ self._instances[class_name] = WeakValueDictionary()
89
+
90
+ class_cache = self._instances[class_name]
91
+
92
+ if cache_key in class_cache:
93
+ return class_cache[cache_key]
94
+
95
+ # Create new instance
96
+ instance = creator_func()
97
+ class_cache[cache_key] = instance
98
+ return instance
99
+
100
+ def clear_cache(self, factory_class: type | None = None):
101
+ """Clear cache"""
102
+ if factory_class:
103
+ class_name = factory_class.__name__
104
+ if class_name in self._instances:
105
+ self._instances[class_name].clear()
106
+ else:
107
+ for cache in self._instances.values():
108
+ cache.clear()
109
+
110
+
111
+ # Global singleton manager
112
+ _factory_singleton = FactorySingleton()
113
+
114
+
115
+ def singleton_factory(factory_class: type | str | None = None):
116
+ """
117
+ Factory singleton decorator
118
+
119
+ Usage:
120
+ @singleton_factory()
121
+ def from_config(cls, config):
122
+ return SomeClass(config)
123
+
124
+ Or specify factory class:
125
+ @singleton_factory(EmbedderFactory)
126
+ def from_config(cls, config):
127
+ return SomeClass(config)
128
+ """
129
+
130
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
131
+ @wraps(func)
132
+ def wrapper(*args, **kwargs) -> T:
133
+ # Determine factory class and config parameter
134
+ target_factory_class = factory_class
135
+ config = None
136
+
137
+ # Simple logic: check if first parameter is a class or config
138
+ if args:
139
+ if hasattr(args[0], "__name__") and hasattr(args[0], "__module__"):
140
+ # First parameter is a class (cls), so this is a @classmethod
141
+ if target_factory_class is None:
142
+ target_factory_class = args[0]
143
+ config = args[1] if len(args) > 1 else None
144
+ else:
145
+ # First parameter is config, so this is a @staticmethod
146
+ if target_factory_class is None:
147
+ raise ValueError(
148
+ "Factory class must be explicitly specified for static methods"
149
+ )
150
+ if isinstance(target_factory_class, str):
151
+ # Convert string to a mock class for caching purposes
152
+ class MockFactoryClass:
153
+ __name__ = target_factory_class
154
+
155
+ target_factory_class = MockFactoryClass
156
+ config = args[0]
157
+
158
+ if config is None:
159
+ # If no configuration parameter, call original function directly
160
+ return func(*args, **kwargs)
161
+
162
+ # Generate cache key based only on config
163
+ cache_key = _factory_singleton._generate_cache_key(config)
164
+
165
+ # Function to create instance
166
+ def creator():
167
+ return func(*args, **kwargs)
168
+
169
+ # Get or create instance
170
+ return _factory_singleton.get_or_create(target_factory_class, cache_key, creator)
171
+
172
+ return wrapper
173
+
174
+ return decorator