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) <noreply@anthropic.com>
This commit is contained in:
@@ -146,3 +146,93 @@ def search_candidates(
|
|||||||
return candidates
|
return 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
|
||||||
|
|||||||
@@ -261,6 +261,105 @@ def test_search_passes_property_filter_to_data_sources_query():
|
|||||||
"prop_filter passed through as `filter` keyword argument")
|
"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():
|
def run_all():
|
||||||
tests = [
|
tests = [
|
||||||
test_call_claude_dispatches_to_sdk_when_available,
|
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_uses_data_source_query_when_databases_specified,
|
||||||
test_search_filters_only_pages,
|
test_search_filters_only_pages,
|
||||||
test_search_passes_property_filter_to_data_sources_query,
|
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:
|
for t in tests:
|
||||||
print(f"\n{t.__name__}")
|
print(f"\n{t.__name__}")
|
||||||
|
|||||||
Reference in New Issue
Block a user