diff --git a/README.md b/README.md index d47edc5..13b033c 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,16 @@ The server currently exposes six MCP tools: | `detect_python_version` | Detect the user's local Python version and report whether that version has been indexed. | | `compare_versions` | Diff a Python stdlib symbol between two indexed versions. Returns `change=added|removed|changed|unchanged` with optional `new_in`, `changed_in`, `deprecated_in`, `signature_delta` (advisory heuristic), `see_also_added/removed`, `section_diff`, and `note` deltas. Token-frugal — emits only changed fields, not full content. | +For `search_docs` with `kind="auto"` and an explicit indexed `version`, the +original lookup and full-text search run first. If both return no hits, the +server looks for dotted Python identifiers in the prompt. Exactly one distinct +identifier registered in that version enables a symbol-lookup fallback; repeated +mentions count once and unknown identifiers do not count. Multiple known +identifiers leave the result empty. A result note identifies fallback matches, +which are symbol documentation, not an answer to the complete prompt. Explicit +kinds, searches without a version, nonempty results, and result budgets are +unchanged. + ## Why not Context7 or generic docs retrieval? Use this server when you want precise local Python docs retrieval rather than diff --git a/src/mcp_server_python_docs/services/search.py b/src/mcp_server_python_docs/services/search.py index d58e2e5..0a35297 100644 --- a/src/mcp_server_python_docs/services/search.py +++ b/src/mcp_server_python_docs/services/search.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import re import sqlite3 from mcp_server_python_docs.models import SearchDocsResult @@ -23,6 +24,8 @@ from mcp_server_python_docs.services.observability import log_tool_call from mcp_server_python_docs.services.version_resolution import resolve_version_permissive +_DOTTED_IDENTIFIER = re.compile(r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+\b") + class SearchService: """Search service dispatching queries through classifier, synonym expansion, @@ -108,4 +111,24 @@ def search( hits = search_symbols(self._db, match_expr, resolved_version, max_results) self._last_resolution = "fts" + if not hits and kind == "auto" and resolved_version is not None: + # Prompt-only candidates; ambiguity is decided in the requested inventory. + known = { + name + for name in set(_DOTTED_IDENTIFIER.findall(query)) + if self._db.execute( + "SELECT 1 FROM symbols s JOIN doc_sets d ON d.id = s.doc_set_id " + "WHERE s.qualified_name = ? AND d.version = ? LIMIT 1", + (name, resolved_version), + ).fetchone() is not None + } + if len(known) == 1: + name = known.pop() + hits = lookup_symbols_exact(self._db, name, resolved_version, max_results) + if hits: + self._last_resolution = "exact" + return SearchDocsResult( + hits=hits, + note=f"No full-query results; showing symbol matches for {name!r}.", + ) return SearchDocsResult(hits=hits) diff --git a/tests/test_search_symbol_fallback.py b/tests/test_search_symbol_fallback.py new file mode 100644 index 0000000..8509b0a --- /dev/null +++ b/tests/test_search_symbol_fallback.py @@ -0,0 +1,116 @@ +"""Empty-query fallback uses only one known prompt identifier in a requested version.""" +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest + +from mcp_server_python_docs.retrieval.ranker import lookup_symbols_exact +from mcp_server_python_docs.services.search import SearchService + + +@pytest.mark.parametrize("prompt", [ + "zzmissing Explain `json.dumps`?", + "zzmissing Explain (json.dumps(obj)).", + "zzmissing json.dumps, json.dumps() and json.dumps!", + "zzmissing json.dumps alongside unknown.missing", + "zzmissing Explain [os.path.join()] for Python 3.13?", +]) +@pytest.mark.parametrize("max_results", [1, 5]) +def test_unique_known_identifier(stability_db, prompt, max_results): + svc = SearchService(stability_db, {}) + name = "os.path.join" if "os.path.join" in prompt else "json.dumps" + expected = lookup_symbols_exact(stability_db, name, "3.13", max_results) + assert expected + result = svc.search(prompt, version="3.13", max_results=max_results) + assert result.hits == expected + assert len(result.hits) <= max_results + assert all(hit.version == "3.13" for hit in result.hits) + assert result.note == f"No full-query results; showing symbol matches for {name!r}." + assert svc._last_resolution == "exact" + + +@pytest.mark.parametrize("prompt", [ + "zzmissing json.dumps and json.loads", + "zzmissing json.loads and json.dumps", + "zzmissing unknown.missing", + "zzmissing json.dump", # A partial inventory name must not enable LIKE fallback. + "zzmissing json.dumps.missing", + "zzmissing Python 3.13 or 3.12", + "3.13", + "", + " ", +]) +def test_ambiguous_unknown_numeric_or_empty_stays_empty(search_service, prompt): + result = search_service.search(prompt, version="3.13") + assert result.hits == [] + assert result.note is None + assert search_service._last_resolution == "fts" + + +@pytest.mark.parametrize("kind", ["symbol", "section", "example", "page"]) +def test_explicit_kinds_do_not_use_fallback(search_service, kind): + result = search_service.search("zzmissing json.dumps", version="3.13", kind=kind) + assert result.hits == [] + assert result.note is None + + +def test_no_version_does_not_use_fallback(search_service): + result = search_service.search("zzmissing json.dumps") + assert result.hits == [] + assert result.note is None + # Existing cross-version direct lookup is still available. + assert search_service.search("json.dumps").hits + + +def test_known_identifiers_are_counted_in_requested_version(stability_db): + stability_db.execute( + "INSERT INTO doc_sets (source, version, language, label, base_url) " + "VALUES ('python-docs', '3.12', 'en', 'Python 3.12', 'https://docs.python.org/3.12/')" + ) + stability_db.execute( + "INSERT INTO symbols (doc_set_id, qualified_name, normalized_name, " + "symbol_type, uri, anchor, module) " + "SELECT d.id, s.qualified_name, s.normalized_name, s.symbol_type, " + "s.uri, s.anchor, s.module FROM symbols s CROSS JOIN doc_sets d " + "WHERE s.qualified_name = 'json.dumps' AND d.version = '3.12'" + ) + stability_db.commit() + svc = SearchService(stability_db, {}) + prompt = "zzmissing json.dumps and json.loads" + assert svc.search(prompt, version="3.13").hits == [] + hits = svc.search(prompt, version="3.12").hits + assert hits and all(hit.version == "3.12" for hit in hits) + assert hits[0].title == "json.dumps" + assert svc.search("zzmissing asyncio.TaskGroup", version="3.12").hits == [] + + +def test_original_lookup_and_both_fts_paths_run_before_fallback(stability_db): + from mcp_server_python_docs.services import search + + trace = Mock() + with ( + patch.object(search, "lookup_symbols_exact", wraps=search.lookup_symbols_exact) as exact, + patch.object(search, "search_sections", wraps=search.search_sections) as sections, + patch.object(search, "search_symbols", wraps=search.search_symbols) as symbols, + ): + trace.attach_mock(exact, "exact") + trace.attach_mock(sections, "sections") + trace.attach_mock(symbols, "symbols") + result = SearchService(stability_db, {}).search("zzmissing json.dumps", "3.13") + assert result.hits + assert [call[0] for call in trace.mock_calls] == ["exact", "sections", "symbols", "exact"] + assert exact.call_args_list[0].args[1] == "zzmissing json.dumps" + assert exact.call_args_list[1].args[1] == "json.dumps" + + +@pytest.mark.parametrize("query,kind", [ + ("json.dumps", "symbol"), + ("json.dumps Serialize", "section"), +]) +def test_nonempty_original_results_are_unchanged(search_service, query, kind): + expected = search_service.search(query, version="3.13", kind=kind) + assert expected.hits + actual = search_service.search(query, version="3.13") + assert actual == expected + assert actual.note is None