cortexdb-connectors 0.2.18__tar.gz → 0.2.19__tar.gz
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-0.2.18 → cortexdb_connectors-0.2.19}/PKG-INFO +1 -1
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/__init__.py +1 -1
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/base.py +1 -1
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/cli.py +1 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/notion/__init__.py +177 -11
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/PKG-INFO +1 -1
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/SOURCES.txt +1 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/pyproject.toml +1 -1
- cortexdb_connectors-0.2.19/tests/test_notion_findings_0_2_19.py +192 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/README.md +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/confluence/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/discord/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/freshdesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/github/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/gitlab/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/google_workspace/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/hubspot/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/api.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/detectors.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/engine.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/intercom/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/jira/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/linear/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/pagerduty/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/salesforce/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/servicenow/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/slack/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/state.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/teams/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/tldv/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/webhooks.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/worker.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/zendesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/requires.txt +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/top_level.txt +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/setup.cfg +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_base.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_connector_coverage.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_discord_normalize.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_github.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_ingest_e2e.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_jira_attachments.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_jira_findings_0_2_11.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_jira_findings_0_2_9.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_notion_comments.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_product_findings_0_2_12.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_product_findings_0_2_14.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_file_ingestion.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_findings_0_2_16.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_findings_0_2_17.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_streaming_cursor.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_tldv_findings_0_2_12.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_tldv_findings_0_2_15.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_webhooks.py +0 -0
- {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_webhooks_replay.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexdb-connectors
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.19
|
|
4
4
|
Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
|
|
5
5
|
Author-email: CortexDB Team <team@cortexdb.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/notion/__init__.py
RENAMED
|
@@ -10,8 +10,11 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import hashlib
|
|
13
|
+
import json
|
|
13
14
|
import logging
|
|
15
|
+
import os
|
|
14
16
|
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
15
18
|
from typing import Any
|
|
16
19
|
|
|
17
20
|
from cortexdb_connectors.base import (
|
|
@@ -36,6 +39,12 @@ _RATE_LIMIT_DELAY = 0.35
|
|
|
36
39
|
# Maximum characters of page body to include in episode content.
|
|
37
40
|
_BODY_EXCERPT_LENGTH = 2000
|
|
38
41
|
|
|
42
|
+
# KAN-132/135: how long a page stays in the seen-pages ledger after it was
|
|
43
|
+
# last observed. Archived pages are EXCLUDED from Notion search and
|
|
44
|
+
# databases.query, so without the ledger their comments become unreachable
|
|
45
|
+
# the moment the parent is archived — the ledger keeps known ids swept.
|
|
46
|
+
_LEDGER_RETENTION_DAYS = 90
|
|
47
|
+
|
|
39
48
|
|
|
40
49
|
class NotionConnector(CortexConnector):
|
|
41
50
|
"""Connector that syncs Notion pages, database entries, and comments
|
|
@@ -59,6 +68,14 @@ class NotionConnector(CortexConnector):
|
|
|
59
68
|
CortexDB namespace for ingested episodes.
|
|
60
69
|
backfill_days:
|
|
61
70
|
Number of days of history to backfill on first sync.
|
|
71
|
+
ledger_path:
|
|
72
|
+
Where the seen-pages ledger lives (env ``NOTION_LEDGER_PATH``,
|
|
73
|
+
default ``~/.cortexdb/notion_pages_ledger.json``). Notion search
|
|
74
|
+
and ``databases.query`` EXCLUDE archived pages/rows, so a page
|
|
75
|
+
that gets archived falls out of discovery — the ledger remembers
|
|
76
|
+
every page seen in prior syncs and keeps sweeping its comments
|
|
77
|
+
(``comments.list`` works on archived parents, KAN-24). Entries
|
|
78
|
+
age out after ``_LEDGER_RETENTION_DAYS`` days unseen.
|
|
62
79
|
"""
|
|
63
80
|
|
|
64
81
|
connector_name: str = "notion"
|
|
@@ -73,6 +90,7 @@ class NotionConnector(CortexConnector):
|
|
|
73
90
|
scope: str = "user:default",
|
|
74
91
|
namespace: str = "notion",
|
|
75
92
|
backfill_days: int = 30,
|
|
93
|
+
ledger_path: str | None = None,
|
|
76
94
|
) -> None:
|
|
77
95
|
super().__init__(cortex_url, cortex_api_key, scope)
|
|
78
96
|
self.notion_token = notion_token
|
|
@@ -80,6 +98,11 @@ class NotionConnector(CortexConnector):
|
|
|
80
98
|
self.page_ids = page_ids or []
|
|
81
99
|
self.namespace = namespace
|
|
82
100
|
self.backfill_days = backfill_days
|
|
101
|
+
self.ledger_path = Path(
|
|
102
|
+
ledger_path
|
|
103
|
+
or os.environ.get("NOTION_LEDGER_PATH")
|
|
104
|
+
or (Path.home() / ".cortexdb" / "notion_pages_ledger.json")
|
|
105
|
+
)
|
|
83
106
|
self._user_cache: dict[str, dict[str, Any]] = {}
|
|
84
107
|
|
|
85
108
|
# -- CortexConnector interface -------------------------------------------
|
|
@@ -136,6 +159,27 @@ class NotionConnector(CortexConnector):
|
|
|
136
159
|
page_ids_seen.add(pid)
|
|
137
160
|
sweep_ids.append(pid)
|
|
138
161
|
|
|
162
|
+
# KAN-132/135: pages seen in PRIOR syncs whose parent has since been
|
|
163
|
+
# archived produce no events this run (search and databases.query
|
|
164
|
+
# exclude trashed items) — without this sweep their comments are
|
|
165
|
+
# silently unreachable forever, even though comments.list still
|
|
166
|
+
# serves them. The ledger remembers every page ever seen under this
|
|
167
|
+
# scope and keeps them swept.
|
|
168
|
+
ledger = self._ledger_load()
|
|
169
|
+
known_pages = ledger.get(self._ledger_key(), {})
|
|
170
|
+
ledger_swept = 0
|
|
171
|
+
for pid in known_pages:
|
|
172
|
+
if pid not in page_ids_seen:
|
|
173
|
+
page_ids_seen.add(pid)
|
|
174
|
+
sweep_ids.append(pid)
|
|
175
|
+
ledger_swept += 1
|
|
176
|
+
if ledger_swept:
|
|
177
|
+
logger.info(
|
|
178
|
+
"notion: sweeping comments for %d ledger page(s) not "
|
|
179
|
+
"discovered this run (archived or unmodified)",
|
|
180
|
+
ledger_swept,
|
|
181
|
+
)
|
|
182
|
+
|
|
139
183
|
failed_sweeps = 0
|
|
140
184
|
for pid in sweep_ids:
|
|
141
185
|
comment_events, failed = await self._fetch_comments(
|
|
@@ -152,8 +196,91 @@ class NotionConnector(CortexConnector):
|
|
|
152
196
|
failed_sweeps,
|
|
153
197
|
)
|
|
154
198
|
|
|
199
|
+
# Record what this run saw: event pages refresh their last_seen;
|
|
200
|
+
# ledger-only pages keep their old stamp so they age out after
|
|
201
|
+
# _LEDGER_RETENTION_DAYS (pruned loudly, never silently).
|
|
202
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
203
|
+
for event in events:
|
|
204
|
+
pid = event.data.get("page_id", "")
|
|
205
|
+
if pid:
|
|
206
|
+
known_pages[pid] = now_iso
|
|
207
|
+
horizon = datetime.now(timezone.utc) - timedelta(
|
|
208
|
+
days=_LEDGER_RETENTION_DAYS
|
|
209
|
+
)
|
|
210
|
+
pruned = []
|
|
211
|
+
for pid, seen in list(known_pages.items()):
|
|
212
|
+
seen_at = self._parse_ledger_ts(seen)
|
|
213
|
+
if seen_at is not None and seen_at < horizon:
|
|
214
|
+
pruned.append(pid)
|
|
215
|
+
del known_pages[pid]
|
|
216
|
+
if pruned:
|
|
217
|
+
logger.warning(
|
|
218
|
+
"notion: %d page(s) aged out of the seen-pages ledger "
|
|
219
|
+
"(unseen for %dd): %s — comments on them are no longer "
|
|
220
|
+
"swept; re-add via NOTION_PAGE_IDS if still wanted",
|
|
221
|
+
len(pruned),
|
|
222
|
+
_LEDGER_RETENTION_DAYS,
|
|
223
|
+
", ".join(pruned[:10]) + ("…" if len(pruned) > 10 else ""),
|
|
224
|
+
)
|
|
225
|
+
ledger[self._ledger_key()] = known_pages
|
|
226
|
+
self._ledger_save(ledger)
|
|
227
|
+
|
|
155
228
|
return events
|
|
156
229
|
|
|
230
|
+
# -- seen-pages ledger (KAN-132/135) --------------------------------------
|
|
231
|
+
|
|
232
|
+
def _ledger_key(self) -> str:
|
|
233
|
+
return f"{self.scope_template or self.scope}"
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _parse_ledger_ts(value: Any) -> datetime | None:
|
|
237
|
+
if not isinstance(value, str) or not value:
|
|
238
|
+
return None
|
|
239
|
+
try:
|
|
240
|
+
parsed = datetime.fromisoformat(value)
|
|
241
|
+
except ValueError:
|
|
242
|
+
return None
|
|
243
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
244
|
+
|
|
245
|
+
def _ledger_load(self) -> dict[str, Any]:
|
|
246
|
+
"""Load the ledger; a broken file degrades LOUDLY to empty.
|
|
247
|
+
|
|
248
|
+
Unlike Jira's deletion ledger (where an empty read would forget
|
|
249
|
+
deletions), an empty comments ledger only shrinks sweep coverage
|
|
250
|
+
for this cycle — not worth failing the whole sync over, but never
|
|
251
|
+
silent either.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
with open(self.ledger_path, "r", encoding="utf-8") as fh:
|
|
255
|
+
data = json.load(fh)
|
|
256
|
+
return data if isinstance(data, dict) else {}
|
|
257
|
+
except FileNotFoundError:
|
|
258
|
+
return {}
|
|
259
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
260
|
+
logger.error(
|
|
261
|
+
"notion: seen-pages ledger at %s is unreadable (%s) — "
|
|
262
|
+
"comments on archived pages will NOT be swept this cycle; "
|
|
263
|
+
"delete or restore the file to rebuild it",
|
|
264
|
+
self.ledger_path,
|
|
265
|
+
exc,
|
|
266
|
+
)
|
|
267
|
+
return {}
|
|
268
|
+
|
|
269
|
+
def _ledger_save(self, data: dict[str, Any]) -> None:
|
|
270
|
+
try:
|
|
271
|
+
self.ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
tmp = self.ledger_path.with_suffix(".tmp")
|
|
273
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
274
|
+
json.dump(data, fh, indent=2, default=str)
|
|
275
|
+
os.replace(tmp, self.ledger_path)
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
logger.error(
|
|
278
|
+
"notion: could not save seen-pages ledger to %s (%s) — "
|
|
279
|
+
"archived-page comment coverage will regress next run",
|
|
280
|
+
self.ledger_path,
|
|
281
|
+
exc,
|
|
282
|
+
)
|
|
283
|
+
|
|
157
284
|
def normalize(self, raw: RawEvent) -> Episode:
|
|
158
285
|
"""Convert a Notion ``RawEvent`` into a CortexDB ``Episode``."""
|
|
159
286
|
data = raw.data
|
|
@@ -209,6 +336,20 @@ class NotionConnector(CortexConnector):
|
|
|
209
336
|
if database_name:
|
|
210
337
|
tags.append(database_name.lower().replace(" ", "_"))
|
|
211
338
|
|
|
339
|
+
metadata = {
|
|
340
|
+
"page_id": data.get("page_id", ""),
|
|
341
|
+
"database_id": data.get("database_id", ""),
|
|
342
|
+
"url": data.get("url", ""),
|
|
343
|
+
"properties": data.get("properties_raw", {}),
|
|
344
|
+
"last_edited_time": data.get("last_edited_time", ""),
|
|
345
|
+
"created_time": data.get("created_time", ""),
|
|
346
|
+
"archived": data.get("archived", False),
|
|
347
|
+
}
|
|
348
|
+
if data.get("body_unavailable"):
|
|
349
|
+
# KAN-134: the body could not be fetched (archived page) — a
|
|
350
|
+
# queryable marker so operators can find these gaps at recall.
|
|
351
|
+
metadata["body_unavailable"] = True
|
|
352
|
+
|
|
212
353
|
return Episode(
|
|
213
354
|
actor=actor,
|
|
214
355
|
source=_SOURCE,
|
|
@@ -220,15 +361,7 @@ class NotionConnector(CortexConnector):
|
|
|
220
361
|
namespace=self.namespace,
|
|
221
362
|
entities=entities,
|
|
222
363
|
tags=tags,
|
|
223
|
-
metadata=
|
|
224
|
-
"page_id": data.get("page_id", ""),
|
|
225
|
-
"database_id": data.get("database_id", ""),
|
|
226
|
-
"url": data.get("url", ""),
|
|
227
|
-
"properties": data.get("properties_raw", {}),
|
|
228
|
-
"last_edited_time": data.get("last_edited_time", ""),
|
|
229
|
-
"created_time": data.get("created_time", ""),
|
|
230
|
-
"archived": data.get("archived", False),
|
|
231
|
-
},
|
|
364
|
+
metadata=metadata,
|
|
232
365
|
thread_id=f"notion:{data.get('page_id', '')}",
|
|
233
366
|
parent_id=(
|
|
234
367
|
f"notion:{data['parent_id']}" if data.get("parent_id") else None
|
|
@@ -355,9 +488,24 @@ class NotionConnector(CortexConnector):
|
|
|
355
488
|
if edited_at < cutoff:
|
|
356
489
|
return []
|
|
357
490
|
|
|
358
|
-
# Fetch page content blocks.
|
|
491
|
+
# Fetch page content blocks. KAN-134: Notion's blocks API errors on
|
|
492
|
+
# archived pages — the body of a page archived before it was ever
|
|
493
|
+
# synced is UNREACHABLE via the API. That loss must be loud and
|
|
494
|
+
# queryable, never a silent title-only episode.
|
|
359
495
|
body_text = await self._fetch_block_children_text(client, page_id)
|
|
496
|
+
body_unavailable = not body_text and bool(page.get("archived"))
|
|
497
|
+
if body_unavailable:
|
|
498
|
+
logger.warning(
|
|
499
|
+
"notion: page %s is archived and its body blocks are not "
|
|
500
|
+
"retrievable via the API — ingesting title/properties only, "
|
|
501
|
+
"marked body_unavailable. Content archived before the first "
|
|
502
|
+
"sync of this page cannot be recovered unless the page is "
|
|
503
|
+
"restored from trash.",
|
|
504
|
+
page_id,
|
|
505
|
+
)
|
|
360
506
|
raw = self._page_to_raw_event(page, body_text=body_text)
|
|
507
|
+
if body_unavailable:
|
|
508
|
+
raw.data["body_unavailable"] = True
|
|
361
509
|
return [raw]
|
|
362
510
|
|
|
363
511
|
async def _search_recent_pages(
|
|
@@ -527,7 +675,25 @@ class NotionConnector(CortexConnector):
|
|
|
527
675
|
|
|
528
676
|
results = client.blocks.children.list(**params)
|
|
529
677
|
await asyncio.sleep(_RATE_LIMIT_DELAY)
|
|
530
|
-
except Exception:
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
# KAN-134: this used to be a bare `break` — a blocks-API
|
|
680
|
+
# failure (archived page, revoked block capability, 5xx)
|
|
681
|
+
# silently truncated the page body to whatever was fetched
|
|
682
|
+
# so far, and the episode ingested as if that were the
|
|
683
|
+
# whole page. Log it; the caller marks archived pages.
|
|
684
|
+
status = (
|
|
685
|
+
getattr(exc, "status", None)
|
|
686
|
+
or getattr(exc, "code", None)
|
|
687
|
+
or "?"
|
|
688
|
+
)
|
|
689
|
+
logger.warning(
|
|
690
|
+
"notion: block children fetch failed for %s "
|
|
691
|
+
"(status=%s): %s — page body truncated at %d chars",
|
|
692
|
+
block_id,
|
|
693
|
+
status,
|
|
694
|
+
exc,
|
|
695
|
+
sum(len(p) for p in parts),
|
|
696
|
+
)
|
|
531
697
|
break
|
|
532
698
|
|
|
533
699
|
for block in results.get("results", []):
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexdb-connectors
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.19
|
|
4
4
|
Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
|
|
5
5
|
Author-email: CortexDB Team <team@cortexdb.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/SOURCES.txt
RENAMED
|
@@ -43,6 +43,7 @@ tests/test_jira_attachments.py
|
|
|
43
43
|
tests/test_jira_findings_0_2_11.py
|
|
44
44
|
tests/test_jira_findings_0_2_9.py
|
|
45
45
|
tests/test_notion_comments.py
|
|
46
|
+
tests/test_notion_findings_0_2_19.py
|
|
46
47
|
tests/test_product_findings_0_2_12.py
|
|
47
48
|
tests/test_product_findings_0_2_14.py
|
|
48
49
|
tests/test_slack.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cortexdb-connectors"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.19"
|
|
8
8
|
description = "Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Pins for the Notion archive-flow campaign findings, fixed in 0.2.19.
|
|
2
|
+
|
|
3
|
+
Campaign 20260806 (KAN-132/134/135): comment-then-archive and archive-page
|
|
4
|
+
flows recalled nothing. Root causes, in the connector:
|
|
5
|
+
|
|
6
|
+
- Notion search and ``databases.query`` EXCLUDE archived pages/rows, and
|
|
7
|
+
the comment sweep only covered pages seen THIS run or explicitly
|
|
8
|
+
configured — so a page seen in a prior sync that later got archived
|
|
9
|
+
silently lost all comment coverage forever (``comments.list`` would have
|
|
10
|
+
served them, KAN-24). Fixed with a persistent seen-pages ledger.
|
|
11
|
+
- ``_fetch_block_children_text`` swallowed every blocks-API failure with a
|
|
12
|
+
bare ``break`` — an archived page's unreachable body (or any 403/5xx
|
|
13
|
+
truncation) ingested as a silently title-only/truncated episode. Now
|
|
14
|
+
logged loudly and, for archived pages, marked ``body_unavailable`` as a
|
|
15
|
+
queryable label.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from cortexdb_connectors.notion import NotionConnector, _LEDGER_RETENTION_DAYS
|
|
26
|
+
|
|
27
|
+
from .test_notion_comments import (
|
|
28
|
+
_RECENT,
|
|
29
|
+
_FakeNotion,
|
|
30
|
+
_comment,
|
|
31
|
+
_connector,
|
|
32
|
+
_fetch,
|
|
33
|
+
_comment_events,
|
|
34
|
+
_page,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Seen-pages ledger (KAN-132 / KAN-135)
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_ledger_records_pages_seen_via_search(tmp_path):
|
|
44
|
+
ledger = tmp_path / "ledger.json"
|
|
45
|
+
c = _connector(ledger_path=str(ledger))
|
|
46
|
+
fake = _FakeNotion(
|
|
47
|
+
pages={"p1": _page("p1")}, search_results=[_page("p1")]
|
|
48
|
+
)
|
|
49
|
+
_fetch(c, fake)
|
|
50
|
+
data = json.loads(ledger.read_text())
|
|
51
|
+
assert "p1" in data.get("user:test", {})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_archived_page_comments_swept_via_ledger(tmp_path):
|
|
55
|
+
"""The KAN-132 flow: page seen in sync 1, archived before sync 2 —
|
|
56
|
+
sync 2 must still fetch its comments even though search no longer
|
|
57
|
+
returns the page and it is not configured."""
|
|
58
|
+
ledger = tmp_path / "ledger.json"
|
|
59
|
+
|
|
60
|
+
# Sync 1: page discovered via search; no comments yet.
|
|
61
|
+
c1 = _connector(ledger_path=str(ledger))
|
|
62
|
+
fake1 = _FakeNotion(pages={"p1": _page("p1")}, search_results=[_page("p1")])
|
|
63
|
+
_fetch(c1, fake1)
|
|
64
|
+
|
|
65
|
+
# Page archived; a comment lands while archived. Search excludes it now.
|
|
66
|
+
c2 = _connector(ledger_path=str(ledger))
|
|
67
|
+
fake2 = _FakeNotion(
|
|
68
|
+
pages={"p1": _page("p1", archived=True)},
|
|
69
|
+
search_results=[_page("p1", archived=True)], # fake search drops archived
|
|
70
|
+
comments={"p1": [_comment("c1", text="orphan canary")]},
|
|
71
|
+
)
|
|
72
|
+
events = _fetch(c2, fake2)
|
|
73
|
+
|
|
74
|
+
comments = _comment_events(events)
|
|
75
|
+
assert [e.data["text"] for e in comments] == ["orphan canary"]
|
|
76
|
+
assert "p1" in fake2.comment_calls # swept from the ledger, not discovery
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_ledger_page_with_active_comments_stays_fresh(tmp_path):
|
|
80
|
+
"""A ledger-swept page whose comments still flow refreshes last_seen —
|
|
81
|
+
an archived-but-active discussion never ages out."""
|
|
82
|
+
ledger = tmp_path / "ledger.json"
|
|
83
|
+
stale = (
|
|
84
|
+
datetime.now(timezone.utc) - timedelta(days=_LEDGER_RETENTION_DAYS - 1)
|
|
85
|
+
).isoformat()
|
|
86
|
+
ledger.write_text(json.dumps({"user:test": {"p1": stale}}))
|
|
87
|
+
|
|
88
|
+
c = _connector(ledger_path=str(ledger))
|
|
89
|
+
fake = _FakeNotion(comments={"p1": [_comment("c1")]})
|
|
90
|
+
_fetch(c, fake)
|
|
91
|
+
|
|
92
|
+
data = json.loads(ledger.read_text())
|
|
93
|
+
refreshed = datetime.fromisoformat(data["user:test"]["p1"])
|
|
94
|
+
assert refreshed > datetime.fromisoformat(stale)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_ledger_prunes_long_unseen_pages_loudly(tmp_path, caplog):
|
|
98
|
+
ledger = tmp_path / "ledger.json"
|
|
99
|
+
ancient = (
|
|
100
|
+
datetime.now(timezone.utc) - timedelta(days=_LEDGER_RETENTION_DAYS + 10)
|
|
101
|
+
).isoformat()
|
|
102
|
+
ledger.write_text(json.dumps({"user:test": {"dead-page": ancient}}))
|
|
103
|
+
|
|
104
|
+
c = _connector(ledger_path=str(ledger))
|
|
105
|
+
with caplog.at_level("WARNING"):
|
|
106
|
+
_fetch(c, _FakeNotion())
|
|
107
|
+
|
|
108
|
+
data = json.loads(ledger.read_text())
|
|
109
|
+
assert "dead-page" not in data.get("user:test", {})
|
|
110
|
+
assert "aged out" in caplog.text and "dead-page" in caplog.text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_corrupt_ledger_degrades_loudly_not_fatally(tmp_path, caplog):
|
|
114
|
+
ledger = tmp_path / "ledger.json"
|
|
115
|
+
ledger.write_text("{not json")
|
|
116
|
+
c = _connector(ledger_path=str(ledger))
|
|
117
|
+
fake = _FakeNotion(pages={"p1": _page("p1")}, search_results=[_page("p1")])
|
|
118
|
+
with caplog.at_level("ERROR"):
|
|
119
|
+
events = _fetch(c, fake)
|
|
120
|
+
assert events # the sync itself still ran
|
|
121
|
+
assert "unreadable" in caplog.text
|
|
122
|
+
# And the save afterwards rebuilt a valid ledger.
|
|
123
|
+
assert "p1" in json.loads(ledger.read_text()).get("user:test", {})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_ledger_is_scope_keyed(tmp_path):
|
|
127
|
+
"""Two connectors with different scopes don't sweep each other's pages."""
|
|
128
|
+
ledger = tmp_path / "ledger.json"
|
|
129
|
+
ledger.write_text(json.dumps({"user:other": {"p9": _RECENT}}))
|
|
130
|
+
c = _connector(ledger_path=str(ledger)) # scope user:test
|
|
131
|
+
fake = _FakeNotion()
|
|
132
|
+
_fetch(c, fake)
|
|
133
|
+
assert "p9" not in fake.comment_calls
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Loud body loss (KAN-134)
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _ArchivedBlocksError(Exception):
|
|
142
|
+
status = 400
|
|
143
|
+
code = "validation_error"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _BrokenBlocksNotion(_FakeNotion):
|
|
147
|
+
def _list_blocks(self, **params):
|
|
148
|
+
raise _ArchivedBlocksError("Can't get children of an archived block")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_archived_page_body_marked_unavailable(tmp_path, caplog):
|
|
152
|
+
c = _connector(
|
|
153
|
+
page_ids=["p1"], ledger_path=str(tmp_path / "ledger.json")
|
|
154
|
+
)
|
|
155
|
+
fake = _BrokenBlocksNotion(pages={"p1": _page("p1", archived=True)})
|
|
156
|
+
with caplog.at_level("WARNING"):
|
|
157
|
+
events = _fetch(c, fake)
|
|
158
|
+
|
|
159
|
+
page_events = [e for e in events if e.data.get("_event_kind") != "comment"]
|
|
160
|
+
assert len(page_events) == 1
|
|
161
|
+
assert page_events[0].data.get("body_unavailable") is True
|
|
162
|
+
assert "archived" in caplog.text and "body_unavailable" in caplog.text
|
|
163
|
+
|
|
164
|
+
ep = c.normalize(page_events[0])
|
|
165
|
+
assert ep.metadata.get("body_unavailable") is True # queryable label
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_blocks_failure_on_live_page_logs_truncation(tmp_path, caplog):
|
|
169
|
+
c = _connector(
|
|
170
|
+
page_ids=["p1"], ledger_path=str(tmp_path / "ledger.json")
|
|
171
|
+
)
|
|
172
|
+
fake = _BrokenBlocksNotion(pages={"p1": _page("p1", archived=False)})
|
|
173
|
+
with caplog.at_level("WARNING"):
|
|
174
|
+
events = _fetch(c, fake)
|
|
175
|
+
|
|
176
|
+
page_events = [e for e in events if e.data.get("_event_kind") != "comment"]
|
|
177
|
+
assert len(page_events) == 1
|
|
178
|
+
# Not archived → no body_unavailable marker, but the failure is loud.
|
|
179
|
+
assert page_events[0].data.get("body_unavailable") is None
|
|
180
|
+
assert "block children fetch failed" in caplog.text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_live_page_with_healthy_blocks_has_no_marker(tmp_path):
|
|
184
|
+
c = _connector(
|
|
185
|
+
page_ids=["p1"], ledger_path=str(tmp_path / "ledger.json")
|
|
186
|
+
)
|
|
187
|
+
fake = _FakeNotion(pages={"p1": _page("p1")})
|
|
188
|
+
events = _fetch(c, fake)
|
|
189
|
+
page_events = [e for e in events if e.data.get("_event_kind") != "comment"]
|
|
190
|
+
assert page_events[0].data.get("body_unavailable") is None
|
|
191
|
+
ep = c.normalize(page_events[0])
|
|
192
|
+
assert "body_unavailable" not in ep.metadata
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/confluence/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/discord/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/freshdesk/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/github/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/gitlab/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/hubspot/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/api.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/detectors.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/engine.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/intercom/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/jira/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/linear/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/pagerduty/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/salesforce/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/servicenow/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/slack/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/teams/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/tldv/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/zendesk/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/requires.txt
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_jira_findings_0_2_11.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_product_findings_0_2_12.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_product_findings_0_2_14.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_file_ingestion.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_findings_0_2_16.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_findings_0_2_17.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_tldv_findings_0_2_12.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_tldv_findings_0_2_15.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|