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.
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