cortexdb-connectors 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,308 @@
1
+ """InsightsEngine -- periodic analysis orchestrator for CortexDB.
2
+
3
+ The engine manages a collection of insight detectors, runs them on a
4
+ configurable schedule, and maintains an in-memory store of generated
5
+ insights that can be queried via the companion FastAPI router.
6
+
7
+ Usage::
8
+
9
+ engine = InsightsEngine(
10
+ cortex_url="http://localhost:8080",
11
+ cortex_api_key="secret",
12
+ interval_seconds=300,
13
+ )
14
+ # One-shot analysis:
15
+ insights = await engine.run_once()
16
+
17
+ # Periodic background loop (runs until cancelled):
18
+ await engine.start()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ from datetime import datetime, timezone
26
+ from typing import Any
27
+
28
+ from cortexdb_connectors.insights.detectors import (
29
+ BaseDetector,
30
+ CortexAPIClient,
31
+ IncidentSpikeDetector,
32
+ Insight,
33
+ InsightSeverity,
34
+ InsightType,
35
+ KnowledgeGapDetector,
36
+ NewDependencyDetector,
37
+ OwnershipGapDetector,
38
+ StaleRunbookDetector,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class InsightsEngine:
45
+ """Orchestrates periodic insight detection against CortexDB data.
46
+
47
+ Parameters
48
+ ----------
49
+ cortex_url:
50
+ Base URL of the CortexDB HTTP API (e.g. ``http://localhost:8080``).
51
+ cortex_api_key:
52
+ Bearer token for CortexDB authentication.
53
+ tenant_id:
54
+ CortexDB tenant identifier (default ``"default"``).
55
+ interval_seconds:
56
+ Seconds between analysis passes when running in periodic mode
57
+ (default 300 = 5 minutes).
58
+ detectors:
59
+ Optional list of detector instances. If not provided, all
60
+ built-in detectors are registered with default settings.
61
+ max_insights:
62
+ Maximum number of insights to retain in memory (default 1000).
63
+ Oldest insights beyond this limit are evicted.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ cortex_url: str,
69
+ cortex_api_key: str,
70
+ tenant_id: str = "default",
71
+ interval_seconds: int = 300,
72
+ detectors: list[BaseDetector] | None = None,
73
+ max_insights: int = 1000,
74
+ ) -> None:
75
+ self.client = CortexAPIClient(
76
+ base_url=cortex_url,
77
+ api_key=cortex_api_key,
78
+ tenant_id=tenant_id,
79
+ )
80
+ self.interval_seconds = interval_seconds
81
+ self.max_insights = max_insights
82
+
83
+ self._detectors: list[BaseDetector] = detectors or self._default_detectors()
84
+ self._insights: dict[str, Insight] = {}
85
+ self._running = False
86
+ self._task: asyncio.Task[None] | None = None
87
+ self._last_run_at: datetime | None = None
88
+ self._run_count: int = 0
89
+
90
+ # -- Detector management -------------------------------------------------
91
+
92
+ @staticmethod
93
+ def _default_detectors() -> list[BaseDetector]:
94
+ """Return the full set of built-in detectors with default config."""
95
+ return [
96
+ IncidentSpikeDetector(),
97
+ NewDependencyDetector(),
98
+ KnowledgeGapDetector(),
99
+ OwnershipGapDetector(),
100
+ StaleRunbookDetector(),
101
+ ]
102
+
103
+ def register_detector(self, detector: BaseDetector) -> None:
104
+ """Add a custom detector to the engine."""
105
+ self._detectors.append(detector)
106
+
107
+ @property
108
+ def detectors(self) -> list[BaseDetector]:
109
+ """Return the list of registered detectors."""
110
+ return list(self._detectors)
111
+
112
+ # -- Insight store -------------------------------------------------------
113
+
114
+ @property
115
+ def insights(self) -> list[Insight]:
116
+ """Return all stored insights, newest first."""
117
+ return sorted(
118
+ self._insights.values(),
119
+ key=lambda i: i.detected_at,
120
+ reverse=True,
121
+ )
122
+
123
+ def get_insight(self, insight_id: str) -> Insight | None:
124
+ """Look up a single insight by id."""
125
+ return self._insights.get(insight_id)
126
+
127
+ def dismiss_insight(self, insight_id: str) -> Insight | None:
128
+ """Mark an insight as dismissed.
129
+
130
+ Returns the updated insight, or ``None`` if not found.
131
+ """
132
+ insight = self._insights.get(insight_id)
133
+ if insight is None:
134
+ return None
135
+ insight.dismissed = True
136
+ insight.dismissed_at = datetime.now(timezone.utc)
137
+ return insight
138
+
139
+ def query_insights(
140
+ self,
141
+ *,
142
+ insight_type: InsightType | None = None,
143
+ severity: InsightSeverity | None = None,
144
+ entity: str | None = None,
145
+ include_dismissed: bool = False,
146
+ limit: int = 50,
147
+ offset: int = 0,
148
+ ) -> list[Insight]:
149
+ """Query stored insights with optional filters.
150
+
151
+ Parameters
152
+ ----------
153
+ insight_type:
154
+ Filter by insight type.
155
+ severity:
156
+ Filter by severity level.
157
+ entity:
158
+ Filter to insights mentioning this entity name (case-insensitive
159
+ substring match).
160
+ include_dismissed:
161
+ Whether to include dismissed insights (default ``False``).
162
+ limit:
163
+ Maximum number of results to return.
164
+ offset:
165
+ Number of results to skip (for pagination).
166
+ """
167
+ results = self.insights # already sorted newest-first
168
+
169
+ if not include_dismissed:
170
+ results = [i for i in results if not i.dismissed]
171
+ if insight_type is not None:
172
+ results = [i for i in results if i.insight_type == insight_type]
173
+ if severity is not None:
174
+ results = [i for i in results if i.severity == severity]
175
+ if entity is not None:
176
+ entity_lower = entity.lower()
177
+ results = [
178
+ i for i in results
179
+ if any(entity_lower in e.lower() for e in i.entities)
180
+ ]
181
+
182
+ return results[offset : offset + limit]
183
+
184
+ def _store_insight(self, insight: Insight) -> None:
185
+ """Store an insight, merging by id and enforcing the size limit."""
186
+ existing = self._insights.get(insight.id)
187
+ if existing is not None and existing.dismissed:
188
+ # Do not overwrite a dismissed insight with a re-detection.
189
+ return
190
+ self._insights[insight.id] = insight
191
+
192
+ # Evict oldest non-dismissed insights beyond the cap.
193
+ if len(self._insights) > self.max_insights:
194
+ sorted_ids = sorted(
195
+ self._insights,
196
+ key=lambda k: self._insights[k].detected_at,
197
+ )
198
+ excess = len(self._insights) - self.max_insights
199
+ for iid in sorted_ids[:excess]:
200
+ if not self._insights[iid].dismissed:
201
+ del self._insights[iid]
202
+
203
+ # -- Execution -----------------------------------------------------------
204
+
205
+ async def run_once(self) -> list[Insight]:
206
+ """Execute all detectors once and return newly generated insights.
207
+
208
+ This is safe to call from application code for on-demand analysis.
209
+ """
210
+ new_insights: list[Insight] = []
211
+ logger.info(
212
+ "InsightsEngine: starting analysis pass (%d detectors)",
213
+ len(self._detectors),
214
+ )
215
+
216
+ for detector in self._detectors:
217
+ detector_name = type(detector).__name__
218
+ try:
219
+ detected = await detector.detect(self.client)
220
+ for insight in detected:
221
+ self._store_insight(insight)
222
+ new_insights.append(insight)
223
+ logger.info(
224
+ "InsightsEngine: %s produced %d insight(s)",
225
+ detector_name, len(detected),
226
+ )
227
+ except Exception:
228
+ logger.exception(
229
+ "InsightsEngine: detector %s raised an unexpected error",
230
+ detector_name,
231
+ )
232
+
233
+ self._last_run_at = datetime.now(timezone.utc)
234
+ self._run_count += 1
235
+
236
+ logger.info(
237
+ "InsightsEngine: analysis pass complete, %d new insight(s), "
238
+ "%d total stored",
239
+ len(new_insights), len(self._insights),
240
+ )
241
+ return new_insights
242
+
243
+ async def start(self) -> None:
244
+ """Start the periodic analysis loop.
245
+
246
+ This method blocks until ``stop()`` is called or the task is
247
+ cancelled. It is designed to be run as an ``asyncio.Task``.
248
+ """
249
+ if self._running:
250
+ logger.warning("InsightsEngine: already running")
251
+ return
252
+
253
+ self._running = True
254
+ logger.info(
255
+ "InsightsEngine: starting periodic loop (interval=%ds)",
256
+ self.interval_seconds,
257
+ )
258
+
259
+ try:
260
+ while self._running:
261
+ try:
262
+ await self.run_once()
263
+ except Exception:
264
+ logger.exception(
265
+ "InsightsEngine: unhandled error in analysis loop"
266
+ )
267
+ await asyncio.sleep(self.interval_seconds)
268
+ finally:
269
+ self._running = False
270
+ logger.info("InsightsEngine: periodic loop stopped")
271
+
272
+ def start_background(self) -> asyncio.Task[None]:
273
+ """Launch the periodic loop as a background asyncio task.
274
+
275
+ Returns the created task so the caller can cancel or await it.
276
+ """
277
+ self._task = asyncio.create_task(self.start(), name="insights-engine")
278
+ return self._task
279
+
280
+ async def stop(self) -> None:
281
+ """Stop the periodic analysis loop gracefully."""
282
+ self._running = False
283
+ if self._task is not None and not self._task.done():
284
+ self._task.cancel()
285
+ try:
286
+ await self._task
287
+ except asyncio.CancelledError:
288
+ pass
289
+ self._task = None
290
+ logger.info("InsightsEngine: stopped")
291
+
292
+ # -- Status / health -----------------------------------------------------
293
+
294
+ def status(self) -> dict[str, Any]:
295
+ """Return engine status suitable for health check endpoints."""
296
+ return {
297
+ "running": self._running,
298
+ "interval_seconds": self.interval_seconds,
299
+ "detector_count": len(self._detectors),
300
+ "insight_count": len(self._insights),
301
+ "active_insight_count": sum(
302
+ 1 for i in self._insights.values() if not i.dismissed
303
+ ),
304
+ "last_run_at": (
305
+ self._last_run_at.isoformat() if self._last_run_at else None
306
+ ),
307
+ "run_count": self._run_count,
308
+ }