okfgraph 0.2.4__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.
@@ -0,0 +1,443 @@
1
+ """Schema / metadata / index orchestration extracted during the OKFRouter Phase 1 refactor.
2
+
3
+ Bodies are verbatim from okfgraph/router.py; the facade (OKFRouter) owns
4
+ the shared resources (conn, embedder, tokenizer, ...) and injects them
5
+ here. Public callers reach these via router.<method> (component bridge).
6
+ """
7
+ import logging
8
+ import re
9
+ from typing import Any, Callable, Dict, List, Optional
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class SchemaManager:
13
+ """Owns schema migrations, meta KV store, and search-index rebuild."""
14
+
15
+ SCHEMA_VERSION = 5 # bumped when the on-disk schema changes
16
+
17
+ def __init__(self, conn, embedding_dim, write_lock_ctx):
18
+ self.conn = conn
19
+ self.embedding_dim = embedding_dim
20
+ self._write_lock_ctx = write_lock_ctx
21
+ self._search_available = False
22
+
23
+ def _migrate_v1_to_v2(self) -> None:
24
+ """v1 → v2: Add Chunk table, PART_OF rel, Chunk vector/FTS indexes.
25
+
26
+ This corresponds to the v4.0 → v5.0 transition (graph-aware chunking).
27
+ """
28
+ dim = self.embedding_dim
29
+ self.conn.execute(f"""
30
+ CREATE NODE TABLE IF NOT EXISTS Chunk (
31
+ id STRING PRIMARY KEY,
32
+ parent_doc_id STRING,
33
+ chunk_index INT64,
34
+ chunk_text STRING,
35
+ block_type STRING,
36
+ start_offset INT64,
37
+ end_offset INT64,
38
+ embedding FLOAT[{dim}]
39
+ )
40
+ """)
41
+ self.conn.execute("""
42
+ CREATE REL TABLE IF NOT EXISTS PART_OF (
43
+ FROM Concept TO Chunk
44
+ )
45
+ """)
46
+ # Create Chunk indexes (idempotent — Ladybug skips if present)
47
+ for table, name, create_sql in self._index_specs():
48
+ if table == "Chunk":
49
+ try:
50
+ self.conn.execute(create_sql)
51
+ except Exception as e:
52
+ logger.debug("chunk index %s skipped during migration: %s", name, e)
53
+ logger.info("Schema migrated: v1 → v2 (Chunk + PART_OF)")
54
+
55
+
56
+ def _migrate_v2_to_v3(self) -> None:
57
+ """v2 → v3: Add FileHash table with concept_id column.
58
+
59
+ This corresponds to the v5.1 transition (delta detection & purge).
60
+ """
61
+ self.conn.execute("""
62
+ CREATE NODE TABLE IF NOT EXISTS FileHash (
63
+ path STRING PRIMARY KEY,
64
+ hash STRING,
65
+ concept_id STRING
66
+ )
67
+ """)
68
+ logger.info("Schema migrated: v2 → v3 (FileHash + concept_id)")
69
+
70
+
71
+ def _migrate_v3_to_v4(self) -> None:
72
+ """v3 → v4: Add DirHash table for directory-level hash aggregation.
73
+
74
+ This corresponds to the v5.5 transition (directory-level delta detection).
75
+ """
76
+ self.conn.execute("""
77
+ CREATE NODE TABLE IF NOT EXISTS DirHash (
78
+ path STRING PRIMARY KEY,
79
+ hash STRING,
80
+ files STRING
81
+ )
82
+ """)
83
+ logger.info("Schema migrated: v3 → v4 (DirHash)")
84
+
85
+
86
+ def _migrate_v4_to_v5(self) -> None:
87
+ """v4 → v5: Add DeletedConcept table for soft-delete recovery.
88
+
89
+ This corresponds to the v5.8 transition (soft-delete with recovery window).
90
+ """
91
+ self.conn.execute("""
92
+ CREATE NODE TABLE IF NOT EXISTS DeletedConcept (
93
+ id STRING PRIMARY KEY,
94
+ original_id STRING,
95
+ title STRING,
96
+ body STRING,
97
+ deleted_at STRING,
98
+ type STRING,
99
+ tags STRING
100
+ )
101
+ """)
102
+ logger.info("Schema migrated: v4 → v5 (DeletedConcept)")
103
+
104
+
105
+ _MIGRATIONS = {}
106
+ _MIGRATIONS[1] = _migrate_v1_to_v2
107
+ _MIGRATIONS[2] = _migrate_v2_to_v3
108
+ _MIGRATIONS[3] = _migrate_v3_to_v4
109
+ _MIGRATIONS[4] = _migrate_v4_to_v5
110
+
111
+ def _ensure_schema(self) -> None:
112
+ """Create schema, extensions, and indexes if they don't exist."""
113
+ # Search extensions are optional at construction time. Text-only
114
+ # ingestion, graph traversal, list_directory and get_by_id all work
115
+ # without them; only hybrid/image *search* needs vector + fts. If the
116
+ # extensions can't be installed/loaded (e.g. the extension repository
117
+ # is unreachable), degrade gracefully rather than making the whole
118
+ # router unusable — a search call will then raise a clear error.
119
+ self._search_available = True
120
+ for ext in ("vector", "fts"):
121
+ try:
122
+ self.conn.execute(f"INSTALL {ext};")
123
+ self.conn.execute(f"LOAD {ext};")
124
+ except Exception as e:
125
+ self._search_available = False
126
+ logger.warning(
127
+ "Could not load the '%s' extension (%s). Vector/FTS search "
128
+ "will be unavailable; ingestion and graph queries still work.",
129
+ ext, e,
130
+ )
131
+
132
+ self.conn.execute(f"""
133
+ CREATE NODE TABLE IF NOT EXISTS Concept (
134
+ id STRING PRIMARY KEY,
135
+ type STRING,
136
+ title STRING,
137
+ description STRING,
138
+ resource STRING,
139
+ tags STRING[],
140
+ timestamp TIMESTAMP,
141
+ body STRING,
142
+ embedding FLOAT[{self.embedding_dim}],
143
+ extra MAP(STRING, STRING)
144
+ )
145
+ """)
146
+ self.conn.execute("""
147
+ CREATE NODE TABLE IF NOT EXISTS Directory (id STRING PRIMARY KEY)
148
+ """)
149
+
150
+ # ImageAsset: unified embedding column (text-model alt-text vectors and
151
+ # omni-model image vectors share this single space / index).
152
+ self.conn.execute(f"""
153
+ CREATE NODE TABLE IF NOT EXISTS ImageAsset (
154
+ id STRING PRIMARY KEY,
155
+ file_name STRING,
156
+ mime_type STRING,
157
+ alt_text STRING,
158
+ caption STRING,
159
+ embed_route STRING,
160
+ content_hash STRING,
161
+ data BLOB,
162
+ embedding FLOAT[{self.embedding_dim}]
163
+ )
164
+ """)
165
+
166
+ self.conn.execute("""
167
+ CREATE REL TABLE IF NOT EXISTS CONTAINS (
168
+ FROM Directory TO Directory,
169
+ FROM Directory TO Concept
170
+ )
171
+ """)
172
+ self.conn.execute("""
173
+ CREATE REL TABLE IF NOT EXISTS LINKS_TO (FROM Concept TO Concept)
174
+ """)
175
+ self.conn.execute("""
176
+ CREATE REL TABLE IF NOT EXISTS INCLUDES_ASSET (FROM Concept TO ImageAsset)
177
+ """)
178
+
179
+ self.conn.execute(f"""
180
+ CREATE NODE TABLE IF NOT EXISTS Chunk (
181
+ id STRING PRIMARY KEY,
182
+ parent_doc_id STRING,
183
+ chunk_index INT64,
184
+ chunk_text STRING,
185
+ block_type STRING,
186
+ start_offset INT64,
187
+ end_offset INT64,
188
+ embedding FLOAT[{self.embedding_dim}]
189
+ )
190
+ """)
191
+
192
+ self.conn.execute("""
193
+ CREATE REL TABLE IF NOT EXISTS PART_OF (
194
+ FROM Concept TO Chunk
195
+ )
196
+ """)
197
+
198
+ # BrokenLink table — tracks links to concepts not yet imported
199
+ self.conn.execute("""
200
+ CREATE NODE TABLE IF NOT EXISTS BrokenLink (
201
+ id STRING PRIMARY KEY,
202
+ source_id STRING,
203
+ target_id STRING,
204
+ timestamp TIMESTAMP
205
+ )
206
+ """)
207
+
208
+ # Meta — small key/value store. Used for index dirty-tracking:
209
+ # 'write_epoch' bumps on every index-affecting write (concept/image
210
+ # upsert or delete); 'indexed_epoch' records the write_epoch at which the
211
+ # search indexes were last (re)built. Indexes are dirty when
212
+ # write_epoch > indexed_epoch, which drives change-driven rebuilds.
213
+ self.conn.execute("""
214
+ CREATE NODE TABLE IF NOT EXISTS Meta (key STRING PRIMARY KEY, value INT64)
215
+ """)
216
+
217
+ # FileHash — per-file SHA-256 mapping for delta detection.
218
+ # Stores which files changed since the last import_bundle() call.
219
+ # concept_id maps the file path to its Concept node for safe purge.
220
+ self.conn.execute("""
221
+ CREATE NODE TABLE IF NOT EXISTS FileHash (
222
+ path STRING PRIMARY KEY,
223
+ hash STRING,
224
+ concept_id STRING
225
+ )
226
+ """)
227
+
228
+ # DirHash — per-directory combined hash for subtree-level delta detection.
229
+ # Stores a SHA-256 hash of all files in a directory subtree. When the
230
+ # hash hasn't changed, the entire subtree is skipped on re-import.
231
+ # The 'files' column stores a JSON string of relative file paths for purge tracking.
232
+ self.conn.execute("""
233
+ CREATE NODE TABLE IF NOT EXISTS DirHash (
234
+ path STRING PRIMARY KEY,
235
+ hash STRING,
236
+ files STRING
237
+ )
238
+ """)
239
+
240
+ # Run schema migrations if the on-disk version is behind.
241
+ self._run_schema_migrations()
242
+
243
+ # When opening a pre-existing DB, the stored embedding column dimension
244
+ # is authoritative (CREATE TABLE IF NOT EXISTS won't have changed it).
245
+ # Adopt it so query vectors match — otherwise a caller that opened the
246
+ # DB with the wrong --dim would silently produce dimension-mismatch
247
+ # errors on every vector search (a common search-browser footgun).
248
+ self._adopt_existing_embedding_dim()
249
+
250
+ # Create the search indexes if they don't yet exist. On construction we
251
+ # do NOT drop/rebuild (that would be costly every time a large DB is
252
+ # merely opened for reading); imports rebuild them explicitly so newly
253
+ # written rows become searchable — see _build_search_indexes().
254
+ self._build_search_indexes(rebuild=False)
255
+
256
+
257
+ def _adopt_existing_embedding_dim(self) -> None:
258
+ """If the Concept.embedding column already exists, honour its dimension.
259
+
260
+ Parses ``FLOAT[N]`` from the stored schema and, if it differs from the
261
+ requested ``embedding_dim``, warns and adopts the stored value so query
262
+ encoding and vector indexes stay consistent with what's on disk.
263
+ """
264
+ try:
265
+ rows = self.conn.execute(
266
+ "CALL TABLE_INFO('Concept') RETURN *"
267
+ ).rows_as_dict().get_all()
268
+ except Exception:
269
+ return
270
+ for r in rows:
271
+ if r.get("name") == "embedding":
272
+ m = re.search(r"\[(\d+)\]", str(r.get("type") or ""))
273
+ if m:
274
+ stored = int(m.group(1))
275
+ if stored != self.embedding_dim:
276
+ logger.warning(
277
+ "Opened a DB whose embedding dimension is %d, but "
278
+ "embedding_dim=%d was requested. Using the stored "
279
+ "dimension (%d) to stay consistent with the data.",
280
+ stored, self.embedding_dim, stored,
281
+ )
282
+ self.embedding_dim = stored
283
+ break
284
+
285
+
286
+ def _run_schema_migrations(self) -> None:
287
+ """Run any pending schema migrations.
288
+
289
+ Reads the stored schema version from the Meta table. If it is behind
290
+ ``SCHEMA_VERSION``, runs each migration function in order and bumps
291
+ the version. New databases start at version 0 (no migrations needed
292
+ because ``_ensure_schema`` creates the full schema).
293
+ """
294
+ current = self._get_meta("schema_version", 0)
295
+ target = self.SCHEMA_VERSION
296
+
297
+ if current >= target:
298
+ return # already up to date
299
+
300
+ if current == 0:
301
+ # Fresh database — schema was created by _ensure_schema above.
302
+ # Just stamp the version.
303
+ self._set_meta("schema_version", target)
304
+ logger.debug("New database — schema version set to %d", target)
305
+ return
306
+
307
+ logger.info(
308
+ "Schema migration: current=%d, target=%d — running %d migration(s)",
309
+ current, target, target - current,
310
+ )
311
+
312
+ for v in range(current, target):
313
+ fn = self._MIGRATIONS.get(v)
314
+ if fn is None:
315
+ logger.error(
316
+ "No migration function for version %d → %d. "
317
+ "Database may be in an inconsistent state.",
318
+ v, v + 1,
319
+ )
320
+ break
321
+ try:
322
+ fn(self)
323
+ except Exception as e:
324
+ logger.error(
325
+ "Migration v%d → v%d failed: %s. Database may be unusable.",
326
+ v, v + 1, e,
327
+ )
328
+ raise
329
+ self._set_meta("schema_version", v + 1)
330
+
331
+ logger.info("Schema migrations complete — version %d", target)
332
+
333
+
334
+ def _index_specs(self):
335
+ vec = (
336
+ "CALL CREATE_VECTOR_INDEX('{table}', '{name}', 'embedding', "
337
+ "mu := 30, ml := 60, metric := 'cosine', efc := 200)"
338
+ )
339
+ return [
340
+ ("Concept", "concept_embedding", vec.format(table="Concept", name="concept_embedding")),
341
+ ("Concept", "concept_fts",
342
+ "CALL CREATE_FTS_INDEX('Concept', 'concept_fts', ['title', 'description', 'body'])"),
343
+ ("ImageAsset", "image_omni_idx", vec.format(table="ImageAsset", name="image_omni_idx")),
344
+ ("Chunk", "chunk_embedding", vec.format(table="Chunk", name="chunk_embedding")),
345
+ ("Chunk", "chunk_fts",
346
+ "CALL CREATE_FTS_INDEX('Chunk', 'chunk_fts', ['chunk_text'])"),
347
+ ]
348
+
349
+
350
+ def _build_search_indexes(self, rebuild: bool, force: bool = False) -> bool:
351
+ """Create (or rebuild) the vector + FTS indexes.
352
+
353
+ Ladybug's vector/FTS indexes are built over a table's *current*
354
+ contents; rows inserted after an index is created are not returned by
355
+ search until the index is rebuilt. Import paths therefore call this with
356
+ ``rebuild=True`` after data is written.
357
+
358
+ Rebuilds are *change-driven*: when ``rebuild`` is requested we skip the
359
+ work unless the indexes are actually dirty (``write_epoch >
360
+ indexed_epoch``), so a no-op import or a redundant call costs nothing.
361
+ Pass ``force=True`` (the manual ``reindex`` path) to rebuild regardless —
362
+ e.g. to repair a DB written by an older build whose markers don't exist.
363
+
364
+ ``rebuild=False`` (construction) only creates missing indexes; it never
365
+ drops, never checks dirty, and never stamps — merely opening a DB should
366
+ not trigger an O(N) rebuild.
367
+
368
+ Returns True if indexes were (re)built, False if skipped.
369
+ """
370
+ if not getattr(self, "_search_available", False):
371
+ return False
372
+ if rebuild and not force and not self._indexes_dirty():
373
+ logger.debug("Search indexes already up to date; skipping rebuild.")
374
+ return False
375
+
376
+ # Capture the epoch we're about to satisfy *before* building, so the
377
+ # stamp reflects the data the index was built over.
378
+ target_epoch = self._get_meta("write_epoch")
379
+ built = False
380
+ for table, name, create_sql in self._index_specs():
381
+ # Ladybug: DROP INDEX leaves stale internal state that prevents
382
+ # recreation with the same name. Instead, rely on CREATE ... IF
383
+ # NOT EXISTS semantics (Ladybug silently skips if present).
384
+ try:
385
+ self.conn.execute(create_sql)
386
+ built = True
387
+ except Exception as e:
388
+ # Already exists (rebuild=False) or table empty — both benign.
389
+ logger.debug("create index %s.%s skipped: %s", table, name, e)
390
+ if rebuild:
391
+ self._set_meta("indexed_epoch", target_epoch)
392
+ return built
393
+
394
+
395
+ def reindex(self, force: bool = True) -> bool:
396
+ """Rebuild the search indexes on demand (recovery / deferred workflows).
397
+
398
+ Use for a DB built by an older version with stale indexes, after a crash
399
+ between the data commit and the index build, or when single-file imports
400
+ deferred rebuilding. Returns True if a rebuild ran.
401
+ """
402
+ # Acquire write lock (Gap #7b)
403
+ with self._write_lock_ctx():
404
+ return self._build_search_indexes(rebuild=True, force=force)
405
+
406
+
407
+ def _get_meta(self, key: str, default: int = 0) -> int:
408
+ try:
409
+ rows = self.conn.execute(
410
+ "MATCH (m:Meta {key: $k}) RETURN m.value AS v", {"k": key}
411
+ ).rows_as_dict().get_all()
412
+ except Exception:
413
+ return default
414
+ return rows[0]["v"] if rows else default
415
+
416
+
417
+ def _set_meta(self, key: str, value: int) -> None:
418
+ try:
419
+ self.conn.execute(
420
+ "MERGE (m:Meta {key: $k}) SET m.value = $v", {"k": key, "v": int(value)}
421
+ )
422
+ except Exception as e:
423
+ logger.debug("could not set meta %s=%s: %s", key, value, e)
424
+
425
+
426
+ def _bump_write_epoch(self) -> None:
427
+ """Mark the search indexes dirty after an index-affecting write."""
428
+ try:
429
+ self.conn.execute(
430
+ """
431
+ MERGE (m:Meta {key: 'write_epoch'})
432
+ ON CREATE SET m.value = 1
433
+ ON MATCH SET m.value = m.value + 1
434
+ """
435
+ )
436
+ except Exception as e:
437
+ logger.debug("could not bump write_epoch: %s", e)
438
+
439
+
440
+ def _indexes_dirty(self) -> bool:
441
+ return self._get_meta("write_epoch") > self._get_meta("indexed_epoch")
442
+
443
+