From 805ff4ba16c86f872b8ac1f45c45f96a9de5308e Mon Sep 17 00:00:00 2001 From: Ramiro Cantu Date: Mon, 1 Jun 2026 13:39:21 -0500 Subject: [PATCH 1/2] fix(notion): parent pages to data_source_id for API 2025-09-03 (RCA-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Notion write-out seam never actually wrote: notion-client 3.1.0 pins API version 2025-09-03, which split databases into databases + data sources. `_ensure_node_page` parented page creation with `{"database_id": }`, but the configured NOTION_WIKI_DB_ID is a data-source id — so every `pages.create` 404'd with ObjectNotFound and the integration silently failed. Parent with `{"type": "data_source_id", "data_source_id": ...}` instead, which keeps the configured env value as-is. Verified live end-to-end via the production `sync_node_to_notion` path against the real workspace. - test_kb_notion.py: pin the parent shape so a regression to the legacy database_id parent fails in CI instead of silently at runtime (the mocked suite never validated the parent, which is why this slipped). - scripts/notion_smoke.py: reusable 3-stage live connectivity check (token / data-source reachable / real write), --write and --keep flags. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/kb/notion.py | 6 +- scripts/notion_smoke.py | 121 ++++++++++++++++++++++++++++++++++++++ tests/test_kb_notion.py | 27 +++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 scripts/notion_smoke.py diff --git a/app/services/kb/notion.py b/app/services/kb/notion.py index a753d77..5cd5c23 100644 --- a/app/services/kb/notion.py +++ b/app/services/kb/notion.py @@ -115,7 +115,11 @@ async def _ensure_node_page( return pointer, False result = await notion_client.pages.create( - parent={"database_id": notion_wiki_db_id}, + # Notion API 2025-09-03 (notion-client>=3): a database's rows live under + # a *data source*, so page creation parents to the data_source_id, not + # the legacy database_id (which 404s against a data-source id). The + # configured NOTION_WIKI_DB_ID is the data-source id. + parent={"type": "data_source_id", "data_source_id": notion_wiki_db_id}, properties={ "Name": {"title": [{"type": "text", "text": {"content": node.name}}]}, }, diff --git a/scripts/notion_smoke.py b/scripts/notion_smoke.py new file mode 100644 index 0000000..9824bf9 --- /dev/null +++ b/scripts/notion_smoke.py @@ -0,0 +1,121 @@ +"""RCA-33 live Notion smoke test — verifies the write-out seam actually +reaches Notion with the configured token + wiki DB. + +Staged so each failure is diagnosable in isolation: + 1. users.me() — token valid + integration identity + 2. databases.retrieve(db_id) — wiki DB reachable + integration shared in + 3. sync_node_to_notion(...) — the real production path creates a page + + appends fact bullets, persisting a pointer + +Read-only stages (1,2) run first; the write stage (3) is gated behind --write +so connectivity can be checked without creating a Notion page. + +Run: uv run python scripts/notion_smoke.py # stages 1-2 only + uv run python scripts/notion_smoke.py --write # + real page create +""" + +from __future__ import annotations + +import asyncio +import sys + +from app.config import settings + + +async def main(do_write: bool, keep: bool) -> int: + if not settings.NOTION_API_TOKEN or not settings.NOTION_WIKI_DB_ID: + print("FAIL: NOTION_API_TOKEN / NOTION_WIKI_DB_ID unset") + return 1 + + from notion_client import AsyncClient + + client = AsyncClient(auth=settings.NOTION_API_TOKEN) + try: + # Stage 1 — token valid. + me = await client.users.me() + bot_name = me.get("name") or me.get("bot", {}).get("owner", {}).get("type", "?") + print(f"[1] users.me OK — integration='{bot_name}' id={me.get('id')}") + + # Stage 2 — wiki data source reachable + shared with integration. + # API 2025-09-03: the configured id is a data_source id, retrieved via + # the data_sources endpoint (databases.retrieve expects a database id). + ds = await client.request( + path=f"data_sources/{settings.NOTION_WIKI_DB_ID}", method="GET" + ) + title = "".join(t.get("plain_text", "") for t in ds.get("title", [])) + props = list(ds.get("properties", {}).keys()) + print(f"[2] data_sources.retrieve OK — title='{title}' props={props}") + if "Name" not in ds.get("properties", {}): + print(" WARN: data source has no 'Name' title property; pages.create sets 'Name'") + + if not do_write: + print("[3] skipped (pass --write to create a real page)") + return 0 + + # Stage 3 — real production write path. + from app.database import AsyncSessionLocal + from app.models.outline import OutlineNode + from app.models.atomic_fact import AtomicFact + from app.services.kb.notion import sync_node_to_notion + from sqlalchemy import select + + from app.models.notion_page import NotionPage + + async with AsyncSessionLocal() as session: + node = ( + await session.execute(select(OutlineNode).limit(1)) + ).scalar_one_or_none() + if node is None: + print("[3] FAIL: no outline node in this branch DB to host a page") + return 1 + # Repeatable: drop any stale pointer from a prior smoke run so this + # always exercises the first-sync pages.create path, not an append + # to an already-archived page. + stale = ( + await session.execute(select(NotionPage).where(NotionPage.node_id == node.id)) + ).scalar_one_or_none() + if stale is not None: + await session.delete(stale) + await session.flush() + # Synthetic in-memory fact (not persisted) just to exercise blocks. + fact = AtomicFact(text="RCA-33 smoke fact — Notion write path live check") + report = await sync_node_to_notion( + session, + notion_client=client, + notion_wiki_db_id=settings.NOTION_WIKI_DB_ID, + node=node, + facts=[fact], + ) + page_id = report.notion_page_id + print( + f"[3] sync_node_to_notion OK — node='{node.name}' " + f"page_id={page_id} created={report.created_page} " + f"blocks={report.appended_blocks}" + ) + if keep: + await session.commit() + print(f"[3] page KEPT (--keep) — view: {report and report.notion_page_id}") + print(f" url: https://www.notion.so/{page_id.replace('-', '')}") + else: + # Cleanup: archive the proof page AND drop the pointer row so the + # live wiki isn't littered and re-runs start clean. + await client.pages.update(page_id=page_id, archived=True) + pointer = ( + await session.execute( + select(NotionPage).where(NotionPage.node_id == node.id) + ) + ).scalar_one_or_none() + if pointer is not None: + await session.delete(pointer) + await session.commit() + print("[3] proof page archived + pointer row dropped (cleanup)") + return 0 + except Exception as exc: # noqa: BLE001 + print(f"FAIL: {type(exc).__name__}: {exc}") + return 1 + finally: + await client.aclose() + + +if __name__ == "__main__": + sys.exit(asyncio.run(main("--write" in sys.argv, "--keep" in sys.argv))) diff --git a/tests/test_kb_notion.py b/tests/test_kb_notion.py index 59958af..c07a426 100644 --- a/tests/test_kb_notion.py +++ b/tests/test_kb_notion.py @@ -131,6 +131,33 @@ async def test_first_sync_creates_page_and_pointer(db_session: AsyncSession): assert pointer.last_synced_at is not None +# --------------------------------------------------------------------------- # +# 1b. RCA-33: pages.create parents to data_source_id, not database_id. +# Notion API 2025-09-03 (notion-client>=3) 404s a database_id parent given a +# data-source id; NOTION_WIKI_DB_ID holds a data-source id. Pin the shape so a +# regression to the legacy {"database_id": ...} parent fails loudly here +# instead of silently at runtime against the live API. +# --------------------------------------------------------------------------- # + + +async def test_pages_create_parents_to_data_source(db_session: AsyncSession): + node = await _make_course_node(db_session) + facts = await _make_facts(db_session, node.course_id, ["Fact long enough to keep."]) + client = _forge_notion_client(page_id="page-ds") + + await sync_node_to_notion( + db_session, + notion_client=client, + notion_wiki_db_id="ds-id", + node=node, + facts=facts, + ) + + _, kwargs = client.pages.create.call_args + assert kwargs["parent"] == {"type": "data_source_id", "data_source_id": "ds-id"} + assert "database_id" not in kwargs["parent"] + + # --------------------------------------------------------------------------- # # 2. Re-sync append-only (V-M3, V-N1) # --------------------------------------------------------------------------- # From a15131412bf93c51a0153b9bf98c5dad66cb98be Mon Sep 17 00:00:00 2001 From: Ramiro Cantu Date: Mon, 1 Jun 2026 14:00:01 -0500 Subject: [PATCH 2/2] fix(notion): tighten notion-client pin + smoke-script cleanup (RCA-33) PR #8 review autofixes: - pyproject: notion-client>=2.2 -> >=3.1. The data_source_id parent shape requires Notion API 2025-09-03, only sent by notion-client 3.x. A 2.x resolve would send the legacy database_id parent and 404, reintroducing the exact silent ObjectNotFound break PR #8 fixes. Refresh uv.lock. - scripts/notion_smoke.py: stage 2 now uses the typed client.data_sources.retrieve(...) instead of a raw client.request(...), fix the docstring (stage 2 was mislabeled databases.retrieve), add a docstring to main() (docstring-coverage gate), and drop a redundant truthiness guard in the --keep branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 5 +++-- scripts/notion_smoke.py | 21 +++++++++++++++------ uv.lock | 2 +- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4efaa4..ba5b20f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,9 @@ dependencies = [ "fastapi>=0.115", "httpx>=0.27", "jsonschema>=4.23", - # P2 KB substrate (V-KB2): - "notion-client>=2.2", + # P2 KB substrate (V-KB2): >=3.1 pins Notion API 2025-09-03 (data_source_id + # parent shape); a 2.x resolve sends the legacy database_id parent and 404s. + "notion-client>=3.1", "pdfplumber>=0.11", "pgvector>=0.3", "pydantic-settings>=2.3", diff --git a/scripts/notion_smoke.py b/scripts/notion_smoke.py index 9824bf9..d5d3e63 100644 --- a/scripts/notion_smoke.py +++ b/scripts/notion_smoke.py @@ -2,9 +2,9 @@ reaches Notion with the configured token + wiki DB. Staged so each failure is diagnosable in isolation: - 1. users.me() — token valid + integration identity - 2. databases.retrieve(db_id) — wiki DB reachable + integration shared in - 3. sync_node_to_notion(...) — the real production path creates a page + + 1. users.me() — token valid + integration identity + 2. data_sources.retrieve(ds_id) — wiki data source reachable + shared in + 3. sync_node_to_notion(...) — the real production path creates a page + appends fact bullets, persisting a pointer Read-only stages (1,2) run first; the write stage (3) is gated behind --write @@ -23,6 +23,15 @@ async def main(do_write: bool, keep: bool) -> int: + """Run the staged Notion connectivity check; return a process exit code. + + Stages 1-2 (token + data-source reachability) are read-only and always + run. Stage 3 (real ``sync_node_to_notion`` write) runs only when + ``do_write`` is set; the created proof page is archived and its pointer + row dropped on exit unless ``keep`` is set. Returns 0 on success, 1 on the + first failed stage or any raised Notion/SDK error. + """ + if not settings.NOTION_API_TOKEN or not settings.NOTION_WIKI_DB_ID: print("FAIL: NOTION_API_TOKEN / NOTION_WIKI_DB_ID unset") return 1 @@ -39,8 +48,8 @@ async def main(do_write: bool, keep: bool) -> int: # Stage 2 — wiki data source reachable + shared with integration. # API 2025-09-03: the configured id is a data_source id, retrieved via # the data_sources endpoint (databases.retrieve expects a database id). - ds = await client.request( - path=f"data_sources/{settings.NOTION_WIKI_DB_ID}", method="GET" + ds = await client.data_sources.retrieve( + data_source_id=settings.NOTION_WIKI_DB_ID ) title = "".join(t.get("plain_text", "") for t in ds.get("title", [])) props = list(ds.get("properties", {}).keys()) @@ -94,7 +103,7 @@ async def main(do_write: bool, keep: bool) -> int: ) if keep: await session.commit() - print(f"[3] page KEPT (--keep) — view: {report and report.notion_page_id}") + print(f"[3] page KEPT (--keep) — page_id={page_id}") print(f" url: https://www.notion.so/{page_id.replace('-', '')}") else: # Cleanup: archive the proof page AND drop the pointer row so the diff --git a/uv.lock b/uv.lock index 3813019..6dee031 100644 --- a/uv.lock +++ b/uv.lock @@ -408,7 +408,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "jsonschema", specifier = ">=4.23" }, - { name = "notion-client", specifier = ">=2.2" }, + { name = "notion-client", specifier = ">=3.1" }, { name = "openai", specifier = ">=1.50" }, { name = "pdfplumber", specifier = ">=0.11" }, { name = "pgvector", specifier = ">=0.3" },