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.
Files changed (59) hide show
  1. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/PKG-INFO +1 -1
  2. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/__init__.py +1 -1
  3. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/base.py +1 -1
  4. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/cli.py +1 -0
  5. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/notion/__init__.py +177 -11
  6. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/PKG-INFO +1 -1
  7. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/SOURCES.txt +1 -0
  8. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/pyproject.toml +1 -1
  9. cortexdb_connectors-0.2.19/tests/test_notion_findings_0_2_19.py +192 -0
  10. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/README.md +0 -0
  11. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/confluence/__init__.py +0 -0
  12. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/discord/__init__.py +0 -0
  13. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/freshdesk/__init__.py +0 -0
  14. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/github/__init__.py +0 -0
  15. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/gitlab/__init__.py +0 -0
  16. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/google_workspace/__init__.py +0 -0
  17. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/hubspot/__init__.py +0 -0
  18. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/__init__.py +0 -0
  19. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/api.py +0 -0
  20. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/detectors.py +0 -0
  21. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/insights/engine.py +0 -0
  22. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/intercom/__init__.py +0 -0
  23. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/jira/__init__.py +0 -0
  24. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/linear/__init__.py +0 -0
  25. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/pagerduty/__init__.py +0 -0
  26. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/salesforce/__init__.py +0 -0
  27. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/servicenow/__init__.py +0 -0
  28. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/slack/__init__.py +0 -0
  29. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/state.py +0 -0
  30. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/teams/__init__.py +0 -0
  31. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/tldv/__init__.py +0 -0
  32. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/webhooks.py +0 -0
  33. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/worker.py +0 -0
  34. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors/zendesk/__init__.py +0 -0
  35. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
  36. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
  37. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/requires.txt +0 -0
  38. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/cortexdb_connectors.egg-info/top_level.txt +0 -0
  39. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/setup.cfg +0 -0
  40. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_base.py +0 -0
  41. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_connector_coverage.py +0 -0
  42. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_discord_normalize.py +0 -0
  43. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_github.py +0 -0
  44. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_ingest_e2e.py +0 -0
  45. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_jira_attachments.py +0 -0
  46. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_jira_findings_0_2_11.py +0 -0
  47. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_jira_findings_0_2_9.py +0 -0
  48. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_notion_comments.py +0 -0
  49. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_product_findings_0_2_12.py +0 -0
  50. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_product_findings_0_2_14.py +0 -0
  51. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack.py +0 -0
  52. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_file_ingestion.py +0 -0
  53. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_findings_0_2_16.py +0 -0
  54. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_slack_findings_0_2_17.py +0 -0
  55. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_streaming_cursor.py +0 -0
  56. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_tldv_findings_0_2_12.py +0 -0
  57. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_tldv_findings_0_2_15.py +0 -0
  58. {cortexdb_connectors-0.2.18 → cortexdb_connectors-0.2.19}/tests/test_webhooks.py +0 -0
  59. {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.18
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
@@ -8,7 +8,7 @@ poll loop; or `from cortexdb_connectors.slack import SlackConnector`
8
8
  to drive it programmatically.
9
9
  """
10
10
 
11
- __version__ = "0.2.18"
11
+ __version__ = "0.2.19"
12
12
 
13
13
  from cortexdb_connectors.base import (
14
14
  Actor,
@@ -114,7 +114,7 @@ class VisibilityLevel(str, Enum):
114
114
  @dataclass(frozen=True)
115
115
  class Source:
116
116
  system: str
117
- connector_version: str = "0.2.18"
117
+ connector_version: str = "0.2.19"
118
118
 
119
119
 
120
120
  @dataclass(frozen=True)
@@ -124,6 +124,7 @@ CONNECTORS: dict[str, dict[str, Any]] = {
124
124
  "options": [
125
125
  ("database_ids", "NOTION_DATABASES", "list"),
126
126
  ("page_ids", "NOTION_PAGES", "list"),
127
+ ("ledger_path", "NOTION_LEDGER_PATH", "str"),
127
128
  ],
128
129
  },
129
130
  "pagerduty": {
@@ -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", []):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-connectors
3
- Version: 0.2.18
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
@@ -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.18"
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