99
1010from kaizen .backend .base import BaseEntityBackend
1111from kaizen .config .filesystem import FilesystemSettings , filesystem_settings
12- from kaizen .llm .conflict_resolution .conflict_resolution import resolve_conflicts
1312from kaizen .schema .conflict_resolution import EntityUpdate
1413from kaizen .schema .core import Entity , Namespace , RecordedEntity
1514from kaizen .schema .exceptions import (
@@ -40,6 +39,8 @@ def __init__(self, config: FilesystemSettings | None = None):
4039 self .data_dir = Path (self .config .data_dir )
4140 self .data_dir .mkdir (parents = True , exist_ok = True )
4241 self ._lock = Lock ()
42+ # Holds the loaded namespace data during update_entities so hooks can access it.
43+ self ._active_data : FilesystemNamespace | None = None
4344
4445 def _namespace_file (self , namespace_id : str ) -> Path :
4546 """Get the path to a namespace's JSON file."""
@@ -65,6 +66,11 @@ def details(self) -> dict:
6566 """Return details about the backend."""
6667 return {"data_dir" : str (self .data_dir )}
6768
69+ def _validate_namespace (self , namespace_id : str ) -> None :
70+ file_path = self ._namespace_file (namespace_id )
71+ if not file_path .exists ():
72+ raise NamespaceNotFoundException (f"Namespace `{ namespace_id } ` not found" )
73+
6874 def create_namespace (self , namespace_id : str | None = None ) -> Namespace :
6975 """Create a new namespace for entities to exist in."""
7076 namespace_id = namespace_id or "ns_" + str (uuid .uuid4 ()).replace ("-" , "_" )
@@ -124,105 +130,56 @@ def delete_namespace(self, namespace_id: str):
124130 return # Already deleted, no-op
125131 file_path .unlink ()
126132
133+ # ── update_entities hooks ────────────────────────────────────────
134+
135+ def _add_entity (self , namespace_id : str , entity_type : str , content_str : str , timestamp : int , metadata : dict ) -> str :
136+ assert self ._active_data is not None
137+ entity_id = str (self ._active_data .next_id )
138+ self ._active_data .next_id += 1
139+ created_at_iso = datetime .datetime .fromtimestamp (timestamp , datetime .UTC ).isoformat ()
140+ self ._active_data .entities .append (
141+ {
142+ "id" : entity_id ,
143+ "type" : entity_type ,
144+ "content" : content_str ,
145+ "created_at" : created_at_iso ,
146+ "metadata" : metadata ,
147+ }
148+ )
149+ return entity_id
150+
151+ def _update_entity (self , namespace_id : str , entity_id : str , entity_type : str , content_str : str , timestamp : int , metadata : dict ) -> None :
152+ assert self ._active_data is not None
153+ created_at_iso = datetime .datetime .fromtimestamp (timestamp , datetime .UTC ).isoformat ()
154+ for ent in self ._active_data .entities :
155+ if ent ["id" ] == entity_id :
156+ ent ["content" ] = content_str
157+ ent ["created_at" ] = created_at_iso
158+ ent ["metadata" ] = metadata
159+ break
160+
161+ def _delete_entity (self , namespace_id : str , entity_id : str ) -> None :
162+ assert self ._active_data is not None
163+ self ._active_data .entities = [e for e in self ._active_data .entities if e ["id" ] != entity_id ]
164+
165+ def _post_update (self , namespace_id : str ) -> None :
166+ assert self ._active_data is not None
167+ self ._active_data .num_entities = len (self ._active_data .entities )
168+ self ._save_namespace_data (namespace_id , self ._active_data )
169+ self ._active_data = None
170+
127171 def update_entities (
128172 self ,
129173 namespace_id : str ,
130174 entities : list [Entity ],
131175 enable_conflict_resolution : bool = True ,
132176 ) -> list [EntityUpdate ]:
133- """Add/update entities in a namespace."""
134- if len (entities ) == 0 :
135- return []
136-
137- entity_type = entities [0 ].type
138- if not all (entity .type == entity_type for entity in entities ):
139- raise KaizenException ("All entities must have the same type." )
140-
141- now = datetime .datetime .now (datetime .UTC )
142- now_iso = now .isoformat ()
143-
144- # Create temporary entities with placeholder IDs
145- entities_with_temporary_ids = []
146- for i , entity in enumerate (entities ):
147- entity_data = entity .model_dump ()
148- if entity_data .get ("metadata" ) is None :
149- entity_data ["metadata" ] = {}
150- entities_with_temporary_ids .append (
151- RecordedEntity (
152- ** entity_data ,
153- created_at = now ,
154- id = f"Unprocessed_Entity_{ i } " ,
155- )
156- )
157-
177+ """Override to wrap the base template in a lock with loaded data."""
158178 with self ._lock :
159- data = self ._load_namespace_data (namespace_id )
160-
161- if enable_conflict_resolution :
162- # Find similar existing entities for conflict resolution
163- old_entities = []
164- for entity in entities :
165- # Convert content to string for search query
166- query_str = entity .content if isinstance (entity .content , str ) else json .dumps (entity .content )
167- similar = self ._search_entities_internal (data , query = query_str , filters = None , limit = 10 )
168- old_entities .extend (similar )
169-
170- updates = resolve_conflicts (old_entities , entities_with_temporary_ids )
171-
172- for update in updates :
173- match update .event :
174- case "ADD" :
175- entity_id = str (data .next_id )
176- data .next_id += 1
177- data .entities .append (
178- {
179- "id" : entity_id ,
180- "type" : entity_type ,
181- "content" : update .content ,
182- "created_at" : now_iso ,
183- "metadata" : update .metadata ,
184- }
185- )
186- update .id = entity_id
187- case "UPDATE" :
188- for ent in data .entities :
189- if ent ["id" ] == update .id :
190- ent ["content" ] = update .content
191- ent ["created_at" ] = now_iso
192- ent ["metadata" ] = update .metadata
193- break
194- case "DELETE" :
195- data .entities = [e for e in data .entities if e ["id" ] != update .id ]
196- case "NONE" :
197- pass
198- else :
199- updates = []
200- for entity in entities :
201- entity_id = str (data .next_id )
202- data .next_id += 1
203- data .entities .append (
204- {
205- "id" : entity_id ,
206- "type" : entity_type ,
207- "content" : entity .content ,
208- "created_at" : now_iso ,
209- "metadata" : entity .metadata ,
210- }
211- )
212- updates .append (
213- EntityUpdate (
214- id = entity_id ,
215- type = entity_type ,
216- content = entity .content ,
217- event = "ADD" ,
218- metadata = entity .metadata ,
219- )
220- )
221-
222- data .num_entities = len (data .entities )
223- self ._save_namespace_data (namespace_id , data )
179+ self ._active_data = self ._load_namespace_data (namespace_id )
180+ return super ().update_entities (namespace_id , entities , enable_conflict_resolution )
224181
225- return updates
182+ # ── search ───────────────────────────────────────────────────────
226183
227184 def _search_entities_internal (
228185 self ,
@@ -291,6 +248,9 @@ def search_entities(
291248 limit : int = 10 ,
292249 ) -> list [RecordedEntity ]:
293250 """Search for entities in a namespace."""
251+ # If called during update_entities (inside the lock), use the active data
252+ if self ._active_data is not None :
253+ return self ._search_entities_internal (self ._active_data , query , filters , limit )
294254 with self ._lock :
295255 data = self ._load_namespace_data (namespace_id )
296256 return self ._search_entities_internal (data , query , filters , limit )
0 commit comments