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