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.
core/vector_backend.py ADDED
@@ -0,0 +1,471 @@
1
+ """
2
+ core/vector_backend.py — Grafo Concierge v3.8.0 (Conversational Expansion)
3
+
4
+ Concrete vector backend based on Qdrant with support for:
5
+ - Namespace separation by scope (scope_type, scope_id)
6
+ - Isolated collection for episodic memory (episodic_memory)
7
+ - Strict validation of temporal payloads
8
+ - Defensive import (Qdrant available or NO-OP)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import time
15
+ from typing import Any, Optional
16
+
17
+ logger = logging.getLogger("grafo-concierge.qdrant-backend")
18
+
19
+ # Defensive import of QdrantClient
20
+ try:
21
+ import qdrant_client
22
+ from qdrant_client.http import models as qdrant_models
23
+ QDRANT_AVAILABLE = True
24
+ except ImportError:
25
+ QDRANT_AVAILABLE = False
26
+ logger.error(
27
+ "[CRITICAL] qdrant-client not found. QdrantVectorStore operating in NO-OP mode. Semantic searches will return empty!"
28
+ )
29
+
30
+ from storage.base_backend import BaseVectorBackend, VectorSearchResult
31
+
32
+
33
+ class QdrantVectorStore(BaseVectorBackend):
34
+ """Vector backend based on Qdrant.
35
+
36
+ Ensures isolation between the code AST collection and the
37
+ episodic/conversational history collection.
38
+
39
+ The episodic_memory collection payload requires keys:
40
+ - scope_type
41
+ - scope_id
42
+ - timestamp
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ url: str = "http://localhost:6333",
48
+ api_key: Optional[str] = None,
49
+ location: Optional[str] = None,
50
+ memory: bool = False,
51
+ collection_name: str = "grafo_concierge",
52
+ embedding_dimensions: int = 384,
53
+ ) -> None:
54
+ self._collection_name = collection_name
55
+ self._dimensions = embedding_dimensions
56
+ self._last_warn_time = 0.0
57
+
58
+ # Saved parameters for reconnection
59
+ self._url = url
60
+ self._api_key = api_key
61
+ self._location = location
62
+ self._memory = memory
63
+
64
+ # Dynamic connection control and backoff
65
+ self._connected = False
66
+ self._client: Optional[qdrant_client.QdrantClient] = None
67
+ self._backoff = 1.0
68
+ self._max_backoff = 60.0
69
+ self._last_connect_attempt = 0.0
70
+
71
+ import os
72
+ if os.environ.get("GRAFO_LIGHTWEIGHT_MODE", "false").lower() == "true":
73
+ logger.info("QdrantVectorStore operating in NO-OP mode (Lightweight Mode active).")
74
+ return
75
+
76
+ # Tries initial connection
77
+ self._ensure_connected()
78
+
79
+ def _ensure_connected(self) -> bool:
80
+ """Ensures a dynamic and resilient connection to Qdrant using exponential backoff."""
81
+ if not QDRANT_AVAILABLE:
82
+ return False
83
+ if self._connected and self._client:
84
+ return True
85
+
86
+ current_time = time.time()
87
+ # Respects the current backoff interval to avoid frantic requests
88
+ if current_time - self._last_connect_attempt < self._backoff:
89
+ return False
90
+
91
+ self._last_connect_attempt = current_time
92
+ try:
93
+ logger.info("Attempting to establish connection to Qdrant...")
94
+ if self._memory:
95
+ self._client = qdrant_client.QdrantClient(":memory:")
96
+ elif self._location:
97
+ self._client = qdrant_client.QdrantClient(location=self._location, api_key=self._api_key)
98
+ else:
99
+ self._client = qdrant_client.QdrantClient(url=self._url, api_key=self._api_key)
100
+
101
+ # Initializes necessary collections
102
+ self._ensure_collection(self._collection_name)
103
+ self._ensure_collection("episodic_memory")
104
+
105
+ self._connected = True
106
+ self._backoff = 1.0 # reset backoff
107
+ logger.info("Qdrant connected and collections initialized successfully.")
108
+ return True
109
+ except Exception as e:
110
+ self._connected = False
111
+ self._client = None
112
+ logger.warning(
113
+ "Failed to connect or initialize Qdrant collections: %s. Next attempt in %.1fs",
114
+ e,
115
+ self._backoff
116
+ )
117
+ # Exponentially increases backoff
118
+ self._backoff = min(self._backoff * 2.0, self._max_backoff)
119
+ return False
120
+
121
+ def _ensure_collection(self, name: str) -> None:
122
+ """Creates the collection if it does not exist in an idempotent way."""
123
+ if not self._client:
124
+ return
125
+ try:
126
+ exists = self._client.collection_exists(collection_name=name)
127
+ if not exists:
128
+ self._client.create_collection(
129
+ collection_name=name,
130
+ vectors_config=qdrant_models.VectorParams(
131
+ size=self._dimensions,
132
+ distance=qdrant_models.Distance.COSINE
133
+ )
134
+ )
135
+ logger.info("Qdrant collection '%s' created successfully.", name)
136
+ else:
137
+ logger.debug("Qdrant collection '%s' already exists.", name)
138
+ except Exception as e:
139
+ logger.error("Failed to initialize Qdrant collection '%s': %s", name, e)
140
+ raise
141
+
142
+ def _log_noop_warning(self) -> None:
143
+ """Logs a warning when operating in NO-OP mode, throttled to once every 5 seconds."""
144
+ current_time = time.time()
145
+ if current_time - self._last_warn_time >= 5.0:
146
+ logger.warning("Operation ignored: Qdrant in NO-OP mode")
147
+ self._last_warn_time = current_time
148
+
149
+ def _validate_payload(self, metadata: dict) -> None:
150
+ """Validates the mandatory fields depending on the target collection."""
151
+ if self._collection_name == "episodic_memory":
152
+ required = ["scope_type", "scope_id", "timestamp", "utility_alpha", "utility_beta"]
153
+ for r in required:
154
+ if r not in metadata:
155
+ raise ValueError(
156
+ f"Payload de 'episodic_memory' exige a chave '{r}' para indexação temporal."
157
+ )
158
+ # Scope type must be one of the permitted scopes
159
+ scope_type = metadata["scope_type"]
160
+ valid_scopes = {"user", "session", "agent", "org"}
161
+ if scope_type not in valid_scopes:
162
+ raise ValueError(
163
+ f"scope_type inválido '{scope_type}'. Aceitos: {sorted(valid_scopes)}"
164
+ )
165
+ else:
166
+ # Default code/AST collection requires node_id and project_uuid
167
+ if "node_id" not in metadata:
168
+ raise ValueError("metadata deve conter 'node_id' (int).")
169
+ if "project_uuid" not in metadata:
170
+ raise ValueError("metadata deve conter 'project_uuid' (str).")
171
+
172
+ # ===================================================================
173
+ # IMPLEMENTATION OF BaseVectorBackend CONTRACT
174
+ # ===================================================================
175
+
176
+ def store_embedding(
177
+ self,
178
+ doc_id: str,
179
+ embedding: list[float],
180
+ metadata: dict,
181
+ ) -> None:
182
+ """Stores an embedding with metadata/payload in Qdrant (with dynamic collection routing)."""
183
+ if not self._ensure_connected():
184
+ self._log_noop_warning()
185
+ return
186
+
187
+ self._validate_payload(metadata)
188
+
189
+ collection = "episodic_memory" if "timestamp" in metadata else self._collection_name
190
+
191
+ self._client.upsert(
192
+ collection_name=collection,
193
+ points=[
194
+ qdrant_models.PointStruct(
195
+ id=doc_id if isinstance(doc_id, (int, str)) else hash(doc_id),
196
+ vector=embedding,
197
+ payload=metadata
198
+ )
199
+ ]
200
+ )
201
+ logger.debug("Embedding stored in Qdrant (collection: %s): %s", collection, doc_id)
202
+
203
+ def store_embeddings_batch(self, items: list[dict]) -> int:
204
+ """Stores multiple embeddings in batch in Qdrant (with multiple collections support)."""
205
+ if not self._ensure_connected():
206
+ self._log_noop_warning()
207
+ return 0
208
+ if not items:
209
+ return 0
210
+
211
+ points_by_collection: dict[str, list] = {}
212
+ for item in items:
213
+ doc_id = item["doc_id"]
214
+ embedding = item["embedding"]
215
+ metadata = item["metadata"]
216
+
217
+ if embedding is None:
218
+ continue
219
+
220
+ try:
221
+ self._validate_payload(metadata)
222
+ collection = "episodic_memory" if "timestamp" in metadata else self._collection_name
223
+ if collection not in points_by_collection:
224
+ points_by_collection[collection] = []
225
+
226
+ points_by_collection[collection].append(
227
+ qdrant_models.PointStruct(
228
+ id=doc_id if isinstance(doc_id, (int, str)) else hash(doc_id),
229
+ vector=embedding,
230
+ payload=metadata
231
+ )
232
+ )
233
+ except (ValueError, KeyError) as e:
234
+ logger.warning("Item ignored in Qdrant batch due to invalid payload: %s", e)
235
+ continue
236
+
237
+ total_upserted = 0
238
+ for collection, points in points_by_collection.items():
239
+ if points:
240
+ self._client.upsert(
241
+ collection_name=collection,
242
+ points=points
243
+ )
244
+ total_upserted += len(points)
245
+ return total_upserted
246
+
247
+ def search(
248
+ self,
249
+ query_embedding: list[float],
250
+ project_uuids: list[str],
251
+ top_k: int = 10,
252
+ filters: Optional[dict] = None,
253
+ ) -> list[VectorSearchResult]:
254
+ """Searches by cosine similarity in Qdrant applying filters."""
255
+ if not self._ensure_connected():
256
+ self._log_noop_warning()
257
+ return []
258
+
259
+ # Builds the search filter in Qdrant format
260
+ qdrant_filter = self._build_filter(project_uuids, filters)
261
+
262
+ try:
263
+ results = self._client.search(
264
+ collection_name=self._collection_name,
265
+ query_vector=query_embedding,
266
+ query_filter=qdrant_filter,
267
+ limit=top_k,
268
+ )
269
+ except Exception as e:
270
+ logger.error("Error in Qdrant vector search: %s", e)
271
+ return []
272
+
273
+ search_results: list[VectorSearchResult] = []
274
+ for res in results:
275
+ payload = res.payload or {}
276
+ # For the code collection, we extract node_id and project_uuid
277
+ node_id = int(payload.get("node_id", 0))
278
+ project_uuid = str(payload.get("project_uuid", ""))
279
+
280
+ search_results.append(
281
+ VectorSearchResult(
282
+ doc_id=str(res.id),
283
+ node_id=node_id,
284
+ project_uuid=project_uuid,
285
+ score=float(res.score),
286
+ metadata=payload,
287
+ )
288
+ )
289
+ return search_results
290
+
291
+ def delete(self, doc_id: str) -> None:
292
+ """Deletes a vector by ID."""
293
+ if not self._ensure_connected():
294
+ self._log_noop_warning()
295
+ return
296
+
297
+ try:
298
+ self._client.delete(
299
+ collection_name=self._collection_name,
300
+ points_selector=qdrant_models.PointIdsList(
301
+ points=[doc_id]
302
+ )
303
+ )
304
+ except Exception as e:
305
+ logger.warning("Failed to delete vector in Qdrant %s: %s", doc_id, e)
306
+
307
+ def delete_batch(self, doc_ids: list[str]) -> int:
308
+ """Deletes multiple vectors in batch."""
309
+ if not self._ensure_connected():
310
+ self._log_noop_warning()
311
+ return 0
312
+ if not doc_ids:
313
+ return 0
314
+
315
+ try:
316
+ self._client.delete(
317
+ collection_name=self._collection_name,
318
+ points_selector=qdrant_models.PointIdsList(
319
+ points=doc_ids
320
+ )
321
+ )
322
+ return len(doc_ids)
323
+ except Exception as e:
324
+ logger.error("Failed to delete batch in Qdrant: %s", e)
325
+ return 0
326
+
327
+ def verify_sync(self, sqlite_node_ids: set[int]) -> list[str]:
328
+ """Detects orphan vectors in Qdrant."""
329
+ if not self._ensure_connected():
330
+ self._log_noop_warning()
331
+ return []
332
+
333
+ orphans: list[str] = []
334
+ try:
335
+ # Scroll all points using Qdrant scroll API
336
+ offset = None
337
+ while True:
338
+ res, next_offset = self._client.scroll(
339
+ collection_name=self._collection_name,
340
+ limit=100,
341
+ with_payload=True,
342
+ offset=offset
343
+ )
344
+ for point in res:
345
+ payload = point.payload or {}
346
+ node_id = payload.get("node_id")
347
+ if node_id is None or int(node_id) not in sqlite_node_ids:
348
+ orphans.append(str(point.id))
349
+ if not next_offset:
350
+ break
351
+ offset = next_offset
352
+ except Exception as e:
353
+ logger.error("Failed in Qdrant verify_sync: %s", e)
354
+
355
+ return orphans
356
+
357
+ def health_check(self) -> bool:
358
+ """Verifies if the Qdrant cluster is accessible."""
359
+ if not self._ensure_connected():
360
+ return False
361
+ try:
362
+ # A simple ping or collection query
363
+ self._client.get_collections()
364
+ return True
365
+ except Exception as e:
366
+ logger.error("Qdrant health check failed: %s", e)
367
+ return False
368
+
369
+ def count(self, project_uuid: Optional[str] = None) -> int:
370
+ """Counts the quantity of stored vectors."""
371
+ if not self._ensure_connected():
372
+ return 0
373
+ try:
374
+ if project_uuid:
375
+ q_filter = qdrant_models.Filter(
376
+ must=[
377
+ qdrant_models.FieldCondition(
378
+ key="project_uuid",
379
+ match=qdrant_models.MatchValue(value=project_uuid)
380
+ )
381
+ ]
382
+ )
383
+ res = self._client.count(
384
+ collection_name=self._collection_name,
385
+ count_filter=q_filter
386
+ )
387
+ else:
388
+ res = self._client.count(collection_name=self._collection_name)
389
+ return res.count
390
+ except Exception as e:
391
+ logger.error("Failed to count vectors in Qdrant: %s", e)
392
+ return 0
393
+
394
+ def _build_filter(
395
+ self,
396
+ project_uuids: list[str],
397
+ extra_filters: Optional[dict] = None
398
+ ) -> Optional[qdrant_models.Filter]:
399
+ """Helper to convert filters to Qdrant Filter format."""
400
+ must_conditions: list[Any] = []
401
+
402
+ if project_uuids:
403
+ if len(project_uuids) == 1:
404
+ must_conditions.append(
405
+ qdrant_models.FieldCondition(
406
+ key="project_uuid",
407
+ match=qdrant_models.MatchValue(value=project_uuids[0])
408
+ )
409
+ )
410
+ else:
411
+ must_conditions.append(
412
+ qdrant_models.FieldCondition(
413
+ key="project_uuid",
414
+ match=qdrant_models.MatchAny(any=project_uuids)
415
+ )
416
+ )
417
+
418
+ if extra_filters:
419
+ for k, v in extra_filters.items():
420
+ must_conditions.append(
421
+ qdrant_models.FieldCondition(
422
+ key=k,
423
+ match=qdrant_models.MatchValue(value=v)
424
+ )
425
+ )
426
+
427
+ if not must_conditions:
428
+ return None
429
+ return qdrant_models.Filter(must=must_conditions)
430
+
431
+ def update_metadata(self, doc_id: str, metadata: dict) -> None:
432
+ """Updates metadata/payload of a point in Qdrant."""
433
+ if not self._ensure_connected() or not self._client:
434
+ self._log_noop_warning()
435
+ return
436
+
437
+ try:
438
+ self._client.set_payload(
439
+ collection_name=self._collection_name,
440
+ payload=metadata,
441
+ points=[doc_id if isinstance(doc_id, (int, str)) else hash(doc_id)]
442
+ )
443
+ except Exception as e:
444
+ logger.error("Failed to update metadata in Qdrant: %s", e)
445
+
446
+ def get_all_stored_node_ids(self) -> set[int]:
447
+ """Returns all numeric node_ids present in the Qdrant collection."""
448
+ if not self._ensure_connected() or not self._client:
449
+ return set()
450
+ node_ids = set()
451
+ try:
452
+ offset = None
453
+ while True:
454
+ res, next_offset = self._client.scroll(
455
+ collection_name=self._collection_name,
456
+ limit=100,
457
+ with_payload=True,
458
+ offset=offset
459
+ )
460
+ for point in res:
461
+ payload = point.payload or {}
462
+ n_id = payload.get("node_id")
463
+ if n_id is not None:
464
+ node_ids.add(int(n_id))
465
+ if not next_offset:
466
+ break
467
+ offset = next_offset
468
+ return node_ids
469
+ except Exception as e:
470
+ logger.error("Error obtaining saved node_ids in Qdrant: %s", e)
471
+ return set()
ingestion/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ ingestion/ - Apex Ingestion Engine - Grafo Concierge v3.8.0
3
+
4
+ Modules:
5
+ crawler.py → Filesystem scanning, delta detection via SHA256, .gitignore
6
+ parser.py → Semantic/AST Chunking with Prompt Armor (XML sanitization)
7
+ summarizer.py → Zoom Gear (L0/L1/L2) with Model Tiering
8
+ orchestrator.py → IngestionManager — coordinates Crawl → Parse → Summarize → Store
9
+ """
10
+ from ingestion.crawler import ProjectCrawler, CrawlReport, CrawlResult
11
+ from ingestion.parser import FileParser, ParsedChunk
12
+ from ingestion.summarizer import ZoomSummarizer, SummaryResult
13
+ from ingestion.orchestrator import IngestionManager, IngestionResult
14
+
15
+ __all__ = [
16
+ "ProjectCrawler",
17
+ "CrawlReport",
18
+ "CrawlResult",
19
+ "FileParser",
20
+ "ParsedChunk",
21
+ "ZoomSummarizer",
22
+ "SummaryResult",
23
+ "IngestionManager",
24
+ "IngestionResult",
25
+ ]