Skip to content

Commit 50efcd2

Browse files
authored
feat: add PostgreSQL/pgvector backend (#86)
feat(backend)!: refactor entity update with template method pattern and upgrade dependencies Major changes: - Implement template method pattern in BaseEntityBackend for update_entities workflow - Add conflict resolution and entity management hooks (_post_update) - Standardize entity update flow across all backend implementations - Add new utility functions for content serialization
1 parent 7f2f432 commit 50efcd2

14 files changed

Lines changed: 2016 additions & 1387 deletions

File tree

kaizen/backend/base.py

Lines changed: 113 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import datetime
12
import logging
23
from abc import ABC, abstractmethod
4+
35
from pydantic_settings import BaseSettings
4-
from kaizen.schema.core import Namespace, Entity, RecordedEntity
6+
57
from kaizen.schema.conflict_resolution import EntityUpdate
8+
from kaizen.schema.core import Entity, Namespace, RecordedEntity
9+
from kaizen.schema.exceptions import KaizenException
10+
from kaizen.utils.utils import serialize_content
611

712
logging.basicConfig(level=logging.INFO)
813
logger = logging.getLogger("entities-db")
@@ -16,6 +21,9 @@ def __init__(self, config: BaseSettings | None = None):
1621
def ready(self) -> bool:
1722
pass
1823

24+
def close(self):
25+
pass
26+
1927
@abstractmethod
2028
def details(self) -> dict:
2129
pass
@@ -36,15 +44,6 @@ def search_namespaces(self, limit: int = 10) -> list[Namespace]:
3644
def delete_namespace(self, namespace_id: str):
3745
pass
3846

39-
@abstractmethod
40-
def update_entities(
41-
self,
42-
namespace_id: str,
43-
entities: list[Entity],
44-
enable_conflict_resolution: bool = True,
45-
) -> list[EntityUpdate]:
46-
pass
47-
4847
@abstractmethod
4948
def search_entities(
5049
self, namespace_id: str, query: str | None = None, filters: dict | None = None, limit: int = 10
@@ -54,3 +53,107 @@ def search_entities(
5453
@abstractmethod
5554
def delete_entity_by_id(self, namespace_id: str, entity_id: str):
5655
pass
56+
57+
# ── update_entities template method ──────────────────────────────
58+
59+
@abstractmethod
60+
def _validate_namespace(self, namespace_id: str) -> None:
61+
"""Raise NamespaceNotFoundException if the namespace does not exist."""
62+
pass
63+
64+
@abstractmethod
65+
def _add_entity(self, namespace_id: str, entity_type: str, content_str: str, timestamp: int, metadata: dict) -> str:
66+
"""Insert a new entity and return its ID as a string."""
67+
pass
68+
69+
@abstractmethod
70+
def _update_entity(self, namespace_id: str, entity_id: str, entity_type: str, content_str: str, timestamp: int, metadata: dict) -> None:
71+
"""Update an existing entity in-place."""
72+
pass
73+
74+
@abstractmethod
75+
def _delete_entity(self, namespace_id: str, entity_id: str) -> None:
76+
"""Delete an entity by ID."""
77+
pass
78+
79+
def _post_update(self, namespace_id: str) -> None:
80+
"""Hook called after all entity mutations are complete. No-op by default."""
81+
pass
82+
83+
def update_entities(
84+
self,
85+
namespace_id: str,
86+
entities: list[Entity],
87+
enable_conflict_resolution: bool = True,
88+
) -> list[EntityUpdate]:
89+
from kaizen.llm.conflict_resolution.conflict_resolution import resolve_conflicts
90+
91+
self._validate_namespace(namespace_id)
92+
if not entities:
93+
logger.warning("No entities to update.")
94+
return []
95+
96+
entity_type = entities[0].type
97+
if not all(entity.type == entity_type for entity in entities):
98+
raise KaizenException("All entities must have the same type.")
99+
100+
now = datetime.datetime.now(datetime.UTC)
101+
timestamp = int(now.timestamp())
102+
103+
entities_with_temporary_ids: list[RecordedEntity] = []
104+
for i, entity in enumerate(entities):
105+
entity_data = entity.model_dump()
106+
if entity_data.get("metadata") is None:
107+
entity_data["metadata"] = {}
108+
entities_with_temporary_ids.append(
109+
RecordedEntity(
110+
**entity_data,
111+
created_at=datetime.datetime.now(datetime.UTC),
112+
id=f"Unprocessed_Entity_{i}",
113+
)
114+
)
115+
116+
if enable_conflict_resolution:
117+
old_entities: list[RecordedEntity] = []
118+
for entity in entities:
119+
query_str = serialize_content(entity.content)
120+
old_entities.extend(
121+
self.search_entities(
122+
namespace_id=namespace_id,
123+
query=query_str,
124+
filters={"type": entity_type},
125+
limit=10,
126+
)
127+
)
128+
129+
updates = resolve_conflicts(old_entities, entities_with_temporary_ids)
130+
for update in updates:
131+
content_str = serialize_content(update.content)
132+
metadata = update.metadata or {}
133+
match update.event:
134+
case "ADD":
135+
update.id = self._add_entity(namespace_id, entity_type, content_str, timestamp, metadata)
136+
case "UPDATE":
137+
self._update_entity(namespace_id, update.id, entity_type, content_str, timestamp, metadata)
138+
case "DELETE":
139+
self._delete_entity(namespace_id, update.id)
140+
case "NONE":
141+
pass
142+
else:
143+
updates = []
144+
for entity in entities:
145+
content_str = serialize_content(entity.content)
146+
metadata = entity.metadata or {}
147+
entity_id = self._add_entity(namespace_id, entity_type, content_str, timestamp, metadata)
148+
updates.append(
149+
EntityUpdate(
150+
id=entity_id,
151+
type=entity_type,
152+
content=entity.content,
153+
event="ADD",
154+
metadata=metadata,
155+
)
156+
)
157+
158+
self._post_update(namespace_id)
159+
return updates

kaizen/backend/filesystem.py

Lines changed: 52 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
from kaizen.backend.base import BaseEntityBackend
1111
from kaizen.config.filesystem import FilesystemSettings, filesystem_settings
12-
from kaizen.llm.conflict_resolution.conflict_resolution import resolve_conflicts
1312
from kaizen.schema.conflict_resolution import EntityUpdate
1413
from kaizen.schema.core import Entity, Namespace, RecordedEntity
1514
from 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

Comments
 (0)