# 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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: "<query> [--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) <noreply@anthropic.com>
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`.
