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,489 @@
1
+ """
2
+ HubSpot connector for CortexDB.
3
+
4
+ Polls HubSpot CRM for Contacts, Companies, Deals, Tickets, Notes, Emails,
5
+ Calls, and Tasks, converting them into CortexDB Episodes. Uses the
6
+ ``hubspot-api-client`` library for API v3 access.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Any
14
+
15
+ from cortexdb_connectors.base import (
16
+ Actor,
17
+ CortexConnector,
18
+ EntityRef,
19
+ Episode,
20
+ EpisodeType,
21
+ RawEvent,
22
+ Source,
23
+ Visibility,
24
+ VisibilityLevel,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _SOURCE = Source(system="hubspot")
30
+
31
+ _DEFAULT_OBJECTS = frozenset(
32
+ ["contacts", "companies", "deals", "tickets", "notes", "emails", "calls", "tasks"]
33
+ )
34
+
35
+ _OBJECT_TYPE_MAP: dict[str, EpisodeType] = {
36
+ "contacts": EpisodeType.DOCUMENT,
37
+ "companies": EpisodeType.DOCUMENT,
38
+ "deals": EpisodeType.DOCUMENT,
39
+ "tickets": EpisodeType.ISSUE,
40
+ "notes": EpisodeType.MESSAGE,
41
+ "emails": EpisodeType.MESSAGE,
42
+ "calls": EpisodeType.MESSAGE,
43
+ "tasks": EpisodeType.ISSUE,
44
+ }
45
+
46
+
47
+ class HubSpotConnector(CortexConnector):
48
+ """Connector that syncs HubSpot CRM objects into CortexDB.
49
+
50
+ Parameters
51
+ ----------
52
+ cortex_url:
53
+ Base URL of the CortexDB API.
54
+ cortex_api_key:
55
+ Bearer token for CortexDB authentication.
56
+ access_token:
57
+ HubSpot private app access token.
58
+ objects:
59
+ HubSpot object types to ingest. Defaults to contacts, companies,
60
+ deals, tickets, notes, emails, calls, tasks.
61
+ namespace:
62
+ CortexDB namespace for ingested episodes.
63
+ backfill_days:
64
+ Number of days of historical data to backfill on first sync.
65
+ tenant_id:
66
+ CortexDB tenant identifier.
67
+ """
68
+
69
+ connector_name: str = "hubspot"
70
+
71
+ def __init__(
72
+ self,
73
+ cortex_url: str,
74
+ cortex_api_key: str,
75
+ access_token: str,
76
+ objects: list[str] | None = None,
77
+ namespace: str = "hubspot",
78
+ backfill_days: int = 90,
79
+ tenant_id: str = "default",
80
+ ) -> None:
81
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
82
+ self.access_token = access_token
83
+ self.object_filter = set(objects) if objects else set(_DEFAULT_OBJECTS)
84
+ self.namespace = namespace
85
+ self.backfill_days = backfill_days
86
+ self._client: Any = None
87
+
88
+ # -- HubSpot client management -------------------------------------------
89
+
90
+ def _get_client(self) -> Any:
91
+ """Lazy-initialise and return the HubSpot API client."""
92
+ if self._client is not None:
93
+ return self._client
94
+
95
+ from hubspot import HubSpot
96
+
97
+ self._client = HubSpot(access_token=self.access_token)
98
+ return self._client
99
+
100
+ # -- CortexConnector interface -------------------------------------------
101
+
102
+ async def fetch_events(
103
+ self, since: datetime | None = None
104
+ ) -> list[RawEvent]:
105
+ """Fetch records from configured HubSpot objects.
106
+
107
+ Uses the CRM search API with ``lastmodifieddate`` filter for
108
+ incremental sync and handles cursor-based pagination.
109
+ """
110
+ cutoff = since or datetime.now(timezone.utc) - timedelta(
111
+ days=self.backfill_days
112
+ )
113
+ cutoff_ms = int(cutoff.timestamp() * 1000)
114
+ raw_events: list[RawEvent] = []
115
+
116
+ crm_objects = {"contacts", "companies", "deals", "tickets"}
117
+ engagement_objects = {"notes", "emails", "calls", "tasks"}
118
+
119
+ for obj_type in self.object_filter:
120
+ try:
121
+ if obj_type in crm_objects:
122
+ raw_events.extend(
123
+ self._fetch_crm_object(obj_type, cutoff_ms)
124
+ )
125
+ elif obj_type in engagement_objects:
126
+ raw_events.extend(
127
+ self._fetch_engagements(obj_type, cutoff_ms)
128
+ )
129
+ except Exception:
130
+ logger.exception("Failed to fetch HubSpot %s", obj_type)
131
+ continue
132
+
133
+ return raw_events
134
+
135
+ def normalize(self, raw: RawEvent) -> Episode:
136
+ """Convert a HubSpot record ``RawEvent`` into a CortexDB ``Episode``."""
137
+ data = raw.data
138
+ obj_type = data.get("object_type", "unknown")
139
+ properties = data.get("properties", {})
140
+ episode_type = _OBJECT_TYPE_MAP.get(obj_type, EpisodeType.CUSTOM)
141
+
142
+ actor = self._extract_actor(properties)
143
+ content = self._build_content(obj_type, properties)
144
+ entities = self._extract_entities(obj_type, data)
145
+ tags = ["hubspot", obj_type]
146
+
147
+ metadata: dict[str, Any] = {
148
+ "hubspot_object": obj_type,
149
+ "hubspot_id": data.get("record_id", ""),
150
+ }
151
+ if properties.get("dealstage"):
152
+ metadata["deal_stage"] = properties["dealstage"]
153
+ if properties.get("pipeline"):
154
+ metadata["pipeline"] = properties["pipeline"]
155
+ if properties.get("hs_ticket_priority"):
156
+ metadata["priority"] = properties["hs_ticket_priority"]
157
+
158
+ thread_id = self._derive_thread_id(obj_type, data)
159
+
160
+ return Episode(
161
+ actor=actor,
162
+ source=_SOURCE,
163
+ episode_type=episode_type,
164
+ occurred_at=raw.timestamp,
165
+ content=content,
166
+ raw_payload=data,
167
+ tenant_id=self.tenant_id,
168
+ namespace=self.namespace,
169
+ entities=entities,
170
+ tags=tags,
171
+ metadata=metadata,
172
+ thread_id=thread_id,
173
+ idempotency_key=f"hs:{obj_type}:{data.get('record_id', raw.external_id)}",
174
+ )
175
+
176
+ def map_permissions(self, raw: RawEvent) -> Visibility:
177
+ """HubSpot CRM data is internal; always ``Organization`` visibility."""
178
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
179
+
180
+ # -- fetchers ------------------------------------------------------------
181
+
182
+ def _fetch_crm_object(
183
+ self, obj_type: str, cutoff_ms: int
184
+ ) -> list[RawEvent]:
185
+ """Fetch a CRM object type via the search API with pagination."""
186
+ client = self._get_client()
187
+ api_map = {
188
+ "contacts": client.crm.contacts,
189
+ "companies": client.crm.companies,
190
+ "deals": client.crm.deals,
191
+ "tickets": client.crm.tickets,
192
+ }
193
+ api = api_map.get(obj_type)
194
+ if api is None:
195
+ return []
196
+
197
+ properties = self._properties_for(obj_type)
198
+ events: list[RawEvent] = []
199
+ after: str | None = None
200
+
201
+ while True:
202
+ filter_groups = [
203
+ {
204
+ "filters": [
205
+ {
206
+ "propertyName": "lastmodifieddate",
207
+ "operator": "GTE",
208
+ "value": str(cutoff_ms),
209
+ }
210
+ ]
211
+ }
212
+ ]
213
+ search_request = {
214
+ "filter_groups": filter_groups,
215
+ "properties": properties,
216
+ "limit": 100,
217
+ "sorts": [
218
+ {"propertyName": "lastmodifieddate", "direction": "DESCENDING"}
219
+ ],
220
+ }
221
+ if after:
222
+ search_request["after"] = after
223
+
224
+ try:
225
+ page = api.search_api.do_search(search_request=search_request)
226
+ except Exception:
227
+ logger.exception("HubSpot search failed for %s", obj_type)
228
+ break
229
+
230
+ for result in page.results:
231
+ props = result.properties or {}
232
+ ts = self._parse_hubspot_ts(
233
+ props.get("lastmodifieddate")
234
+ or props.get("hs_lastmodifieddate")
235
+ )
236
+ events.append(
237
+ RawEvent(
238
+ source="hubspot",
239
+ external_id=str(result.id),
240
+ timestamp=ts,
241
+ data={
242
+ "object_type": obj_type,
243
+ "record_id": str(result.id),
244
+ "properties": props,
245
+ },
246
+ permissions={},
247
+ )
248
+ )
249
+
250
+ if page.paging and page.paging.next:
251
+ after = page.paging.next.after
252
+ else:
253
+ break
254
+
255
+ return events
256
+
257
+ def _fetch_engagements(
258
+ self, obj_type: str, cutoff_ms: int
259
+ ) -> list[RawEvent]:
260
+ """Fetch engagement objects (notes, emails, calls, tasks)."""
261
+ client = self._get_client()
262
+ api_map = {
263
+ "notes": client.crm.objects.notes,
264
+ "emails": client.crm.objects.emails,
265
+ "calls": client.crm.objects.calls,
266
+ "tasks": client.crm.objects.tasks,
267
+ }
268
+ api = api_map.get(obj_type)
269
+ if api is None:
270
+ return []
271
+
272
+ events: list[RawEvent] = []
273
+ after: str | None = None
274
+
275
+ while True:
276
+ try:
277
+ kwargs: dict[str, Any] = {"limit": 100}
278
+ if after:
279
+ kwargs["after"] = after
280
+ page = api.basic_api.get_page(**kwargs)
281
+ except Exception:
282
+ logger.exception("HubSpot list failed for %s", obj_type)
283
+ break
284
+
285
+ for result in page.results:
286
+ props = result.properties or {}
287
+ ts = self._parse_hubspot_ts(
288
+ props.get("hs_lastmodifieddate")
289
+ or props.get("hs_timestamp")
290
+ )
291
+ if ts.timestamp() * 1000 < cutoff_ms:
292
+ continue
293
+
294
+ events.append(
295
+ RawEvent(
296
+ source="hubspot",
297
+ external_id=str(result.id),
298
+ timestamp=ts,
299
+ data={
300
+ "object_type": obj_type,
301
+ "record_id": str(result.id),
302
+ "properties": props,
303
+ },
304
+ permissions={},
305
+ )
306
+ )
307
+
308
+ if page.paging and page.paging.next:
309
+ after = page.paging.next.after
310
+ else:
311
+ break
312
+
313
+ return events
314
+
315
+ # -- helpers -------------------------------------------------------------
316
+
317
+ @staticmethod
318
+ def _properties_for(obj_type: str) -> list[str]:
319
+ """Return properties to request for a CRM object type."""
320
+ props_map: dict[str, list[str]] = {
321
+ "contacts": [
322
+ "firstname", "lastname", "email", "company",
323
+ "phone", "lifecyclestage", "lastmodifieddate",
324
+ "hubspot_owner_id",
325
+ ],
326
+ "companies": [
327
+ "name", "domain", "industry", "numberofemployees",
328
+ "annualrevenue", "lastmodifieddate", "hubspot_owner_id",
329
+ ],
330
+ "deals": [
331
+ "dealname", "dealstage", "amount", "pipeline",
332
+ "closedate", "hubspot_owner_id", "lastmodifieddate",
333
+ ],
334
+ "tickets": [
335
+ "subject", "content", "hs_pipeline", "hs_pipeline_stage",
336
+ "hs_ticket_priority", "hubspot_owner_id",
337
+ "lastmodifieddate",
338
+ ],
339
+ }
340
+ return props_map.get(obj_type, ["lastmodifieddate"])
341
+
342
+ @staticmethod
343
+ def _parse_hubspot_ts(value: str | None) -> datetime:
344
+ """Parse a HubSpot timestamp (epoch-ms string or ISO)."""
345
+ if not value:
346
+ return datetime.now(timezone.utc)
347
+ try:
348
+ return datetime.fromtimestamp(int(value) / 1000, tz=timezone.utc)
349
+ except (ValueError, TypeError, OSError):
350
+ pass
351
+ try:
352
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
353
+ except (ValueError, TypeError):
354
+ return datetime.now(timezone.utc)
355
+
356
+ @staticmethod
357
+ def _extract_actor(properties: dict[str, Any]) -> Actor:
358
+ """Extract actor from HubSpot record properties."""
359
+ owner_id = properties.get("hubspot_owner_id") or ""
360
+ email = properties.get("email") or ""
361
+ name = " ".join(
362
+ filter(
363
+ None,
364
+ [
365
+ properties.get("firstname", ""),
366
+ properties.get("lastname", ""),
367
+ ],
368
+ )
369
+ ) or owner_id
370
+ return Actor(id=owner_id or "unknown", name=name, email=email or None)
371
+
372
+ @staticmethod
373
+ def _build_content(obj_type: str, properties: dict[str, Any]) -> str:
374
+ """Build human-readable content from HubSpot record properties."""
375
+ if obj_type == "contacts":
376
+ name = " ".join(
377
+ filter(
378
+ None,
379
+ [properties.get("firstname"), properties.get("lastname")],
380
+ )
381
+ ) or "Unknown contact"
382
+ company = properties.get("company", "")
383
+ email = properties.get("email", "")
384
+ parts = [f"Contact: {name}"]
385
+ if company:
386
+ parts.append(f"Company: {company}")
387
+ if email:
388
+ parts.append(f"Email: {email}")
389
+ return " | ".join(parts)
390
+
391
+ if obj_type == "companies":
392
+ name = properties.get("name", "Unnamed company")
393
+ industry = properties.get("industry", "")
394
+ domain = properties.get("domain", "")
395
+ return f"Company: {name} | {industry} | {domain}".rstrip(" | ")
396
+
397
+ if obj_type == "deals":
398
+ name = properties.get("dealname", "Untitled deal")
399
+ stage = properties.get("dealstage", "")
400
+ amount = properties.get("amount")
401
+ amount_str = f" (${float(amount):,.2f})" if amount else ""
402
+ return f"Deal [{stage}]: {name}{amount_str}"
403
+
404
+ if obj_type == "tickets":
405
+ subject = properties.get("subject", "Untitled ticket")
406
+ content = (properties.get("content") or "")[:500]
407
+ priority = properties.get("hs_ticket_priority", "")
408
+ return f"Ticket [{priority}]: {subject}\n{content}".strip()
409
+
410
+ if obj_type == "notes":
411
+ body = (properties.get("hs_note_body") or "")[:500]
412
+ return f"Note: {body}" if body else "Note (empty)"
413
+
414
+ if obj_type == "emails":
415
+ subject = properties.get("hs_email_subject", "No subject")
416
+ text = (properties.get("hs_email_text") or "")[:500]
417
+ return f"Email: {subject}\n{text}".strip()
418
+
419
+ if obj_type == "calls":
420
+ title = properties.get("hs_call_title", "Call")
421
+ body = (properties.get("hs_call_body") or "")[:500]
422
+ duration = properties.get("hs_call_duration")
423
+ dur_str = f" ({duration}ms)" if duration else ""
424
+ return f"Call: {title}{dur_str}\n{body}".strip()
425
+
426
+ if obj_type == "tasks":
427
+ subject = properties.get("hs_task_subject", "Untitled task")
428
+ body = (properties.get("hs_task_body") or "")[:300]
429
+ status = properties.get("hs_task_status", "")
430
+ return f"Task [{status}]: {subject}\n{body}".strip()
431
+
432
+ return f"HubSpot {obj_type}"
433
+
434
+ @staticmethod
435
+ def _extract_entities(
436
+ obj_type: str, data: dict[str, Any]
437
+ ) -> list[EntityRef]:
438
+ """Extract entity references from a HubSpot record."""
439
+ entities: list[EntityRef] = []
440
+ record_id = data.get("record_id", "")
441
+ properties = data.get("properties", {})
442
+
443
+ if record_id:
444
+ display = (
445
+ properties.get("dealname")
446
+ or properties.get("name")
447
+ or properties.get("subject")
448
+ or properties.get("email")
449
+ or record_id
450
+ )
451
+ entities.append(
452
+ EntityRef(
453
+ entity_type=obj_type.rstrip("s"),
454
+ entity_id=record_id,
455
+ display_name=str(display),
456
+ )
457
+ )
458
+
459
+ owner_id = properties.get("hubspot_owner_id")
460
+ if owner_id:
461
+ entities.append(
462
+ EntityRef(
463
+ entity_type="user",
464
+ entity_id=str(owner_id),
465
+ display_name=str(owner_id),
466
+ )
467
+ )
468
+
469
+ pipeline = properties.get("pipeline") or properties.get("hs_pipeline")
470
+ if pipeline:
471
+ entities.append(
472
+ EntityRef(
473
+ entity_type="pipeline",
474
+ entity_id=str(pipeline),
475
+ display_name=str(pipeline),
476
+ )
477
+ )
478
+
479
+ return entities
480
+
481
+ @staticmethod
482
+ def _derive_thread_id(
483
+ obj_type: str, data: dict[str, Any]
484
+ ) -> str | None:
485
+ """Derive a thread_id for grouping related records."""
486
+ record_id = data.get("record_id", "")
487
+ if not record_id:
488
+ return None
489
+ return f"hs:{obj_type}:{record_id}"
@@ -0,0 +1,40 @@
1
+ """Proactive Insights Engine for CortexDB.
2
+
3
+ Auto-generates intelligence from stored episodes and entities by running
4
+ periodic analysis detectors. Each detector scans the CortexDB data plane
5
+ for a specific pattern (incident spikes, knowledge gaps, stale runbooks,
6
+ etc.) and produces ``Insight`` objects that surface actionable information
7
+ to operators and AI agents.
8
+
9
+ Typical usage::
10
+
11
+ from cortexdb_connectors.insights import InsightsEngine
12
+
13
+ engine = InsightsEngine(cortex_url="http://localhost:8080", cortex_api_key="...")
14
+ await engine.run_once() # single analysis pass
15
+ await engine.start() # periodic background loop
16
+ """
17
+
18
+ from cortexdb_connectors.insights.detectors import (
19
+ Insight,
20
+ InsightSeverity,
21
+ InsightType,
22
+ IncidentSpikeDetector,
23
+ KnowledgeGapDetector,
24
+ NewDependencyDetector,
25
+ OwnershipGapDetector,
26
+ StaleRunbookDetector,
27
+ )
28
+ from cortexdb_connectors.insights.engine import InsightsEngine
29
+
30
+ __all__ = [
31
+ "Insight",
32
+ "InsightSeverity",
33
+ "InsightType",
34
+ "InsightsEngine",
35
+ "IncidentSpikeDetector",
36
+ "KnowledgeGapDetector",
37
+ "NewDependencyDetector",
38
+ "OwnershipGapDetector",
39
+ "StaleRunbookDetector",
40
+ ]