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:
2026-04-28 07:56:35 +09:00
parent a40d1f06b5
commit 8aa0fa26e9
2 changed files with 193 additions and 0 deletions

View File

@@ -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