# Notion Semantic Search Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a CLI skill that searches Notion workspaces semantically, using LLM-based query expansion + rerank to close the gap left by Notion's keyword-only native search. Output is a terminal table for browsing or JSON for piping into downstream tools. **Architecture:** Eight independent modules composed by a thin CLI entrypoint. Query expansion and rerank both call Claude Haiku via a small `_search_llm.py` abstraction (SDK preferred, `claude -p` CLI fallback). Notion search uses the existing `_notion_compat.py` resolver and client factory shipped in Phase 2. A SHA256-keyed JSON-file cache short-circuits repeat reranks. All LLM and Notion I/O is dependency-injected so unit tests run without real API keys. **Tech Stack:** Python 3.11+, `notion-client` v3 SDK (already in 32-notion-writer venv), optional `anthropic` SDK (Haiku 4.5 — `claude-haiku-4-5`), `unittest.mock` for test doubles. Tests via `python3 test_notion_search.py` (flat-function `_assert` style matching `32-notion-writer/code/scripts/test_parser.py`). **Spec:** `docs/superpowers/specs/2026-04-28-notion-semantic-search-design.md` --- ## File Structure | File | Purpose | Approx LOC | |---|---|---| | `custom-skills/31-notion-organizer/code/scripts/_search_llm.py` | Claude client abstraction (SDK + CLI fallback) | ~120 | | `custom-skills/31-notion-organizer/code/scripts/_search_cache.py` | SHA256-keyed JSON file cache | ~80 | | `custom-skills/31-notion-organizer/code/scripts/notion_search.py` | Main pipeline: expand, search, enrich, rerank, output, CLI | ~450 | | `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` | Unit tests, all I/O mocked | ~300 | | `custom-skills/31-notion-organizer/code/scripts/_notion_compat.py` | Symlink to 32's helper (already exists in 32) | (link only) | | `custom-skills/31-notion-organizer/commands/notion-search.md` | Slash command definition | ~50 | | `custom-skills/31-notion-organizer/code/CLAUDE.md` | Add usage section | +60 lines | | `custom-skills/31-notion-organizer/code/scripts/requirements.txt` | Add `anthropic` (optional) | +1 line | The plan keeps `notion_search.py` as one focused file (~450 LOC) rather than splitting into `expand.py`/`search.py`/`rerank.py`/`output.py`. Reason: each module is small (~80 LOC), they share helpers, and the existing 31 codebase uses single-file scripts (`async_organizer.py`, `schema_migrator.py`). Splitting would over-engineer for the LOC budget. --- ## Test Conventions The 31-notion-organizer codebase has no existing test file, but 32-notion-writer's `test_parser.py` sets the repo convention. Follow it: - Flat function tests: `def test_X(): ...` - `_assert(cond, msg)` helper at the top — prints ✓ or ✗ and exits on failure - `run_all()` lists tests by name and calls each - `if __name__ == "__main__": run_all()` - Run via `python3 test_notion_search.py` For test doubles: - **LLM:** dependency-injected via `llm_caller=` parameter — tests pass `lambda prompt, **kw: '["fake response"]'` - **Notion client:** `unittest.mock.MagicMock` constructed locally per test - **Cache:** pass `cache_dir=` parameter — tests use `tempfile.TemporaryDirectory` --- ## Task 1: Create test scaffolding + `_search_llm.py` (LLM client abstraction) **Files:** - Create: `custom-skills/31-notion-organizer/code/scripts/_search_llm.py` - Create: `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` - Create: `custom-skills/31-notion-organizer/code/scripts/_notion_compat.py` (symlink) The LLM abstraction has three responsibilities: detect available client (SDK or CLI), dispatch the call, surface a clear error if neither is available. Tests cover dispatch logic via dependency injection — no real API calls. - [ ] **Step 1: Create the symlink to `_notion_compat.py`** The compat helper already exists in 32-notion-writer. Symlink it into 31 so both skills share a single source of truth. ```bash cd /Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/code/scripts ln -sf ../../../32-notion-writer/code/scripts/_notion_compat.py _notion_compat.py ls -la _notion_compat.py ``` Expected: `_notion_compat.py -> ../../../32-notion-writer/code/scripts/_notion_compat.py` - [ ] **Step 2: Create the test scaffolding (`test_notion_search.py`)** ```python #!/usr/bin/env python3 """Tests for notion_search.py — run with `python3 test_notion_search.py`.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) def _assert(cond, msg): if not cond: print(f" ✗ FAIL: {msg}") raise SystemExit(1) print(f" ✓ {msg}") def run_all(): tests = [ # tests added per task ] for t in tests: print(f"\n{t.__name__}") t() print("\n" + "=" * 50) print(f"✅ All {len(tests)} tests passed") print("=" * 50) if __name__ == "__main__": run_all() ``` - [ ] **Step 3: Write the failing tests for `_search_llm.py`** Add to `test_notion_search.py` BEFORE `run_all`: ```python def test_call_claude_dispatches_to_sdk_when_available(): """When SDK is available and ANTHROPIC_API_KEY is set, use SDK path.""" import os from unittest.mock import patch import _search_llm with patch.object(_search_llm, "_have_anthropic_sdk", return_value=True), \ patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-fake"}), \ patch.object(_search_llm, "_call_via_sdk", return_value="sdk-response") as sdk_mock, \ patch.object(_search_llm, "_call_via_cli", return_value="cli-response") as cli_mock: result = _search_llm.call_claude("hello") _assert(result == "sdk-response", "SDK path returned its response") _assert(sdk_mock.called, "SDK path was invoked") _assert(not cli_mock.called, "CLI path was NOT invoked") def test_call_claude_falls_back_to_cli_when_no_sdk(): """When SDK is missing but CLI is available, use CLI path.""" from unittest.mock import patch import _search_llm with patch.object(_search_llm, "_have_anthropic_sdk", return_value=False), \ patch.object(_search_llm, "_have_claude_cli", return_value=True), \ patch.object(_search_llm, "_call_via_cli", return_value="cli-response") as cli_mock: result = _search_llm.call_claude("hello") _assert(result == "cli-response", "CLI path returned its response") _assert(cli_mock.called, "CLI path was invoked") def test_call_claude_raises_when_neither_available(): """Both clients missing → RuntimeError with setup hint.""" from unittest.mock import patch import _search_llm with patch.object(_search_llm, "_have_anthropic_sdk", return_value=False), \ patch.object(_search_llm, "_have_claude_cli", return_value=False): try: _search_llm.call_claude("hello") _assert(False, "should have raised RuntimeError") except RuntimeError as exc: _assert("ANTHROPIC_API_KEY" in str(exc) or "claude" in str(exc).lower(), "error mentions setup options") ``` Add the three tests to `tests` list in `run_all()`: ```python def run_all(): tests = [ test_call_claude_dispatches_to_sdk_when_available, test_call_claude_falls_back_to_cli_when_no_sdk, test_call_claude_raises_when_neither_available, ] # ... rest unchanged ``` - [ ] **Step 4: Run tests to verify they fail** ```bash cd /Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/code/scripts python3 test_notion_search.py 2>&1 | head -10 ``` Expected: ImportError or ModuleNotFoundError on `_search_llm`. - [ ] **Step 5: Create `_search_llm.py`** ```python """Claude client abstraction. SDK preferred, `claude -p` CLI fallback.""" from __future__ import annotations import os import shutil import subprocess from typing import Callable LLMCaller = Callable[..., str] # (prompt, *, model, max_tokens) -> response text def _have_anthropic_sdk() -> bool: try: import anthropic # noqa: F401 return True except ImportError: return False def _have_claude_cli() -> bool: return shutil.which("claude") is not None def _call_via_sdk(prompt: str, model: str, max_tokens: int) -> str: import anthropic client = anthropic.Anthropic() response = client.messages.create( model=model, max_tokens=max_tokens, messages=[{"role": "user", "content": prompt}], ) return response.content[0].text def _call_via_cli(prompt: str, model: str, max_tokens: int) -> str: """Shell out to Claude Code CLI. max_tokens is ignored (CLI handles defaults).""" result = subprocess.run( ["claude", "-p", prompt, "--model", model], capture_output=True, text=True, check=True, timeout=60, ) return result.stdout.strip() def call_claude( prompt: str, *, model: str = "claude-haiku-4-5", max_tokens: int = 1000, ) -> str: """Send a prompt to Claude. Tries the anthropic SDK first (requires ANTHROPIC_API_KEY), falls back to the `claude -p` CLI if available. Raises RuntimeError if neither works. """ if _have_anthropic_sdk() and os.getenv("ANTHROPIC_API_KEY"): return _call_via_sdk(prompt, model, max_tokens) if _have_claude_cli(): return _call_via_cli(prompt, model, max_tokens) raise RuntimeError( "No Claude client available. Either:\n" " - Install the `anthropic` SDK and set ANTHROPIC_API_KEY, or\n" " - Install Claude Code CLI (`claude` on PATH)" ) ``` - [ ] **Step 6: Run tests to verify they pass** ```bash python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 3 tests passed`. - [ ] **Step 7: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/code/scripts/_search_llm.py \ custom-skills/31-notion-organizer/code/scripts/test_notion_search.py \ custom-skills/31-notion-organizer/code/scripts/_notion_compat.py git commit -m "$(cat <<'EOF' feat(notion-search): add LLM client abstraction with SDK + CLI fallback First task of Phase 3b-i (notion semantic search). Adds _search_llm.py that dispatches to the anthropic SDK when ANTHROPIC_API_KEY is set, falls back to `claude -p` CLI otherwise, and raises a clear setup error if neither works. Symlinks _notion_compat.py from 32-notion-writer so both skills share one source of truth. 3 tests cover the three dispatch paths. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 2: Cache layer (`_search_cache.py`) **Files:** - Create: `custom-skills/31-notion-organizer/code/scripts/_search_cache.py` - Modify: `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` SHA256(query + sorted_candidate_ids) → JSON file. Filesystem-only, no DB. Tests use `tempfile.TemporaryDirectory` for isolation. - [ ] **Step 1: Write the failing tests** Add to `test_notion_search.py` before `run_all`: ```python def test_cache_miss_returns_none(): import tempfile from pathlib import Path import _search_cache with tempfile.TemporaryDirectory() as tmpdir: result = _search_cache.cache_get("query", ["id1", "id2"], cache_dir=Path(tmpdir)) _assert(result is None, "cache miss returns None") def test_cache_hit_returns_cached_results(): import tempfile from pathlib import Path import _search_cache with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) results = [{"id": "abc", "title": "Test"}] _search_cache.cache_put("query", ["id1", "id2"], results, cache_dir=cache_dir) hit = _search_cache.cache_get("query", ["id1", "id2"], cache_dir=cache_dir) _assert(hit == results, "cache hit returns the stored results") def test_cache_miss_on_different_candidates(): import tempfile from pathlib import Path import _search_cache with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) _search_cache.cache_put("query", ["id1", "id2"], [{"x": 1}], cache_dir=cache_dir) miss = _search_cache.cache_get("query", ["id1", "id3"], cache_dir=cache_dir) _assert(miss is None, "different candidate set hashes to different key") def test_cache_miss_after_ttl_expires(): import tempfile from pathlib import Path import _search_cache with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) _search_cache.cache_put("query", ["id1"], [{"x": 1}], cache_dir=cache_dir) # TTL=0 means immediately expired miss = _search_cache.cache_get("query", ["id1"], cache_dir=cache_dir, ttl_seconds=0) _assert(miss is None, "expired cache returns None") def test_cache_handles_corrupted_file(): import tempfile import hashlib from pathlib import Path import _search_cache with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) cache_dir.mkdir(exist_ok=True) # Write garbage to the expected key path key = hashlib.sha256("query|id1".encode()).hexdigest() (cache_dir / f"{key}.json").write_text("not json {{{") result = _search_cache.cache_get("query", ["id1"], cache_dir=cache_dir) _assert(result is None, "corrupted cache file returns None") ``` Add to `tests` list: ```python def run_all(): tests = [ test_call_claude_dispatches_to_sdk_when_available, test_call_claude_falls_back_to_cli_when_no_sdk, test_call_claude_raises_when_neither_available, test_cache_miss_returns_none, test_cache_hit_returns_cached_results, test_cache_miss_on_different_candidates, test_cache_miss_after_ttl_expires, test_cache_handles_corrupted_file, ] # ... rest unchanged ``` - [ ] **Step 2: Run tests to verify they fail** ```bash cd /Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/code/scripts python3 test_notion_search.py 2>&1 | grep -A 1 "test_cache" | head -10 ``` Expected: ImportError on `_search_cache`. - [ ] **Step 3: Create `_search_cache.py`** ```python """Filesystem cache for rerank results, keyed by SHA256(query + candidate_ids).""" from __future__ import annotations import hashlib import json import time from pathlib import Path from typing import List, Optional DEFAULT_CACHE_DIR = Path.home() / ".cache" / "notion-search" DEFAULT_TTL_SECONDS = 86400 # 24 hours def _cache_key(query: str, candidate_ids: List[str]) -> str: payload = f"{query}|{','.join(sorted(candidate_ids))}" return hashlib.sha256(payload.encode("utf-8")).hexdigest() def cache_get( query: str, candidate_ids: List[str], *, cache_dir: Path = DEFAULT_CACHE_DIR, ttl_seconds: int = DEFAULT_TTL_SECONDS, ) -> Optional[List]: """Return cached results if file exists and is fresh, else None. Corrupted cache files are silently treated as misses (and removed). """ key = _cache_key(query, candidate_ids) path = cache_dir / f"{key}.json" if not path.exists(): return None try: data = json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): try: path.unlink() except OSError: pass return None cached_at = data.get("cached_at_epoch", 0) if time.time() - cached_at > ttl_seconds: return None return data.get("results") def cache_put( query: str, candidate_ids: List[str], results: List, *, cache_dir: Path = DEFAULT_CACHE_DIR, ) -> None: """Write results to cache. Creates cache_dir if missing.""" cache_dir.mkdir(parents=True, exist_ok=True) key = _cache_key(query, candidate_ids) path = cache_dir / f"{key}.json" payload = { "query": query, "candidate_ids": list(candidate_ids), "results": results, "cached_at_epoch": time.time(), } path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") ``` - [ ] **Step 4: Run tests to verify they pass** ```bash python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 8 tests passed`. - [ ] **Step 5: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/code/scripts/_search_cache.py \ custom-skills/31-notion-organizer/code/scripts/test_notion_search.py git commit -m "$(cat <<'EOF' feat(notion-search): add SHA256-keyed JSON file cache Cache layer for rerank results. Key is SHA256 of query + sorted candidate IDs, so changing the candidate set automatically invalidates. 1-day TTL by default; corrupted cache files are silently dropped. 5 tests cover hit, miss, different-candidates, TTL expiry, corruption. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 3: Query expansion module **Files:** - Create (initial scaffold): `custom-skills/31-notion-organizer/code/scripts/notion_search.py` - Modify: `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` This task starts `notion_search.py` with one function (`expand_query`). Subsequent tasks add the rest. - [ ] **Step 1: Write the failing tests** Add to `test_notion_search.py` before `run_all`: ```python def test_expand_query_returns_variants_with_original_first(): """LLM mock returns variants; expansion includes original verbatim as first item.""" import notion_search fake_llm = lambda prompt, **kw: '["AI agents", "multi-agent systems", "autonomous LLMs"]' variants = notion_search.expand_query("AI agents", llm_caller=fake_llm) _assert(variants[0] == "AI agents", "original query is first variant") _assert(len(variants) >= 2, "at least 2 variants returned") _assert("multi-agent systems" in variants, "LLM-suggested variant included") def test_expand_query_dedupes_and_caps(): """Duplicates removed; total ≤ max_variants.""" import notion_search fake_llm = lambda prompt, **kw: '["AI", "AI", "agents", "agents", "systems", "tools", "models"]' variants = notion_search.expand_query("AI", llm_caller=fake_llm, max_variants=3) _assert(len(variants) <= 3, "capped at max_variants=3") _assert(len(variants) == len(set(variants)), "no duplicates in result") def test_expand_query_falls_back_on_invalid_json(): """LLM returns prose instead of JSON → fall back to [original].""" import notion_search fake_llm = lambda prompt, **kw: "I'm sorry, I can't help with that." variants = notion_search.expand_query("AI agents", llm_caller=fake_llm) _assert(variants == ["AI agents"], "non-JSON response falls back to original only") def test_expand_query_falls_back_on_llm_exception(): """LLM raises → fall back to [original].""" import notion_search def boom(prompt, **kw): raise RuntimeError("API error") variants = notion_search.expand_query("AI agents", llm_caller=boom) _assert(variants == ["AI agents"], "LLM exception falls back to original only") ``` Add to `tests` list: ```python test_expand_query_returns_variants_with_original_first, test_expand_query_dedupes_and_caps, test_expand_query_falls_back_on_invalid_json, test_expand_query_falls_back_on_llm_exception, ``` - [ ] **Step 2: Run tests to verify they fail** ```bash cd /Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/code/scripts python3 test_notion_search.py 2>&1 | grep "test_expand" | head -5 ``` Expected: ImportError on `notion_search`. - [ ] **Step 3: Create `notion_search.py` with `expand_query`** ```python #!/usr/bin/env python3 """Notion semantic search — query expansion + LLM rerank over Notion API search. CLI: python3 notion_search.py "query" [--databases ID,...] [--filter JSON] \ [--limit N] [--no-rerank] [--no-expand] \ [--no-cache] [--json] """ from __future__ import annotations import json import re import sys from typing import Callable, List from _search_llm import call_claude as default_llm_caller EXPAND_PROMPT = """You are a query expander for a Notion semantic search tool. Generate up to {n} variants of the user's query that capture related concepts, synonyms, and cross-language alternates (especially Korean ↔ English). Rules: - Always include the original query verbatim as the first variant. - Variants should help find pages that the original query might miss due to keyword-only search. - For Korean queries, include English synonyms; for English queries, include Korean alternates if the topic has common Korean usage. - Keep variants concise (under 8 words each). Query: {query} Return ONLY a JSON array of strings, no prose. Example: ["original query", "synonym variant", "cross-language variant"]""" def expand_query( query: str, *, llm_caller: Callable[..., str] = None, max_variants: int = 5, ) -> List[str]: """Expand a query into related variants. Returns [query] on any failure.""" if llm_caller is None: llm_caller = default_llm_caller prompt = EXPAND_PROMPT.format(n=max_variants, query=query) try: response = llm_caller(prompt) except Exception as exc: print(f"Warning: query expansion failed ({exc}); using original query only", file=sys.stderr) return [query] # Be permissive: extract the first JSON array from the response. match = re.search(r'\[.*\]', response, re.DOTALL) if not match: print(f"Warning: query expansion returned no JSON array; using original query only", file=sys.stderr) return [query] try: variants = json.loads(match.group(0)) except json.JSONDecodeError: print(f"Warning: query expansion returned invalid JSON; using original query only", file=sys.stderr) return [query] if not isinstance(variants, list) or not all(isinstance(v, str) for v in variants): return [query] # Always include the original query first; dedupe; cap at max_variants. seen = set() result = [] for v in [query] + variants: v = v.strip() if v and v not in seen: seen.add(v) result.append(v) if len(result) >= max_variants: break return result ``` - [ ] **Step 4: Run tests to verify they pass** ```bash python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 12 tests passed`. - [ ] **Step 5: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/code/scripts/notion_search.py \ custom-skills/31-notion-organizer/code/scripts/test_notion_search.py git commit -m "$(cat <<'EOF' feat(notion-search): add query expansion via Claude Haiku Generates up to 5 query variants (synonyms + cross-language KR↔EN) so later Notion API search can union over them. Permissive failure modes: LLM error or non-JSON response falls back to [original] with stderr warning. Dedupes and caps variants. 4 tests: variants list, dedup+cap, JSON-parse fallback, exception fallback. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 4: Search execution (Notion API) **Files:** - Modify: `custom-skills/31-notion-organizer/code/scripts/notion_search.py` - Modify: `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` For each query variant, hit Notion API. Workspace-wide via `client.search` when no `--databases` flag, per-database via `client.data_sources.query` otherwise. Union and dedupe results by page ID, cap at 30. - [ ] **Step 1: Write the failing tests** Add to `test_notion_search.py` before `run_all`: ```python def test_search_unions_and_dedupes_workspace(): """Two variants returning overlapping pages produce a deduped candidate set.""" from unittest.mock import MagicMock import notion_search notion = MagicMock() # Variant 1 returns pages A, B # Variant 2 returns pages B, C # Final union should be A, B, C notion.search.side_effect = [ {"results": [ {"id": "page-a", "object": "page", "url": "https://notion.so/a"}, {"id": "page-b", "object": "page", "url": "https://notion.so/b"}, ]}, {"results": [ {"id": "page-b", "object": "page", "url": "https://notion.so/b"}, {"id": "page-c", "object": "page", "url": "https://notion.so/c"}, ]}, ] candidates = notion_search.search_candidates( notion, ["query1", "query2"], databases=None ) ids = [c["id"] for c in candidates] _assert(set(ids) == {"page-a", "page-b", "page-c"}, "union of pages from both variants") _assert(len(ids) == 3, "duplicate page-b deduped") def test_search_caps_candidates_at_30(): """If total candidates exceed 30, cap to 30.""" from unittest.mock import MagicMock import notion_search notion = MagicMock() notion.search.return_value = { "results": [ {"id": f"page-{i:02d}", "object": "page", "url": f"https://notion.so/{i}"} for i in range(40) ] } candidates = notion_search.search_candidates(notion, ["query"], databases=None) _assert(len(candidates) == 30, f"capped to 30 (got {len(candidates)})") def test_search_uses_data_source_query_when_databases_specified(): """--databases flag → per-DB data_sources.query instead of workspace search.""" from unittest.mock import MagicMock, patch import notion_search notion = MagicMock() # Mock the resolver and the per-DB query notion.data_sources.query.return_value = { "results": [{"id": "page-a", "url": "https://notion.so/a"}] } with patch("notion_search.compat.resolve_data_source_id", side_effect=lambda c, ds: ds): # passthrough candidates = notion_search.search_candidates( notion, ["query"], databases=["db-id-1"] ) _assert(notion.data_sources.query.called, "data_sources.query was called") _assert(not notion.search.called, "workspace search NOT called when databases specified") _assert(len(candidates) == 1, "candidate from data source returned") def test_search_filters_only_pages(): """search() may return databases too; we keep only object='page'.""" from unittest.mock import MagicMock import notion_search notion = MagicMock() notion.search.return_value = {"results": [ {"id": "page-a", "object": "page", "url": "https://notion.so/a"}, {"id": "db-1", "object": "database", "url": "https://notion.so/db1"}, ]} candidates = notion_search.search_candidates(notion, ["query"], databases=None) ids = [c["id"] for c in candidates] _assert("page-a" in ids, "page kept") _assert("db-1" not in ids, "database object filtered out") def test_search_passes_property_filter_to_data_sources_query(): """--filter JSON is passed through to data_sources.query as the `filter` parameter.""" from unittest.mock import MagicMock, patch import notion_search notion = MagicMock() notion.data_sources.query.return_value = {"results": []} prop_filter = {"property": "Status", "status": {"equals": "Done"}} with patch("notion_search.compat.resolve_data_source_id", side_effect=lambda c, ds: ds): notion_search.search_candidates( notion, ["query"], databases=["db-id-1"], prop_filter=prop_filter, ) _assert(notion.data_sources.query.called, "data_sources.query was called") call_kwargs = notion.data_sources.query.call_args.kwargs _assert(call_kwargs.get("filter") == prop_filter, "prop_filter passed through as `filter` keyword argument") ``` Add to `tests` list: ```python test_search_unions_and_dedupes_workspace, test_search_caps_candidates_at_30, test_search_uses_data_source_query_when_databases_specified, test_search_filters_only_pages, test_search_passes_property_filter_to_data_sources_query, ``` - [ ] **Step 2: Run tests to verify they fail** ```bash python3 test_notion_search.py 2>&1 | grep "test_search" | head -5 ``` Expected: AttributeError on `notion_search.search_candidates`. - [ ] **Step 3: Add `search_candidates` to `notion_search.py`** Add these imports at the top of `notion_search.py`, after the existing imports: ```python from typing import Callable, Dict, List, Optional import _notion_compat as compat ``` Add the function (place after `expand_query`): ```python MAX_CANDIDATES = 30 def search_candidates( notion, queries: List[str], *, databases: Optional[List[str]] = None, prop_filter: Optional[Dict] = None, ) -> List[Dict]: """Run each query against Notion (workspace or per-DB), dedupe, cap at MAX_CANDIDATES. Returns a list of page result objects (whatever Notion returned), order preserves first-seen position across all variants — used as fallback ordering when rerank is off. """ seen_ids = set() candidates: List[Dict] = [] for query in queries: if databases: # Per-database query (data_sources.query) for db_id in databases: try: data_source_id = compat.resolve_data_source_id(notion, db_id) except Exception as exc: print(f"Warning: could not resolve database {db_id}: {exc}", file=sys.stderr) continue params = {"data_source_id": data_source_id, "page_size": 100} if prop_filter: params["filter"] = prop_filter response = notion.data_sources.query(**params) for page in response.get("results", []): page_id = page.get("id") if page_id and page_id not in seen_ids: seen_ids.add(page_id) candidates.append(page) if len(candidates) >= MAX_CANDIDATES: return candidates else: # Workspace-wide search response = notion.search(query=query, page_size=100) for item in response.get("results", []): if item.get("object") != "page": continue page_id = item.get("id") if page_id and page_id not in seen_ids: seen_ids.add(page_id) candidates.append(item) if len(candidates) >= MAX_CANDIDATES: return candidates return candidates ``` - [ ] **Step 4: Run tests to verify they pass** ```bash python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 17 tests passed`. - [ ] **Step 5: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/code/scripts/notion_search.py \ custom-skills/31-notion-organizer/code/scripts/test_notion_search.py git commit -m "$(cat <<'EOF' feat(notion-search): add Notion search execution with workspace/DB modes For each expanded query variant: hit Notion's workspace search OR per-database data_sources.query (when --databases is specified). Union and dedupe by page ID, cap at 30 candidates total. Filters out non-page objects (databases) from workspace search results. Property filters (--filter JSON) pass through to data_sources.query when in per-database mode. 5 tests: workspace dedup, 30-cap, DB-mode dispatch, page-only filter, property-filter passthrough. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 5: Property + excerpt enrichment **Files:** - Modify: `custom-skills/31-notion-organizer/code/scripts/notion_search.py` - Modify: `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` For each candidate: extract title from properties, gather key properties, fetch first text-bearing block (paragraph/heading/quote) as a 200-char excerpt. Empty excerpt is acceptable. - [ ] **Step 1: Write the failing tests** Add to `test_notion_search.py`: ```python 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") ``` Add to `tests` list: ```python test_enrich_extracts_title_and_properties, test_enrich_extracts_first_paragraph_excerpt, test_enrich_falls_back_to_empty_excerpt, test_enrich_truncates_long_excerpt, ``` - [ ] **Step 2: Run tests to verify they fail** ```bash python3 test_notion_search.py 2>&1 | grep "test_enrich" | head -5 ``` Expected: AttributeError on `notion_search.enrich_candidates`. - [ ] **Step 3: Add `enrich_candidates` to `notion_search.py`** Add the function (place after `search_candidates`): ```python 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", []): 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 ``` - [ ] **Step 4: Run tests to verify they pass** ```bash python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 21 tests passed`. - [ ] **Step 5: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/code/scripts/notion_search.py \ custom-skills/31-notion-organizer/code/scripts/test_notion_search.py git commit -m "$(cat <<'EOF' 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. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 6: Rerank module **Files:** - Modify: `custom-skills/31-notion-organizer/code/scripts/notion_search.py` - Modify: `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` Build the rerank prompt, call Claude, parse JSON, sort by score, take top N. Permissive on parse errors. - [ ] **Step 1: Write the failing tests** Add to `test_notion_search.py`: ```python def test_rerank_orders_by_score_descending(): """LLM returns scores; output sorted score-desc, top N.""" import notion_search candidates = [ {"id": "p1", "url": "u1", "title": "Page 1", "properties": {}, "excerpt": "x"}, {"id": "p2", "url": "u2", "title": "Page 2", "properties": {}, "excerpt": "x"}, {"id": "p3", "url": "u3", "title": "Page 3", "properties": {}, "excerpt": "x"}, ] # LLM returns: p3 most relevant, then p1, then p2 fake_llm = lambda prompt, **kw: '''[ {"index": 2, "score": 0.95, "why": "best match"}, {"index": 0, "score": 0.7, "why": "ok match"}, {"index": 1, "score": 0.3, "why": "weak match"} ]''' ranked = notion_search.rerank("query", candidates, llm_caller=fake_llm, limit=3) _assert(ranked[0]["id"] == "p3", "highest score first") _assert(ranked[0]["relevance"] == 0.95, "score attached to result") _assert(ranked[0]["snippet"] == "best match", "why text attached as snippet") _assert(ranked[1]["id"] == "p1", "second place correct") _assert(ranked[2]["id"] == "p2", "lowest score last") def test_rerank_respects_limit(): """limit=2 → returns top 2 only.""" import notion_search candidates = [ {"id": f"p{i}", "url": f"u{i}", "title": f"Page {i}", "properties": {}, "excerpt": "x"} for i in range(5) ] fake_llm = lambda prompt, **kw: '''[ {"index": 0, "score": 0.9, "why": "x"}, {"index": 1, "score": 0.8, "why": "x"}, {"index": 2, "score": 0.7, "why": "x"}, {"index": 3, "score": 0.6, "why": "x"}, {"index": 4, "score": 0.5, "why": "x"} ]''' ranked = notion_search.rerank("query", candidates, llm_caller=fake_llm, limit=2) _assert(len(ranked) == 2, "exactly limit results returned") def test_rerank_falls_back_on_parse_error(): """Non-JSON response → return candidates in input order, unranked (no scores).""" import notion_search candidates = [ {"id": "p1", "url": "u1", "title": "Page 1", "properties": {}, "excerpt": "x"}, {"id": "p2", "url": "u2", "title": "Page 2", "properties": {}, "excerpt": "x"}, ] fake_llm = lambda prompt, **kw: "I cannot help with that." ranked = notion_search.rerank("query", candidates, llm_caller=fake_llm, limit=10) _assert(len(ranked) == 2, "all candidates returned in fallback") _assert(ranked[0]["id"] == "p1", "fallback preserves input order") _assert(ranked[0]["relevance"] is None, "no score in fallback") _assert(ranked[0]["snippet"] is None, "no snippet in fallback") def test_rerank_falls_back_on_llm_exception(): """LLM raises → fallback unranked.""" import notion_search candidates = [{"id": "p1", "url": "u1", "title": "Page 1", "properties": {}, "excerpt": "x"}] def boom(prompt, **kw): raise RuntimeError("API down") ranked = notion_search.rerank("query", candidates, llm_caller=boom, limit=10) _assert(len(ranked) == 1, "candidate returned despite LLM failure") _assert(ranked[0]["relevance"] is None, "no score in fallback") ``` Add to `tests` list: ```python test_rerank_orders_by_score_descending, test_rerank_respects_limit, test_rerank_falls_back_on_parse_error, test_rerank_falls_back_on_llm_exception, ``` - [ ] **Step 2: Run tests to verify they fail** ```bash python3 test_notion_search.py 2>&1 | grep "test_rerank" | head -5 ``` Expected: AttributeError on `notion_search.rerank`. - [ ] **Step 3: Add `rerank` to `notion_search.py`** Add (place after `enrich_candidates`): ```python RERANK_PROMPT = """You are a reranker for Notion semantic search. Score each candidate 0.0-1.0 by how relevant it is to the user's ORIGINAL query (not any expanded variants). User's query: {query} Candidates: {candidates} Return ONLY a JSON array, ordered however you like. Each object has: - "index": integer matching the candidate's [N] number - "score": float 0.0-1.0 - "why": one short sentence (under 80 chars) explaining the score Example output: [{{"index": 0, "score": 0.95, "why": "Direct match — covers exactly this topic"}}, {{"index": 2, "score": 0.6, "why": "Adjacent — shares context but not topic"}}]""" def _format_candidate_for_rerank(idx: int, c: Dict) -> str: parts = [f"[{idx}] {c['title']}"] props_str = ", ".join(f"{k}: {v}" for k, v in c.get("properties", {}).items()) if props_str: parts.append(f" Properties: {props_str}") if c.get("excerpt"): parts.append(f" Excerpt: {c['excerpt']}") return "\n".join(parts) def _fallback_rank(candidates: List[Dict], limit: int) -> List[Dict]: """Return candidates in input order with null relevance/snippet.""" return [ {**c, "relevance": None, "snippet": None} for c in candidates[:limit] ] def rerank( query: str, candidates: List[Dict], *, llm_caller: Callable[..., str] = None, limit: int = 10, ) -> List[Dict]: """Rerank candidates against the original query. Fallback to input order on failure.""" if llm_caller is None: llm_caller = default_llm_caller if not candidates: return [] formatted = "\n\n".join( _format_candidate_for_rerank(i, c) for i, c in enumerate(candidates) ) prompt = RERANK_PROMPT.format(query=query, candidates=formatted) try: response = llm_caller(prompt) except Exception as exc: print(f"Warning: rerank failed ({exc}); returning unranked results", file=sys.stderr) return _fallback_rank(candidates, limit) match = re.search(r'\[.*\]', response, re.DOTALL) if not match: print(f"Warning: rerank returned no JSON array; returning unranked results", file=sys.stderr) return _fallback_rank(candidates, limit) try: scored = json.loads(match.group(0)) except json.JSONDecodeError: print(f"Warning: rerank returned invalid JSON; returning unranked results", file=sys.stderr) return _fallback_rank(candidates, limit) if not isinstance(scored, list): return _fallback_rank(candidates, limit) # Sort by score descending and map back to candidates scored.sort(key=lambda s: s.get("score", 0), reverse=True) out = [] for entry in scored[:limit]: idx = entry.get("index") if not isinstance(idx, int) or idx < 0 or idx >= len(candidates): continue c = candidates[idx] out.append({ **c, "relevance": float(entry.get("score", 0.0)), "snippet": entry.get("why", ""), }) return out ``` - [ ] **Step 4: Run tests to verify they pass** ```bash python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 25 tests passed`. - [ ] **Step 5: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/code/scripts/notion_search.py \ custom-skills/31-notion-organizer/code/scripts/test_notion_search.py git commit -m "$(cat <<'EOF' feat(notion-search): add Claude Haiku rerank module Builds a rerank prompt with title + flattened properties + excerpt for each candidate, calls Claude, parses JSON, sorts by score descending, takes top N. On any failure (LLM error, missing JSON, parse error), falls back to candidates in input order with null relevance/snippet. 4 tests: ordering, limit, parse-error fallback, exception fallback. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 7: CLI assembly + output formatting **Files:** - Modify: `custom-skills/31-notion-organizer/code/scripts/notion_search.py` - Modify: `custom-skills/31-notion-organizer/code/scripts/test_notion_search.py` Tie the modules together. argparse, cache wiring, terminal table or JSON output. Tests use mocked notion + LLM. - [ ] **Step 1: Write the failing tests** Add to `test_notion_search.py`: ```python def test_pipeline_search_returns_json_serializable_results(): """End-to-end pipeline returns results matching the spec's JSON schema.""" from unittest.mock import MagicMock import json as json_mod import notion_search notion = MagicMock() notion.search.return_value = { "results": [ { "id": "page-a", "object": "page", "url": "https://notion.so/a", "properties": { "Name": {"type": "title", "title": [{"plain_text": "AI Agents Page"}]}, "Status": {"type": "status", "status": {"name": "Done"}}, }, } ] } notion.blocks.children.list.return_value = { "results": [ {"type": "paragraph", "paragraph": {"rich_text": [{"plain_text": "Notes about AI agents."}]}} ] } expand_llm = lambda prompt, **kw: '["AI agents"]' rerank_llm = lambda prompt, **kw: '[{"index": 0, "score": 0.9, "why": "match"}]' results = notion_search.run_search( notion, "AI agents", expand_llm=expand_llm, rerank_llm=rerank_llm, limit=10, use_cache=False, ) _assert(len(results) == 1, "one result") r = results[0] _assert(r["id"] == "page-a", "id present") _assert(r["title"] == "AI Agents Page", "title extracted") _assert(r["relevance"] == 0.9, "relevance score from rerank") _assert(r["properties"]["Status"] == "Done", "property included") _assert(r["excerpt"] == "Notes about AI agents.", "excerpt included") # Schema must be JSON-serializable json_mod.dumps(results) def test_pipeline_no_rerank_skips_llm(): """--no-rerank flag → results have null relevance, no rerank LLM call.""" from unittest.mock import MagicMock import notion_search notion = MagicMock() notion.search.return_value = { "results": [ {"id": "p1", "object": "page", "url": "u1", "properties": {"Name": {"type": "title", "title": [{"plain_text": "Page 1"}]}}} ] } notion.blocks.children.list.return_value = {"results": []} expand_llm = lambda prompt, **kw: '["query"]' rerank_calls = [] def rerank_llm(prompt, **kw): rerank_calls.append(prompt) return "[]" results = notion_search.run_search( notion, "query", expand_llm=expand_llm, rerank_llm=rerank_llm, no_rerank=True, use_cache=False, ) _assert(len(rerank_calls) == 0, "rerank LLM not called when --no-rerank") _assert(results[0]["relevance"] is None, "no relevance score when no rerank") def test_pipeline_no_expand_uses_single_query(): """--no-expand → expand_llm not called, Notion gets only original query.""" from unittest.mock import MagicMock import notion_search notion = MagicMock() notion.search.return_value = {"results": []} expand_calls = [] def expand_llm(prompt, **kw): expand_calls.append(prompt) return '["should not be used"]' rerank_llm = lambda prompt, **kw: "[]" notion_search.run_search( notion, "exact term", expand_llm=expand_llm, rerank_llm=rerank_llm, no_expand=True, use_cache=False, ) _assert(len(expand_calls) == 0, "expand LLM not called when --no-expand") _assert(notion.search.call_count == 1, "Notion search called exactly once") def test_pipeline_uses_cache_on_second_call(): """Two identical pipeline calls → second one skips rerank LLM.""" import tempfile from pathlib import Path from unittest.mock import MagicMock import notion_search notion = MagicMock() notion.search.return_value = { "results": [ {"id": "p1", "object": "page", "url": "u1", "properties": {"Name": {"type": "title", "title": [{"plain_text": "Page 1"}]}}} ] } notion.blocks.children.list.return_value = {"results": []} expand_llm = lambda prompt, **kw: '["query"]' rerank_calls = [] def rerank_llm(prompt, **kw): rerank_calls.append(prompt) return '[{"index": 0, "score": 0.5, "why": "x"}]' with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) notion_search.run_search( notion, "query", expand_llm=expand_llm, rerank_llm=rerank_llm, use_cache=True, cache_dir=cache_dir, ) notion_search.run_search( notion, "query", expand_llm=expand_llm, rerank_llm=rerank_llm, use_cache=True, cache_dir=cache_dir, ) _assert(len(rerank_calls) == 1, "second call skipped rerank LLM (cache hit)") ``` Add to `tests` list: ```python test_pipeline_search_returns_json_serializable_results, test_pipeline_no_rerank_skips_llm, test_pipeline_no_expand_uses_single_query, test_pipeline_uses_cache_on_second_call, ``` - [ ] **Step 2: Run tests to verify they fail** ```bash python3 test_notion_search.py 2>&1 | grep "test_pipeline" | head -5 ``` Expected: AttributeError on `notion_search.run_search`. - [ ] **Step 3: Add `run_search`, output formatters, and `main` to `notion_search.py`** Add at the top (after existing imports): ```python import argparse import os from pathlib import Path import _search_cache ``` Add (place after `rerank`): ```python def run_search( notion, query: str, *, databases: Optional[List[str]] = None, prop_filter: Optional[Dict] = None, limit: int = 10, no_rerank: bool = False, no_expand: bool = False, use_cache: bool = True, cache_dir: Optional[Path] = None, expand_llm: Callable[..., str] = None, rerank_llm: Callable[..., str] = None, ) -> List[Dict]: """Full pipeline: expand → search → enrich → rerank → return. expand_llm and rerank_llm are dependency-injected for tests. """ # Stage 1: expand if no_expand: queries = [query] else: queries = expand_query(query, llm_caller=expand_llm) # Stage 2: search candidates = search_candidates( notion, queries, databases=databases, prop_filter=prop_filter, ) if not candidates: return [] # Stage 3: enrich enriched = enrich_candidates(notion, candidates) # Stage 4: rerank (or skip) if no_rerank: return [ {**c, "relevance": None, "snippet": None} for c in enriched[:limit] ] candidate_ids = [c["id"] for c in enriched] if use_cache: kwargs = {"cache_dir": cache_dir} if cache_dir else {} cached = _search_cache.cache_get(query, candidate_ids, **kwargs) if cached is not None: return cached ranked = rerank(query, enriched, llm_caller=rerank_llm, limit=limit) if use_cache: kwargs = {"cache_dir": cache_dir} if cache_dir else {} _search_cache.cache_put(query, candidate_ids, ranked, **kwargs) return ranked def format_terminal(results: List[Dict]) -> str: """Human-readable terminal output.""" if not results: return "No matches." lines = [] for i, r in enumerate(results, 1): rel = f"(rel: {r['relevance']:.2f}) " if r.get("relevance") is not None else "" lines.append(f"[{i}] {rel}{r['title']}") props = r.get("properties", {}) if props: prop_strs = [] for k, v in props.items(): if isinstance(v, list): v = ", ".join(str(x) for x in v) prop_strs.append(f"{k}: {v}") lines.append(f" {' · '.join(prop_strs)}") if r.get("snippet"): lines.append(f" Why: {r['snippet']}") if r.get("url"): lines.append(f" {r['url']}") lines.append("") return "\n".join(lines).rstrip() + "\n" def main(): parser = argparse.ArgumentParser( prog="notion-search", description="Semantic search across Notion workspace via LLM expand + rerank.", ) parser.add_argument("query", help="Search query (natural language)") parser.add_argument("--databases", "-d", default=None, help="Comma-separated database/data_source IDs (default: workspace-wide)") parser.add_argument("--filter", "-f", default=None, help="JSON property filter (per-database mode only)") parser.add_argument("--limit", "-l", type=int, default=10, help="Max results after rerank (default: 10)") parser.add_argument("--no-rerank", action="store_true", help="Skip Claude rerank stage") parser.add_argument("--no-expand", action="store_true", help="Skip query-variant generation") parser.add_argument("--no-cache", action="store_true", help="Bypass result cache") parser.add_argument("--json", action="store_true", help="Output JSON array instead of terminal table") args = parser.parse_args() # Parse databases and filter databases = args.databases.split(",") if args.databases else None prop_filter = json.loads(args.filter) if args.filter else None # Build notion client api_key = os.getenv("NOTION_API_KEY") or os.getenv("NOTION_TOKEN") if not api_key: print("Error: NOTION_API_KEY (or NOTION_TOKEN) not set", file=sys.stderr) sys.exit(1) notion = compat.make_client(api_key) results = run_search( notion, args.query, databases=databases, prop_filter=prop_filter, limit=args.limit, no_rerank=args.no_rerank, no_expand=args.no_expand, use_cache=not args.no_cache, ) if args.json: print(json.dumps(results, ensure_ascii=False, indent=2)) else: print(format_terminal(results)) if __name__ == "__main__": main() ``` - [ ] **Step 4: Run tests to verify they pass** ```bash python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 29 tests passed`. - [ ] **Step 5: Verify CLI help works** ```bash python3 notion_search.py --help 2>&1 | head -20 ``` Expected: argparse help text listing all flags. - [ ] **Step 6: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/code/scripts/notion_search.py \ custom-skills/31-notion-organizer/code/scripts/test_notion_search.py git commit -m "$(cat <<'EOF' feat(notion-search): add CLI entrypoint with argparse + output formatting Wires together the four stages (expand → search → enrich → rerank) into run_search(). CLI flags: --databases, --filter, --limit, --no-rerank, --no-expand, --no-cache, --json. Terminal output renders as a numbered table with title, relevance, properties, snippet, URL. Cache lookup happens BEFORE rerank, with cache_put after success. NOTION_API_KEY (or NOTION_TOKEN) env var required. 4 end-to-end pipeline tests (mocked Notion + LLM): JSON-serializable output, --no-rerank skip, --no-expand skip, cache hit on repeat. Total: 29 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Task 8: Slash command + docs **Files:** - Create: `custom-skills/31-notion-organizer/commands/notion-search.md` - Modify: `custom-skills/31-notion-organizer/code/CLAUDE.md` - Modify: `custom-skills/31-notion-organizer/code/scripts/requirements.txt` Wire up the slash command, document the new tool in CLAUDE.md, list `anthropic` as an optional dependency. - [ ] **Step 1: Create the slash command file** Create directory if it doesn't exist, then write the file: ```bash mkdir -p /Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/commands ``` Create `custom-skills/31-notion-organizer/commands/notion-search.md`: ```markdown --- description: Search Notion workspace semantically (LLM-expanded + reranked). argument-hint: " [--databases ID,...] [--filter JSON] [--limit N] [--no-rerank] [--no-expand] [--no-cache] [--json]" --- Search the user's Notion workspace using semantic search powered by Claude Haiku query expansion and result reranking. Closes the gap left by Notion's keyword-only native search — handles synonyms, related concepts, and Korean ↔ English cross-language queries. ## Usage Run the search script with the user's arguments: ```bash cd ~/Project/our-claude-skills/custom-skills/31-notion-organizer/code/scripts python3 notion_search.py "$ARGUMENTS" ``` ## Common patterns - **Default browse mode** (terminal table): `notion-search "AI agents in 2026"` - **JSON for piping** (e.g. into the future notion-to-notebooklm push skill): `notion-search "AI agents" --json | jq '.[].id'` - **Constrain to specific databases**: `notion-search "MCP" --databases f8f19ede-32bd-43ac-9f60-0651f6f40afe` - **Property filter** (per-database mode): `notion-search "MCP" --databases ID --filter '{"Status": "Done"}'` - **Fast mode (no LLM)**: `notion-search "exact phrase" --no-rerank --no-expand` ## Requirements - `NOTION_API_KEY` or `NOTION_TOKEN` env var - One of: - `anthropic` SDK installed + `ANTHROPIC_API_KEY` env var, or - Claude Code CLI (`claude` on PATH) The skill auto-detects which is available; SDK is preferred when both are present. ## Output schema (JSON mode) ```json [ { "id": "abc-def-...", "url": "https://notion.so/...", "title": "Page title", "relevance": 0.94, // null when --no-rerank "snippet": "Why it matched", // null when --no-rerank "excerpt": "First paragraph text...", "properties": {"Status": "Done", "Topic": ["AI", "MCP"]} } ] ``` This schema is the contract for downstream tools (e.g., the future `notion-to-notebooklm` push skill). ``` - [ ] **Step 2: Add Notion search section to CLAUDE.md** Read the current `custom-skills/31-notion-organizer/code/CLAUDE.md` and find the "Quick Start" section. Insert the following section AFTER "Quick Start" and BEFORE "Scripts": ```markdown ## Semantic Search ```bash # Default browse mode (terminal table) python scripts/notion_search.py "AI agents in 2026" # JSON output for piping python scripts/notion_search.py "AI agents" --json | jq '.[].id' # Constrain to specific databases python scripts/notion_search.py "MCP" --databases f8f19ede-32bd-43ac-9f60-0651f6f40afe # Property filter python scripts/notion_search.py "MCP" --databases ID \ --filter '{"Status": "Done", "Topic": "AI"}' # Fast mode (skip LLM stages) python scripts/notion_search.py "exact term" --no-rerank --no-expand ``` The search runs four stages: 1. **Expand** — Claude Haiku generates up to 5 query variants (synonyms + cross-language KR↔EN) 2. **Search** — Notion API searched per variant; results unioned + deduped at 30 candidates 3. **Enrich** — title, properties, and 200-char excerpt fetched per candidate 4. **Rerank** — Claude Haiku scores candidates against the *original* query; top N returned Results are cached for 24h (SHA256 of query + candidate IDs). Bypass with `--no-cache`. ### Requirements | Env var | Purpose | |---------|---------| | `NOTION_API_KEY` (or legacy `NOTION_TOKEN`) | Notion integration token | | `ANTHROPIC_API_KEY` (optional) | Use Claude SDK directly. If missing, the skill falls back to `claude -p` CLI. | ``` - [ ] **Step 3: Add `anthropic` to requirements.txt** Read the current `custom-skills/31-notion-organizer/code/scripts/requirements.txt` and append: ``` # Optional: required only for direct Anthropic SDK use. # If missing, the search skill falls back to `claude -p` CLI. anthropic>=0.40.0 ``` - [ ] **Step 4: Run the full test suite as a final sanity check** ```bash cd /Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/code/scripts python3 test_notion_search.py 2>&1 | tail -5 ``` Expected: `✅ All 29 tests passed`. - [ ] **Step 5: Verify the slash command file is well-formed** ```bash python3 -c " content = open('/Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/commands/notion-search.md').read() assert 'description:' in content, 'frontmatter description present' assert 'python3 notion_search.py' in content, 'script invocation present' assert 'NOTION_API_KEY' in content, 'env requirement documented' print('Slash command file looks valid') " ``` Expected: `Slash command file looks valid`. - [ ] **Step 6: Commit** ```bash cd /Users/ourdigital/Project/our-claude-skills git add custom-skills/31-notion-organizer/commands/notion-search.md \ custom-skills/31-notion-organizer/code/CLAUDE.md \ custom-skills/31-notion-organizer/code/scripts/requirements.txt git commit -m "$(cat <<'EOF' docs(notion-search): add /notion-search slash command + CLAUDE.md section Slash command at custom-skills/31-notion-organizer/commands/notion-search.md documents the CLI surface and JSON output schema. CLAUDE.md gains a Semantic Search section explaining the 4-stage pipeline and env var requirements. requirements.txt notes the optional anthropic SDK dependency (the skill falls back to the claude CLI if missing). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` --- ## Final verification After all eight tasks: - [ ] **Run the full test suite**: 29 tests passing - [ ] **Sanity-check imports**: ```bash python3 -c " import sys sys.path.insert(0, '/Users/ourdigital/Project/our-claude-skills/custom-skills/31-notion-organizer/code/scripts') from notion_search import expand_query, search_candidates, enrich_candidates, rerank, run_search, format_terminal, main print('All public functions importable') " ``` - [ ] **Confirm git log shows 8 new commits** since `08e3dd7` (the spec commit), one per task --- ## Out-of-scope follow-ups - **Phase 3b-ii (`notion-to-notebooklm` push skill)**: consumes the JSON schema produced by `--json`. Separate brainstorm + plan when this ships. - **Block-level result granularity**: surface matching block snippets within pages instead of (or in addition to) page-level results. Defer. - **Cross-workspace search**: requires per-workspace integrations and credential management. Future. - **Embedding-based search**: vector store + sync pipeline. Belongs in a separate tool, not a skill. - **Live API smoke test against the AI database**: not in tests (mocked only). The user can run the real CLI against their workspace once credentials are in `.env`.