Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/services/kb/notion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}]},
},
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
130 changes: 130 additions & 0 deletions scripts/notion_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""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. 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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:
"""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

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.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())
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) — 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
# 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)))
27 changes: 27 additions & 0 deletions tests/test_kb_notion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# --------------------------------------------------------------------------- #
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.