flock-core 0.4.0b50__py3-none-any.whl → 0.4.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 flock-core might be problematic. Click here for more details.

Files changed (34) hide show
  1. flock/adapter/__init__.py +14 -0
  2. flock/adapter/azure_adapter.py +68 -0
  3. flock/adapter/chroma_adapter.py +73 -0
  4. flock/adapter/faiss_adapter.py +97 -0
  5. flock/adapter/pinecone_adapter.py +51 -0
  6. flock/adapter/vector_base.py +47 -0
  7. flock/config.py +1 -1
  8. flock/core/context/context.py +20 -0
  9. flock/core/flock.py +71 -91
  10. flock/core/flock_agent.py +58 -3
  11. flock/core/flock_module.py +5 -0
  12. flock/di.py +41 -0
  13. flock/modules/enterprise_memory/README.md +99 -0
  14. flock/modules/enterprise_memory/enterprise_memory_module.py +526 -0
  15. flock/modules/mem0/mem0_module.py +79 -16
  16. flock/modules/mem0_async/async_mem0_module.py +126 -0
  17. flock/modules/memory/memory_module.py +28 -8
  18. flock/modules/performance/metrics_module.py +24 -1
  19. flock/modules/zep/__init__.py +1 -0
  20. flock/modules/zep/zep_module.py +192 -0
  21. flock/webapp/app/api/execution.py +79 -2
  22. flock/webapp/app/chat.py +83 -3
  23. flock/webapp/app/services/sharing_models.py +38 -0
  24. flock/webapp/app/services/sharing_store.py +60 -1
  25. flock/webapp/static/css/chat.css +2 -0
  26. flock/webapp/templates/partials/_chat_messages.html +50 -4
  27. flock/webapp/templates/partials/_results_display.html +39 -0
  28. {flock_core-0.4.0b50.dist-info → flock_core-0.4.1.dist-info}/METADATA +6 -4
  29. {flock_core-0.4.0b50.dist-info → flock_core-0.4.1.dist-info}/RECORD +33 -22
  30. flock/modules/mem0graph/mem0_graph_module.py +0 -63
  31. /flock/modules/{mem0graph → mem0_async}/__init__.py +0 -0
  32. {flock_core-0.4.0b50.dist-info → flock_core-0.4.1.dist-info}/WHEEL +0 -0
  33. {flock_core-0.4.0b50.dist-info → flock_core-0.4.1.dist-info}/entry_points.txt +0 -0
  34. {flock_core-0.4.0b50.dist-info → flock_core-0.4.1.dist-info}/licenses/LICENSE +0 -0
flock/di.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ """Flock – Dependency-Injection helpers.
4
+
5
+ This module provides a very small façade over the `wd.di` container so that
6
+ other parts of the codebase do not need to know where the active container is
7
+ stored. The bootstrap code – usually located in the runner initialisation –
8
+ should store the `ServiceProvider` instance (returned by ``ServiceCollection.
9
+ build()``) on the `FlockContext` under the key ``di.container``.
10
+
11
+ Example
12
+ -------
13
+ >>> from wd.di import ServiceCollection
14
+ >>> sc = ServiceCollection()
15
+ >>> sc.add_singleton(str, lambda _: "hello")
16
+ >>> container = sc.build()
17
+ >>> ctx = FlockContext()
18
+ >>> ctx.set_variable("di.container", container)
19
+ >>> from flock.di import get_current_container
20
+ >>> assert get_current_container(ctx).get_service(str) == "hello"
21
+ """
22
+
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ from wd.di.container import (
27
+ ServiceProvider, # noqa: F401 – import only for typing
28
+ )
29
+
30
+ from flock.core.context.context import FlockContext
31
+
32
+
33
+ def get_current_container(context: FlockContext | None = None):
34
+ """Return the active `wd.di` container from *context* if present.
35
+
36
+ If *context* is ``None`` or no container has been attached to it the
37
+ function returns ``None``.
38
+ """
39
+ if context is None:
40
+ return None
41
+ return context.get_variable("di.container")
@@ -0,0 +1,99 @@
1
+ # Enterprise Memory Module
2
+
3
+ The **EnterpriseMemoryModule** brings durable, scalable memory to Flock agents by
4
+ combining a true vector store (Chroma) with a property-graph database
5
+ (Neo4j / Memgraph). It is a drop‐in replacement for the default
6
+ `memory` module when you need:
7
+
8
+ * millions of memory chunks without exhausting RAM
9
+ * concurrent writers (many agents / processes / machines)
10
+ * rich concept-graph queries and visualisation
11
+
12
+ ---
13
+ ## How it works
14
+
15
+ | Concern | Technology |
16
+ |--------------------- |------------|
17
+ | Vector similarity | **Pinecone**, **Chroma**, **Azure Cognitive Search** |
18
+ | Concept graph | **Cypher** database (Neo4j / Memgraph) |
19
+ | Embeddings | `sentence-transformers` (`all-MiniLM-L6-v2`) |
20
+ | Concept extraction | Agent's LLM via DSPy signature |
21
+
22
+ * Each memory chunk is embedded and added to the Chroma collection.
23
+ * Concepts are extracted; duplicates are eliminated via case-insensitive
24
+ and fuzzy matching (≥ 0.85 similarity).
25
+ * Memory nodes and `(:Memory)-[:MENTIONS]->(:Concept)` edges are merged
26
+ into the graph DB in batched transactions.
27
+ * Optional: export a PNG of the concept graph after every update.
28
+
29
+ ---
30
+ ## Configuration options (`EnterpriseMemoryModuleConfig`)
31
+
32
+ ```yaml
33
+ chroma_path: ./vector_store # disk path if running embedded
34
+ chroma_host: null # host of remote Chroma server (optional)
35
+ chroma_port: 8000
36
+ chroma_collection: flock_memories # collection name
37
+
38
+ # or Pinecone
39
+ vector_backend: pinecone
40
+ pinecone_api_key: <YOUR_KEY>
41
+ pinecone_env: gcp-starter
42
+ pinecone_index: flock-memories
43
+
44
+ # or Azure Cognitive Search
45
+ vector_backend: azure
46
+ azure_search_endpoint: https://<service>.search.windows.net
47
+ azure_search_key: <KEY>
48
+ azure_search_index_name: flock-memories
49
+
50
+ cypher_uri: bolt://localhost:7687
51
+ cypher_username: neo4j
52
+ cypher_password: password
53
+
54
+ similarity_threshold: 0.5 # for retrieval
55
+ max_results: 10
56
+ number_of_concepts_to_extract: 3
57
+ save_interval: 10 # batch size before commit
58
+
59
+ export_graph_image: false # set true to emit PNGs
60
+ graph_image_dir: ./concept_graphs # where to store images
61
+ ```
62
+
63
+ ---
64
+ ## Dependencies
65
+
66
+ Add the following to your project (examples with pip):
67
+
68
+ ```bash
69
+ pip install chromadb>=0.4.20
70
+ pip install neo4j>=5.14.0
71
+ pip install sentence-transformers>=2.7.0
72
+ pip install matplotlib networkx # only needed when export_graph_image = true
73
+ pip install pinecone-client # if using Pinecone
74
+ pip install azure-search-documents # if using Azure Search
75
+ ```
76
+
77
+ You also need a running Neo4j **or** Memgraph instance. The module uses
78
+ the Bolt protocol and Cypher `MERGE`, which works on both.
79
+
80
+ ---
81
+ ## Usage
82
+
83
+ ```python
84
+ from flock.modules.enterprise_memory.enterprise_memory_module import (
85
+ EnterpriseMemoryModule, EnterpriseMemoryModuleConfig,
86
+ )
87
+
88
+ agent.add_module(
89
+ EnterpriseMemoryModule(
90
+ name="enterprise_memory",
91
+ config=EnterpriseMemoryModuleConfig(
92
+ cypher_password=os.environ["NEO4J_PASSWORD"],
93
+ export_graph_image=True,
94
+ ),
95
+ )
96
+ )
97
+ ```
98
+
99
+ The rest of the agent code stays unchanged.
@@ -0,0 +1,526 @@
1
+ from __future__ import annotations
2
+
3
+ """Enterprise-grade memory module for Flock.
4
+
5
+ This module persists:
6
+ • vector embeddings in a Chroma collection (or any collection that
7
+ implements the same API)
8
+ • a concept graph in Neo4j/Memgraph (Cypher-compatible)
9
+
10
+ It follows the same life-cycle callbacks as the standard MemoryModule but
11
+ is designed for large-scale, concurrent deployments.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+
21
+ from neo4j import AsyncGraphDatabase
22
+ from opentelemetry import trace
23
+ from pydantic import Field
24
+ from sentence_transformers import SentenceTransformer
25
+
26
+ from flock.adapter.azure_adapter import AzureSearchAdapter
27
+ from flock.adapter.chroma_adapter import ChromaAdapter
28
+ from flock.adapter.faiss_adapter import FAISSAdapter
29
+ from flock.adapter.pinecone_adapter import PineconeAdapter
30
+
31
+ # Adapter imports
32
+ from flock.adapter.vector_base import VectorAdapter
33
+ from flock.core.context.context import FlockContext
34
+ from flock.core.flock_agent import FlockAgent
35
+ from flock.core.flock_module import FlockModule, FlockModuleConfig
36
+ from flock.core.flock_registry import flock_component
37
+ from flock.core.logging.logging import get_logger
38
+ from flock.modules.performance.metrics_module import MetricsModule
39
+
40
+ logger = get_logger("enterprise_memory")
41
+ tracer = trace.get_tracer(__name__)
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Configuration
46
+ # ---------------------------------------------------------------------------
47
+ class EnterpriseMemoryModuleConfig(FlockModuleConfig):
48
+ """Configuration for EnterpriseMemoryModule."""
49
+
50
+ # ---------------------
51
+ # Vector store settings
52
+ # ---------------------
53
+
54
+ vector_backend: Literal["chroma", "pinecone", "azure"] = Field(
55
+ default="chroma",
56
+ description="Which vector backend to use (chroma | pinecone | azure)",
57
+ )
58
+
59
+ # --- Chroma ---
60
+ chroma_path: str | None = Field(
61
+ default="./vector_store",
62
+ description="Disk path for Chroma persistent storage (if running embedded).",
63
+ )
64
+ chroma_collection: str = Field(
65
+ default="flock_memories", description="Chroma collection name"
66
+ )
67
+ chroma_host: str | None = Field(
68
+ default=None,
69
+ description="If provided, connect to a remote Chroma HTTP server at this host",
70
+ )
71
+ chroma_port: int = Field(default=8000, description="Remote Chroma HTTP port")
72
+
73
+ # --- Pinecone ---
74
+ pinecone_api_key: str | None = Field(default=None, description="Pinecone API key")
75
+ pinecone_env: str | None = Field(default=None, description="Pinecone environment")
76
+ pinecone_index: str | None = Field(default=None, description="Pinecone index name")
77
+
78
+ # --- Azure Cognitive Search ---
79
+ azure_search_endpoint: str | None = Field(default=None, description="Azure search endpoint (https://<service>.search.windows.net)")
80
+ azure_search_key: str | None = Field(default=None, description="Azure search admin/key")
81
+ azure_search_index_name: str | None = Field(default=None, description="Azure search index name")
82
+
83
+ # Graph DB (Neo4j / Memgraph) settings
84
+ cypher_uri: str = Field(
85
+ default="bolt://localhost:7687", description="Bolt URI for the graph DB"
86
+ )
87
+ cypher_username: str = Field(default="neo4j", description="Username for DB")
88
+ cypher_password: str = Field(default="password", description="Password for DB")
89
+
90
+ similarity_threshold: float = Field(
91
+ default=0.5, description="Cosine-similarity threshold for retrieval"
92
+ )
93
+ max_results: int = Field(default=10, description="Maximum retrieved memories")
94
+ number_of_concepts_to_extract: int = Field(
95
+ default=3, description="Number of concepts extracted per chunk"
96
+ )
97
+ save_interval: int = Field(
98
+ default=10,
99
+ description="Persist to disk after this many new chunks (0 disables auto-save)",
100
+ )
101
+
102
+ export_graph_image: bool = Field(
103
+ default=False,
104
+ description="If true, exports a PNG image of the concept graph each time it is updated.",
105
+ )
106
+ graph_image_dir: str = Field(
107
+ default="./concept_graphs",
108
+ description="Directory where graph images will be stored when export_graph_image is true.",
109
+ )
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Storage Abstraction
114
+ # ---------------------------------------------------------------------------
115
+ class EnterpriseMemoryStore:
116
+ """Persistence layer that wraps Chroma + Cypher graph."""
117
+
118
+ def __init__(self, cfg: EnterpriseMemoryModuleConfig, metrics_module: MetricsModule | None = None):
119
+ self.cfg = cfg
120
+ # Metrics module (DI-resolved or fallback)
121
+ self._metrics = metrics_module or MetricsModule # can be either instance or class exposing .record
122
+ # Lazy initialise expensive resources
123
+ self._embedding_model: SentenceTransformer | None = None
124
+ self._adapter: VectorAdapter | None = None
125
+ self._driver = None # Neo4j driver
126
+ self._pending_writes: list[tuple[str, dict[str, Any]]] = []
127
+ self._write_lock = asyncio.Lock()
128
+ self._concept_cache: set[str] | None = None # names of known concepts
129
+
130
+ # ---------------------------------------------------------------------
131
+ # Connections
132
+ # ---------------------------------------------------------------------
133
+ def _ensure_embedding_model(self) -> SentenceTransformer:
134
+ if self._embedding_model is None:
135
+ logger.debug("Loading embedding model 'all-MiniLM-L6-v2'")
136
+ with tracer.start_as_current_span("memory.load_embedding_model") as span:
137
+ try:
138
+ self._embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
139
+ span.set_attribute("model", "all-MiniLM-L6-v2")
140
+ except Exception as e:
141
+ span.record_exception(e)
142
+ raise
143
+ return self._embedding_model
144
+
145
+ def _ensure_adapter(self) -> VectorAdapter:
146
+ if self._adapter is not None:
147
+ return self._adapter
148
+
149
+ backend = self.cfg.vector_backend
150
+
151
+ if backend == "chroma":
152
+ self._adapter = ChromaAdapter(
153
+ collection=self.cfg.chroma_collection,
154
+ host=self.cfg.chroma_host,
155
+ port=self.cfg.chroma_port,
156
+ path=self.cfg.chroma_path,
157
+ )
158
+ elif backend == "pinecone":
159
+ self._adapter = PineconeAdapter(
160
+ api_key=self.cfg.pinecone_api_key,
161
+ environment=self.cfg.pinecone_env,
162
+ index=self.cfg.pinecone_index,
163
+ )
164
+ elif backend == "azure":
165
+ self._adapter = AzureSearchAdapter(
166
+ endpoint=self.cfg.azure_search_endpoint,
167
+ key=self.cfg.azure_search_key,
168
+ index_name=self.cfg.azure_search_index_name,
169
+ )
170
+ elif backend == "faiss":
171
+ self._adapter = FAISSAdapter(index_path="./faiss.index")
172
+ else:
173
+ raise ValueError(f"Unsupported vector backend: {backend}")
174
+
175
+ return self._adapter
176
+
177
+ def _ensure_graph_driver(self):
178
+ if self._driver is None:
179
+ self._driver = AsyncGraphDatabase.driver(
180
+ self.cfg.cypher_uri,
181
+ auth=(self.cfg.cypher_username, self.cfg.cypher_password),
182
+ encrypted=False,
183
+ )
184
+ return self._driver
185
+
186
+ # ---------------------------------------------------------------------
187
+ # Public API
188
+ # ---------------------------------------------------------------------
189
+ async def add_entry(
190
+ self,
191
+ content: str,
192
+ concepts: set[str],
193
+ metadata: dict[str, Any] | None = None,
194
+ ) -> str:
195
+ """Store a chunk in both vector store and graph DB and return its id."""
196
+ with tracer.start_as_current_span("memory.add_entry") as span:
197
+ span.set_attribute("entry_id", str(uuid.uuid4()))
198
+
199
+ # Embed
200
+ embedding = self._ensure_embedding_model().encode(content).tolist()
201
+ span.set_attribute("embedding_length", len(embedding))
202
+
203
+ # Vector store write
204
+ adapter = self._ensure_adapter()
205
+ span.set_attribute("vector_backend", self.cfg.vector_backend)
206
+
207
+ start_t = time.perf_counter()
208
+ try:
209
+ adapter.add(
210
+ id=span.get_attribute("entry_id"),
211
+ content=content,
212
+ embedding=embedding,
213
+ metadata=metadata,
214
+ )
215
+ except Exception as e:
216
+ span.record_exception(e)
217
+ raise
218
+ finally:
219
+ elapsed = (time.perf_counter() - start_t) * 1000 # ms
220
+ self._metrics.record(
221
+ "memory_add_latency_ms",
222
+ elapsed,
223
+ {"backend": self.cfg.vector_backend},
224
+ )
225
+
226
+ # Schedule graph writes (batched)
227
+ async with self._write_lock:
228
+ self._pending_writes.append((span.get_attribute("entry_id"), {"concepts": concepts}))
229
+ if self.cfg.save_interval and len(self._pending_writes) >= self.cfg.save_interval:
230
+ await self._flush_pending_graph_writes()
231
+ return span.get_attribute("entry_id")
232
+
233
+ async def search(
234
+ self, query_text: str, threshold: float, k: int
235
+ ) -> list[dict[str, Any]]:
236
+ """Vector similarity search followed by graph enrichment."""
237
+ with tracer.start_as_current_span("memory.search") as span:
238
+ span.set_attribute("vector_backend", self.cfg.vector_backend)
239
+ embedding = (
240
+ self._ensure_embedding_model().encode(query_text).tolist()
241
+ )
242
+ span.set_attribute("embedding_length", len(embedding))
243
+ adapter = self._ensure_adapter()
244
+ backend = self.cfg.vector_backend
245
+ results: list[dict[str, Any]] = []
246
+
247
+ search_start = time.perf_counter()
248
+ vector_hits = adapter.query(embedding=embedding, k=k)
249
+ search_elapsed = (time.perf_counter() - search_start) * 1000
250
+ self._metrics.record(
251
+ "memory_search_hits", len(vector_hits), {"backend": backend}
252
+ )
253
+ for hit in vector_hits:
254
+ if hit.score < threshold:
255
+ continue
256
+ results.append(
257
+ {
258
+ "id": hit.id,
259
+ "content": hit.content,
260
+ "metadata": hit.metadata,
261
+ "score": hit.score,
262
+ }
263
+ )
264
+
265
+ span.set_attribute("results_count", len(results))
266
+ self._metrics.record(
267
+ "memory_search_latency_ms", search_elapsed, {"backend": backend}
268
+ )
269
+ return results
270
+
271
+ # ------------------------------------------------------------------
272
+ # Graph persistence helpers
273
+ # ------------------------------------------------------------------
274
+ async def _flush_pending_graph_writes(self):
275
+ """Commit queued node/edge creations to the Cypher store."""
276
+ if not self._pending_writes:
277
+ return
278
+ driver = self._ensure_graph_driver()
279
+ async with driver.session() as session:
280
+ tx_commands: list[str] = []
281
+ params: dict[str, Any] = {}
282
+ # Build Cypher in one transaction
283
+ for idx, (entry_id, extra) in enumerate(self._pending_writes):
284
+ concept_param = f"concepts_{idx}"
285
+ tx_commands.append(
286
+ f"MERGE (e:Memory {{id: '{entry_id}'}}) "
287
+ f"SET e.created = datetime() "
288
+ )
289
+ if extra.get("concepts"):
290
+ tx_commands.append(
291
+ f"WITH e UNWIND ${concept_param} AS c "
292
+ "MERGE (co:Concept {name: c}) "
293
+ "MERGE (e)-[:MENTIONS]->(co)"
294
+ )
295
+ params[concept_param] = list(extra["concepts"])
296
+ cypher = "\n".join(tx_commands)
297
+ await session.run(cypher, params)
298
+ # Export graph image if requested
299
+ if self.cfg.export_graph_image:
300
+ await self._export_graph_image(session)
301
+ self._pending_writes.clear()
302
+
303
+ async def _export_graph_image(self, session):
304
+ """Generate and save a PNG of the concept graph."""
305
+ try:
306
+ import matplotlib
307
+ matplotlib.use("Agg")
308
+ import matplotlib.pyplot as plt
309
+ import networkx as nx
310
+
311
+ records = await session.run(
312
+ "MATCH (c1:Concept)<-[:MENTIONS]-(:Memory)-[:MENTIONS]->(c2:Concept) "
313
+ "RETURN DISTINCT c1.name AS source, c2.name AS target"
314
+ )
315
+ edges = [(r["source"], r["target"]) for r in await records.values("source", "target")]
316
+ if not edges:
317
+ return
318
+
319
+ G = nx.Graph()
320
+ G.add_edges_from(edges)
321
+
322
+ pos = nx.spring_layout(G, k=0.4)
323
+ plt.figure(figsize=(12, 9), dpi=100)
324
+ nx.draw_networkx_nodes(G, pos, node_color="#8fa8d6", node_size=500, edgecolors="white")
325
+ nx.draw_networkx_edges(G, pos, alpha=0.5, width=1.5)
326
+ nx.draw_networkx_labels(G, pos, font_size=8)
327
+ plt.axis("off")
328
+
329
+ img_dir = Path(self.cfg.graph_image_dir)
330
+ img_dir.mkdir(parents=True, exist_ok=True)
331
+ filename = img_dir / f"concept_graph_{uuid.uuid4().hex[:8]}.png"
332
+ plt.savefig(filename, bbox_inches="tight", facecolor="white")
333
+ plt.close()
334
+ logger.info("Concept graph image exported to %s", filename)
335
+ except Exception as e:
336
+ logger.warning("Failed to export concept graph image: %s", e)
337
+
338
+ async def close(self):
339
+ if self._pending_writes:
340
+ await self._flush_pending_graph_writes()
341
+ if self._driver:
342
+ await self._driver.close()
343
+ if self._adapter and hasattr(self._adapter, "close"):
344
+ self._adapter.close()
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # Module
349
+ # ---------------------------------------------------------------------------
350
+ @flock_component(config_class=EnterpriseMemoryModuleConfig)
351
+ class EnterpriseMemoryModule(FlockModule):
352
+ """Enterprise-ready memory module using real datastores."""
353
+
354
+ name: str = "enterprise_memory"
355
+ config: EnterpriseMemoryModuleConfig = Field(default_factory=EnterpriseMemoryModuleConfig)
356
+
357
+ _store: EnterpriseMemoryStore | None = None
358
+ _container: Any | None = None # DI container if supplied
359
+ _metrics_module: MetricsModule | None = None
360
+
361
+ # ----------------------------------------------------------
362
+ # DI-enabled constructor
363
+ # ----------------------------------------------------------
364
+ def __init__(
365
+ self,
366
+ name: str = "enterprise_memory",
367
+ config: EnterpriseMemoryModuleConfig | None = None,
368
+ *,
369
+ container: object | None = None,
370
+ **kwargs,
371
+ ):
372
+ """Create a new EnterpriseMemoryModule instance.
373
+
374
+ Parameters
375
+ ----------
376
+ container : ServiceProvider | None
377
+ Optional DI container used to resolve shared services. When
378
+ provided, the module will attempt to resolve
379
+ :class:`flock.modules.performance.metrics_module.MetricsModule` from
380
+ it. Falling back to the global singleton when not available keeps
381
+ backward-compatibility.
382
+ """
383
+ from wd.di.container import (
384
+ ServiceProvider, # Local import to avoid hard dependency if wd.di is absent
385
+ )
386
+
387
+ if config is None:
388
+ config = EnterpriseMemoryModuleConfig()
389
+
390
+ super().__init__(name=name, config=config, **kwargs)
391
+
392
+ self._container = container if isinstance(container, ServiceProvider) else None
393
+
394
+ # Attempt to resolve MetricsModule via DI, then via FlockModule registry
395
+ resolved_metrics: MetricsModule | None = None
396
+ if self._container is not None:
397
+ try:
398
+ resolved_metrics = self._container.get_service(MetricsModule)
399
+ except Exception:
400
+ resolved_metrics = None
401
+
402
+ if resolved_metrics is None:
403
+ resolved_metrics = MetricsModule._INSTANCE
404
+
405
+ self._metrics_module = resolved_metrics
406
+
407
+ # ----------------------------------------------------------
408
+ # Life-cycle hooks
409
+ # ----------------------------------------------------------
410
+ async def on_initialize(
411
+ self,
412
+ agent: FlockAgent,
413
+ inputs: dict[str, Any],
414
+ context: FlockContext | None = None,
415
+ ) -> None:
416
+ self._store = EnterpriseMemoryStore(self.config, self._metrics_module)
417
+ logger.info("EnterpriseMemoryModule initialised", agent=agent.name)
418
+
419
+ async def on_pre_evaluate(
420
+ self,
421
+ agent: FlockAgent,
422
+ inputs: dict[str, Any],
423
+ context: FlockContext | None = None,
424
+ ) -> dict[str, Any]:
425
+ if not self._store:
426
+ return inputs
427
+ try:
428
+ query_str = json.dumps(inputs)
429
+ matches = await self._store.search(
430
+ query_str,
431
+ threshold=self.config.similarity_threshold,
432
+ k=self.config.max_results,
433
+ )
434
+ if matches:
435
+ inputs = {**inputs, "context": matches}
436
+ # Advertise new input key to DSPy signature if needed
437
+ if isinstance(agent.input, str) and "context:" not in agent.input:
438
+ agent.input += ", context: list | retrieved memories"
439
+ except Exception as e:
440
+ logger.warning("Enterprise memory retrieval failed: %s", e, agent=agent.name)
441
+ return inputs
442
+
443
+ async def on_post_evaluate(
444
+ self,
445
+ agent: FlockAgent,
446
+ inputs: dict[str, Any],
447
+ context: FlockContext | None = None,
448
+ result: dict[str, Any] | None = None,
449
+ ) -> dict[str, Any] | None:
450
+ if not self._store:
451
+ return result
452
+ try:
453
+ full_text = json.dumps(inputs) + (json.dumps(result) if result else "")
454
+ concepts = await self._extract_concepts(agent, full_text)
455
+ if self._store:
456
+ concepts = await self._store._deduplicate_concepts(concepts)
457
+ await self._store.add_entry(full_text, concepts)
458
+ except Exception as e:
459
+ logger.warning("Enterprise memory store failed: %s", e, agent=agent.name)
460
+ return result
461
+
462
+ async def on_terminate(
463
+ self,
464
+ agent: FlockAgent,
465
+ inputs: dict[str, Any],
466
+ result: dict[str, Any],
467
+ context: FlockContext | None = None,
468
+ ) -> None:
469
+ if self._store:
470
+ await self._store.close()
471
+
472
+ # ----------------------------------------------------------
473
+ # Helpers (mostly copied from original module but simplified)
474
+ # ----------------------------------------------------------
475
+ async def _extract_concepts(self, agent: FlockAgent, text: str) -> set[str]:
476
+ """Use the LLM to extract concept tokens."""
477
+ concept_signature = agent.create_dspy_signature_class(
478
+ f"{agent.name}_concept_extractor_enterprise",
479
+ "Extract key concepts from text",
480
+ "text: str | Input text -> concepts: list[str] | key concepts lower case",
481
+ )
482
+ agent._configure_language_model(agent.model, True, 0.0, 8192)
483
+ predictor = agent._select_task(concept_signature, "Completion")
484
+ res = predictor(text=text)
485
+ return set(getattr(res, "concepts", []))
486
+
487
+ # --------------------------------------------------------------
488
+ # Concept helpers
489
+ # --------------------------------------------------------------
490
+ async def _ensure_concept_cache(self):
491
+ if self._concept_cache is not None:
492
+ return
493
+ driver = self._ensure_graph_driver()
494
+ async with driver.session() as session:
495
+ records = await session.run("MATCH (c:Concept) RETURN c.name AS name")
496
+ self._concept_cache = {r["name"] for r in await records.values("name")}
497
+
498
+ async def _deduplicate_concepts(self, new_concepts: set[str]) -> set[str]:
499
+ """Return a set of concept names that merges with existing ones to avoid duplicates.
500
+
501
+ Strategy: case-insensitive equality first, then fuzzy match via difflib with cutoff 0.85.
502
+ """
503
+ await self._ensure_concept_cache()
504
+ assert self._concept_cache is not None
505
+
506
+ from difflib import get_close_matches
507
+
508
+ unified: set[str] = set()
509
+ for concept in new_concepts:
510
+ # Exact (case-insensitive) match
511
+ lower = concept.lower()
512
+ exact = next((c for c in self._concept_cache if c.lower() == lower), None)
513
+ if exact:
514
+ unified.add(exact)
515
+ continue
516
+
517
+ # Fuzzy match (>=0.85 similarity)
518
+ close = get_close_matches(concept, list(self._concept_cache), n=1, cutoff=0.85)
519
+ if close:
520
+ unified.add(close[0])
521
+ continue
522
+
523
+ # No match – treat as new
524
+ unified.add(concept)
525
+ self._concept_cache.add(concept)
526
+ return unified