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.
- agents/__init__.py +14 -0
- agents/revisor_critico.py +610 -0
- concierge_graph-3.8.2.dist-info/METADATA +327 -0
- concierge_graph-3.8.2.dist-info/RECORD +37 -0
- concierge_graph-3.8.2.dist-info/WHEEL +5 -0
- concierge_graph-3.8.2.dist-info/entry_points.txt +3 -0
- concierge_graph-3.8.2.dist-info/licenses/LICENSE +21 -0
- concierge_graph-3.8.2.dist-info/top_level.txt +8 -0
- core/__init__.py +23 -0
- core/config.py +192 -0
- core/hybrid_search.py +288 -0
- core/memory_extractor.py +245 -0
- core/middleware.py +723 -0
- core/probabilistic_retriever.py +99 -0
- core/project_index.py +316 -0
- core/vector_backend.py +471 -0
- ingestion/__init__.py +25 -0
- ingestion/crawler.py +722 -0
- ingestion/orchestrator.py +920 -0
- ingestion/parser.py +984 -0
- ingestion/summarizer.py +948 -0
- interface/__init__.py +16 -0
- interface/action_hooks.py +261 -0
- interface/cli.py +391 -0
- interface/mcp_server.py +1737 -0
- main.py +281 -0
- scripts/bootstrap_core_memory.py +93 -0
- services/__init__.py +13 -0
- services/janitor.py +762 -0
- storage/__init__.py +31 -0
- storage/base_backend.py +241 -0
- storage/connection.py +310 -0
- storage/logic.py +703 -0
- storage/schema.py +390 -0
- storage/semantic_logic.py +125 -0
- storage/store.py +802 -0
- storage/vector_store.py +759 -0
|
@@ -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
|