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.
- cortexdb_connectors/__init__.py +40 -0
- cortexdb_connectors/base.py +364 -0
- cortexdb_connectors/cli.py +476 -0
- cortexdb_connectors/confluence/__init__.py +359 -0
- cortexdb_connectors/discord/__init__.py +646 -0
- cortexdb_connectors/github/__init__.py +309 -0
- cortexdb_connectors/gitlab/__init__.py +749 -0
- cortexdb_connectors/google_workspace/__init__.py +1033 -0
- cortexdb_connectors/hubspot/__init__.py +489 -0
- cortexdb_connectors/insights/__init__.py +40 -0
- cortexdb_connectors/insights/api.py +252 -0
- cortexdb_connectors/insights/detectors.py +771 -0
- cortexdb_connectors/insights/engine.py +308 -0
- cortexdb_connectors/intercom/__init__.py +631 -0
- cortexdb_connectors/jira/__init__.py +298 -0
- cortexdb_connectors/linear/__init__.py +705 -0
- cortexdb_connectors/notion/__init__.py +702 -0
- cortexdb_connectors/pagerduty/__init__.py +298 -0
- cortexdb_connectors/salesforce/__init__.py +399 -0
- cortexdb_connectors/servicenow/__init__.py +460 -0
- cortexdb_connectors/slack/__init__.py +331 -0
- cortexdb_connectors/state.py +145 -0
- cortexdb_connectors/teams/__init__.py +656 -0
- cortexdb_connectors/webhooks.py +672 -0
- cortexdb_connectors/worker.py +192 -0
- cortexdb_connectors/zendesk/__init__.py +496 -0
- cortexdb_connectors-0.2.0.dist-info/METADATA +207 -0
- cortexdb_connectors-0.2.0.dist-info/RECORD +31 -0
- cortexdb_connectors-0.2.0.dist-info/WHEEL +5 -0
- cortexdb_connectors-0.2.0.dist-info/entry_points.txt +2 -0
- cortexdb_connectors-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""cortexdb_connectors — data connector SDK for the CortexDB v1 API.
|
|
2
|
+
|
|
3
|
+
Each connector subclass implements `fetch_events`, `normalize`, and
|
|
4
|
+
`map_permissions`; the base class POSTs every normalised Episode to
|
|
5
|
+
`/v1/experience` with the required Authorization + X-Cortex-Actor
|
|
6
|
+
headers. Use the `cortexdb-sync` CLI to run a connector once or in a
|
|
7
|
+
poll loop; or `from cortexdb_connectors.slack import SlackConnector`
|
|
8
|
+
to drive it programmatically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
from cortexdb_connectors.base import (
|
|
14
|
+
Actor,
|
|
15
|
+
CortexConnector,
|
|
16
|
+
EntityRef,
|
|
17
|
+
Episode,
|
|
18
|
+
EpisodeType,
|
|
19
|
+
RawEvent,
|
|
20
|
+
Source,
|
|
21
|
+
SyncResult,
|
|
22
|
+
SyncState,
|
|
23
|
+
Visibility,
|
|
24
|
+
VisibilityLevel,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"__version__",
|
|
29
|
+
"Actor",
|
|
30
|
+
"CortexConnector",
|
|
31
|
+
"EntityRef",
|
|
32
|
+
"Episode",
|
|
33
|
+
"EpisodeType",
|
|
34
|
+
"RawEvent",
|
|
35
|
+
"Source",
|
|
36
|
+
"SyncResult",
|
|
37
|
+
"SyncState",
|
|
38
|
+
"Visibility",
|
|
39
|
+
"VisibilityLevel",
|
|
40
|
+
]
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""CortexDB Connector SDK — base classes and data models (v1 surface).
|
|
2
|
+
|
|
3
|
+
Each connector subclass implements `fetch_events`, `normalize`, and
|
|
4
|
+
`map_permissions`. The base class orchestrates a fetch → normalize →
|
|
5
|
+
ingest cycle and POSTs each Episode to `POST /v1/experience` as a fully
|
|
6
|
+
formed envelope. v1 requires `Authorization: Bearer <PASETO>` AND
|
|
7
|
+
`X-Cortex-Actor: <actor>` on every request, both of which the base
|
|
8
|
+
class stamps for you.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import uuid
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
DEFAULT_API_URL = "https://api-v1.cortexdb.ai"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Enums
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EpisodeType(str, Enum):
|
|
31
|
+
"""Canonical episode types understood by CortexDB.
|
|
32
|
+
|
|
33
|
+
These map to v1 `modality` values during ingest. Most messaging-like
|
|
34
|
+
events become `conversation`; documents become `document`; events
|
|
35
|
+
without a natural author become `observation`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
MESSAGE = "message"
|
|
39
|
+
CODE_CHANGE = "code_change"
|
|
40
|
+
ISSUE = "issue"
|
|
41
|
+
INCIDENT = "incident"
|
|
42
|
+
DOCUMENT = "document"
|
|
43
|
+
REVIEW = "review"
|
|
44
|
+
COMMENT = "comment"
|
|
45
|
+
DEPLOYMENT = "deployment"
|
|
46
|
+
ALERT = "alert"
|
|
47
|
+
MEETING = "meeting"
|
|
48
|
+
CUSTOM = "custom"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# How each EpisodeType lands as a v1 modality. The connector can override
|
|
52
|
+
# by setting `episode.metadata["modality"]` explicitly.
|
|
53
|
+
_TYPE_TO_MODALITY: dict[EpisodeType, str] = {
|
|
54
|
+
EpisodeType.MESSAGE: "conversation",
|
|
55
|
+
EpisodeType.COMMENT: "conversation",
|
|
56
|
+
EpisodeType.REVIEW: "feedback",
|
|
57
|
+
EpisodeType.CODE_CHANGE: "document",
|
|
58
|
+
EpisodeType.ISSUE: "document",
|
|
59
|
+
EpisodeType.DOCUMENT: "document",
|
|
60
|
+
EpisodeType.INCIDENT: "observation",
|
|
61
|
+
EpisodeType.DEPLOYMENT: "observation",
|
|
62
|
+
EpisodeType.ALERT: "observation",
|
|
63
|
+
EpisodeType.MEETING: "conversation",
|
|
64
|
+
EpisodeType.CUSTOM: "observation",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class VisibilityLevel(str, Enum):
|
|
69
|
+
"""Controls who may access an episode within CortexDB.
|
|
70
|
+
|
|
71
|
+
Kept for connector-side modelling; the v1 surface uses scope paths
|
|
72
|
+
and capabilities, not these labels. We currently fold visibility
|
|
73
|
+
into `context.labels` for traceability rather than enforcement.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
PUBLIC = "public"
|
|
77
|
+
ORGANIZATION = "organization"
|
|
78
|
+
RESTRICTED = "restricted"
|
|
79
|
+
PRIVATE = "private"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Data-transfer objects
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Source:
|
|
89
|
+
system: str
|
|
90
|
+
connector_version: str = "0.2.0"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Actor:
|
|
95
|
+
id: str
|
|
96
|
+
name: str
|
|
97
|
+
email: str | None = None
|
|
98
|
+
avatar_url: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class EntityRef:
|
|
103
|
+
entity_type: str
|
|
104
|
+
entity_id: str
|
|
105
|
+
display_name: str = ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Visibility:
|
|
110
|
+
level: VisibilityLevel
|
|
111
|
+
allowed_principals: tuple[str, ...] = ()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class RawEvent:
|
|
116
|
+
source: str
|
|
117
|
+
external_id: str
|
|
118
|
+
timestamp: datetime
|
|
119
|
+
data: dict[str, Any]
|
|
120
|
+
permissions: dict[str, Any] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Episode:
|
|
125
|
+
"""A normalized unit of knowledge ready for ingestion into CortexDB."""
|
|
126
|
+
|
|
127
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
128
|
+
actor: Actor | None = None
|
|
129
|
+
source: Source | None = None
|
|
130
|
+
episode_type: EpisodeType = EpisodeType.CUSTOM
|
|
131
|
+
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
132
|
+
content: str = ""
|
|
133
|
+
raw_payload: dict[str, Any] = field(default_factory=dict)
|
|
134
|
+
visibility: Visibility = field(
|
|
135
|
+
default_factory=lambda: Visibility(level=VisibilityLevel.ORGANIZATION)
|
|
136
|
+
)
|
|
137
|
+
tenant_id: str = "default"
|
|
138
|
+
namespace: str = "default"
|
|
139
|
+
entities: list[EntityRef] = field(default_factory=list)
|
|
140
|
+
tags: list[str] = field(default_factory=list)
|
|
141
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
142
|
+
thread_id: str | None = None
|
|
143
|
+
parent_id: str | None = None
|
|
144
|
+
idempotency_key: str | None = None
|
|
145
|
+
ttl_seconds: int | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class SyncResult:
|
|
150
|
+
episodes_fetched: int = 0
|
|
151
|
+
episodes_ingested: int = 0
|
|
152
|
+
errors: list[str] = field(default_factory=list)
|
|
153
|
+
duration_seconds: float = 0.0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class SyncState:
|
|
158
|
+
connector_name: str
|
|
159
|
+
tenant_id: str
|
|
160
|
+
last_synced_at: datetime | None = None
|
|
161
|
+
cursor: str | None = None
|
|
162
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Abstract base connector
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CortexConnector(ABC):
|
|
171
|
+
"""Base class for CortexDB data connectors.
|
|
172
|
+
|
|
173
|
+
Concrete connectors implement `fetch_events`, `normalize`, and
|
|
174
|
+
`map_permissions`. The base orchestrates a sync cycle and ingests
|
|
175
|
+
each normalised Episode via `POST /v1/experience`.
|
|
176
|
+
|
|
177
|
+
Constructor accepts the v0 positional shape `(cortex_url,
|
|
178
|
+
cortex_api_key, tenant_id)` so existing connector classes that pass
|
|
179
|
+
`super().__init__(cortex_url, cortex_api_key, tenant_id)` keep
|
|
180
|
+
working without modification. Pass `actor=` / `scope_template=` (or
|
|
181
|
+
set the matching env vars) to target a specific scope on v1.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
cortex_url: str | None = None,
|
|
187
|
+
cortex_api_key: str | None = None,
|
|
188
|
+
tenant_id: str = "default",
|
|
189
|
+
*,
|
|
190
|
+
actor: str | None = None,
|
|
191
|
+
scope_template: str | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
self.cortex_url = (cortex_url or os.environ.get("CORTEXDB_URL") or DEFAULT_API_URL).rstrip("/")
|
|
194
|
+
self.cortex_api_key = cortex_api_key or os.environ.get("CORTEXDB_API_KEY") or ""
|
|
195
|
+
self.tenant_id = tenant_id
|
|
196
|
+
# v1-specific fields. Fall back to env so a freshly signed-up actor
|
|
197
|
+
# (state.json from `cortexdb init` or the MCP server) is picked up
|
|
198
|
+
# without any code change in the connector subclass.
|
|
199
|
+
self.actor = actor or os.environ.get("CORTEXDB_ACTOR") or ""
|
|
200
|
+
self.scope_template = (
|
|
201
|
+
scope_template
|
|
202
|
+
or os.environ.get("CORTEXDB_SCOPE_TEMPLATE")
|
|
203
|
+
or os.environ.get("CORTEXDB_SCOPE")
|
|
204
|
+
or self.actor
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# -- abstract interface --------------------------------------------------
|
|
208
|
+
|
|
209
|
+
@abstractmethod
|
|
210
|
+
async def fetch_events(self, since: datetime | None = None) -> list[RawEvent]:
|
|
211
|
+
...
|
|
212
|
+
|
|
213
|
+
@abstractmethod
|
|
214
|
+
def normalize(self, raw: RawEvent) -> Episode:
|
|
215
|
+
...
|
|
216
|
+
|
|
217
|
+
@abstractmethod
|
|
218
|
+
def map_permissions(self, raw: RawEvent) -> Visibility:
|
|
219
|
+
...
|
|
220
|
+
|
|
221
|
+
# -- default sync loop ---------------------------------------------------
|
|
222
|
+
|
|
223
|
+
async def sync(self, since: datetime | None = None) -> SyncResult:
|
|
224
|
+
import time
|
|
225
|
+
|
|
226
|
+
start = time.monotonic()
|
|
227
|
+
result = SyncResult()
|
|
228
|
+
raw_events = await self.fetch_events(since=since)
|
|
229
|
+
result.episodes_fetched = len(raw_events)
|
|
230
|
+
|
|
231
|
+
for raw in raw_events:
|
|
232
|
+
try:
|
|
233
|
+
episode = self.normalize(raw)
|
|
234
|
+
episode.visibility = self.map_permissions(raw)
|
|
235
|
+
await self.ingest_episode(episode)
|
|
236
|
+
result.episodes_ingested += 1
|
|
237
|
+
except Exception as exc: # noqa: BLE001
|
|
238
|
+
result.errors.append(
|
|
239
|
+
f"Error processing event {raw.external_id}: {exc}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
result.duration_seconds = round(time.monotonic() - start, 3)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
# -- v1 ingest -----------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _resolve_scope(self, episode: Episode) -> str:
|
|
248
|
+
"""Resolve the scope template against this episode.
|
|
249
|
+
|
|
250
|
+
Placeholders: `{tenant}`, `{source}`, `{namespace}`, `{actor}`,
|
|
251
|
+
plus any `{entity.<type>}` shorthand for the first entity of that
|
|
252
|
+
type attached to the episode (e.g. `{entity.channel}`).
|
|
253
|
+
"""
|
|
254
|
+
tpl = self.scope_template or self.actor or "org:default"
|
|
255
|
+
if not ("{" in tpl):
|
|
256
|
+
return tpl
|
|
257
|
+
|
|
258
|
+
replacements: dict[str, str] = {
|
|
259
|
+
"tenant": episode.tenant_id or self.tenant_id,
|
|
260
|
+
"source": (episode.source.system if episode.source else episode.namespace),
|
|
261
|
+
"namespace": episode.namespace,
|
|
262
|
+
"actor": self.actor,
|
|
263
|
+
}
|
|
264
|
+
for ent in episode.entities:
|
|
265
|
+
replacements[f"entity.{ent.entity_type}"] = ent.entity_id
|
|
266
|
+
|
|
267
|
+
for key, value in replacements.items():
|
|
268
|
+
tpl = tpl.replace("{" + key + "}", value or "")
|
|
269
|
+
return tpl
|
|
270
|
+
|
|
271
|
+
def _envelope(self, episode: Episode) -> dict[str, Any]:
|
|
272
|
+
"""Build the v1 ExperienceItem JSON for this episode."""
|
|
273
|
+
scope = self._resolve_scope(episode)
|
|
274
|
+
modality = (
|
|
275
|
+
episode.metadata.get("modality")
|
|
276
|
+
or _TYPE_TO_MODALITY.get(episode.episode_type, "observation")
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
labels = list(episode.tags)
|
|
280
|
+
if episode.source and episode.source.system:
|
|
281
|
+
labels.append(f"source:{episode.source.system}")
|
|
282
|
+
if episode.visibility and episode.visibility.level:
|
|
283
|
+
labels.append(f"visibility:{episode.visibility.level.value}")
|
|
284
|
+
for ent in episode.entities:
|
|
285
|
+
labels.append(f"{ent.entity_type}:{ent.entity_id}")
|
|
286
|
+
|
|
287
|
+
content: dict[str, Any] = {
|
|
288
|
+
"kind": "message",
|
|
289
|
+
"role": "user" if modality == "conversation" else "system",
|
|
290
|
+
"text": episode.content,
|
|
291
|
+
}
|
|
292
|
+
context: dict[str, Any] = {
|
|
293
|
+
"observed_at": episode.occurred_at.isoformat(),
|
|
294
|
+
"labels": labels,
|
|
295
|
+
}
|
|
296
|
+
if episode.thread_id:
|
|
297
|
+
context["thread_id"] = episode.thread_id
|
|
298
|
+
if episode.parent_id:
|
|
299
|
+
context["preceded_by"] = [episode.parent_id]
|
|
300
|
+
if episode.metadata:
|
|
301
|
+
# Forward non-reserved metadata under a single key — the v1
|
|
302
|
+
# surface doesn't reject unknown context keys but we tuck
|
|
303
|
+
# everything under `meta` to keep the envelope tidy.
|
|
304
|
+
context["meta"] = {k: v for k, v in episode.metadata.items() if k != "modality"}
|
|
305
|
+
|
|
306
|
+
envelope: dict[str, Any] = {
|
|
307
|
+
"scope": scope,
|
|
308
|
+
"modality": modality,
|
|
309
|
+
"content": content,
|
|
310
|
+
"context": context,
|
|
311
|
+
"idempotency_key": (
|
|
312
|
+
episode.idempotency_key
|
|
313
|
+
or self._fallback_idem(episode, scope)
|
|
314
|
+
),
|
|
315
|
+
}
|
|
316
|
+
if episode.actor:
|
|
317
|
+
# v1 wants ActorRef = {id: "kind:local"} not a bare string.
|
|
318
|
+
envelope["observed_actor"] = {"id": episode.actor.id}
|
|
319
|
+
|
|
320
|
+
return envelope
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _fallback_idem(episode: Episode, scope: str) -> str:
|
|
324
|
+
"""Deterministic idempotency_key when the connector didn't set one."""
|
|
325
|
+
src = episode.source.system if episode.source else "cx"
|
|
326
|
+
seed = f"{scope}|{src}|{episode.id}|{episode.content[:512]}"
|
|
327
|
+
return f"conn:{src}:{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:24]}"
|
|
328
|
+
|
|
329
|
+
async def ingest_episode(self, episode: Episode) -> str:
|
|
330
|
+
"""POST `/v1/experience` with the v1 envelope.
|
|
331
|
+
|
|
332
|
+
Returns the server-side event_id on success.
|
|
333
|
+
"""
|
|
334
|
+
import httpx
|
|
335
|
+
|
|
336
|
+
if not self.cortex_api_key:
|
|
337
|
+
raise RuntimeError(
|
|
338
|
+
"CortexDB API key missing — set CORTEXDB_API_KEY or pass "
|
|
339
|
+
"cortex_api_key explicitly. Run `cortexdb auth signup --save` "
|
|
340
|
+
"for a free-tier token."
|
|
341
|
+
)
|
|
342
|
+
if not self.actor:
|
|
343
|
+
raise RuntimeError(
|
|
344
|
+
"CortexDB actor missing — set CORTEXDB_ACTOR or pass actor= "
|
|
345
|
+
"explicitly. The actor must match the token's `sub` claim."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
envelope = self._envelope(episode)
|
|
349
|
+
headers = {
|
|
350
|
+
"Authorization": f"Bearer {self.cortex_api_key}",
|
|
351
|
+
"X-Cortex-Actor": self.actor,
|
|
352
|
+
"Content-Type": "application/json",
|
|
353
|
+
"User-Agent": f"cortexdb-connectors/{Source(system='base').connector_version}",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
357
|
+
resp = await client.post(
|
|
358
|
+
f"{self.cortex_url}/v1/experience",
|
|
359
|
+
json=envelope,
|
|
360
|
+
headers=headers,
|
|
361
|
+
)
|
|
362
|
+
resp.raise_for_status()
|
|
363
|
+
body = resp.json()
|
|
364
|
+
return body.get("event_id") or body.get("id") or episode.id
|