mempalace-code 1.0.0__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.
- mempalace/README.md +40 -0
- mempalace/__init__.py +6 -0
- mempalace/__main__.py +5 -0
- mempalace/cli.py +811 -0
- mempalace/config.py +149 -0
- mempalace/convo_miner.py +415 -0
- mempalace/dialect.py +1075 -0
- mempalace/entity_detector.py +853 -0
- mempalace/entity_registry.py +639 -0
- mempalace/export.py +378 -0
- mempalace/general_extractor.py +521 -0
- mempalace/knowledge_graph.py +410 -0
- mempalace/layers.py +515 -0
- mempalace/mcp_server.py +873 -0
- mempalace/migrate.py +153 -0
- mempalace/miner.py +1285 -0
- mempalace/normalize.py +328 -0
- mempalace/onboarding.py +489 -0
- mempalace/palace_graph.py +225 -0
- mempalace/py.typed +0 -0
- mempalace/room_detector_local.py +310 -0
- mempalace/searcher.py +305 -0
- mempalace/spellcheck.py +269 -0
- mempalace/split_mega_files.py +309 -0
- mempalace/storage.py +807 -0
- mempalace/version.py +3 -0
- mempalace_code-1.0.0.dist-info/METADATA +489 -0
- mempalace_code-1.0.0.dist-info/RECORD +32 -0
- mempalace_code-1.0.0.dist-info/WHEEL +4 -0
- mempalace_code-1.0.0.dist-info/entry_points.txt +2 -0
- mempalace_code-1.0.0.dist-info/licenses/LICENSE +192 -0
- mempalace_code-1.0.0.dist-info/licenses/NOTICE +17 -0
mempalace/storage.py
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
"""
|
|
2
|
+
storage.py — Pluggable storage backend for MemPalace
|
|
3
|
+
=====================================================
|
|
4
|
+
|
|
5
|
+
Provides a unified interface for drawer storage, abstracting away
|
|
6
|
+
the underlying vector database. Ships with LanceDB (default, crash-safe)
|
|
7
|
+
and ChromaDB (legacy) backends.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from mempalace.storage import open_store
|
|
11
|
+
|
|
12
|
+
store = open_store("/path/to/palace") # auto-detect or create LanceDB
|
|
13
|
+
store = open_store("/path/to/palace", "lance") # explicit backend
|
|
14
|
+
store = open_store("/path/to/palace", "chroma") # legacy ChromaDB
|
|
15
|
+
|
|
16
|
+
The store object exposes a collection-like API that all MemPalace code
|
|
17
|
+
uses instead of calling ChromaDB/LanceDB directly.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("mempalace")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ─── Abstract interface ────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DrawerStore(ABC):
|
|
35
|
+
"""Minimal interface that every storage backend must implement."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def count(self) -> int:
|
|
39
|
+
"""Total number of drawers."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def add(
|
|
43
|
+
self,
|
|
44
|
+
ids: List[str],
|
|
45
|
+
documents: List[str],
|
|
46
|
+
metadatas: List[Dict[str, Any]],
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Insert new drawers. Raises on duplicate IDs."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def upsert(
|
|
52
|
+
self,
|
|
53
|
+
ids: List[str],
|
|
54
|
+
documents: List[str],
|
|
55
|
+
metadatas: List[Dict[str, Any]],
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Insert or update drawers."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def get(
|
|
61
|
+
self,
|
|
62
|
+
ids: Optional[List[str]] = None,
|
|
63
|
+
where: Optional[Dict[str, Any]] = None,
|
|
64
|
+
include: Optional[List[str]] = None,
|
|
65
|
+
limit: int = 10000,
|
|
66
|
+
offset: int = 0,
|
|
67
|
+
) -> Dict[str, List]:
|
|
68
|
+
"""
|
|
69
|
+
Retrieve drawers by ID or metadata filter.
|
|
70
|
+
|
|
71
|
+
Returns dict with keys: ids, documents, metadatas
|
|
72
|
+
(each key present only if requested via `include` or always for ids).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def query(
|
|
77
|
+
self,
|
|
78
|
+
query_texts: List[str],
|
|
79
|
+
n_results: int = 5,
|
|
80
|
+
where: Optional[Dict[str, Any]] = None,
|
|
81
|
+
include: Optional[List[str]] = None,
|
|
82
|
+
) -> Dict[str, List[List]]:
|
|
83
|
+
"""
|
|
84
|
+
Semantic search. Returns nested lists (one per query text):
|
|
85
|
+
ids: [[id, ...]]
|
|
86
|
+
documents: [[doc, ...]]
|
|
87
|
+
metadatas: [[meta, ...]]
|
|
88
|
+
distances: [[dist, ...]]
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def delete(self, ids: List[str]) -> None:
|
|
93
|
+
"""Delete drawers by ID."""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def delete_wing(self, wing: str) -> int:
|
|
97
|
+
"""Delete all drawers in a wing. Returns the count of deleted drawers."""
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def count_by(self, column: str) -> Dict[str, int]:
|
|
101
|
+
"""Return {value: count} for every distinct value in *column*."""
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def count_by_pair(self, col_a: str, col_b: str) -> Dict[str, Dict[str, int]]:
|
|
105
|
+
"""Return {a_value: {b_value: count}} for every (col_a, col_b) pair."""
|
|
106
|
+
|
|
107
|
+
def get_source_files(self, wing: str) -> Optional[set]:
|
|
108
|
+
"""Return a set of all source_file values for a wing, or None if unsupported.
|
|
109
|
+
|
|
110
|
+
Returning None signals the caller to fall back to per-file file_already_mined()
|
|
111
|
+
checks. The base implementation returns None — override in backends that support
|
|
112
|
+
efficient bulk retrieval (LanceDB).
|
|
113
|
+
"""
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def delete_by_source_file(self, source_file: str, wing: str) -> int:
|
|
117
|
+
"""Delete all drawers for a given source_file within a wing. Returns deleted count."""
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
def get_source_file_hashes(self, wing: str) -> dict:
|
|
121
|
+
"""Return {source_file: source_hash} for all drawers in wing.
|
|
122
|
+
|
|
123
|
+
Returns an empty dict if unsupported. Override in LanceDB backend.
|
|
124
|
+
"""
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
def iter_all(self, where=None, batch_size=1000, include_vectors=False):
|
|
128
|
+
"""Yield batches of drawers as lists of dicts. Streams without loading full table.
|
|
129
|
+
|
|
130
|
+
Each batch is a list of dicts with keys: id, text, and all metadata fields.
|
|
131
|
+
If include_vectors is True, a 'vector' key with the float list is also present.
|
|
132
|
+
"""
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
def optimize(self) -> None:
|
|
136
|
+
"""Merge Lance fragments and prune old versions. No-op on unsupported backends."""
|
|
137
|
+
|
|
138
|
+
def warmup(self) -> None:
|
|
139
|
+
"""Force embedding model init so HuggingFace output appears before batch processing."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ─── LanceDB backend ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
_LANCE_TABLE = "mempalace_drawers"
|
|
145
|
+
DEFAULT_EMBED_MODEL = "all-MiniLM-L6-v2" # same model ChromaDB uses by default
|
|
146
|
+
|
|
147
|
+
# Single source of truth for metadata fields.
|
|
148
|
+
# Adding a new metadata column? Append ONE tuple here.
|
|
149
|
+
# Format: (field_name, arrow_type_tag, default_value)
|
|
150
|
+
# arrow_type_tag: "string" | "int32" | "float32"
|
|
151
|
+
_META_FIELD_SPEC: tuple = (
|
|
152
|
+
# Core metadata
|
|
153
|
+
("wing", "string", ""),
|
|
154
|
+
("room", "string", ""),
|
|
155
|
+
("source_file", "string", ""),
|
|
156
|
+
("chunk_index", "int32", 0),
|
|
157
|
+
("added_by", "string", ""),
|
|
158
|
+
("filed_at", "string", ""),
|
|
159
|
+
# Diary/graph fields
|
|
160
|
+
("hall", "string", ""),
|
|
161
|
+
("topic", "string", ""),
|
|
162
|
+
("type", "string", ""),
|
|
163
|
+
("agent", "string", ""),
|
|
164
|
+
("date", "string", ""),
|
|
165
|
+
# Convo mining
|
|
166
|
+
("ingest_mode", "string", ""),
|
|
167
|
+
("extract_mode", "string", ""),
|
|
168
|
+
# Compression
|
|
169
|
+
("compression_ratio", "float32", 0.0),
|
|
170
|
+
("original_tokens", "int32", 0),
|
|
171
|
+
# Language detection
|
|
172
|
+
("language", "string", ""),
|
|
173
|
+
# Symbol metadata
|
|
174
|
+
("symbol_name", "string", ""),
|
|
175
|
+
("symbol_type", "string", ""),
|
|
176
|
+
# Provenance (CODE-INCREMENTAL)
|
|
177
|
+
("source_hash", "string", ""),
|
|
178
|
+
("extractor_version", "string", ""),
|
|
179
|
+
("chunker_strategy", "string", ""),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
_META_KEYS: frozenset = frozenset(name for name, _, _ in _META_FIELD_SPEC)
|
|
183
|
+
_META_DEFAULTS: dict = {name: default for name, _, default in _META_FIELD_SPEC}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _target_drawer_schema(dim: int):
|
|
187
|
+
"""Return the canonical PyArrow schema for the drawers table.
|
|
188
|
+
|
|
189
|
+
Single source of truth — used by both the create-table and migrate-existing paths in
|
|
190
|
+
``LanceStore._open_or_create()``. Any new column additions must be made here only.
|
|
191
|
+
"""
|
|
192
|
+
import pyarrow as pa
|
|
193
|
+
|
|
194
|
+
_ARROW_TYPES = {"string": pa.string(), "int32": pa.int32(), "float32": pa.float32()}
|
|
195
|
+
fields = [
|
|
196
|
+
pa.field("id", pa.string()),
|
|
197
|
+
pa.field("text", pa.string()),
|
|
198
|
+
pa.field("vector", pa.list_(pa.float32(), dim)),
|
|
199
|
+
]
|
|
200
|
+
for name, type_tag, _ in _META_FIELD_SPEC:
|
|
201
|
+
fields.append(pa.field(name, _ARROW_TYPES[type_tag]))
|
|
202
|
+
return pa.schema(fields)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _sql_default_for_arrow_type(arrow_type) -> str:
|
|
206
|
+
"""Map a PyArrow scalar type to its SQL literal default for ``add_columns()``.
|
|
207
|
+
|
|
208
|
+
Raises ``RuntimeError`` for unsupported types. In particular, ``pa.list_(...)``
|
|
209
|
+
(the vector column type) is not supported — the vector column must already exist in
|
|
210
|
+
the base schema; if it is missing the table is corrupt or unsupported.
|
|
211
|
+
"""
|
|
212
|
+
import pyarrow as pa
|
|
213
|
+
|
|
214
|
+
if pa.types.is_string(arrow_type) or pa.types.is_large_string(arrow_type):
|
|
215
|
+
return "CAST('' AS string)"
|
|
216
|
+
if pa.types.is_int32(arrow_type):
|
|
217
|
+
return "0"
|
|
218
|
+
if pa.types.is_int64(arrow_type):
|
|
219
|
+
return "0"
|
|
220
|
+
if pa.types.is_float32(arrow_type):
|
|
221
|
+
return "0.0"
|
|
222
|
+
raise RuntimeError(
|
|
223
|
+
f"No SQL default defined for Arrow type {arrow_type!r}. "
|
|
224
|
+
"The vector column (list type) must already exist in the base schema — "
|
|
225
|
+
"if it is missing the table is corrupt or unsupported."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class LanceStore(DrawerStore):
|
|
230
|
+
"""
|
|
231
|
+
Crash-safe drawer storage using LanceDB.
|
|
232
|
+
|
|
233
|
+
Data is stored in Lance columnar format with proper transactions —
|
|
234
|
+
an interrupted write does not corrupt the entire dataset.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(self, palace_path: str, create: bool = True, embed_model: Optional[str] = None):
|
|
238
|
+
import lancedb
|
|
239
|
+
|
|
240
|
+
self._model_name = embed_model or DEFAULT_EMBED_MODEL
|
|
241
|
+
self._db = lancedb.connect(os.path.join(palace_path, "lance"))
|
|
242
|
+
self._embedder = self._get_embedder()
|
|
243
|
+
self._table = self._open_or_create(create)
|
|
244
|
+
|
|
245
|
+
def _get_embedder(self):
|
|
246
|
+
"""Load the sentence-transformers embedding model."""
|
|
247
|
+
from lancedb.embeddings import get_registry
|
|
248
|
+
|
|
249
|
+
return get_registry().get("sentence-transformers").create(name=self._model_name)
|
|
250
|
+
|
|
251
|
+
def _open_or_create(self, create: bool):
|
|
252
|
+
"""Open existing table or create a new one, migrating schema if needed."""
|
|
253
|
+
dim = self._embedder.ndims()
|
|
254
|
+
target = _target_drawer_schema(dim)
|
|
255
|
+
|
|
256
|
+
# Try to open existing table first
|
|
257
|
+
_existing_table = None
|
|
258
|
+
try:
|
|
259
|
+
_existing_table = self._db.open_table(_LANCE_TABLE)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.debug("Table %r not found, will create: %s", _LANCE_TABLE, e)
|
|
262
|
+
|
|
263
|
+
if _existing_table is not None:
|
|
264
|
+
existing_names = set(_existing_table.schema.names)
|
|
265
|
+
missing_fields = [f for f in target if f.name not in existing_names]
|
|
266
|
+
if missing_fields:
|
|
267
|
+
cols_to_add = {f.name: _sql_default_for_arrow_type(f.type) for f in missing_fields}
|
|
268
|
+
logger.info(
|
|
269
|
+
"Migrating palace schema: adding columns %s",
|
|
270
|
+
sorted(cols_to_add),
|
|
271
|
+
)
|
|
272
|
+
_existing_table.add_columns(cols_to_add)
|
|
273
|
+
# Reload the handle so its schema reflects the updated on-disk table
|
|
274
|
+
_existing_table = self._db.open_table(_LANCE_TABLE)
|
|
275
|
+
reloaded_names = set(_existing_table.schema.names)
|
|
276
|
+
if not set(target.names) <= reloaded_names:
|
|
277
|
+
still_missing = set(target.names) - reloaded_names
|
|
278
|
+
raise RuntimeError(
|
|
279
|
+
f"Post-migration assertion failed — still missing columns: {still_missing}"
|
|
280
|
+
)
|
|
281
|
+
return _existing_table
|
|
282
|
+
|
|
283
|
+
if not create:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
return self._db.create_table(_LANCE_TABLE, schema=target)
|
|
287
|
+
|
|
288
|
+
def _embed(self, texts: List[str]) -> List[List[float]]:
|
|
289
|
+
"""Generate embeddings for a list of texts."""
|
|
290
|
+
return self._embedder.compute_source_embeddings(texts)
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _meta_defaults(meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
294
|
+
"""Fill in default values for metadata fields; drop unknown keys."""
|
|
295
|
+
# Start with defaults, overlay known keys from meta, drop unknowns
|
|
296
|
+
merged = dict(_META_DEFAULTS)
|
|
297
|
+
for k, v in meta.items():
|
|
298
|
+
if k in _META_DEFAULTS:
|
|
299
|
+
merged[k] = v
|
|
300
|
+
# Ensure numeric fields have correct types
|
|
301
|
+
merged["chunk_index"] = int(merged.get("chunk_index", 0))
|
|
302
|
+
merged["compression_ratio"] = float(merged.get("compression_ratio", 0.0))
|
|
303
|
+
merged["original_tokens"] = int(merged.get("original_tokens", 0))
|
|
304
|
+
return merged
|
|
305
|
+
|
|
306
|
+
def count(self) -> int:
|
|
307
|
+
if self._table is None:
|
|
308
|
+
return 0
|
|
309
|
+
return self._table.count_rows()
|
|
310
|
+
|
|
311
|
+
def add(self, ids, documents, metadatas):
|
|
312
|
+
if self._table is None:
|
|
313
|
+
raise RuntimeError("Table does not exist and create=False")
|
|
314
|
+
|
|
315
|
+
vectors = self._embed(documents)
|
|
316
|
+
rows = []
|
|
317
|
+
for id_, doc, meta, vec in zip(ids, documents, metadatas, vectors):
|
|
318
|
+
row = self._meta_defaults(meta)
|
|
319
|
+
row["id"] = id_
|
|
320
|
+
row["text"] = doc
|
|
321
|
+
row["vector"] = vec
|
|
322
|
+
rows.append(row)
|
|
323
|
+
|
|
324
|
+
self._table.add(rows)
|
|
325
|
+
|
|
326
|
+
def upsert(self, ids, documents, metadatas):
|
|
327
|
+
# LanceDB merge_insert for upsert
|
|
328
|
+
if self._table is None:
|
|
329
|
+
raise RuntimeError("Table does not exist and create=False")
|
|
330
|
+
|
|
331
|
+
vectors = self._embed(documents)
|
|
332
|
+
rows = []
|
|
333
|
+
for id_, doc, meta, vec in zip(ids, documents, metadatas, vectors):
|
|
334
|
+
row = self._meta_defaults(meta)
|
|
335
|
+
row["id"] = id_
|
|
336
|
+
row["text"] = doc
|
|
337
|
+
row["vector"] = vec
|
|
338
|
+
rows.append(row)
|
|
339
|
+
|
|
340
|
+
self._table.merge_insert(
|
|
341
|
+
"id"
|
|
342
|
+
).when_matched_update_all().when_not_matched_insert_all().execute(rows)
|
|
343
|
+
|
|
344
|
+
def get(self, ids=None, where=None, include=None, limit=10000, offset=0):
|
|
345
|
+
if self._table is None:
|
|
346
|
+
return {"ids": [], "documents": [], "metadatas": []}
|
|
347
|
+
|
|
348
|
+
include = include or []
|
|
349
|
+
|
|
350
|
+
if ids is not None:
|
|
351
|
+
if not ids:
|
|
352
|
+
return {"ids": [], "documents": [], "metadatas": []}
|
|
353
|
+
# Fetch by explicit IDs
|
|
354
|
+
id_list = ", ".join(f"'{id_}'" for id_ in ids)
|
|
355
|
+
try:
|
|
356
|
+
results = self._table.search().where(f"id IN ({id_list})").limit(len(ids)).to_list()
|
|
357
|
+
except Exception:
|
|
358
|
+
results = []
|
|
359
|
+
elif where is not None:
|
|
360
|
+
sql = self._where_to_sql(where)
|
|
361
|
+
try:
|
|
362
|
+
results = self._table.search().where(sql).limit(limit).offset(offset).to_list()
|
|
363
|
+
except Exception:
|
|
364
|
+
results = []
|
|
365
|
+
else:
|
|
366
|
+
try:
|
|
367
|
+
results = self._table.search().limit(limit).offset(offset).to_list()
|
|
368
|
+
except Exception:
|
|
369
|
+
results = []
|
|
370
|
+
|
|
371
|
+
out_ids = [r["id"] for r in results]
|
|
372
|
+
out: Dict[str, List] = {"ids": out_ids}
|
|
373
|
+
|
|
374
|
+
if "documents" in include:
|
|
375
|
+
out["documents"] = [r["text"] for r in results]
|
|
376
|
+
if "metadatas" in include:
|
|
377
|
+
out["metadatas"] = [{k: r.get(k, "") for k in _META_KEYS} for r in results]
|
|
378
|
+
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
def query(self, query_texts, n_results=5, where=None, include=None):
|
|
382
|
+
if self._table is None:
|
|
383
|
+
return {"ids": [[]], "documents": [[]], "metadatas": [[]], "distances": [[]]}
|
|
384
|
+
|
|
385
|
+
include = include or []
|
|
386
|
+
all_ids, all_docs, all_metas, all_dists = [], [], [], []
|
|
387
|
+
|
|
388
|
+
for text in query_texts:
|
|
389
|
+
vec = self._embed([text])[0]
|
|
390
|
+
q = self._table.search(vec).limit(n_results)
|
|
391
|
+
if where:
|
|
392
|
+
sql = self._where_to_sql(where)
|
|
393
|
+
q = q.where(sql)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
results = q.to_list()
|
|
397
|
+
except Exception:
|
|
398
|
+
results = []
|
|
399
|
+
|
|
400
|
+
ids = [r["id"] for r in results]
|
|
401
|
+
docs = [r["text"] for r in results]
|
|
402
|
+
metas = []
|
|
403
|
+
dists = []
|
|
404
|
+
|
|
405
|
+
for r in results:
|
|
406
|
+
metas.append({k: r.get(k, "") for k in _META_KEYS})
|
|
407
|
+
# LanceDB returns _distance (L2 distance)
|
|
408
|
+
dists.append(r.get("_distance", 0.0))
|
|
409
|
+
|
|
410
|
+
all_ids.append(ids)
|
|
411
|
+
all_docs.append(docs)
|
|
412
|
+
all_metas.append(metas)
|
|
413
|
+
all_dists.append(dists)
|
|
414
|
+
|
|
415
|
+
out: Dict[str, List[List]] = {"ids": all_ids}
|
|
416
|
+
if "documents" in include:
|
|
417
|
+
out["documents"] = all_docs
|
|
418
|
+
if "metadatas" in include:
|
|
419
|
+
out["metadatas"] = all_metas
|
|
420
|
+
if "distances" in include:
|
|
421
|
+
out["distances"] = all_dists
|
|
422
|
+
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
def delete(self, ids):
|
|
426
|
+
if self._table is None:
|
|
427
|
+
return
|
|
428
|
+
if not ids:
|
|
429
|
+
return
|
|
430
|
+
id_list = ", ".join(f"'{id_}'" for id_ in ids)
|
|
431
|
+
self._table.delete(f"id IN ({id_list})")
|
|
432
|
+
|
|
433
|
+
def delete_wing(self, wing: str) -> int:
|
|
434
|
+
if self._table is None:
|
|
435
|
+
return 0
|
|
436
|
+
escaped = wing.replace("'", "''")
|
|
437
|
+
count = self._table.count_rows(f"wing = '{escaped}'")
|
|
438
|
+
if count == 0:
|
|
439
|
+
return 0
|
|
440
|
+
self._table.delete(f"wing = '{escaped}'")
|
|
441
|
+
return count
|
|
442
|
+
|
|
443
|
+
def delete_by_source_file(self, source_file: str, wing: str) -> int:
|
|
444
|
+
"""Delete all drawers for a given source_file within a wing."""
|
|
445
|
+
if self._table is None:
|
|
446
|
+
return 0
|
|
447
|
+
escaped_file = source_file.replace("'", "''")
|
|
448
|
+
escaped_wing = wing.replace("'", "''")
|
|
449
|
+
count = self._table.count_rows(
|
|
450
|
+
f"source_file = '{escaped_file}' AND wing = '{escaped_wing}'"
|
|
451
|
+
)
|
|
452
|
+
if count == 0:
|
|
453
|
+
return 0
|
|
454
|
+
self._table.delete(f"source_file = '{escaped_file}' AND wing = '{escaped_wing}'")
|
|
455
|
+
return count
|
|
456
|
+
|
|
457
|
+
def get_source_file_hashes(self, wing: str) -> dict:
|
|
458
|
+
"""Return {source_file: source_hash} for all drawers in wing.
|
|
459
|
+
|
|
460
|
+
Uses PyArrow column projection — no vector scan.
|
|
461
|
+
Deduplicates by taking the first hash per source_file.
|
|
462
|
+
Returns an empty dict if the table is empty or column is absent.
|
|
463
|
+
"""
|
|
464
|
+
if self._table is None:
|
|
465
|
+
return {}
|
|
466
|
+
import pyarrow.compute as pc
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
arrow_tbl = self._table.to_arrow().select(["source_file", "source_hash", "wing"])
|
|
470
|
+
except Exception:
|
|
471
|
+
# Table predates migration (source_hash column missing) — return empty
|
|
472
|
+
return {}
|
|
473
|
+
filtered = arrow_tbl.filter(pc.field("wing") == wing)
|
|
474
|
+
result: dict = {}
|
|
475
|
+
for sf, sh in zip(
|
|
476
|
+
filtered.column("source_file").to_pylist(),
|
|
477
|
+
filtered.column("source_hash").to_pylist(),
|
|
478
|
+
):
|
|
479
|
+
if sf not in result:
|
|
480
|
+
result[sf] = sh
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
def count_by(self, column: str) -> Dict[str, int]:
|
|
484
|
+
if self._table is None:
|
|
485
|
+
return {}
|
|
486
|
+
arrow_tbl = self._table.to_arrow().select([column])
|
|
487
|
+
result = arrow_tbl.group_by(column).aggregate([(column, "count")])
|
|
488
|
+
d = result.to_pydict()
|
|
489
|
+
return dict(zip(d[column], d[f"{column}_count"]))
|
|
490
|
+
|
|
491
|
+
def count_by_pair(self, col_a: str, col_b: str) -> Dict[str, Dict[str, int]]:
|
|
492
|
+
if self._table is None:
|
|
493
|
+
return {}
|
|
494
|
+
arrow_tbl = self._table.to_arrow().select([col_a, col_b])
|
|
495
|
+
result = arrow_tbl.group_by([col_a, col_b]).aggregate([(col_b, "count")])
|
|
496
|
+
d = result.to_pydict()
|
|
497
|
+
out: Dict[str, Dict[str, int]] = {}
|
|
498
|
+
for a, b, c in zip(d[col_a], d[col_b], d[f"{col_b}_count"]):
|
|
499
|
+
out.setdefault(a, {})[b] = c
|
|
500
|
+
return out
|
|
501
|
+
|
|
502
|
+
def get_source_files(self, wing: str) -> Optional[set]:
|
|
503
|
+
"""Return the set of all source_file values already stored for *wing*.
|
|
504
|
+
|
|
505
|
+
Uses PyArrow column projection and filter — no vector scan required.
|
|
506
|
+
Returns an empty set if the table is empty or doesn't exist.
|
|
507
|
+
"""
|
|
508
|
+
if self._table is None:
|
|
509
|
+
return set()
|
|
510
|
+
import pyarrow.compute as pc
|
|
511
|
+
|
|
512
|
+
arrow_tbl = self._table.to_arrow().select(["source_file", "wing"])
|
|
513
|
+
filtered = arrow_tbl.filter(pc.field("wing") == wing)
|
|
514
|
+
return set(filtered.column("source_file").to_pylist())
|
|
515
|
+
|
|
516
|
+
def iter_all(self, where=None, batch_size=1000, include_vectors=False):
|
|
517
|
+
"""Yield batches of drawers as lists of dicts using PyArrow column projection.
|
|
518
|
+
|
|
519
|
+
Loads all non-vector columns via to_arrow() (no vector scan), applies an
|
|
520
|
+
optional PyArrow-level filter, then yields one list of dicts per batch.
|
|
521
|
+
"""
|
|
522
|
+
if self._table is None:
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
meta_columns = ["id", "text"] + [name for name, _, _ in _META_FIELD_SPEC]
|
|
526
|
+
columns = meta_columns + (["vector"] if include_vectors else [])
|
|
527
|
+
# Only include columns that actually exist in the schema
|
|
528
|
+
existing = set(self._table.schema.names)
|
|
529
|
+
columns = [c for c in columns if c in existing]
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
arrow_tbl = self._table.to_arrow().select(columns)
|
|
533
|
+
except Exception:
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
if where:
|
|
537
|
+
mask = self._where_to_arrow_mask(arrow_tbl, where)
|
|
538
|
+
if mask is not None:
|
|
539
|
+
arrow_tbl = arrow_tbl.filter(mask)
|
|
540
|
+
|
|
541
|
+
for batch in arrow_tbl.to_batches(max_chunksize=batch_size):
|
|
542
|
+
rows = batch.to_pydict()
|
|
543
|
+
n = len(rows["id"])
|
|
544
|
+
result = []
|
|
545
|
+
for i in range(n):
|
|
546
|
+
row = {col: rows[col][i] for col in rows}
|
|
547
|
+
result.append(row)
|
|
548
|
+
yield result
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def _where_to_arrow_mask(arrow_tbl, where):
|
|
552
|
+
"""Recursively convert a where dict to a PyArrow boolean array for filtering.
|
|
553
|
+
|
|
554
|
+
Mirrors _where_to_sql semantics but operates on an in-memory Arrow table.
|
|
555
|
+
Supports $and, $or, and simple {field: value} equality clauses.
|
|
556
|
+
"""
|
|
557
|
+
import pyarrow.compute as pc
|
|
558
|
+
|
|
559
|
+
if "$and" in where:
|
|
560
|
+
masks = [LanceStore._where_to_arrow_mask(arrow_tbl, sub) for sub in where["$and"]]
|
|
561
|
+
masks = [m for m in masks if m is not None]
|
|
562
|
+
if not masks:
|
|
563
|
+
return None
|
|
564
|
+
result = masks[0]
|
|
565
|
+
for m in masks[1:]:
|
|
566
|
+
result = pc.and_(result, m)
|
|
567
|
+
return result
|
|
568
|
+
|
|
569
|
+
if "$or" in where:
|
|
570
|
+
masks = [LanceStore._where_to_arrow_mask(arrow_tbl, sub) for sub in where["$or"]]
|
|
571
|
+
masks = [m for m in masks if m is not None]
|
|
572
|
+
if not masks:
|
|
573
|
+
return None
|
|
574
|
+
result = masks[0]
|
|
575
|
+
for m in masks[1:]:
|
|
576
|
+
result = pc.or_(result, m)
|
|
577
|
+
return result
|
|
578
|
+
|
|
579
|
+
parts = []
|
|
580
|
+
for key, value in where.items():
|
|
581
|
+
if key not in arrow_tbl.schema.names:
|
|
582
|
+
continue
|
|
583
|
+
col = arrow_tbl.column(key)
|
|
584
|
+
if isinstance(value, str):
|
|
585
|
+
parts.append(pc.equal(col, value))
|
|
586
|
+
elif isinstance(value, (int, float)):
|
|
587
|
+
parts.append(pc.equal(col, value))
|
|
588
|
+
if not parts:
|
|
589
|
+
return None
|
|
590
|
+
result = parts[0]
|
|
591
|
+
for p in parts[1:]:
|
|
592
|
+
result = pc.and_(result, p)
|
|
593
|
+
return result
|
|
594
|
+
|
|
595
|
+
def optimize(self) -> None:
|
|
596
|
+
"""Merge Lance fragments and prune old versions (post-mining compaction)."""
|
|
597
|
+
if self._table is not None:
|
|
598
|
+
self._table.optimize()
|
|
599
|
+
|
|
600
|
+
def warmup(self) -> None:
|
|
601
|
+
"""Embed a throwaway string to force model loading before batch processing."""
|
|
602
|
+
self._embed(["warmup"])
|
|
603
|
+
|
|
604
|
+
@staticmethod
|
|
605
|
+
def _where_to_sql(where: Dict[str, Any]) -> str:
|
|
606
|
+
"""
|
|
607
|
+
Convert ChromaDB-style where filters to SQL WHERE clauses.
|
|
608
|
+
|
|
609
|
+
Supports:
|
|
610
|
+
{"wing": "foo"} → wing = 'foo'
|
|
611
|
+
{"$and": [{"wing": "a"}, {"room": "b"}]} → (wing = 'a') AND (room = 'b')
|
|
612
|
+
{"wing": {"$in": ["a", "b"]}} → wing IN ('a', 'b')
|
|
613
|
+
{"wing": {"$in": []}} → 1 = 0
|
|
614
|
+
{"wing": {"$in": ["a"]}} → wing = 'a' (single-element optimisation)
|
|
615
|
+
"""
|
|
616
|
+
if "$and" in where:
|
|
617
|
+
clauses = [LanceStore._where_to_sql(sub) for sub in where["$and"]]
|
|
618
|
+
return " AND ".join(f"({c})" for c in clauses)
|
|
619
|
+
if "$or" in where:
|
|
620
|
+
clauses = [LanceStore._where_to_sql(sub) for sub in where["$or"]]
|
|
621
|
+
return " OR ".join(f"({c})" for c in clauses)
|
|
622
|
+
|
|
623
|
+
parts = []
|
|
624
|
+
for key, value in where.items():
|
|
625
|
+
if isinstance(value, str):
|
|
626
|
+
escaped = value.replace("'", "''")
|
|
627
|
+
parts.append(f"{key} = '{escaped}'")
|
|
628
|
+
elif isinstance(value, (int, float)):
|
|
629
|
+
parts.append(f"{key} = {value}")
|
|
630
|
+
elif isinstance(value, dict):
|
|
631
|
+
# Operator filters: {"field": {"$eq": val}} etc.
|
|
632
|
+
for op, val in value.items():
|
|
633
|
+
if op == "$eq":
|
|
634
|
+
if isinstance(val, (int, float)):
|
|
635
|
+
parts.append(f"{key} = {val}")
|
|
636
|
+
else:
|
|
637
|
+
escaped = str(val).replace("'", "''")
|
|
638
|
+
parts.append(f"{key} = '{escaped}'")
|
|
639
|
+
elif op == "$ne":
|
|
640
|
+
if isinstance(val, (int, float)):
|
|
641
|
+
parts.append(f"{key} != {val}")
|
|
642
|
+
else:
|
|
643
|
+
escaped = str(val).replace("'", "''")
|
|
644
|
+
parts.append(f"{key} != '{escaped}'")
|
|
645
|
+
elif op in ("$gt", "$gte", "$lt", "$lte"):
|
|
646
|
+
sql_op = {"$gt": ">", "$gte": ">=", "$lt": "<", "$lte": "<="}[op]
|
|
647
|
+
parts.append(f"{key} {sql_op} {val}")
|
|
648
|
+
elif op == "$in":
|
|
649
|
+
if not val:
|
|
650
|
+
parts.append("1 = 0")
|
|
651
|
+
elif len(val) == 1:
|
|
652
|
+
# Single-element optimisation — reuse $eq escaping logic
|
|
653
|
+
v = val[0]
|
|
654
|
+
if isinstance(v, (int, float)):
|
|
655
|
+
parts.append(f"{key} = {v}")
|
|
656
|
+
else:
|
|
657
|
+
escaped = str(v).replace("'", "''")
|
|
658
|
+
parts.append(f"{key} = '{escaped}'")
|
|
659
|
+
else:
|
|
660
|
+
first = val[0]
|
|
661
|
+
if isinstance(first, str):
|
|
662
|
+
if not all(isinstance(v, str) for v in val):
|
|
663
|
+
raise ValueError(
|
|
664
|
+
f"$in list for '{key}' must be all str or all numeric, not mixed"
|
|
665
|
+
)
|
|
666
|
+
items = ", ".join(
|
|
667
|
+
f"'{str(v).replace(chr(39), chr(39) * 2)}'" for v in val
|
|
668
|
+
)
|
|
669
|
+
elif isinstance(first, (int, float)):
|
|
670
|
+
if not all(isinstance(v, (int, float)) for v in val):
|
|
671
|
+
raise ValueError(
|
|
672
|
+
f"$in list for '{key}' must be all str or all numeric, not mixed"
|
|
673
|
+
)
|
|
674
|
+
items = ", ".join(str(v) for v in val)
|
|
675
|
+
else:
|
|
676
|
+
raise ValueError(
|
|
677
|
+
f"$in list for '{key}' contains unsupported type: {type(first)}"
|
|
678
|
+
)
|
|
679
|
+
parts.append(f"{key} IN ({items})")
|
|
680
|
+
else:
|
|
681
|
+
parts.append(f"{key} = '{value}'")
|
|
682
|
+
|
|
683
|
+
return " AND ".join(parts) if parts else "1=1"
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# ─── ChromaDB backend (legacy) ────────────────────────────────────────────────
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
class ChromaStore(DrawerStore):
|
|
690
|
+
"""
|
|
691
|
+
Legacy ChromaDB-backed storage. Kept for migration and compatibility.
|
|
692
|
+
|
|
693
|
+
WARNING: ChromaDB PersistentClient uses HNSW with no WAL.
|
|
694
|
+
An interrupted write can corrupt the entire collection.
|
|
695
|
+
"""
|
|
696
|
+
|
|
697
|
+
def __init__(
|
|
698
|
+
self, palace_path: str, collection_name: str = "mempalace_drawers", create: bool = True
|
|
699
|
+
):
|
|
700
|
+
import chromadb
|
|
701
|
+
|
|
702
|
+
self._client = chromadb.PersistentClient(path=palace_path)
|
|
703
|
+
if create:
|
|
704
|
+
self._col = self._client.get_or_create_collection(collection_name)
|
|
705
|
+
else:
|
|
706
|
+
try:
|
|
707
|
+
self._col = self._client.get_collection(collection_name)
|
|
708
|
+
except Exception:
|
|
709
|
+
self._col = None
|
|
710
|
+
|
|
711
|
+
def count(self) -> int:
|
|
712
|
+
if self._col is None:
|
|
713
|
+
return 0
|
|
714
|
+
return self._col.count()
|
|
715
|
+
|
|
716
|
+
def add(self, ids, documents, metadatas):
|
|
717
|
+
self._col.add(ids=ids, documents=documents, metadatas=metadatas)
|
|
718
|
+
|
|
719
|
+
def upsert(self, ids, documents, metadatas):
|
|
720
|
+
self._col.upsert(ids=ids, documents=documents, metadatas=metadatas)
|
|
721
|
+
|
|
722
|
+
def get(self, ids=None, where=None, include=None, limit=10000, offset=0):
|
|
723
|
+
kwargs: Dict[str, Any] = {}
|
|
724
|
+
if ids is not None:
|
|
725
|
+
kwargs["ids"] = ids
|
|
726
|
+
if where:
|
|
727
|
+
kwargs["where"] = where
|
|
728
|
+
if include:
|
|
729
|
+
kwargs["include"] = include
|
|
730
|
+
kwargs["limit"] = limit
|
|
731
|
+
if offset > 0:
|
|
732
|
+
kwargs["offset"] = offset
|
|
733
|
+
return self._col.get(**kwargs)
|
|
734
|
+
|
|
735
|
+
def query(self, query_texts, n_results=5, where=None, include=None):
|
|
736
|
+
kwargs: Dict[str, Any] = {
|
|
737
|
+
"query_texts": query_texts,
|
|
738
|
+
"n_results": n_results,
|
|
739
|
+
}
|
|
740
|
+
if where:
|
|
741
|
+
kwargs["where"] = where
|
|
742
|
+
if include:
|
|
743
|
+
kwargs["include"] = include
|
|
744
|
+
return self._col.query(**kwargs)
|
|
745
|
+
|
|
746
|
+
def delete(self, ids):
|
|
747
|
+
self._col.delete(ids=ids)
|
|
748
|
+
|
|
749
|
+
def delete_wing(self, wing: str) -> int:
|
|
750
|
+
if self._col is None:
|
|
751
|
+
return 0
|
|
752
|
+
results = self._col.get(where={"wing": wing})
|
|
753
|
+
ids = results.get("ids", [])
|
|
754
|
+
if not ids:
|
|
755
|
+
return 0
|
|
756
|
+
self._col.delete(ids=ids)
|
|
757
|
+
return len(ids)
|
|
758
|
+
|
|
759
|
+
def count_by(self, column: str) -> Dict[str, int]:
|
|
760
|
+
raise NotImplementedError("count_by not supported on deprecated ChromaStore")
|
|
761
|
+
|
|
762
|
+
def count_by_pair(self, col_a: str, col_b: str) -> Dict[str, Dict[str, int]]:
|
|
763
|
+
raise NotImplementedError("count_by_pair not supported on deprecated ChromaStore")
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ─── Store factory ─────────────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _detect_backend(palace_path: str) -> str:
|
|
770
|
+
"""Auto-detect which backend a palace uses based on directory contents."""
|
|
771
|
+
p = Path(palace_path)
|
|
772
|
+
if (p / "lance").exists():
|
|
773
|
+
return "lance"
|
|
774
|
+
if (p / "chroma.sqlite3").exists():
|
|
775
|
+
return "chroma"
|
|
776
|
+
# New palace — default to LanceDB
|
|
777
|
+
return "lance"
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def open_store(
|
|
781
|
+
palace_path: str,
|
|
782
|
+
backend: Optional[str] = None,
|
|
783
|
+
collection_name: str = "mempalace_drawers",
|
|
784
|
+
create: bool = True,
|
|
785
|
+
embed_model: Optional[str] = None,
|
|
786
|
+
) -> DrawerStore:
|
|
787
|
+
"""
|
|
788
|
+
Open a drawer store. Auto-detects backend if not specified.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
palace_path: Path to the palace data directory.
|
|
792
|
+
backend: "lance" or "chroma". None = auto-detect.
|
|
793
|
+
collection_name: Collection name (ChromaDB only).
|
|
794
|
+
create: Create table/collection if it doesn't exist.
|
|
795
|
+
embed_model: Embedding model name (LanceDB only). None = default.
|
|
796
|
+
"""
|
|
797
|
+
os.makedirs(palace_path, exist_ok=True)
|
|
798
|
+
|
|
799
|
+
if backend is None:
|
|
800
|
+
backend = _detect_backend(palace_path)
|
|
801
|
+
|
|
802
|
+
if backend == "lance":
|
|
803
|
+
return LanceStore(palace_path, create=create, embed_model=embed_model)
|
|
804
|
+
elif backend == "chroma":
|
|
805
|
+
return ChromaStore(palace_path, collection_name=collection_name, create=create)
|
|
806
|
+
else:
|
|
807
|
+
raise ValueError(f"Unknown storage backend: {backend!r}. Use 'lance' or 'chroma'.")
|