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,920 @@
1
+ """
2
+ ingestion/orchestrator.py - Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ IngestionManager — Orchestrator of the Apex Ingestion Engine.
5
+
6
+ This is the engine behind the `concierge mine` command.
7
+ Coordinates the complete flow: Crawl → Parse → Summarize → Store (SQLite + Vector).
8
+
9
+ Execution Flow:
10
+ 1. CRAWL: ProjectCrawler scans the filesystem, detects deltas (SHA256).
11
+ 2. PARSE: FileParser splits new/modified files into semantic chunks.
12
+ 3. SUMMARIZE: ZoomSummarizer generates L0 summaries for each chunk.
13
+ 4. EMBED: EmbeddingManager generates vectors for each chunk.
14
+ 5. STORE (SQLite): Creates nodes, structural edges, and commit_log.
15
+ 6. STORE (Vector): QdrantVectorStore/ChromaVectorStore stores embeddings in batch.
16
+ 7. GARBAGE COLLECTION: Removes nodes and vectors of deleted files.
17
+ 8. ZOOM GEAR (async/optional): Generates L1 and L2 summaries.
18
+
19
+ Return:
20
+ Dict compatible with the concierge_mine MCP tool response:
21
+ {
22
+ "files_processed": int,
23
+ "categories": {"code": int, "doc": int, "config": int, "conversation": int},
24
+ "nodes_created": int,
25
+ "embeddings_stored": int,
26
+ "tags_applied": list[str]
27
+ }
28
+
29
+ Integration:
30
+ - storage.store.SqliteStore → Relational persistence.
31
+ - storage.vector_store.ChromaVectorStore/QdrantVectorStore → Vector persistence.
32
+ - storage.vector_store.EmbeddingManager → Embedding generation.
33
+ - ingestion.crawler.ProjectCrawler → Filesystem scanning.
34
+ - ingestion.parser.FileParser → Semantic chunking.
35
+ - ingestion.summarizer.ZoomSummarizer → Zoom Gear.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import logging
41
+ import time
42
+ from dataclasses import dataclass, field
43
+ from typing import Any, Optional
44
+
45
+ from storage import SqliteStore, ChromaVectorStore, EmbeddingManager
46
+ from ingestion.crawler import ProjectCrawler, CrawlReport, CrawlResult, FileCategory
47
+ from ingestion.parser import FileParser, ParsedChunk
48
+ from ingestion.summarizer import ZoomSummarizer, SummaryResult, ZoomLevel
49
+
50
+ logger = logging.getLogger("grafo-concierge.orchestrator")
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # IngestionResult — standardized return of concierge mine
55
+ # ---------------------------------------------------------------------------
56
+
57
+ @dataclass
58
+ class IngestionResult:
59
+ """Consolidated result of the ingestion pipeline.
60
+
61
+ Compatible with the response of the concierge_mine MCP Tool (v3.8).
62
+ """
63
+ files_processed: int = 0
64
+ categories: dict[str, int] = field(default_factory=lambda: {
65
+ "code": 0, "doc": 0, "config": 0, "conversation": 0
66
+ })
67
+ nodes_created: int = 0
68
+ embeddings_stored: int = 0
69
+ tags_applied: list[str] = field(default_factory=list)
70
+ files_skipped: int = 0
71
+ files_deleted: int = 0
72
+ summaries_generated: int = 0
73
+ errors: list[str] = field(default_factory=list)
74
+
75
+ def to_dict(self) -> dict:
76
+ """Converts to dict compatible with MCP response."""
77
+ return {
78
+ "files_processed": self.files_processed,
79
+ "categories": self.categories,
80
+ "nodes_created": self.nodes_created,
81
+ "embeddings_stored": self.embeddings_stored,
82
+ "tags_applied": self.tags_applied,
83
+ "files_skipped": self.files_skipped,
84
+ "files_deleted": self.files_deleted,
85
+ "summaries_generated": self.summaries_generated,
86
+ "errors": self.errors,
87
+ }
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # IngestionManager — Pipeline Orchestrator
92
+ # ---------------------------------------------------------------------------
93
+
94
+ class IngestionManager:
95
+ """Orchestrator of the Apex Ingestion Engine (concierge mine).
96
+
97
+ Coordinates the flow Crawl → Parse → Summarize → Embed → Store → GC.
98
+ Each step is isolated and resilient (Semantic Fallback).
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ sqlite_store: SqliteStore,
104
+ vector_store: ChromaVectorStore,
105
+ embedding_manager: EmbeddingManager,
106
+ summarizer: Optional[ZoomSummarizer] = None,
107
+ crawler: Optional[ProjectCrawler] = None,
108
+ parser: Optional[FileParser] = None,
109
+ ) -> None:
110
+ self._store = sqlite_store
111
+ self._vector = vector_store
112
+ self._embedder = embedding_manager
113
+ self._summarizer = summarizer
114
+ self._crawler = crawler or ProjectCrawler(sqlite_store)
115
+ self._parser = parser or FileParser()
116
+
117
+ logger.info(
118
+ "IngestionManager inicializado: summarizer=%s, embedder_tier=%s",
119
+ "ON" if summarizer else "OFF",
120
+ embedding_manager.tier.value if hasattr(embedding_manager, 'tier') else "unknown",
121
+ )
122
+
123
+ # ===================================================================
124
+ # MINE — Entry Point (concierge mine)
125
+ # ===================================================================
126
+
127
+ def mine(
128
+ self,
129
+ project_uuid: str,
130
+ source_path: str,
131
+ auto_tag: bool = True,
132
+ ) -> IngestionResult:
133
+ """Executes the complete ingestion pipeline for a project.
134
+
135
+ This is the main method called by the concierge_mine MCP Tool.
136
+ """
137
+ t0 = time.perf_counter()
138
+ result = IngestionResult()
139
+
140
+ logger.info("=" * 60)
141
+ logger.info("MINE STARTED: project=%s, source=%s", project_uuid, source_path)
142
+ logger.info("=" * 60)
143
+
144
+ # --- Validation: project exists? ---
145
+ try:
146
+ self._store.get_project(project_uuid)
147
+ except Exception as e:
148
+ raise ValueError(f"Project not found: {project_uuid}") from e
149
+
150
+ # --- STEP 1: CRAWL ---
151
+ logger.info("[1/7] CRAWL — Filesystem scanning...")
152
+ crawl_report = self._step_crawl(source_path, project_uuid)
153
+ result.files_processed = len(crawl_report.new_files)
154
+ result.files_skipped = len(crawl_report.unchanged_files)
155
+ result.categories = crawl_report.categories
156
+ logger.info(
157
+ "[1/7] CRAWL completed: %d new, %d unchanged, %d deleted.",
158
+ result.files_processed, result.files_skipped, len(crawl_report.deleted_node_ids),
159
+ )
160
+
161
+ # --- Short-circuit: nothing new and nothing deleted ---
162
+ if not crawl_report.new_files and not crawl_report.deleted_node_ids:
163
+ logger.info("No change detected — ending pipeline.")
164
+ elapsed = time.perf_counter() - t0
165
+ logger.info("MINE COMPLETED in %.2fs (noop).", elapsed)
166
+ return result
167
+
168
+ # --- STEP 2: PARSE ---
169
+ chunks: list[ParsedChunk] = []
170
+ if crawl_report.new_files:
171
+ logger.info("[2/8] PARSE — Semantic chunking of %d files...", len(crawl_report.new_files))
172
+ chunks = self._step_parse(crawl_report.new_files, result)
173
+ logger.info("[2/8] PARSE completed: %d chunks extracted.", len(chunks))
174
+
175
+ # --- STEP 2.5: DELTA CACHE ---
176
+ cached_count = 0
177
+ if chunks:
178
+ logger.info("[2.5/8] DELTA CACHE — Checking unchanged chunks...")
179
+ cached_count = self._detect_cached_chunks(chunks, project_uuid)
180
+ logger.info(
181
+ "[2.5/8] DELTA CACHE completed: %d cached, %d new for LLM.",
182
+ cached_count, len(chunks) - cached_count,
183
+ )
184
+
185
+ # --- STEP 3: SUMMARIZE (L0) ---
186
+ uncached_chunks = [c for c in chunks if not c.cached]
187
+ summaries: list[SummaryResult] = []
188
+ if uncached_chunks and self._summarizer:
189
+ logger.info("[3/8] SUMMARIZE — Generating %d L0 summaries (skipping %d cached)...",
190
+ len(uncached_chunks), cached_count)
191
+ summaries = self._step_summarize(uncached_chunks, result)
192
+ result.summaries_generated = len(summaries)
193
+ logger.info("[3/8] SUMMARIZE completed: %d L0 summaries generated.", len(summaries))
194
+ else:
195
+ logger.info("[3/8] SUMMARIZE — Ignored (summarizer=%s, new=%d).",
196
+ "OFF" if not self._summarizer else "ON", len(uncached_chunks))
197
+
198
+ # --- STEP 4: STORE SQLite ---
199
+ if chunks:
200
+ logger.info("[4/8] STORE SQLite — Persisting %d chunks...", len(chunks))
201
+ nodes_created = self._step_store_sqlite(chunks, project_uuid, auto_tag, result)
202
+ result.nodes_created = nodes_created
203
+ logger.info("[4/8] STORE SQLite completed: %d nodes created.", nodes_created)
204
+
205
+ # Update file_hash of cached nodes to avoid Garbage Collection
206
+ if cached_count > 0:
207
+ self._apply_cache_updates(chunks, project_uuid)
208
+
209
+ # Remove obsolete nodes of files that were modified
210
+ if crawl_report.new_files:
211
+ for f in crawl_report.new_files:
212
+ try:
213
+ self._store.cleanup_obsolete_nodes(project_uuid, f.relative_path, f.file_hash)
214
+ logger.debug("Cleanup post-ingestion: obsolete nodes of %s removed.", f.relative_path)
215
+ except Exception as e:
216
+ logger.warning("Failed post-ingestion cleanup for %s: %s", f.relative_path, e)
217
+
218
+
219
+ # --- STEP 5: EMBED ---
220
+ embed_items: list[dict] = []
221
+ if chunks:
222
+ logger.info("[5/8] EMBED — Generating embeddings...", len(chunks))
223
+ embed_items = self._step_embed(chunks, project_uuid, result)
224
+ logger.info("[5/8] EMBED completed: %d embeddings generated.", len(embed_items))
225
+
226
+ # --- STEP 6: STORE Vector ---
227
+ if embed_items:
228
+ logger.info("[6/8] STORE Vector — Batch insert of %d embeddings...", len(embed_items))
229
+ stored = self._step_store_vector(embed_items, result)
230
+ result.embeddings_stored = stored
231
+ logger.info("[6/8] STORE Vector completed: %d embeddings stored.", stored)
232
+
233
+ # --- STEP 7: GARBAGE COLLECTION ---
234
+ if crawl_report.deleted_node_ids:
235
+ logger.info("[7/8] GC — Removing %d orphan nodes...", len(crawl_report.deleted_node_ids))
236
+ deleted = self._step_garbage_collection(crawl_report.deleted_node_ids, project_uuid, result)
237
+ result.files_deleted = deleted
238
+ logger.info("[7/8] GC completed: %d nodes removed.", deleted)
239
+ else:
240
+ logger.info("[7/8] GC — No orphan nodes detected.")
241
+
242
+ # --- Tag consolidation ---
243
+ result.tags_applied = self._consolidate_tags(chunks)
244
+
245
+ elapsed = time.perf_counter() - t0
246
+ logger.info("=" * 60)
247
+ logger.info(
248
+ "MINE COMPLETED in %.2fs: %d processed, %d nodes, %d embeddings, %d errors.",
249
+ elapsed, result.files_processed, result.nodes_created,
250
+ result.embeddings_stored, len(result.errors),
251
+ )
252
+ logger.info("=" * 60)
253
+
254
+ return result
255
+
256
+ # ===================================================================
257
+ # ETAPAS DO PIPELINE
258
+ # ===================================================================
259
+
260
+ def _step_crawl(self, source_path: str, project_uuid: str) -> CrawlReport:
261
+ """Step 1: Filesystem scan."""
262
+ return self._crawler.crawl(source_path, project_uuid)
263
+
264
+ def _step_parse(self, new_files: list[CrawlResult], result: IngestionResult) -> list[ParsedChunk]:
265
+ """Step 2: Semantic Chunking via parse_batch (native Semantic Fallback).
266
+
267
+ Delegates to self._parser.parse_batch() which already implements the same
268
+ Semantic Fallback per file, eliminating loop duplication.
269
+ """
270
+ try:
271
+ return self._parser.parse_batch(new_files)
272
+ except Exception as e:
273
+ error_msg = f"parse_batch failed: {e}"
274
+ logger.error(error_msg)
275
+ result.errors.append(error_msg)
276
+ return []
277
+
278
+ def _detect_cached_chunks(self, chunks: list[ParsedChunk], project_uuid: str) -> int:
279
+ """Step 2.5: Delta Cache — detects chunks whose content has not changed.
280
+
281
+ Compares each parsed chunk with existing nodes in SQLite by label
282
+ (source_file::symbol_name). If the textual content is identical,
283
+ marks the chunk as cached and copies existing summary/tags.
284
+
285
+ Returns:
286
+ Number of chunks marked as cached.
287
+ """
288
+ try:
289
+ existing_nodes = self._store.get_nodes_by_project(project_uuid)
290
+ except Exception as e:
291
+ logger.warning("Delta Cache: failed to fetch existing nodes: %s", e)
292
+ return 0
293
+
294
+ if not existing_nodes:
295
+ return 0
296
+
297
+ # Builds map: label → {content, summary, tags, id, file_hash}
298
+ import json as _json
299
+ node_map: dict[str, dict] = {}
300
+ for node in existing_nodes:
301
+ if node.get("type") in ("directory", "cluster", "project"):
302
+ continue
303
+ label = node.get("label", "")
304
+ if not label:
305
+ continue
306
+
307
+ tags = node.get("tags", [])
308
+ if isinstance(tags, str):
309
+ try:
310
+ tags = _json.loads(tags)
311
+ except Exception:
312
+ tags = []
313
+
314
+ node_map[label] = {
315
+ "content": node.get("content", ""),
316
+ "summary": node.get("summary", ""),
317
+ "tags": tags if isinstance(tags, list) else [],
318
+ "id": node["id"],
319
+ "file_hash": node.get("file_hash", ""),
320
+ }
321
+
322
+ cached_count = 0
323
+ for chunk in chunks:
324
+ label = f"{chunk.source_file}::{chunk.symbol_name}"
325
+ existing = node_map.get(label)
326
+
327
+ if existing and existing["content"] and existing["content"] == chunk.content:
328
+ # Content identical — reuse summary and tags
329
+ chunk.cached = True
330
+ chunk.node_id = existing["id"]
331
+ chunk.cached_summary = existing["summary"]
332
+ chunk.cached_tags = existing["tags"]
333
+ cached_count += 1
334
+ logger.debug("Delta Cache HIT: %s (node_id=%d)", label, existing["id"])
335
+
336
+ return cached_count
337
+
338
+ def _apply_cache_updates(self, chunks: list[ParsedChunk], project_uuid: str) -> None:
339
+ """Updates file_hash of cached nodes to the new file hash.
340
+
341
+ When a file changes hash (file content changed) but a specific chunk
342
+ remained identical, we need to update the file_hash of this node
343
+ to the new hash, otherwise the Garbage Collector will consider it orphan.
344
+ """
345
+ updates: list[tuple[int, str]] = []
346
+ for chunk in chunks:
347
+ if chunk.cached and chunk.node_id is not None:
348
+ updates.append((chunk.node_id, chunk.file_hash))
349
+
350
+ if updates:
351
+ try:
352
+ updated = self._store.update_nodes_file_hash_bulk(updates)
353
+ logger.info("Delta Cache: %d nodes with updated file_hash.", updated)
354
+ except Exception as e:
355
+ logger.error("Delta Cache: failed to update file_hash: %s", e)
356
+
357
+
358
+ def generate_community_summary(self, nodes_block: str) -> Optional[dict]:
359
+ """Generates a community summary via LLM if the summarizer is configured.
360
+
361
+ Public API that encapsulates LLM access, preventing JanitorService
362
+ from accessing self._ingestion._summarizer._llm directly (violation of Law of Demeter).
363
+
364
+ Args:
365
+ nodes_block: Text with the community nodes to summarize.
366
+
367
+ Returns:
368
+ Dict with 'summary' (str) and 'tags' (list[str]), or None if LLM
369
+ is not configured or fails.
370
+ """
371
+ if not self._summarizer or not hasattr(self._summarizer, "_llm") or not self._summarizer._llm:
372
+ return None
373
+ try:
374
+ prompt = (
375
+ "You are a software architect. Synthesize the following node descriptions belonging "
376
+ "to a logical community in the project graph into a single cohesive community summary.\n"
377
+ "Return ONLY a valid JSON object with these fields:\n"
378
+ '- "summary": A cohesive description of this community\'s purpose (max 3 sentences).\n'
379
+ '- "tags": Consolidated list of key technologies and concepts.\n\n'
380
+ f"Nodes in community:\n{nodes_block}\n\n"
381
+ "Respond with ONLY the JSON object, no markdown fences, no extra text."
382
+ )
383
+ raw = self._summarizer._llm.generate(prompt, max_tokens=300)
384
+ parsed = self._summarizer._extract_json_with_fallback(raw)
385
+ if parsed and "summary" in parsed:
386
+ return {
387
+ "summary": parsed["summary"],
388
+ "tags": parsed.get("tags", []) if isinstance(parsed.get("tags"), list) else [],
389
+ }
390
+ except Exception as e:
391
+ logger.warning("generate_community_summary: LLM failed: %s", e)
392
+ return None
393
+
394
+
395
+ def _step_summarize(self, chunks: list[ParsedChunk], result: IngestionResult) -> list[SummaryResult]:
396
+ """Step 3: L0 summaries generation — async + grouping + fallback.
397
+
398
+ Optimization pipeline:
399
+ 1. Groups small chunks (< 50 tokens) into batched prompts.
400
+ 2. Sends remaining chunks via asyncio.gather with Semaphore.
401
+ 3. Fallback to ThreadPoolExecutor if asyncio fails.
402
+ """
403
+ import asyncio
404
+
405
+ SMALL_CHUNK_THRESHOLD = 50 # tokens
406
+ GROUP_SIZE = 5 # chunks per grouped prompt
407
+ MAX_CONCURRENCY = 20 # semaphore limit
408
+
409
+ # --- Phase 1: Separate small chunks for grouping ---
410
+ small_chunks: list[tuple[int, ParsedChunk]] = []
411
+ regular_chunks: list[tuple[int, ParsedChunk]] = []
412
+
413
+ for idx, chunk in enumerate(chunks):
414
+ if chunk.estimated_tokens < SMALL_CHUNK_THRESHOLD and chunk.estimated_tokens > 0:
415
+ small_chunks.append((idx, chunk))
416
+ else:
417
+ regular_chunks.append((idx, chunk))
418
+
419
+ results = [None] * len(chunks)
420
+
421
+ # --- Phase 2: Process small groups (batching in prompt) ---
422
+ if small_chunks and self._summarizer:
423
+ logger.info("Grouping: %d small chunks into groups of %d.", len(small_chunks), GROUP_SIZE)
424
+ for batch_start in range(0, len(small_chunks), GROUP_SIZE):
425
+ batch = small_chunks[batch_start:batch_start + GROUP_SIZE]
426
+ batch_indices = [idx for idx, _ in batch]
427
+ batch_chunks = [chunk for _, chunk in batch]
428
+
429
+ try:
430
+ grouped_results = self._summarizer.summarize_l0_grouped(batch_chunks, batch_indices)
431
+ for orig_idx, summary in grouped_results:
432
+ results[orig_idx] = summary
433
+ except Exception as e:
434
+ logger.warning("Grouped summarization failed, individual fallback: %s", e)
435
+
436
+ # Chunks not resolved by the group → move to regular
437
+ for idx, chunk in batch:
438
+ if results[idx] is None:
439
+ regular_chunks.append((idx, chunk))
440
+
441
+ # --- Phase 3: Process regular chunks via asyncio ---
442
+ if regular_chunks:
443
+ async def _run_async_summaries():
444
+ sem = asyncio.Semaphore(MAX_CONCURRENCY)
445
+
446
+ async def _bounded_summarize(idx: int, chunk: ParsedChunk):
447
+ async with sem:
448
+ try:
449
+ s = await self._summarizer.summarize_l0_async(chunk)
450
+ return idx, s, None
451
+ except Exception as e:
452
+ return idx, None, f"Async L0 failed for {chunk.source_file}: {e}"
453
+
454
+ tasks = [_bounded_summarize(idx, chunk) for idx, chunk in regular_chunks]
455
+ return await asyncio.gather(*tasks)
456
+
457
+ try:
458
+ # Tenta usar loop existente ou criar novo
459
+ try:
460
+ loop = asyncio.get_running_loop()
461
+ # Already inside a loop — use to_thread for the whole block
462
+ import concurrent.futures
463
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
464
+ async_results = pool.submit(
465
+ lambda: asyncio.run(_run_async_summaries())
466
+ ).result()
467
+ except RuntimeError:
468
+ # No loop running — create a new one
469
+ async_results = asyncio.run(_run_async_summaries())
470
+
471
+ for idx, summary, error_msg in async_results:
472
+ if error_msg:
473
+ logger.error(error_msg)
474
+ result.errors.append(error_msg)
475
+ if summary:
476
+ results[idx] = summary
477
+
478
+ except Exception as e:
479
+ logger.warning("Async summarization failed, ThreadPool fallback: %s", e)
480
+ # Fallback to ThreadPoolExecutor
481
+ self._step_summarize_threaded(regular_chunks, results, result)
482
+
483
+ return [r for r in results if r is not None]
484
+
485
+ def _step_summarize_threaded(
486
+ self,
487
+ indexed_chunks: list[tuple[int, ParsedChunk]],
488
+ results: list,
489
+ result: IngestionResult,
490
+ ) -> None:
491
+ """Fallback: summarization via ThreadPoolExecutor (if asyncio fails)."""
492
+ from concurrent.futures import ThreadPoolExecutor
493
+
494
+ def process_chunk(index: int, chunk: ParsedChunk):
495
+ try:
496
+ s = self._summarizer.summarize_l0(chunk)
497
+ return index, s, None
498
+ except Exception as e:
499
+ return index, None, f"Summarize L0 failed for {chunk.source_file}: {e}"
500
+
501
+ with ThreadPoolExecutor(max_workers=16) as executor:
502
+ futures = [executor.submit(process_chunk, idx, chunk) for idx, chunk in indexed_chunks]
503
+ for future in futures:
504
+ idx, summary, error_msg = future.result()
505
+ if error_msg:
506
+ logger.error(error_msg)
507
+ result.errors.append(error_msg)
508
+ if summary:
509
+ results[idx] = summary
510
+
511
+
512
+ def _step_store_sqlite(
513
+ self,
514
+ chunks: list[ParsedChunk],
515
+ project_uuid: str,
516
+ auto_tag: bool,
517
+ result: IngestionResult,
518
+ ) -> int:
519
+ """Step 4: Persistence in SQLite (nodes + edges) via Bulk Insert (WAL-friendly).
520
+
521
+ Separates already cached nodes (keeping their IDs) from new nodes, inserting
522
+ only the new ones in SQLite, while mapping and creating structural and call edges
523
+ for ALL chunks (new and cached).
524
+ """
525
+ if not chunks:
526
+ return 0
527
+
528
+ # Coleta nós existentes no projeto para cachear diretórios e mapear símbolos globais
529
+ try:
530
+ existing_nodes = self._store.get_nodes_by_project(project_uuid)
531
+ except Exception as e:
532
+ logger.warning("Error fetching existing nodes for cache: %s", e)
533
+ existing_nodes = []
534
+
535
+ dir_node_cache = {n["label"]: n["id"] for n in existing_nodes if n.get("type") == "directory"}
536
+
537
+ # 1. PREPARE NODES FOR BULK CREATION
538
+ nodes_to_create = []
539
+ new_dirs = set()
540
+
541
+ for chunk in chunks:
542
+ parent_dir = chunk.source_file.replace("\\", "/")
543
+ if "/" in parent_dir:
544
+ parent_dir = parent_dir.rsplit("/", 1)[0]
545
+ else:
546
+ parent_dir = "<root>"
547
+
548
+ if parent_dir not in dir_node_cache:
549
+ new_dirs.add(parent_dir)
550
+
551
+ # Adds new directories first
552
+ for d in sorted(new_dirs):
553
+ nodes_to_create.append({
554
+ "project_uuid": project_uuid,
555
+ "label": d,
556
+ "summary": None,
557
+ "content": None,
558
+ "node_type": "FACT",
559
+ "type": "directory",
560
+ "tags": None,
561
+ "file_hash": None,
562
+ "status": "ACTIVE"
563
+ })
564
+
565
+ # Adds only new (not cached) code/doc chunks
566
+ new_chunks = [c for c in chunks if not c.cached]
567
+ dir_offset = len(nodes_to_create)
568
+ from ingestion.parser import ChunkType
569
+
570
+ for chunk in new_chunks:
571
+ summary_text = chunk.cached_summary
572
+
573
+ ntype = "FACT"
574
+ if chunk.chunk_type in (ChunkType.CLASS, ChunkType.FUNCTION, ChunkType.METHOD, ChunkType.MODULE):
575
+ ntype = chunk.chunk_type.value.upper()
576
+
577
+ tags = chunk.detected_tags if auto_tag else None
578
+ nodes_to_create.append({
579
+ "project_uuid": project_uuid,
580
+ "label": f"{chunk.source_file}::{chunk.symbol_name}",
581
+ "summary": summary_text,
582
+ "content": chunk.content,
583
+ "node_type": ntype,
584
+ "type": chunk.chunk_type.value,
585
+ "tags": tags,
586
+ "file_hash": chunk.file_hash,
587
+ "status": "ACTIVE"
588
+ })
589
+
590
+ # Executes bulk insert of nodes (new directories + new chunks)
591
+ node_ids = []
592
+ if nodes_to_create:
593
+ try:
594
+ node_ids = self._store.create_nodes_and_edges_bulk(nodes_to_create, [])
595
+ except Exception as e:
596
+ error_msg = f"Bulk insert of nodes failed: {e}"
597
+ logger.error(error_msg)
598
+ result.errors.append(error_msg)
599
+ raise RuntimeError(error_msg) from e
600
+
601
+ # Updates directory caches with the new generated IDs
602
+ for idx, d in enumerate(sorted(new_dirs)):
603
+ if idx < len(node_ids):
604
+ dir_node_cache[d] = node_ids[idx]
605
+
606
+ # Associates new IDs to the created chunks
607
+ chunk_node_ids = node_ids[dir_offset:]
608
+ for i, chunk in enumerate(new_chunks):
609
+ if i < len(chunk_node_ids):
610
+ chunk.node_id = chunk_node_ids[i]
611
+
612
+ # 2. RESOLVE SYMBOL MAPS FOR EDGE MAPPING (new + cached)
613
+ project_symbol_map = {}
614
+ global_symbol_map = {}
615
+
616
+ # Maps pre-existing nodes in the database
617
+ for n in existing_nodes:
618
+ label = n["label"]
619
+ nid = n["id"]
620
+ sf = label
621
+ sym = ""
622
+ if "::" in label:
623
+ sf, sym = label.split("::", 1)
624
+ project_symbol_map[(sf, sym)] = nid
625
+ if sym:
626
+ if sym not in global_symbol_map:
627
+ global_symbol_map[sym] = []
628
+ global_symbol_map[sym].append(nid)
629
+
630
+ # Maps all chunks (new and cached) of the current batch
631
+ for chunk in chunks:
632
+ nid = chunk.node_id
633
+ if nid is None:
634
+ continue
635
+ sf = chunk.source_file
636
+ sym = chunk.symbol_name
637
+ project_symbol_map[(sf, sym)] = nid
638
+ if sym:
639
+ if sym not in global_symbol_map:
640
+ global_symbol_map[sym] = []
641
+ global_symbol_map[sym].append(nid)
642
+
643
+ # 3. PREPARE EDGES FOR BULK CREATION (new + cached)
644
+ edges_to_create = []
645
+ file_module_map = {}
646
+
647
+ # Maps modules (files) of the current batch
648
+ for chunk in chunks:
649
+ if chunk.chunk_type.value == "module" and chunk.node_id is not None:
650
+ file_module_map[chunk.source_file] = chunk.node_id
651
+
652
+ for chunk in chunks:
653
+ chunk_node_id = chunk.node_id
654
+ if chunk_node_id is None:
655
+ continue
656
+
657
+ # Structural edges: directory -> file, file -> symbol
658
+ parent_dir = chunk.source_file.replace("\\", "/")
659
+ if "/" in parent_dir:
660
+ parent_dir = parent_dir.rsplit("/", 1)[0]
661
+ else:
662
+ parent_dir = "<root>"
663
+
664
+ dir_id = dir_node_cache.get(parent_dir, -1)
665
+
666
+ if chunk.chunk_type.value == "module":
667
+ if dir_id > 0:
668
+ edges_to_create.append({
669
+ "source_id": dir_id,
670
+ "target_id": chunk_node_id,
671
+ "relation_type": "contains",
672
+ "weight": 1.0
673
+ })
674
+ else:
675
+ module_id = file_module_map.get(chunk.source_file)
676
+ if module_id:
677
+ edges_to_create.append({
678
+ "source_id": module_id,
679
+ "target_id": chunk_node_id,
680
+ "relation_type": "contains",
681
+ "weight": 1.0
682
+ })
683
+ elif dir_id > 0:
684
+ edges_to_create.append({
685
+ "source_id": dir_id,
686
+ "target_id": chunk_node_id,
687
+ "relation_type": "contains",
688
+ "weight": 1.0
689
+ })
690
+
691
+ # Call edges (calls)
692
+ calls = getattr(chunk, "calls", [])
693
+ for call_name in calls:
694
+ target_node_id = None
695
+
696
+ # Check 1: Symbol lookup in the same file
697
+ if (chunk.source_file, call_name) in project_symbol_map:
698
+ target_node_id = project_symbol_map[(chunk.source_file, call_name)]
699
+
700
+ # Check 2: Dotted path (ex: Class.method)
701
+ if not target_node_id and "." in call_name:
702
+ parts = call_name.split(".")
703
+ if (chunk.source_file, call_name) in project_symbol_map:
704
+ target_node_id = project_symbol_map[(chunk.source_file, call_name)]
705
+ else:
706
+ last_part = parts[-1]
707
+ if (chunk.source_file, last_part) in project_symbol_map:
708
+ target_node_id = project_symbol_map[(chunk.source_file, last_part)]
709
+
710
+ # Check 3: Global symbol lookup in the project
711
+ if not target_node_id and call_name in global_symbol_map:
712
+ target_node_id = global_symbol_map[call_name][0]
713
+
714
+ if target_node_id and target_node_id != chunk_node_id:
715
+ edges_to_create.append({
716
+ "source_id": chunk_node_id,
717
+ "target_id": target_node_id,
718
+ "relation_type": "calls",
719
+ "weight": 1.0
720
+ })
721
+
722
+ # Executes bulk insert of edges
723
+ if edges_to_create:
724
+ try:
725
+ self._store.create_nodes_and_edges_bulk([], edges_to_create)
726
+ except Exception as e:
727
+ logger.warning("Failed to persist edges in bulk (non-fatal): %s", e)
728
+
729
+ return len(new_chunks)
730
+
731
+ def _step_embed(self, chunks: list[ParsedChunk], project_uuid: str, result: IngestionResult) -> list[dict]:
732
+ """Step 5: Batch embedding generation (only for new chunks)."""
733
+ new_chunks = [c for c in chunks if not c.cached]
734
+ if not new_chunks:
735
+ return []
736
+
737
+ # Collects texts for batch embedding
738
+ texts = [c.content for c in new_chunks]
739
+
740
+ try:
741
+ embeddings = self._embedder.embed_batch(texts)
742
+ except Exception as e:
743
+ error_msg = f"Embed batch failed: {e}"
744
+ logger.error(error_msg)
745
+ result.errors.append(error_msg)
746
+ embeddings = [None] * len(new_chunks)
747
+
748
+ # Assemble items for vector store
749
+ items: list[dict] = []
750
+ for chunk, embedding in zip(new_chunks, embeddings):
751
+ node_id = chunk.node_id
752
+ if node_id is None:
753
+ continue
754
+
755
+ items.append({
756
+ "doc_id": f"node_{node_id}",
757
+ "embedding": embedding, # can be None (Semantic Fallback)
758
+ "metadata": {
759
+ "node_id": node_id,
760
+ "project_uuid": project_uuid,
761
+ "source_file": chunk.source_file,
762
+ "chunk_type": chunk.chunk_type.value,
763
+ "symbol_name": chunk.symbol_name,
764
+ },
765
+ })
766
+
767
+ return items
768
+
769
+ def _step_store_vector(self, embed_items: list[dict], result: IngestionResult) -> int:
770
+ """Step 6: Persistence in the vector backend (batch)."""
771
+ try:
772
+ stored = self._vector.store_embeddings_batch(embed_items)
773
+ return stored
774
+ except Exception as e:
775
+ error_msg = f"Store Vector batch failed: {e}"
776
+ logger.error(error_msg)
777
+ result.errors.append(error_msg)
778
+ return 0
779
+
780
+ def _step_garbage_collection(
781
+ self,
782
+ deleted_node_ids: list[int],
783
+ project_uuid: str,
784
+ result: IngestionResult,
785
+ ) -> int:
786
+ """Step 7: Garbage Collection — removes orphaned nodes and vectors."""
787
+ removed = 0
788
+
789
+ for node_id in deleted_node_ids:
790
+ try:
791
+ # Remove from SQLite
792
+ self._store.delete_node(node_id)
793
+
794
+ # Remove from vector backend
795
+ doc_id = f"node_{node_id}"
796
+ try:
797
+ self._vector.delete(doc_id)
798
+ except Exception as ve:
799
+ logger.debug("GC: vector %s not found (ok): %s", doc_id, ve)
800
+
801
+ removed += 1
802
+ except Exception as e:
803
+ error_msg = f"GC failed for node_id={node_id}: {e}"
804
+ logger.error(error_msg)
805
+ result.errors.append(error_msg)
806
+
807
+ # Reconciliation Loop: verifies SQLite ↔ Vector synchronization
808
+ try:
809
+ existing_nodes = self._store.get_nodes_by_project(project_uuid)
810
+ sqlite_ids = [f"node_{n['id']}" for n in existing_nodes if n.get("type") not in ("directory", "cluster", "project")]
811
+ sync_report = self._vector.verify_sync(sqlite_ids)
812
+ orphan_count = sync_report.get("orphans_removed", 0)
813
+ if orphan_count > 0:
814
+ logger.warning("Reconciliation: %d orphan vectors removed post-GC.", orphan_count)
815
+ except Exception as e:
816
+ logger.debug("Reconciliation loop failed (non-fatal): %s", e)
817
+
818
+ return removed
819
+
820
+ # ===================================================================
821
+ # ZOOM GEAR (L1/L2)
822
+ # ===================================================================
823
+
824
+ def generate_project_context(self, project_uuid: str) -> dict:
825
+ """Generates L1 and L2 summaries for the project (complete Zoom Gear).
826
+
827
+ Can be called after mine() or by the Background Janitor.
828
+
829
+ Returns:
830
+ Dict with l1_count, l2_summary, and l2_tags.
831
+ """
832
+ if not self._summarizer:
833
+ logger.warning("generate_project_context: summarizer not configured.")
834
+ return {"l1_count": 0, "l2_summary": None, "l2_tags": []}
835
+
836
+ logger.info("ZOOM GEAR started for project %s...", project_uuid)
837
+
838
+ # Fetch nodes with populated summary (L0s already generated)
839
+ all_nodes = self._store.get_nodes_by_project(project_uuid)
840
+ l0_summaries: list[SummaryResult] = []
841
+
842
+ for node in all_nodes:
843
+ if node.get("type") in ("directory", "cluster", "project") or not node.get("summary"):
844
+ continue
845
+
846
+ tags = node.get("tags", [])
847
+ if isinstance(tags, str):
848
+ import json
849
+ try:
850
+ tags = json.loads(tags)
851
+ except Exception:
852
+ tags = []
853
+
854
+ l0_summaries.append(SummaryResult(
855
+ level=ZoomLevel.L0,
856
+ summary=node["summary"],
857
+ source_label=node.get("label", "unknown"),
858
+ source_chunks=1,
859
+ detected_tags=tags if isinstance(tags, list) else [],
860
+ ))
861
+
862
+ if not l0_summaries:
863
+ logger.warning("ZOOM GEAR: nenhum resumo L0 encontrado para %s.", project_uuid)
864
+ return {"l1_count": 0, "l2_summary": None, "l2_tags": []}
865
+
866
+ # Group into clusters and generate L1
867
+ clusters = self._summarizer.build_l1_clusters(l0_summaries)
868
+ l1_summaries: list[SummaryResult] = []
869
+
870
+ for cluster_label, cluster_l0s in clusters.items():
871
+ try:
872
+ l1 = self._summarizer.summarize_l1(cluster_l0s, cluster_label)
873
+ l1_summaries.append(l1)
874
+ logger.debug("L1 gerado: %s (%d L0s)", cluster_label, len(cluster_l0s))
875
+ except Exception as e:
876
+ logger.error("L1 falhou para cluster %s: %s", cluster_label, e)
877
+
878
+ # Generate L2 (Compass)
879
+ project = self._store.get_project(project_uuid)
880
+ project_name = project.get("folder_name", project_uuid)
881
+
882
+ try:
883
+ l2 = self._summarizer.summarize_l2(l1_summaries, project_name)
884
+ logger.info("Bússola L2 gerada: %s", l2.summary[:80])
885
+ except Exception as e:
886
+ logger.error("L2 (Bússola) falhou para %s: %s", project_name, e)
887
+ l2 = SummaryResult(level=ZoomLevel.L2, summary="", source_label=project_name)
888
+
889
+ result = {
890
+ "l1_count": len(l1_summaries),
891
+ "l2_summary": l2.summary,
892
+ "l2_tags": l2.detected_tags,
893
+ }
894
+
895
+ logger.info(
896
+ "ZOOM GEAR concluído: %d L1 gerados, Bússola L2 = %.60s...",
897
+ len(l1_summaries), l2.summary,
898
+ )
899
+
900
+ return result
901
+
902
+ # ===================================================================
903
+ # UTILITIES
904
+ # ===================================================================
905
+
906
+ def _consolidate_tags(self, chunks: list[ParsedChunk]) -> list[str]:
907
+ """Consolidates unique tags from all chunks."""
908
+ tags: set[str] = set()
909
+ for c in chunks:
910
+ tags.update(c.detected_tags)
911
+ return sorted(tags)
912
+
913
+ def _consolidate_categories(self, chunks: list[ParsedChunk]) -> dict[str, int]:
914
+ """Counts chunks by category."""
915
+ cats: dict[str, int] = {"code": 0, "doc": 0, "config": 0, "conversation": 0}
916
+ for c in chunks:
917
+ key = c.category.value
918
+ if key in cats:
919
+ cats[key] += 1
920
+ return cats