flock-core 0.4.0b49__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.
- flock/adapter/__init__.py +14 -0
- flock/adapter/azure_adapter.py +68 -0
- flock/adapter/chroma_adapter.py +73 -0
- flock/adapter/faiss_adapter.py +97 -0
- flock/adapter/pinecone_adapter.py +51 -0
- flock/adapter/vector_base.py +47 -0
- flock/config.py +1 -1
- flock/core/context/context.py +20 -0
- flock/core/flock.py +71 -91
- flock/core/flock_agent.py +58 -3
- flock/core/flock_module.py +5 -0
- flock/di.py +41 -0
- flock/modules/enterprise_memory/README.md +99 -0
- flock/modules/enterprise_memory/enterprise_memory_module.py +526 -0
- flock/modules/mem0/mem0_module.py +79 -16
- flock/modules/mem0_async/async_mem0_module.py +126 -0
- flock/modules/memory/memory_module.py +28 -8
- flock/modules/performance/metrics_module.py +24 -1
- flock/modules/zep/__init__.py +1 -0
- flock/modules/zep/zep_module.py +192 -0
- flock/webapp/app/api/execution.py +108 -73
- flock/webapp/app/chat.py +96 -12
- flock/webapp/app/config.py +1 -1
- flock/webapp/app/main.py +14 -12
- flock/webapp/app/services/sharing_models.py +38 -0
- flock/webapp/app/services/sharing_store.py +60 -1
- flock/webapp/static/css/chat.css +2 -0
- flock/webapp/templates/base.html +91 -1
- flock/webapp/templates/chat.html +64 -1
- flock/webapp/templates/partials/_agent_detail_form.html +3 -3
- flock/webapp/templates/partials/_chat_messages.html +50 -4
- flock/webapp/templates/partials/_flock_properties_form.html +2 -2
- flock/webapp/templates/partials/_results_display.html +54 -11
- flock/webapp/templates/partials/_structured_data_view.html +2 -2
- flock/webapp/templates/shared_run_page.html +27 -0
- {flock_core-0.4.0b49.dist-info → flock_core-0.4.1.dist-info}/METADATA +6 -4
- {flock_core-0.4.0b49.dist-info → flock_core-0.4.1.dist-info}/RECORD +41 -30
- flock/modules/mem0graph/mem0_graph_module.py +0 -63
- /flock/modules/{mem0graph → mem0_async}/__init__.py +0 -0
- {flock_core-0.4.0b49.dist-info → flock_core-0.4.1.dist-info}/WHEEL +0 -0
- {flock_core-0.4.0b49.dist-info → flock_core-0.4.1.dist-info}/entry_points.txt +0 -0
- {flock_core-0.4.0b49.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
|