concierge-graph 3.8.2__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,759 @@
1
+ """
2
+ storage/vector_store.py — Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ Concrete vector backend based on ChromaDB (default) with support for:
5
+ - Model Tiering (Flash vs Elite) for cost optimization
6
+ - Batch Processing for massive ingestion (concierge mine)
7
+ - Reconciliation Loop (verify_sync) to eliminate orphan vectors
8
+ - Semantic Fallback (embedding failed → log + skip, without blocking pipeline)
9
+ - Vector search with scores ready for Hybrid Search / Reranking
10
+
11
+ Dependency: chromadb>=0.4.0 (pip install chromadb)
12
+
13
+ Architecture:
14
+ EmbeddingManager → Generates embeddings with Flash/Elite tiering + fallback
15
+ ChromaVectorStore → Implements BaseVectorBackend on ChromaDB
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import math
22
+ from pathlib import Path
23
+ from typing import Any, Optional
24
+
25
+ logger = logging.getLogger("grafo-concierge.vector-store")
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Defensive import of ChromaDB — allows importing the module without the dependency
29
+ # ---------------------------------------------------------------------------
30
+
31
+ try:
32
+ import chromadb
33
+ from chromadb.config import Settings as ChromaSettings
34
+ CHROMADB_AVAILABLE = True
35
+ except ImportError:
36
+ CHROMADB_AVAILABLE = False
37
+ logger.warning(
38
+ "chromadb not installed. Install with: pip install chromadb>=0.4.0"
39
+ )
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Defensive import of SentenceTransformers (local Flash model)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ try:
46
+ from sentence_transformers import SentenceTransformer
47
+ SENTENCE_TRANSFORMERS_AVAILABLE = True
48
+ except ImportError:
49
+ SENTENCE_TRANSFORMERS_AVAILABLE = False
50
+ logger.warning(
51
+ "sentence-transformers not installed. Tier FLASH unavailable. "
52
+ "Install with: pip install sentence-transformers"
53
+ )
54
+
55
+ from storage.base_backend import (
56
+ BaseVectorBackend,
57
+ EmbeddingTier,
58
+ VectorSearchResult,
59
+ )
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Vector module exceptions
63
+ # ---------------------------------------------------------------------------
64
+
65
+ class EmbeddingError(Exception):
66
+ """Error during embedding generation (model not available, invalid input)."""
67
+
68
+ class VectorStoreNotAvailableError(Exception):
69
+ """ChromaDB is not installed or accessible."""
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # EmbeddingManager — embedding generation with tiering and fallback
73
+ # ---------------------------------------------------------------------------
74
+
75
+ class EmbeddingManager:
76
+ """Generates embeddings with support for Model Tiering.
77
+
78
+ Tiers:
79
+ FLASH: all-MiniLM-L6-v2 (local, 384 dims, free)
80
+ ELITE: text-embedding-3-small (OpenAI API, 1536 dims, paid)
81
+
82
+ Semantic Fallback:
83
+ If generation fails, returns None + logs the error.
84
+ The caller decides whether to skip the item or abort.
85
+
86
+ Args:
87
+ tier: EmbeddingTier.FLASH or EmbeddingTier.ELITE.
88
+ openai_api_key: OpenAI API key (required for tier ELITE).
89
+ """
90
+
91
+ # Configurations per tier
92
+ TIER_CONFIG: dict[EmbeddingTier, dict] = {
93
+ EmbeddingTier.FLASH: {
94
+ "model_name": "all-MiniLM-L6-v2",
95
+ "dimensions": 384,
96
+ "provider": "local",
97
+ },
98
+ EmbeddingTier.ELITE: {
99
+ "model_name": "text-embedding-3-small",
100
+ "dimensions": 1536,
101
+ "provider": "openai",
102
+ },
103
+ }
104
+
105
+ def __init__(
106
+ self,
107
+ tier: EmbeddingTier = EmbeddingTier.FLASH,
108
+ openai_api_key: Optional[str] = None,
109
+ ) -> None:
110
+ self._tier = tier
111
+ self._config = self.TIER_CONFIG[tier]
112
+ self._dimensions = self._config["dimensions"]
113
+ self._model: Any = None
114
+ self._openai_key = openai_api_key
115
+
116
+ logger.info(
117
+ "EmbeddingManager initialized: tier=%s, model=%s, dims=%d",
118
+ tier.value, self._config["model_name"], self._dimensions
119
+ )
120
+
121
+ @property
122
+ def dimensions(self) -> int:
123
+ """Number of dimensions of the active model."""
124
+ return self._dimensions
125
+
126
+ @property
127
+ def tier(self) -> EmbeddingTier:
128
+ """Current tier (FLASH or ELITE)."""
129
+ return self._tier
130
+
131
+ def _load_model(self) -> None:
132
+ """Loads the embedding model (lazy loading).
133
+
134
+ Raises:
135
+ EmbeddingError: If the model cannot be loaded.
136
+ """
137
+ if self._model is not None:
138
+ return
139
+
140
+ import os
141
+ if os.environ.get("GRAFO_LIGHTWEIGHT_MODE", "false").lower() == "true":
142
+ raise EmbeddingError("Lightweight mode active - local model disabled.")
143
+
144
+ if self._tier == EmbeddingTier.FLASH:
145
+ if not SENTENCE_TRANSFORMERS_AVAILABLE:
146
+ raise EmbeddingError(
147
+ "sentence-transformers not installed. "
148
+ "Install with: pip install sentence-transformers"
149
+ )
150
+ try:
151
+ self._model = SentenceTransformer(self._config["model_name"])
152
+ logger.info("FLASH model loaded: %s", self._config["model_name"])
153
+ except Exception as e:
154
+ raise EmbeddingError(f"Failed to load FLASH model: {e}") from e
155
+
156
+ elif self._tier == EmbeddingTier.ELITE:
157
+ if not self._openai_key:
158
+ raise EmbeddingError(
159
+ "openai_api_key is required for the ELITE tier."
160
+ )
161
+ try:
162
+ import openai
163
+ self._model = openai.OpenAI(api_key=self._openai_key)
164
+ logger.info("OpenAI client initialized for ELITE tier.")
165
+ except ImportError:
166
+ raise EmbeddingError(
167
+ "openai not installed. Install with: pip install openai>=1.0"
168
+ )
169
+
170
+ def embed(self, text: str) -> Optional[list[float]]:
171
+ """Generates embedding for a text.
172
+
173
+ Semantic Fallback: returns None in case of error (does not block the pipeline).
174
+
175
+ Args:
176
+ text: Text to generate embedding.
177
+
178
+ Returns:
179
+ List of floats (vector) or None if it failed.
180
+ """
181
+ try:
182
+ self._load_model()
183
+ except EmbeddingError as e:
184
+ logger.error("Model unavailable for embed(): %s", e)
185
+ return None
186
+
187
+ try:
188
+ if self._tier == EmbeddingTier.FLASH:
189
+ vector = self._model.encode(text).tolist()
190
+ return vector
191
+
192
+ elif self._tier == EmbeddingTier.ELITE:
193
+ response = self._model.embeddings.create(
194
+ model=self._config["model_name"],
195
+ input=text,
196
+ )
197
+ return response.data[0].embedding
198
+
199
+ except Exception as e:
200
+ # SEMANTIC FALLBACK: logs the error but DOES NOT propagate
201
+ logger.error(
202
+ "Semantic Fallback activated — embed failed for text (%.40s...): %s",
203
+ text, e
204
+ )
205
+ return None
206
+
207
+ def embed_batch(self, texts: list[str]) -> list[Optional[list[float]]]:
208
+ """Generates embeddings in batch.
209
+
210
+ Semantic Fallback applied individually: failing items
211
+ return None in the corresponding position.
212
+
213
+ Args:
214
+ texts: List of texts to generate embeddings.
215
+
216
+ Returns:
217
+ List of vectors (or None for items that failed).
218
+ """
219
+ try:
220
+ self._load_model()
221
+ except EmbeddingError as e:
222
+ logger.error("Model unavailable for embed_batch(): %s", e)
223
+ return [None] * len(texts)
224
+
225
+ results: list[Optional[list[float]]] = []
226
+
227
+ if self._tier == EmbeddingTier.FLASH:
228
+ try:
229
+ vectors = self._model.encode(texts)
230
+ return [v.tolist() for v in vectors]
231
+ except Exception as e:
232
+ logger.error("Semantic Fallback (batch FLASH): %s", e)
233
+ return [None] * len(texts)
234
+
235
+ elif self._tier == EmbeddingTier.ELITE:
236
+ # OpenAI supports native batch
237
+ try:
238
+ response = self._model.embeddings.create(
239
+ model=self._config["model_name"],
240
+ input=texts,
241
+ )
242
+ return [item.embedding for item in response.data]
243
+ except Exception as e:
244
+ logger.error("Semantic Fallback (batch ELITE): %s", e)
245
+ return [None] * len(texts)
246
+
247
+ return results
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # ChromaVectorStore — concrete implementation of BaseVectorBackend
251
+ # ---------------------------------------------------------------------------
252
+
253
+ class ChromaVectorStore(BaseVectorBackend):
254
+ """Vector backend based on ChromaDB.
255
+
256
+ Implements all methods of BaseVectorBackend:
257
+ - store_embedding / store_embeddings_batch (Batch Processing)
258
+ - search (Top-K with scores for Hybrid Search)
259
+ - delete / delete_batch
260
+ - verify_sync (Reconciliation Loop)
261
+ - health_check / count
262
+
263
+ Args:
264
+ persist_dir: Persistence directory of ChromaDB.
265
+ collection_name: Name of the collection (default: 'grafo_concierge').
266
+ embedding_manager: Instance of EmbeddingManager to generate embeddings.
267
+ """
268
+
269
+ # Maximum size of each batch for ChromaDB (avoids OOM in massive projects)
270
+ BATCH_SIZE: int = 100
271
+
272
+ def __init__(
273
+ self,
274
+ persist_dir: str | None = None,
275
+ collection_name: str = "grafo_concierge",
276
+ embedding_manager: Optional[EmbeddingManager] = None,
277
+ ) -> None:
278
+ if persist_dir is None:
279
+ persist_dir = str(Path(__file__).parent.parent.resolve() / "data" / "chroma")
280
+ self._embedding_mgr = embedding_manager or EmbeddingManager()
281
+ self._collection_name = collection_name
282
+
283
+ import os
284
+ if os.environ.get("GRAFO_LIGHTWEIGHT_MODE", "false").lower() == "true":
285
+ logger.info("ChromaVectorStore operating in NO-OP mode (Lightweight Mode active).")
286
+ self._available = False
287
+ self._client = None
288
+ self._collection = None
289
+ return
290
+
291
+ self._available = CHROMADB_AVAILABLE
292
+
293
+ if not CHROMADB_AVAILABLE:
294
+ logger.warning(
295
+ "ChromaVectorStore operating in NO-OP mode (chromadb not installed)."
296
+ )
297
+ self._client = None
298
+ self._collection = None
299
+ return
300
+
301
+ resolved_dir = str(Path(persist_dir).expanduser().absolute())
302
+ Path(resolved_dir).mkdir(parents=True, exist_ok=True)
303
+
304
+ self._client = chromadb.PersistentClient(
305
+ path=resolved_dir,
306
+ settings=ChromaSettings(anonymized_telemetry=False),
307
+ )
308
+ self._collection = self._client.get_or_create_collection(
309
+ name=collection_name,
310
+ metadata={"hnsw:space": "cosine"},
311
+ )
312
+
313
+ logger.info(
314
+ "ChromaVectorStore initialized: dir=%s, collection=%s, tier=%s",
315
+ resolved_dir, collection_name, self._embedding_mgr.tier.value
316
+ )
317
+
318
+ # ===================================================================
319
+ # STORE — individual storage
320
+ # ===================================================================
321
+
322
+ def store_embedding(
323
+ self,
324
+ doc_id: str,
325
+ embedding: list[float],
326
+ metadata: dict,
327
+ ) -> None:
328
+ """Stores an embedding with metadata in ChromaDB.
329
+
330
+ Args:
331
+ doc_id: Unique ID (recommended format: 'node_{node_id}').
332
+ embedding: Embedding vector.
333
+ metadata: Dict with 'node_id' (int) and 'project_uuid' (str).
334
+
335
+ Raises:
336
+ ValueError: If metadata does not contain required fields.
337
+ """
338
+ if not self._available:
339
+ logger.warning("store_embedding ignored: ChromaDB unavailable.")
340
+ return
341
+
342
+ self._validate_metadata(metadata)
343
+
344
+ # ChromaDB requires all metadata values to be str, int or float
345
+ safe_meta = self._sanitize_metadata(metadata)
346
+
347
+ self._collection.upsert(
348
+ ids=[doc_id],
349
+ embeddings=[embedding],
350
+ metadatas=[safe_meta],
351
+ )
352
+ logger.debug("Embedding stored: doc_id=%s, node_id=%s", doc_id, metadata.get("node_id"))
353
+
354
+ # ===================================================================
355
+ # STORE BATCH — batch insertion
356
+ # ===================================================================
357
+
358
+ def store_embeddings_batch(self, items: list[dict]) -> int:
359
+ """Stores multiple embeddings in controlled batches.
360
+
361
+ Processes in chunks of BATCH_SIZE to avoid OOM.
362
+ Items with embedding=None (Semantic Fallback) are ignored silently.
363
+
364
+ Args:
365
+ items: List of dicts, each containing:
366
+ - doc_id (str)
367
+ - embedding (list[float] or None)
368
+ - metadata (dict with node_id and project_uuid)
369
+
370
+ Returns:
371
+ Number of embeddings effectively stored.
372
+ """
373
+ if not self._available:
374
+ logger.warning("store_embeddings_batch ignored: ChromaDB unavailable.")
375
+ return 0
376
+
377
+ stored = 0
378
+ # Filters items with valid embedding
379
+ valid_items = [
380
+ item for item in items
381
+ if item.get("embedding") is not None
382
+ ]
383
+
384
+ skipped = len(items) - len(valid_items)
385
+ if skipped > 0:
386
+ logger.warning(
387
+ "Batch store: %d items ignored (Semantic Fallback — embedding None)", skipped
388
+ )
389
+
390
+ # Processes in chunks
391
+ for i in range(0, len(valid_items), self.BATCH_SIZE):
392
+ chunk = valid_items[i:i + self.BATCH_SIZE]
393
+
394
+ ids = []
395
+ embeddings = []
396
+ metadatas = []
397
+
398
+ for item in chunk:
399
+ try:
400
+ self._validate_metadata(item["metadata"])
401
+ ids.append(item["doc_id"])
402
+ embeddings.append(item["embedding"])
403
+ metadatas.append(self._sanitize_metadata(item["metadata"]))
404
+ except (ValueError, KeyError) as e:
405
+ logger.warning("Item ignored in batch (invalid metadata): %s", e)
406
+ continue
407
+
408
+ if ids:
409
+ self._collection.upsert(
410
+ ids=ids,
411
+ embeddings=embeddings,
412
+ metadatas=metadatas,
413
+ )
414
+ stored += len(ids)
415
+ logger.debug("Batch chunk stored: %d embeddings", len(ids))
416
+
417
+ logger.info("Batch store finished: %d/%d embeddings stored.", stored, len(items))
418
+ return stored
419
+
420
+ # ===================================================================
421
+ # SEARCH — vector search for Hybrid Search
422
+ # ===================================================================
423
+
424
+ def search(
425
+ self,
426
+ query_embedding: list[float],
427
+ project_uuids: list[str],
428
+ top_k: int = 10,
429
+ filters: Optional[dict] = None,
430
+ ) -> list[VectorSearchResult]:
431
+ """Search by cosine similarity with Strict Scoping per project.
432
+
433
+ Results are returned with normalized scores [0, 1],
434
+ ready for the Hybrid Search / Reranking pipeline.
435
+
436
+ Args:
437
+ query_embedding: Vector of the query.
438
+ project_uuids: List of UUIDs to filter (Strict Scoping).
439
+ top_k: Maximum results.
440
+ filters: Additional filters (e.g. {'node_type': 'FACT'}).
441
+
442
+ Returns:
443
+ List of VectorSearchResult sorted by score DESC.
444
+ """
445
+ if not self._available:
446
+ logger.warning("search ignored: ChromaDB unavailable.")
447
+ return []
448
+
449
+ # Builds the ChromaDB filter
450
+ where_filter = self._build_where_filter(project_uuids, filters)
451
+
452
+ try:
453
+ results = self._collection.query(
454
+ query_embeddings=[query_embedding],
455
+ n_results=top_k,
456
+ where=where_filter if where_filter else None,
457
+ include=["metadatas", "distances"],
458
+ )
459
+ except Exception as e:
460
+ logger.error("Error in vector search: %s", e)
461
+ return []
462
+
463
+ # ChromaDB returns distances (less = more similar for cosine)
464
+ # We convert to score (greater = more similar): score = 1 - distance
465
+ search_results: list[VectorSearchResult] = []
466
+
467
+ if results and results.get("ids") and results["ids"][0]:
468
+ ids = results["ids"][0]
469
+ distances = results["distances"][0] if results.get("distances") else [0.0] * len(ids)
470
+ metadatas = results["metadatas"][0] if results.get("metadatas") else [{}] * len(ids)
471
+
472
+ for doc_id, distance, meta in zip(ids, distances, metadatas):
473
+ # Cosine distance -> similarity score
474
+ score = max(1.0 - distance, 0.0)
475
+ search_results.append(VectorSearchResult(
476
+ doc_id=doc_id,
477
+ node_id=int(meta.get("node_id", 0)),
478
+ project_uuid=str(meta.get("project_uuid", "")),
479
+ score=round(score, 4),
480
+ metadata=meta,
481
+ ))
482
+
483
+ logger.debug("Vector search returned %d results (top_k=%d)", len(search_results), top_k)
484
+ return search_results
485
+
486
+ # ===================================================================
487
+ # DELETE — individual and batch deletion
488
+ # ===================================================================
489
+
490
+ def delete(self, doc_id: str) -> None:
491
+ """Removes an embedding by doc_id."""
492
+ if not self._available:
493
+ return
494
+
495
+ try:
496
+ self._collection.delete(ids=[doc_id])
497
+ logger.debug("Embedding removed: %s", doc_id)
498
+ except Exception as e:
499
+ logger.warning("Failed to delete embedding %s: %s", doc_id, e)
500
+
501
+ def delete_batch(self, doc_ids: list[str]) -> int:
502
+ """Removes multiple embeddings in batch.
503
+
504
+ Args:
505
+ doc_ids: List of doc_ids to remove.
506
+
507
+ Returns:
508
+ Number of processed IDs for removal.
509
+ """
510
+ if not self._available:
511
+ return 0
512
+
513
+ if not doc_ids:
514
+ return 0
515
+
516
+ deleted = 0
517
+ for i in range(0, len(doc_ids), self.BATCH_SIZE):
518
+ chunk = doc_ids[i:i + self.BATCH_SIZE]
519
+ try:
520
+ self._collection.delete(ids=chunk)
521
+ deleted += len(chunk)
522
+ except Exception as e:
523
+ logger.error("Failed to delete batch of %d embeddings: %s", len(chunk), e)
524
+
525
+ logger.info("Batch delete finished: %d/%d removed.", deleted, len(doc_ids))
526
+ return deleted
527
+
528
+ # ===================================================================
529
+ # RECONCILIATION LOOP — verify_sync
530
+ # ===================================================================
531
+
532
+ def verify_sync(self, sqlite_node_ids: set[int]) -> list[str]:
533
+ """Detects orphan vectors by comparing with the valid SQLite IDs.
534
+
535
+ Paged flow (OOM protection):
536
+ 1. Gets only doc_ids from ChromaDB (without heavy metadata).
537
+ 2. Iterates in batches of BATCH_SIZE, loading metadata per slice.
538
+ 3. Compares node_id of each batch with sqlite_node_ids.
539
+ 4. Accumulates doc_ids whose node_id does NOT exist in SQLite.
540
+
541
+ Args:
542
+ sqlite_node_ids: Set of valid node_ids in SQLite.
543
+
544
+ Returns:
545
+ List of orphan doc_ids (exist in vector but not in SQLite).
546
+ """
547
+ if not self._available:
548
+ return []
549
+
550
+ orphans: list[str] = []
551
+
552
+ try:
553
+ # Phase 1: get only IDs (without heavy metadata in RAM)
554
+ id_data = self._collection.get(include=[])
555
+ all_ids = id_data.get("ids", [])
556
+
557
+ if not all_ids:
558
+ logger.info("Reconciliation Loop: collection empty — nothing to verify.")
559
+ return []
560
+
561
+ # Phase 2: iterate in batches of BATCH_SIZE, loading metadata per slice
562
+ for offset in range(0, len(all_ids), self.BATCH_SIZE):
563
+ batch_ids = all_ids[offset:offset + self.BATCH_SIZE]
564
+ batch_data = self._collection.get(
565
+ ids=batch_ids,
566
+ include=["metadatas"],
567
+ )
568
+ batch_metas = batch_data.get("metadatas", [])
569
+ batch_doc_ids = batch_data.get("ids", batch_ids)
570
+
571
+ for doc_id, meta in zip(batch_doc_ids, batch_metas):
572
+ node_id = meta.get("node_id") if meta else None
573
+ if node_id is None:
574
+ # Metadata corrupted — consider orphan
575
+ orphans.append(doc_id)
576
+ continue
577
+
578
+ if int(node_id) not in sqlite_node_ids:
579
+ orphans.append(doc_id)
580
+
581
+ except Exception as e:
582
+ logger.error("Failed in Reconciliation Loop (verify_sync): %s", e)
583
+
584
+ if orphans:
585
+ logger.warning(
586
+ "Reconciliation Loop: %d orphan vectors detected.", len(orphans)
587
+ )
588
+ else:
589
+ logger.info("Reconciliation Loop: all vectors are synchronized.")
590
+
591
+ return orphans
592
+
593
+ # ===================================================================
594
+ # HEALTH CHECK + COUNT
595
+ # ===================================================================
596
+
597
+ def health_check(self) -> bool:
598
+ """Verifies if ChromaDB is operational."""
599
+ if not self._available:
600
+ return False
601
+
602
+ try:
603
+ self._client.heartbeat()
604
+ return True
605
+ except Exception as e:
606
+ logger.error("Health check failed: %s", e)
607
+ return False
608
+
609
+ def count(self, project_uuid: Optional[str] = None) -> int:
610
+ """Returns the total number of stored vectors.
611
+
612
+ Args:
613
+ project_uuid: If provided, counts only vectors of this project.
614
+ """
615
+ if not self._available:
616
+ return 0
617
+
618
+ try:
619
+ if project_uuid:
620
+ result = self._collection.get(
621
+ where={"project_uuid": project_uuid},
622
+ include=[],
623
+ )
624
+ return len(result.get("ids", []))
625
+ return self._collection.count()
626
+ except Exception as e:
627
+ logger.error("Failed to count vectors: %s", e)
628
+ return 0
629
+
630
+ def reset_collection(self) -> bool:
631
+ """Destroys and recreates the physical collection of vectors (emergency repair).
632
+
633
+ WARNING: This operation deletes ALL embeddings irreversibly.
634
+ After reset, it will be necessary to re-ingest projects to recreate vectors.
635
+
636
+ Returns:
637
+ True if the operation was successful, False otherwise.
638
+ """
639
+ if not self._available or self._client is None:
640
+ logger.warning("reset_collection ignored: ChromaDB unavailable.")
641
+ return False
642
+
643
+ try:
644
+ old_count = self._collection.count() if self._collection else 0
645
+ self._client.delete_collection(self._collection_name)
646
+ self._collection = self._client.get_or_create_collection(
647
+ name=self._collection_name,
648
+ metadata={"hnsw:space": "cosine"},
649
+ )
650
+ logger.info(
651
+ "reset_collection OK: collection '%s' destroyed and recreated "
652
+ "(%d embeddings eliminated).",
653
+ self._collection_name, old_count,
654
+ )
655
+ return True
656
+ except Exception as e:
657
+ logger.error("reset_collection FAILED: %s", e)
658
+ return False
659
+
660
+ # ===================================================================
661
+ # AUXILIARY INTERNAL METHODS
662
+ # ===================================================================
663
+
664
+ def update_metadata(self, doc_id: str, metadata: dict) -> None:
665
+ """Updates the metadata of an existing vector without replacing the embedding.
666
+
667
+ Allows JanitorService to inject community_id and other attributes
668
+ without needing to access self._collection directly.
669
+
670
+ Args:
671
+ doc_id: Document identifier (e.g. 'node_42').
672
+ metadata: Metadata dictionary to apply.
673
+ """
674
+ if not self._available or self._collection is None:
675
+ logger.debug("update_metadata: ChromaDB unavailable — ignored for %s.", doc_id)
676
+ return
677
+
678
+ try:
679
+ safe_meta = self._sanitize_metadata(metadata)
680
+ self._collection.update(ids=[doc_id], metadatas=[safe_meta])
681
+ logger.debug("Vector metadata updated: doc_id=%s", doc_id)
682
+ except Exception as e:
683
+ logger.error("Failed to update metadata in ChromaDB for %s: %s", doc_id, e)
684
+
685
+
686
+ @staticmethod
687
+ def _validate_metadata(metadata: dict) -> None:
688
+ """Validates that metadata contains required fields."""
689
+ if "node_id" not in metadata:
690
+ raise ValueError("metadata must contain 'node_id' (int).")
691
+ if "project_uuid" not in metadata:
692
+ raise ValueError("metadata must contain 'project_uuid' (str).")
693
+
694
+ @staticmethod
695
+ def _sanitize_metadata(metadata: dict) -> dict:
696
+ """Ensures that all metadata values are types accepted by ChromaDB.
697
+
698
+ ChromaDB accepts: str, int, float, bool. Lists and dicts are converted to str.
699
+ """
700
+ safe = {}
701
+ for k, v in metadata.items():
702
+ if isinstance(v, (str, int, float, bool)):
703
+ safe[k] = v
704
+ elif v is None:
705
+ safe[k] = ""
706
+ else:
707
+ safe[k] = str(v)
708
+ return safe
709
+
710
+ @staticmethod
711
+ def _build_where_filter(
712
+ project_uuids: list[str],
713
+ extra_filters: Optional[dict] = None,
714
+ ) -> Optional[dict]:
715
+ """Builds the ChromaDB 'where' filter combining project + extra filters.
716
+
717
+ Args:
718
+ project_uuids: List of UUIDs for Strict Scoping.
719
+ extra_filters: Additional filters (e.g. {'node_type': 'FACT'}).
720
+
721
+ Returns:
722
+ Dict in ChromaDB where format, or None if no filters.
723
+ """
724
+ conditions: list[dict] = []
725
+
726
+ if project_uuids:
727
+ if len(project_uuids) == 1:
728
+ conditions.append({"project_uuid": project_uuids[0]})
729
+ else:
730
+ conditions.append({"project_uuid": {"$in": project_uuids}})
731
+
732
+ if extra_filters:
733
+ for key, value in extra_filters.items():
734
+ conditions.append({key: value})
735
+
736
+ if not conditions:
737
+ return None
738
+ if len(conditions) == 1:
739
+ return conditions[0]
740
+ return {"$and": conditions}
741
+
742
+ def get_all_stored_node_ids(self) -> set[int]:
743
+ """Returns all numerical node_ids present in the Chroma collection."""
744
+ if not self._available:
745
+ return set()
746
+ try:
747
+ id_data = self._collection.get(include=[])
748
+ ids = id_data.get("ids", [])
749
+ node_ids = set()
750
+ for doc_id in ids:
751
+ if doc_id.startswith("node_"):
752
+ try:
753
+ node_ids.add(int(doc_id.split("_")[1]))
754
+ except (IndexError, ValueError):
755
+ pass
756
+ return node_ids
757
+ except Exception as e:
758
+ logger.error("Error obtaining stored node_ids in Chroma: %s", e)
759
+ return set()