Source code for pydocs_mcp.application.docs_search
"""DocsSearch — thin wrapper around chunk_pipeline.run (spec §5.1)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TypeVar, cast
from pydocs_mcp.application.branch_search import ChunkBranchHydrator, NullChunkBranchHydrator
from pydocs_mcp.models import Chunk, ChunkList, PipelineResultItem, SearchQuery, SearchResponse
from pydocs_mcp.retrieval.pipeline import CodeRetrieverPipeline
from pydocs_mcp.retrieval.steps import rows_dropped_by_limit
_Rows = TypeVar("_Rows", bound=PipelineResultItem | None)
[docs]
@dataclass(frozen=True, slots=True)
class DocsSearch:
"""Runs the chunk retrieval pipeline and wraps its state as a SearchResponse.
Deliberately thin: all ranking/filtering logic lives in the pipeline
stages. This class only threads query → pipeline → response.
Reads ``state.result`` first (composite output from
``token_budget_formatter`` — what ``chunk_search.yaml`` produces) and
falls back to ``state.candidates`` (ranked top-K from
``chunk_search_ranked.yaml``-style presets that omit the formatter).
Avoids the silent-empty-results footgun if a deployment overlays the
ranked preset onto the MCP server.
NOTE (spec S21): this class and :class:`ApiSearch` are intentionally
near-duplicate thin wrappers. Keeping them as two separate classes
instead of one parameterized ``PipelineSearchService`` is deliberate
— the duplication is grep-friendly (a developer looking for "where
do chunk searches happen" finds exactly :class:`DocsSearch`) and
avoids over-parameterization. If a third near-identical service
appears in the future, that's the trigger to parameterize into a
shared base class — not now.
"""
chunk_pipeline: CodeRetrieverPipeline
# Spec §6.4 (#312): a pinned query's rows take the selected branch's spans
# — whichever step produced them. The Null default serves the services
# composed without a bundle (tests, harnesses); pins never reach them.
branch_hydrator: ChunkBranchHydrator = field(default_factory=NullChunkBranchHydrator)
async def search(self, query: SearchQuery) -> SearchResponse:
state = await self.chunk_pipeline.run(query)
candidates = await self._on_branch(state.candidates, query.branch)
if state.result is not None:
result = await self._on_branch(state.result, query.branch)
else:
# Ranked preset (no token_budget_formatter) — surface the
# candidates (hydrated once, #312) instead of a silent empty.
result = candidates if candidates is not None else ChunkList(items=())
return SearchResponse(
result=result,
query=state.query,
duration_ms=state.duration_ms,
# Ranked rows ride along so items[] (contract §3.2) come from the
# SAME pipeline run the composite body was rendered from.
candidates=candidates,
# …and so does what the limit step cut, which no surviving row can
# show (#271): the caller marks the listing partial from this.
dropped_by_limit=rows_dropped_by_limit(state),
)
[docs]
async def ranked(self, query: SearchQuery) -> ChunkList:
"""Return the RANKED candidate chunks (pre composite collapse).
Multi-repo union needs item-level candidates — each chunk's per-item
``relevance`` score and ``package`` / ``qualified_name`` metadata — to
merge and dedup across databases, which the single composite ``search``
output cannot provide. Reads ``state.candidates``; falls back to
``state.result`` for a ranked preset that skips the formatter.
"""
state = await self.chunk_pipeline.run(query)
# A chunk pipeline's candidates / result are always ChunkList; the state
# field is a union across pipeline kinds, so narrow explicitly.
candidates = state.candidates if state.candidates is not None else state.result
ranked = cast("ChunkList", candidates) if candidates is not None else ChunkList(items=())
return await self._on_branch(ranked, query.branch)
async def _on_branch(self, rows: _Rows, branch: str) -> _Rows:
"""``rows`` as ``branch`` holds them (spec §6.4, #312); an unpinned
query's rows come back as the very same object."""
if not branch or not isinstance(rows, ChunkList):
return rows
items: tuple[Chunk, ...] = await self.branch_hydrator.hydrate_on_branch(rows.items, branch)
return cast("_Rows", ChunkList(items=items))