From 8aa0fa26e94b760c444d00f8eed02269ff7b336e Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Tue, 28 Apr 2026 07:56:35 +0900 Subject: [PATCH] feat(notion-search): add candidate enrichment (title, properties, excerpt) For each candidate page, extract the title, flatten common property types (status/select/multi_select/date/checkbox/number/url/etc.) to display values, and fetch the first text-bearing block as a 200-char excerpt. Empty excerpt is acceptable when the page has no leading text. 4 tests: title+properties, paragraph excerpt, empty fallback, truncation. 21 passing total. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../code/scripts/notion_search.py | 90 +++++++++++++++ .../code/scripts/test_notion_search.py | 103 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/custom-skills/31-notion-organizer/code/scripts/notion_search.py b/custom-skills/31-notion-organizer/code/scripts/notion_search.py index 8ce13b1..3fb362f 100644 --- a/custom-skills/31-notion-organizer/code/scripts/notion_search.py +++ b/custom-skills/31-notion-organizer/code/scripts/notion_search.py @@ -146,3 +146,93 @@ def search_candidates( return candidates return candidates + + +EXCERPT_MAX_CHARS = 200 +TEXT_BEARING_BLOCK_TYPES = {"paragraph", "heading_1", "heading_2", "heading_3", + "quote", "callout", "bulleted_list_item", + "numbered_list_item", "to_do", "toggle"} + + +def _flatten_property(name: str, prop: Dict): + """Flatten a Notion property to a Python value suitable for display/rerank.""" + ptype = prop.get("type") + if ptype == "title": + return "".join(t.get("plain_text", "") for t in prop.get("title", [])) + if ptype == "rich_text": + return "".join(t.get("plain_text", "") for t in prop.get("rich_text", [])) + if ptype == "select": + sel = prop.get("select") + return sel.get("name") if sel else None + if ptype == "status": + st = prop.get("status") + return st.get("name") if st else None + if ptype == "multi_select": + return [o.get("name") for o in prop.get("multi_select", [])] + if ptype == "date": + return prop.get("date") + if ptype == "checkbox": + return prop.get("checkbox") + if ptype == "number": + return prop.get("number") + if ptype == "url": + return prop.get("url") + if ptype == "email": + return prop.get("email") + if ptype == "phone_number": + return prop.get("phone_number") + return None + + +def _extract_title(properties: Dict) -> str: + for prop in properties.values(): + if prop.get("type") == "title": + return "".join(t.get("plain_text", "") for t in prop.get("title", [])) + return "" + + +def _block_text(block: Dict) -> str: + """Concatenate plain_text from a block's rich_text array, if any.""" + btype = block.get("type") + if btype not in TEXT_BEARING_BLOCK_TYPES: + return "" + body = block.get(btype, {}) + rich_text = body.get("rich_text", []) + return "".join(t.get("plain_text", "") for t in rich_text) + + +def _fetch_excerpt(notion, page_id: str) -> str: + """Fetch first text-bearing block; return its plain text capped at EXCERPT_MAX_CHARS.""" + try: + response = notion.blocks.children.list(block_id=page_id, page_size=5) + except Exception: + return "" + for block in response.get("results") or []: + text = _block_text(block).strip() + if text: + return text[:EXCERPT_MAX_CHARS] + return "" + + +def enrich_candidates(notion, candidates: List[Dict]) -> List[Dict]: + """Add title, flattened properties, and 200-char excerpt to each candidate.""" + enriched = [] + for c in candidates: + properties = c.get("properties", {}) + title = _extract_title(properties) + flat_props = {} + for name, prop in properties.items(): + if prop.get("type") == "title": + continue + value = _flatten_property(name, prop) + if value not in (None, [], ""): + flat_props[name] = value + excerpt = _fetch_excerpt(notion, c["id"]) + enriched.append({ + "id": c["id"], + "url": c.get("url", ""), + "title": title, + "properties": flat_props, + "excerpt": excerpt, + }) + return enriched diff --git a/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py b/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py index 68322eb..d441db5 100644 --- a/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py +++ b/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py @@ -261,6 +261,105 @@ def test_search_passes_property_filter_to_data_sources_query(): "prop_filter passed through as `filter` keyword argument") +def test_enrich_extracts_title_and_properties(): + """Candidate with properties → title and properties extracted.""" + from unittest.mock import MagicMock + import notion_search + + notion = MagicMock() + notion.blocks.children.list.return_value = {"results": []} # no body + + candidate = { + "id": "page-a", + "url": "https://notion.so/a", + "properties": { + "Name": { + "type": "title", + "title": [{"plain_text": "Test Page"}], + }, + "Status": { + "type": "status", + "status": {"name": "Done"}, + }, + "Topic": { + "type": "multi_select", + "multi_select": [{"name": "AI"}, {"name": "MCP"}], + }, + }, + } + enriched = notion_search.enrich_candidates(notion, [candidate]) + _assert(len(enriched) == 1, "one enriched candidate") + e = enriched[0] + _assert(e["title"] == "Test Page", "title extracted from title property") + _assert(e["properties"]["Status"] == "Done", "status flattened to name") + _assert(e["properties"]["Topic"] == ["AI", "MCP"], "multi_select flattened to names list") + + +def test_enrich_extracts_first_paragraph_excerpt(): + """First paragraph block → excerpt (200-char max).""" + from unittest.mock import MagicMock + import notion_search + + notion = MagicMock() + notion.blocks.children.list.return_value = { + "results": [ + { + "type": "paragraph", + "paragraph": { + "rich_text": [{"plain_text": "This is the first paragraph of the page."}] + }, + } + ] + } + candidate = { + "id": "page-a", "url": "https://notion.so/a", + "properties": {"Name": {"type": "title", "title": [{"plain_text": "Test"}]}}, + } + enriched = notion_search.enrich_candidates(notion, [candidate]) + _assert(enriched[0]["excerpt"] == "This is the first paragraph of the page.", + "excerpt is first paragraph plain text") + + +def test_enrich_falls_back_to_empty_excerpt(): + """Page leads with image/table only → excerpt is empty string, no crash.""" + from unittest.mock import MagicMock + import notion_search + + notion = MagicMock() + notion.blocks.children.list.return_value = { + "results": [ + {"type": "image", "image": {}}, + {"type": "divider", "divider": {}}, + ] + } + candidate = { + "id": "page-a", "url": "https://notion.so/a", + "properties": {"Name": {"type": "title", "title": [{"plain_text": "Test"}]}}, + } + enriched = notion_search.enrich_candidates(notion, [candidate]) + _assert(enriched[0]["excerpt"] == "", "empty excerpt when no text-bearing block") + + +def test_enrich_truncates_long_excerpt(): + """Excerpt is capped at 200 chars.""" + from unittest.mock import MagicMock + import notion_search + + notion = MagicMock() + long_text = "x" * 500 + notion.blocks.children.list.return_value = { + "results": [ + {"type": "paragraph", "paragraph": {"rich_text": [{"plain_text": long_text}]}} + ] + } + candidate = { + "id": "page-a", "url": "https://notion.so/a", + "properties": {"Name": {"type": "title", "title": [{"plain_text": "Test"}]}}, + } + enriched = notion_search.enrich_candidates(notion, [candidate]) + _assert(len(enriched[0]["excerpt"]) == 200, "excerpt truncated to 200 chars") + + def run_all(): tests = [ test_call_claude_dispatches_to_sdk_when_available, @@ -280,6 +379,10 @@ def run_all(): test_search_uses_data_source_query_when_databases_specified, test_search_filters_only_pages, test_search_passes_property_filter_to_data_sources_query, + test_enrich_extracts_title_and_properties, + test_enrich_extracts_first_paragraph_excerpt, + test_enrich_falls_back_to_empty_excerpt, + test_enrich_truncates_long_excerpt, ] for t in tests: print(f"\n{t.__name__}")