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
services/janitor.py
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""
|
|
2
|
+
services/janitor.py - Grafo Concierge v3.8.0 (Absolute Solidity)
|
|
3
|
+
|
|
4
|
+
Background Janitor — Autonomous maintenance of the Memory Graph.
|
|
5
|
+
|
|
6
|
+
Responsibilities:
|
|
7
|
+
- Trajectories Decay: Marks stale episodic trajectories (>30d)
|
|
8
|
+
as DECAYED to prevent context pollution.
|
|
9
|
+
- Atomic Sync: Reconciles SQLite ↔ ChromaDB via verify_sync,
|
|
10
|
+
removing orphan vectors left behind after partial GC.
|
|
11
|
+
- Auto-Zoom: Detects when >N changes occurred since the last L2
|
|
12
|
+
and automatically triggers generate_project_context (L1/L2).
|
|
13
|
+
- Inactive Nodes Cleanup: Marks nodes with no access for >60d as ARCHIVED.
|
|
14
|
+
- FTS Rebuild: Rebuilds Full-Text Search index after heavy maintenance.
|
|
15
|
+
|
|
16
|
+
Thread Safety:
|
|
17
|
+
The Janitor operates exclusively via SqliteStore (which already uses
|
|
18
|
+
SerializedWriteQueue with thread-safe write() and contextmanager read()).
|
|
19
|
+
It can run in a separate thread without contention risk.
|
|
20
|
+
|
|
21
|
+
Idle-Lock:
|
|
22
|
+
The Janitor checks if there are mine() operations in progress before
|
|
23
|
+
executing destructive tasks (GC, decay). If it detects activity,
|
|
24
|
+
it postpones the execution (backoff).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timedelta, timezone
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
from storage import SqliteStore, ChromaVectorStore
|
|
37
|
+
from ingestion.orchestrator import IngestionManager
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("grafo-concierge.janitor")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Background Janitor Settings
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
# Trajectories decay
|
|
47
|
+
STALE_TRAJECTORY_DAYS: int = 30
|
|
48
|
+
|
|
49
|
+
# Auto-Zoom: minimum number of new nodes to trigger L1/L2
|
|
50
|
+
AUTO_ZOOM_THRESHOLD: int = 10
|
|
51
|
+
|
|
52
|
+
# Inactive nodes: days without access to mark as ARCHIVED
|
|
53
|
+
INACTIVE_NODE_DAYS: int = 60
|
|
54
|
+
|
|
55
|
+
# Interval between Janitor cycles (in seconds)
|
|
56
|
+
DEFAULT_INTERVAL_SECONDS: int = 300 # 5 minutes
|
|
57
|
+
|
|
58
|
+
# Idle-Lock: maximum waiting time (in seconds)
|
|
59
|
+
IDLE_LOCK_TIMEOUT: int = 30
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# MaintenanceReport — report of a Janitor execution
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class MaintenanceReport:
|
|
68
|
+
"""Report of a maintenance round."""
|
|
69
|
+
timestamp: str = ""
|
|
70
|
+
project_uuid: str = ""
|
|
71
|
+
trajectories_decayed: int = 0
|
|
72
|
+
orphan_vectors_removed: int = 0
|
|
73
|
+
inactive_nodes_archived: int = 0
|
|
74
|
+
communities_detected: int = 0
|
|
75
|
+
summaries_generated: int = 0
|
|
76
|
+
zoom_triggered: bool = False
|
|
77
|
+
zoom_l1_count: int = 0
|
|
78
|
+
zoom_l2_summary: str = ""
|
|
79
|
+
fts_rebuilt: bool = False
|
|
80
|
+
errors: list[str] = field(default_factory=list)
|
|
81
|
+
duration_seconds: float = 0.0
|
|
82
|
+
skipped_idle_lock: bool = False
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"timestamp": self.timestamp,
|
|
87
|
+
"project_uuid": self.project_uuid,
|
|
88
|
+
"trajectories_decayed": self.trajectories_decayed,
|
|
89
|
+
"orphan_vectors_removed": self.orphan_vectors_removed,
|
|
90
|
+
"inactive_nodes_archived": self.inactive_nodes_archived,
|
|
91
|
+
"communities_detected": self.communities_detected,
|
|
92
|
+
"summaries_generated": self.summaries_generated,
|
|
93
|
+
"zoom_triggered": self.zoom_triggered,
|
|
94
|
+
"zoom_l1_count": self.zoom_l1_count,
|
|
95
|
+
"zoom_l2_summary": self.zoom_l2_summary,
|
|
96
|
+
"fts_rebuilt": self.fts_rebuilt,
|
|
97
|
+
"errors": self.errors,
|
|
98
|
+
"duration_seconds": round(self.duration_seconds, 3),
|
|
99
|
+
"skipped_idle_lock": self.skipped_idle_lock,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# JanitorService — Autonomous Maintenance Engine
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
class JanitorService:
|
|
108
|
+
"""Background Janitor — autonomous maintenance of the graph.
|
|
109
|
+
|
|
110
|
+
Thread Safety guaranteed by SerializedWriteQueue of SqliteStore.
|
|
111
|
+
All writes are queued atomically.
|
|
112
|
+
|
|
113
|
+
Usage:
|
|
114
|
+
janitor = JanitorService(store, vector_store, ingestion_manager)
|
|
115
|
+
# Manual execution (single-shot):
|
|
116
|
+
report = janitor.run_maintenance(project_uuid)
|
|
117
|
+
# Continuous execution (background thread):
|
|
118
|
+
janitor.start_background(project_uuid, interval=300)
|
|
119
|
+
janitor.stop_background()
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
sqlite_store: SqliteStore,
|
|
125
|
+
vector_store: ChromaVectorStore,
|
|
126
|
+
ingestion_manager: Optional[IngestionManager] = None,
|
|
127
|
+
stale_days: int = STALE_TRAJECTORY_DAYS,
|
|
128
|
+
auto_zoom_threshold: int = AUTO_ZOOM_THRESHOLD,
|
|
129
|
+
inactive_days: int = INACTIVE_NODE_DAYS,
|
|
130
|
+
super_node_threshold: int = 10,
|
|
131
|
+
) -> None:
|
|
132
|
+
self._store = sqlite_store
|
|
133
|
+
self._vector = vector_store
|
|
134
|
+
self._ingestion = ingestion_manager
|
|
135
|
+
self._stale_days = stale_days
|
|
136
|
+
self._zoom_threshold = auto_zoom_threshold
|
|
137
|
+
self._inactive_days = inactive_days
|
|
138
|
+
self._super_node_threshold = super_node_threshold
|
|
139
|
+
|
|
140
|
+
# Idle-Lock: shared flag to detect mine() in progress
|
|
141
|
+
self._mine_active = threading.Event()
|
|
142
|
+
self._mine_timestamp = 0.0
|
|
143
|
+
|
|
144
|
+
# Background thread control
|
|
145
|
+
self._bg_thread: Optional[threading.Thread] = None
|
|
146
|
+
self._stop_event = threading.Event()
|
|
147
|
+
self._last_reports: list[MaintenanceReport] = []
|
|
148
|
+
|
|
149
|
+
# Vector payloads in tests or mock vector stores
|
|
150
|
+
self.vector_payloads: dict[int, dict[str, Any]] = {}
|
|
151
|
+
|
|
152
|
+
logger.info(
|
|
153
|
+
"JanitorService initialized: stale=%dd, zoom_threshold=%d, inactive=%dd, super_node_threshold=%d",
|
|
154
|
+
stale_days, auto_zoom_threshold, inactive_days, super_node_threshold,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# ===================================================================
|
|
158
|
+
# Idle-Lock API — called by IngestionManager
|
|
159
|
+
# ===================================================================
|
|
160
|
+
|
|
161
|
+
def signal_mine_start(self) -> None:
|
|
162
|
+
"""Signals that mine() is in progress (Idle-Lock active)."""
|
|
163
|
+
self._mine_active.set()
|
|
164
|
+
self._mine_timestamp = time.monotonic()
|
|
165
|
+
logger.debug("Idle-Lock: mine() active — Janitor waiting.")
|
|
166
|
+
|
|
167
|
+
def signal_mine_end(self) -> None:
|
|
168
|
+
"""Signals that mine() has finished (Idle-Lock released)."""
|
|
169
|
+
self._mine_active.clear()
|
|
170
|
+
logger.debug("Idle-Lock: mine() finished — Janitor released.")
|
|
171
|
+
|
|
172
|
+
def is_system_active(self) -> bool:
|
|
173
|
+
"""Returns True if there is active activity in the system (mine active or queue busy)."""
|
|
174
|
+
if self._mine_active.is_set():
|
|
175
|
+
elapsed = time.monotonic() - getattr(self, "_mine_timestamp", 0.0)
|
|
176
|
+
if elapsed > 300.0:
|
|
177
|
+
logger.warning(
|
|
178
|
+
"Idle-Lock: deadlock detected! mine() active for %.1fs (> 300s). Forcing flag release.",
|
|
179
|
+
elapsed
|
|
180
|
+
)
|
|
181
|
+
self._mine_active.clear()
|
|
182
|
+
else:
|
|
183
|
+
return True
|
|
184
|
+
# Verifies write queue via public API (without violating encapsulation)
|
|
185
|
+
if self._store and not self._store.is_write_queue_empty():
|
|
186
|
+
return True
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
def _wait_for_idle(self) -> bool:
|
|
190
|
+
"""Waits until mine() finishes or timeout expires.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True if system became idle, False if timeout.
|
|
194
|
+
"""
|
|
195
|
+
if not self.is_system_active():
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
logger.info("Idle-Lock: waiting for system to become idle (timeout=%ds)...", IDLE_LOCK_TIMEOUT)
|
|
199
|
+
start = time.monotonic()
|
|
200
|
+
while self.is_system_active():
|
|
201
|
+
if time.monotonic() - start > IDLE_LOCK_TIMEOUT:
|
|
202
|
+
logger.warning("Idle-Lock: timeout — maintenance postponed.")
|
|
203
|
+
return False
|
|
204
|
+
time.sleep(0.5)
|
|
205
|
+
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
# ===================================================================
|
|
209
|
+
# RUN MAINTENANCE — Single-shot execution
|
|
210
|
+
# ===================================================================
|
|
211
|
+
|
|
212
|
+
def run_maintenance(self, project_uuid: str) -> MaintenanceReport:
|
|
213
|
+
"""Executes a full maintenance round for a project.
|
|
214
|
+
|
|
215
|
+
Flow:
|
|
216
|
+
1. Idle-Lock check: postpones if mine() active or queue has tasks.
|
|
217
|
+
2. Decay of stale trajectories.
|
|
218
|
+
3. SQLite ↔ ChromaDB sync (orphan vectors).
|
|
219
|
+
4. Archiving of inactive nodes.
|
|
220
|
+
5. Community Detection (WITH RECURSIVE on edges table with FTS5).
|
|
221
|
+
6. Synthesis/summarization of Communities and vector injection.
|
|
222
|
+
7. Auto-Zoom (L1/L2) if threshold reached.
|
|
223
|
+
8. FTS Rebuild if significant changes occurred.
|
|
224
|
+
"""
|
|
225
|
+
t0 = time.perf_counter()
|
|
226
|
+
report = MaintenanceReport(
|
|
227
|
+
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
|
|
228
|
+
project_uuid=project_uuid,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
logger.info("=" * 50)
|
|
232
|
+
logger.info("JANITOR: maintenance started for %s", project_uuid)
|
|
233
|
+
logger.info("=" * 50)
|
|
234
|
+
|
|
235
|
+
# --- Idle-Lock ---
|
|
236
|
+
if not self._wait_for_idle():
|
|
237
|
+
report.skipped_idle_lock = True
|
|
238
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
239
|
+
logger.info("JANITOR: maintenance postponed (Idle-Lock).")
|
|
240
|
+
return report
|
|
241
|
+
|
|
242
|
+
# --- STEP 1: Trajectory decay ---
|
|
243
|
+
if self.is_system_active():
|
|
244
|
+
report.skipped_idle_lock = True
|
|
245
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
246
|
+
return report
|
|
247
|
+
report.trajectories_decayed = self._decay_trajectories(project_uuid, report)
|
|
248
|
+
|
|
249
|
+
# --- STEP 2: Atomic sync (orphan vectors) ---
|
|
250
|
+
if self.is_system_active():
|
|
251
|
+
report.skipped_idle_lock = True
|
|
252
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
253
|
+
return report
|
|
254
|
+
report.orphan_vectors_removed = self._sync_vectors(project_uuid, report)
|
|
255
|
+
|
|
256
|
+
# --- STEP 3: Inactive nodes archiving ---
|
|
257
|
+
if self.is_system_active():
|
|
258
|
+
report.skipped_idle_lock = True
|
|
259
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
260
|
+
return report
|
|
261
|
+
report.inactive_nodes_archived = self._archive_inactive_nodes(project_uuid, report)
|
|
262
|
+
|
|
263
|
+
# --- STEP 4: Community Detection (GraphRAG) ---
|
|
264
|
+
if self.is_system_active():
|
|
265
|
+
report.skipped_idle_lock = True
|
|
266
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
267
|
+
return report
|
|
268
|
+
communities = self.detect_communities(project_uuid)
|
|
269
|
+
report.communities_detected = len(communities)
|
|
270
|
+
|
|
271
|
+
# --- STEP 5: Summarization and Injection ---
|
|
272
|
+
if communities:
|
|
273
|
+
if self.is_system_active():
|
|
274
|
+
report.skipped_idle_lock = True
|
|
275
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
276
|
+
return report
|
|
277
|
+
summaries = self.generate_and_persist_community_summaries(project_uuid, communities)
|
|
278
|
+
report.summaries_generated = len(summaries)
|
|
279
|
+
|
|
280
|
+
# --- STEP 6: Auto-Zoom ---
|
|
281
|
+
if self.is_system_active():
|
|
282
|
+
report.skipped_idle_lock = True
|
|
283
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
284
|
+
return report
|
|
285
|
+
self._auto_zoom(project_uuid, report)
|
|
286
|
+
|
|
287
|
+
# --- STEP 7: FTS Rebuild (if changes occurred) ---
|
|
288
|
+
if self.is_system_active():
|
|
289
|
+
report.skipped_idle_lock = True
|
|
290
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
291
|
+
return report
|
|
292
|
+
changes = (report.trajectories_decayed
|
|
293
|
+
+ report.orphan_vectors_removed
|
|
294
|
+
+ report.inactive_nodes_archived
|
|
295
|
+
+ report.summaries_generated)
|
|
296
|
+
if changes > 0:
|
|
297
|
+
self._fts_rebuild(report)
|
|
298
|
+
|
|
299
|
+
report.duration_seconds = time.perf_counter() - t0
|
|
300
|
+
|
|
301
|
+
logger.info("=" * 50)
|
|
302
|
+
logger.info(
|
|
303
|
+
"JANITOR completed in %.2fs: decayed=%d, orphans=%d, archived=%d, communities=%d, summaries=%d, zoom=%s",
|
|
304
|
+
report.duration_seconds,
|
|
305
|
+
report.trajectories_decayed,
|
|
306
|
+
report.orphan_vectors_removed,
|
|
307
|
+
report.inactive_nodes_archived,
|
|
308
|
+
report.communities_detected,
|
|
309
|
+
report.summaries_generated,
|
|
310
|
+
report.zoom_triggered,
|
|
311
|
+
)
|
|
312
|
+
logger.info("=" * 50)
|
|
313
|
+
|
|
314
|
+
self._last_reports.append(report)
|
|
315
|
+
if len(self._last_reports) > 100:
|
|
316
|
+
self._last_reports.pop(0)
|
|
317
|
+
return report
|
|
318
|
+
|
|
319
|
+
# ===================================================================
|
|
320
|
+
# STEP 1: Trajectory Decay
|
|
321
|
+
# ===================================================================
|
|
322
|
+
|
|
323
|
+
def _decay_trajectories(self, project_uuid: str, report: MaintenanceReport) -> int:
|
|
324
|
+
"""Marks stale trajectories as DECAYED."""
|
|
325
|
+
try:
|
|
326
|
+
decayed = self._store.bulk_decay_stale_trajectories(
|
|
327
|
+
project_uuid, stale_threshold_days=self._stale_days,
|
|
328
|
+
)
|
|
329
|
+
if decayed > 0:
|
|
330
|
+
logger.info(
|
|
331
|
+
"Decay: %d trajectories marked as DECAYED (>%dd).",
|
|
332
|
+
decayed, self._stale_days,
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
logger.debug("Decay: no stale trajectory detected.")
|
|
336
|
+
return decayed
|
|
337
|
+
except Exception as e:
|
|
338
|
+
error_msg = f"Decay failed: {e}"
|
|
339
|
+
logger.error(error_msg)
|
|
340
|
+
report.errors.append(error_msg)
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
# ===================================================================
|
|
344
|
+
# STEP 2: Atomic Sync (Reconciliation Loop)
|
|
345
|
+
# ===================================================================
|
|
346
|
+
|
|
347
|
+
def _sync_vectors(self, project_uuid: str, report: MaintenanceReport) -> int:
|
|
348
|
+
"""Detects/removes orphan vectors and auto-generates missing vectors on the active backend."""
|
|
349
|
+
try:
|
|
350
|
+
# Collect active nodes with content from SQLite
|
|
351
|
+
nodes = self._store.get_nodes_by_project(project_uuid, status="ACTIVE")
|
|
352
|
+
nodes_with_content = [n for n in nodes if n.get("content")]
|
|
353
|
+
valid_ids: set[int] = {n["id"] for n in nodes}
|
|
354
|
+
|
|
355
|
+
# 1. Remove orphan vectors
|
|
356
|
+
orphans = self._vector.verify_sync(valid_ids)
|
|
357
|
+
removed = 0
|
|
358
|
+
if orphans:
|
|
359
|
+
removed = self._vector.delete_batch(orphans)
|
|
360
|
+
logger.info("Sync: %d orphan vectors removed.", removed)
|
|
361
|
+
|
|
362
|
+
# 2. Auto-generates and syncs missing embeddings (e.g. Chroma ↔ Qdrant migration)
|
|
363
|
+
if hasattr(self._vector, "get_all_stored_node_ids") and self._ingestion and hasattr(self._ingestion, "_embedder"):
|
|
364
|
+
sqlite_ids = {n["id"] for n in nodes_with_content}
|
|
365
|
+
stored_ids = self._vector.get_all_stored_node_ids()
|
|
366
|
+
|
|
367
|
+
missing_ids = sqlite_ids - stored_ids
|
|
368
|
+
if missing_ids:
|
|
369
|
+
logger.info("Sync: %d SQLite nodes without match in active vector database. Auto-generating embeddings...", len(missing_ids))
|
|
370
|
+
|
|
371
|
+
items_to_store = []
|
|
372
|
+
embedder = self._ingestion._embedder
|
|
373
|
+
|
|
374
|
+
for n in nodes_with_content:
|
|
375
|
+
if n["id"] in missing_ids:
|
|
376
|
+
try:
|
|
377
|
+
emb = embedder.embed(n["content"])
|
|
378
|
+
items_to_store.append({
|
|
379
|
+
"doc_id": f"node_{n['id']}",
|
|
380
|
+
"embedding": emb,
|
|
381
|
+
"metadata": {
|
|
382
|
+
"node_id": n["id"],
|
|
383
|
+
"project_uuid": project_uuid,
|
|
384
|
+
"label": n.get("label", ""),
|
|
385
|
+
"node_type": n.get("type", "FACT")
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
except Exception as embed_err:
|
|
389
|
+
logger.error("Failed to generate embedding for node %d in auto-sync: %s", n["id"], embed_err)
|
|
390
|
+
|
|
391
|
+
if items_to_store:
|
|
392
|
+
stored_count = self._vector.store_embeddings_batch(items_to_store)
|
|
393
|
+
logger.info("Sync: %d missing vectors auto-generated and synchronized successfully.", stored_count)
|
|
394
|
+
|
|
395
|
+
return removed
|
|
396
|
+
|
|
397
|
+
except Exception as e:
|
|
398
|
+
error_msg = f"Vector sync failed: {e}"
|
|
399
|
+
logger.error(error_msg)
|
|
400
|
+
report.errors.append(error_msg)
|
|
401
|
+
return 0
|
|
402
|
+
|
|
403
|
+
# ===================================================================
|
|
404
|
+
# STEP 3: Inactive Nodes Archiving
|
|
405
|
+
# ===================================================================
|
|
406
|
+
|
|
407
|
+
def _archive_inactive_nodes(self, project_uuid: str, report: MaintenanceReport) -> int:
|
|
408
|
+
"""Marks nodes without recent access as ARCHIVED."""
|
|
409
|
+
try:
|
|
410
|
+
nodes = self._store.get_nodes_by_project(project_uuid, status="ACTIVE")
|
|
411
|
+
threshold = datetime.now(timezone.utc) - timedelta(days=self._inactive_days)
|
|
412
|
+
threshold_str = threshold.strftime("%Y-%m-%d %H:%M:%S")
|
|
413
|
+
archived = 0
|
|
414
|
+
|
|
415
|
+
for node in nodes:
|
|
416
|
+
# Uses last_accessed if available, otherwise updated_at
|
|
417
|
+
last_access = node.get("last_accessed") or node.get("updated_at") or node.get("created_at")
|
|
418
|
+
if not last_access:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
# Normalizes to comparable string
|
|
422
|
+
if isinstance(last_access, str) and last_access < threshold_str:
|
|
423
|
+
try:
|
|
424
|
+
self._store.update_node(node["id"], status="ARCHIVED")
|
|
425
|
+
archived += 1
|
|
426
|
+
except Exception as e:
|
|
427
|
+
logger.debug("Failed to archive node %d: %s", node["id"], e)
|
|
428
|
+
|
|
429
|
+
if archived > 0:
|
|
430
|
+
logger.info(
|
|
431
|
+
"Archive: %d nodes marked as ARCHIVED (inactive >%dd).",
|
|
432
|
+
archived, self._inactive_days,
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
logger.debug("Archive: no inactive node detected.")
|
|
436
|
+
|
|
437
|
+
return archived
|
|
438
|
+
|
|
439
|
+
except Exception as e:
|
|
440
|
+
error_msg = f"Archiving of nodes failed: {e}"
|
|
441
|
+
logger.error(error_msg)
|
|
442
|
+
report.errors.append(error_msg)
|
|
443
|
+
return 0
|
|
444
|
+
|
|
445
|
+
# ===================================================================
|
|
446
|
+
# STEP 4: Auto-Zoom (L1/L2 Trigger)
|
|
447
|
+
# ===================================================================
|
|
448
|
+
|
|
449
|
+
def _auto_zoom(self, project_uuid: str, report: MaintenanceReport) -> None:
|
|
450
|
+
"""Checks if there are enough changes to trigger Zoom Gear."""
|
|
451
|
+
if not self._ingestion:
|
|
452
|
+
logger.debug("Auto-Zoom: IngestionManager not configured — ignored.")
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
# Count active nodes — proxy for recent activity
|
|
457
|
+
nodes = self._store.get_nodes_by_project(project_uuid, status="ACTIVE")
|
|
458
|
+
recent_count = len(nodes)
|
|
459
|
+
|
|
460
|
+
if recent_count < self._zoom_threshold:
|
|
461
|
+
logger.debug(
|
|
462
|
+
"Auto-Zoom: %d recent changes < threshold %d — ignored.",
|
|
463
|
+
recent_count, self._zoom_threshold,
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
logger.info(
|
|
468
|
+
"Auto-Zoom: %d recent changes >= threshold %d — triggering Zoom Gear...",
|
|
469
|
+
recent_count, self._zoom_threshold,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
zoom_result = self._ingestion.generate_project_context(project_uuid)
|
|
473
|
+
report.zoom_triggered = True
|
|
474
|
+
report.zoom_l1_count = zoom_result.get("l1_count", 0)
|
|
475
|
+
report.zoom_l2_summary = zoom_result.get("l2_summary", "")
|
|
476
|
+
|
|
477
|
+
logger.info(
|
|
478
|
+
"Auto-Zoom: %d L1s generated, L2 Compass = %.60s...",
|
|
479
|
+
report.zoom_l1_count, report.zoom_l2_summary,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
error_msg = f"Auto-Zoom failed: {e}"
|
|
484
|
+
logger.error(error_msg)
|
|
485
|
+
report.errors.append(error_msg)
|
|
486
|
+
|
|
487
|
+
# ===================================================================
|
|
488
|
+
# STEP 5: FTS Rebuild
|
|
489
|
+
# ===================================================================
|
|
490
|
+
|
|
491
|
+
def _fts_rebuild(self, report: MaintenanceReport) -> None:
|
|
492
|
+
"""Rebuilds the FTS5 index after significant changes."""
|
|
493
|
+
try:
|
|
494
|
+
self._store.fts_rebuild()
|
|
495
|
+
report.fts_rebuilt = True
|
|
496
|
+
logger.info("FTS Rebuild: index rebuilt successfully.")
|
|
497
|
+
except Exception as e:
|
|
498
|
+
error_msg = f"FTS Rebuild failed: {e}"
|
|
499
|
+
logger.error(error_msg)
|
|
500
|
+
report.errors.append(error_msg)
|
|
501
|
+
|
|
502
|
+
# ===================================================================
|
|
503
|
+
# STEP 4 & 5: Community Detection and Summarization (GraphRAG)
|
|
504
|
+
# ===================================================================
|
|
505
|
+
|
|
506
|
+
def detect_communities(self, project_uuid: str) -> dict[int, list[int]]:
|
|
507
|
+
"""Detects communities in the graph using WITH RECURSIVE and FTS5.
|
|
508
|
+
Returns a dictionary mapping the super-node ID to the list of node IDs belonging to the community.
|
|
509
|
+
"""
|
|
510
|
+
communities: dict[int, list[int]] = {}
|
|
511
|
+
try:
|
|
512
|
+
# 1. Finds super-nodes (in_degree >= self._super_node_threshold)
|
|
513
|
+
super_nodes_rows = self._store.execute_read_sql(
|
|
514
|
+
"""
|
|
515
|
+
SELECT n.id
|
|
516
|
+
FROM nodes n
|
|
517
|
+
LEFT JOIN edges e ON n.id = e.target_id
|
|
518
|
+
WHERE n.project_uuid = ? AND n.status = 'ACTIVE'
|
|
519
|
+
GROUP BY n.id
|
|
520
|
+
HAVING COUNT(e.source_id) >= ?
|
|
521
|
+
""",
|
|
522
|
+
(project_uuid, self._super_node_threshold)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
super_node_ids = [row["id"] for row in super_nodes_rows]
|
|
526
|
+
logger.info("Janitor detected %d super-nodes (threshold=%d) in project %s.",
|
|
527
|
+
len(super_node_ids), self._super_node_threshold, project_uuid)
|
|
528
|
+
|
|
529
|
+
# 2. For each super-node, recursively searches the associated community
|
|
530
|
+
for sn_id in super_node_ids:
|
|
531
|
+
community_rows = self._store.execute_read_sql(
|
|
532
|
+
"""
|
|
533
|
+
WITH RECURSIVE community(id, depth) AS (
|
|
534
|
+
SELECT ? AS id, 0 AS depth
|
|
535
|
+
UNION
|
|
536
|
+
SELECT e.source_id, c.depth + 1
|
|
537
|
+
FROM edges e
|
|
538
|
+
JOIN community c ON e.target_id = c.id
|
|
539
|
+
JOIN nodes n ON e.source_id = n.id
|
|
540
|
+
WHERE c.depth < 5 AND n.project_uuid = ? AND n.status = 'ACTIVE'
|
|
541
|
+
UNION
|
|
542
|
+
SELECT e.target_id, c.depth + 1
|
|
543
|
+
FROM edges e
|
|
544
|
+
JOIN community c ON e.source_id = c.id
|
|
545
|
+
JOIN nodes n ON e.target_id = n.id
|
|
546
|
+
WHERE c.depth < 5 AND n.project_uuid = ? AND n.status = 'ACTIVE'
|
|
547
|
+
)
|
|
548
|
+
SELECT DISTINCT id FROM community
|
|
549
|
+
""",
|
|
550
|
+
(sn_id, project_uuid, project_uuid)
|
|
551
|
+
)
|
|
552
|
+
communities[sn_id] = [row["id"] for row in community_rows]
|
|
553
|
+
|
|
554
|
+
except Exception as e:
|
|
555
|
+
logger.error("Failed community detection: %s", e)
|
|
556
|
+
|
|
557
|
+
return communities
|
|
558
|
+
|
|
559
|
+
def generate_and_persist_community_summaries(
|
|
560
|
+
self,
|
|
561
|
+
project_uuid: str,
|
|
562
|
+
communities: dict[int, list[int]],
|
|
563
|
+
) -> list[dict[str, Any]]:
|
|
564
|
+
"""Generates summaries for the detected communities, saves as INSIGHT nodes and updates Qdrant."""
|
|
565
|
+
import json
|
|
566
|
+
|
|
567
|
+
summaries: list[dict[str, Any]] = []
|
|
568
|
+
for community_id, node_ids in communities.items():
|
|
569
|
+
# Concurrency Protection (Idle-Lock): suspends if the system becomes active
|
|
570
|
+
if self.is_system_active():
|
|
571
|
+
logger.warning("Janitor: community suspension activated due to bus activity.")
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
# Fetches node details in the community
|
|
575
|
+
node_details = []
|
|
576
|
+
try:
|
|
577
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
578
|
+
node_details = self._store.execute_read_sql(
|
|
579
|
+
f"SELECT id, label, summary, node_type, type, tags FROM nodes WHERE id IN ({placeholders})",
|
|
580
|
+
tuple(node_ids)
|
|
581
|
+
)
|
|
582
|
+
except Exception as e:
|
|
583
|
+
logger.error("Failed to load details of community nodes %d: %s", community_id, e)
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
if not node_details:
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
nodes_block = "\n".join(
|
|
590
|
+
f"- [{n['label']}] ({n['node_type']}/{n['type']}): {n['summary'] or 'Sem resumo'}"
|
|
591
|
+
for n in node_details
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
summary_text = None
|
|
595
|
+
tags: list[str] = []
|
|
596
|
+
for n in node_details:
|
|
597
|
+
if n.get("tags"):
|
|
598
|
+
try:
|
|
599
|
+
t_list = json.loads(n["tags"]) if isinstance(n["tags"], str) else n["tags"]
|
|
600
|
+
if isinstance(t_list, list):
|
|
601
|
+
tags.extend(t_list)
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
|
|
605
|
+
# Delegates to IngestionManager which encapsulates access to LLM
|
|
606
|
+
if self._ingestion:
|
|
607
|
+
result = self._ingestion.generate_community_summary(nodes_block)
|
|
608
|
+
if result:
|
|
609
|
+
summary_text = result.get("summary")
|
|
610
|
+
extra_tags = result.get("tags", [])
|
|
611
|
+
if isinstance(extra_tags, list):
|
|
612
|
+
tags.extend(extra_tags)
|
|
613
|
+
|
|
614
|
+
if not summary_text:
|
|
615
|
+
# Heuristic / Dumb fallback
|
|
616
|
+
labels_str = ", ".join(n["label"] for n in node_details[:5])
|
|
617
|
+
if len(node_details) > 5:
|
|
618
|
+
labels_str += f" and {len(node_details) - 5} more"
|
|
619
|
+
summary_text = f"Logical community anchored by super-node {community_id}, containing nodes: {labels_str}."
|
|
620
|
+
|
|
621
|
+
# Saves the INSIGHT to SQLite
|
|
622
|
+
try:
|
|
623
|
+
insight_node_id = self._store.create_node(
|
|
624
|
+
project_uuid=project_uuid,
|
|
625
|
+
label=f"community_{community_id}_summary",
|
|
626
|
+
summary=summary_text,
|
|
627
|
+
node_type="INSIGHT",
|
|
628
|
+
type_="community_summary",
|
|
629
|
+
tags=sorted(list(set(tags))),
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Creates the edge connecting the INSIGHT to the super-node
|
|
633
|
+
self._store.create_edge(
|
|
634
|
+
source_id=insight_node_id,
|
|
635
|
+
target_id=community_id,
|
|
636
|
+
relation_type="summarizes",
|
|
637
|
+
weight=1.0,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Injects community IDs directly into vector metadata
|
|
641
|
+
for nid in node_ids:
|
|
642
|
+
self._update_vector_metadata(nid, project_uuid, community_id)
|
|
643
|
+
|
|
644
|
+
summaries.append({
|
|
645
|
+
"community_id": community_id,
|
|
646
|
+
"insight_node_id": insight_node_id,
|
|
647
|
+
"summary": summary_text,
|
|
648
|
+
"tags": sorted(list(set(tags))),
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
except Exception as e:
|
|
652
|
+
logger.error("Failed to save community INSIGHT %d in SQLite: %s", community_id, e)
|
|
653
|
+
|
|
654
|
+
return summaries
|
|
655
|
+
|
|
656
|
+
def _update_vector_metadata(self, node_id: int, project_uuid: str, community_id: int) -> None:
|
|
657
|
+
"""Updates metadata in the vector store injecting the community_id."""
|
|
658
|
+
metadata = {
|
|
659
|
+
"node_id": node_id,
|
|
660
|
+
"project_uuid": project_uuid,
|
|
661
|
+
"community_id": community_id,
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
# 1. Stores in Janitor's local cache (useful for tests/mocks)
|
|
665
|
+
self.vector_payloads[node_id] = metadata
|
|
666
|
+
|
|
667
|
+
# 2. Updates via public API (without accessing _collection directly)
|
|
668
|
+
if self._vector:
|
|
669
|
+
doc_id = f"node_{node_id}"
|
|
670
|
+
self._vector.update_metadata(doc_id, metadata)
|
|
671
|
+
|
|
672
|
+
# ===================================================================
|
|
673
|
+
# BACKGROUND THREAD — Continuous execution
|
|
674
|
+
# ===================================================================
|
|
675
|
+
|
|
676
|
+
def start_background(
|
|
677
|
+
self,
|
|
678
|
+
project_uuid: str,
|
|
679
|
+
interval: int = DEFAULT_INTERVAL_SECONDS,
|
|
680
|
+
) -> None:
|
|
681
|
+
"""Starts Janitor in background thread.
|
|
682
|
+
|
|
683
|
+
The thread runs run_maintenance() every `interval` seconds.
|
|
684
|
+
Thread-safe: uses SerializedWriteQueue of SqliteStore.
|
|
685
|
+
"""
|
|
686
|
+
if self._bg_thread and self._bg_thread.is_alive():
|
|
687
|
+
logger.warning("Janitor background is already running.")
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
self._stop_event.clear()
|
|
691
|
+
|
|
692
|
+
def _loop():
|
|
693
|
+
logger.info("Janitor background started (interval=%ds).", interval)
|
|
694
|
+
while not self._stop_event.is_set():
|
|
695
|
+
try:
|
|
696
|
+
self.run_maintenance(project_uuid)
|
|
697
|
+
except Exception as e:
|
|
698
|
+
logger.error("Janitor background — unhandled error: %s", e)
|
|
699
|
+
|
|
700
|
+
# Interruptible sleep
|
|
701
|
+
self._stop_event.wait(timeout=interval)
|
|
702
|
+
|
|
703
|
+
logger.info("Janitor background stopped.")
|
|
704
|
+
|
|
705
|
+
self._bg_thread = threading.Thread(
|
|
706
|
+
target=_loop,
|
|
707
|
+
name="grafo-janitor",
|
|
708
|
+
daemon=True,
|
|
709
|
+
)
|
|
710
|
+
self._bg_thread.start()
|
|
711
|
+
logger.info("Janitor background thread started: name=%s", self._bg_thread.name)
|
|
712
|
+
|
|
713
|
+
def stop_background(self, timeout: float = 10.0) -> None:
|
|
714
|
+
"""Stops the Janitor background thread."""
|
|
715
|
+
if not self._bg_thread or not self._bg_thread.is_alive():
|
|
716
|
+
logger.debug("Janitor background is not running.")
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
logger.info("Stopping Janitor background...")
|
|
720
|
+
self._stop_event.set()
|
|
721
|
+
self._bg_thread.join(timeout=timeout)
|
|
722
|
+
|
|
723
|
+
if self._bg_thread.is_alive():
|
|
724
|
+
logger.warning("Janitor background did not stop within timeout of %.1fs.", timeout)
|
|
725
|
+
else:
|
|
726
|
+
logger.info("Janitor background stopped successfully.")
|
|
727
|
+
|
|
728
|
+
@property
|
|
729
|
+
def is_running(self) -> bool:
|
|
730
|
+
"""Checks if the background thread is active."""
|
|
731
|
+
return self._bg_thread is not None and self._bg_thread.is_alive()
|
|
732
|
+
|
|
733
|
+
@property
|
|
734
|
+
def last_reports(self) -> list[MaintenanceReport]:
|
|
735
|
+
"""Maintenance reports history (latest)."""
|
|
736
|
+
return list(self._last_reports)
|
|
737
|
+
|
|
738
|
+
# ===================================================================
|
|
739
|
+
# FULL MAINTENANCE — all projects
|
|
740
|
+
# ===================================================================
|
|
741
|
+
|
|
742
|
+
def run_all_projects(self) -> list[MaintenanceReport]:
|
|
743
|
+
"""Executes maintenance on ALL registered projects."""
|
|
744
|
+
reports: list[MaintenanceReport] = []
|
|
745
|
+
try:
|
|
746
|
+
projects = self._store.list_projects()
|
|
747
|
+
except Exception as e:
|
|
748
|
+
logger.error("Failed to list projects for global maintenance: %s", e)
|
|
749
|
+
return reports
|
|
750
|
+
|
|
751
|
+
for project in projects:
|
|
752
|
+
puuid = project.get("uuid", "")
|
|
753
|
+
if not puuid:
|
|
754
|
+
continue
|
|
755
|
+
try:
|
|
756
|
+
report = self.run_maintenance(puuid)
|
|
757
|
+
reports.append(report)
|
|
758
|
+
except Exception as e:
|
|
759
|
+
logger.error("Maintenance failed for project %s: %s", puuid, e)
|
|
760
|
+
|
|
761
|
+
logger.info("Global maintenance: %d projects processed.", len(reports))
|
|
762
|
+
return reports
|