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: 30 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 08:07:00 +09:00
parent 20029ecc9c
commit 72d4b36943
2 changed files with 278 additions and 0 deletions

View File

@@ -457,6 +457,143 @@ def test_rerank_falls_back_on_llm_exception():
_assert(ranked[0]["relevance"] is None, "no score in fallback")
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)")
def run_all():
tests = [
test_call_claude_dispatches_to_sdk_when_available,
@@ -485,6 +622,10 @@ def run_all():
test_rerank_respects_limit,
test_rerank_falls_back_on_parse_error,
test_rerank_falls_back_on_llm_exception,
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,
]
for t in tests:
print(f"\n{t.__name__}")