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:
@@ -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__}")
|
||||
|
||||
Reference in New Issue
Block a user