Files
chemenu/tools/chemenu/search/service.py
T
torben bb097f614b
CI / verify (push) Failing after 40s
Release / release (push) Successful in 36s
search: Pfad und Titel vollstaendig in der Trefferzeile, Trunkierung wird benannt (schliesst #100)
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/wiki-query/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/api.py
- tools/chemenu/commands/search.py
- tools/chemenu/mcp/server.py
- tools/chemenu/search/service.py
- tools/chemenu/search/types.py
- tools/chemenu/tests/test_api.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_search.py
2026-09-15 21:26:39 +02:00

124 lines
4.8 KiB
Python

"""The search core, with no CLI attached.
`run_search()` and the corpus loader used to live in `commands/search.py`,
which imports `typer` at module level and `rich` through `_util`. Any
in-process caller therefore dragged the whole CLI head in behind it - which
made the "two third-party packages" the read core actually needs (`yaml`,
`jsonschema`) an accounting fiction rather than a fact about the import graph.
Nothing here imports `typer`, `rich`, or anything under `chemenu.commands`.
That is the boundary, and it is worth keeping: `commands/search.py` is now the
adapter that turns these values into terminal output and these exceptions into
exit codes, and the MCP server (Gitea #19) is a second adapter over the same
functions rather than a second implementation of them.
Errors are raised, never printed: `PredicateError` for a bad `--field`,
`RipgrepMissing`/`RipgrepFailed` for the backend. All of them are
`ChemenuError` - see `chemenu/errors.py`.
"""
from __future__ import annotations
from pathlib import Path
from chemenu import config
from chemenu.frontmatter_io import read_page_with_error
from chemenu.kb_scan import iter_kb_pages
from chemenu.page import Page
from chemenu.search import filters
from chemenu.search.base import page_key
from chemenu.search.fuse import reciprocal_rank_fusion
from chemenu.search.ripgrep import build_hit
from chemenu.search.types import SearchHit, SearchQuery, SearchResult
def load_pages_by_path(kb_dir: Path | None = None, root: Path | None = None) -> dict[str, Page]:
"""Every page under `kb/`, keyed by repo-relative path.
Path-keyed rather than title-keyed on purpose: `load_kb_pages()` drops one
of two pages sharing a stem, and search should still find both - a
duplicate title is a lint finding, not a reason to hide a page.
"""
kb_dir = kb_dir or config.KB_DIR
root = root or config.ROOT
pages: dict[str, Page] = {}
for path in iter_kb_pages(kb_dir):
frontmatter, body, error = read_page_with_error(path)
pages[page_key(path, root)] = Page(
path=path, frontmatter=frontmatter, body=body, frontmatter_error=error
)
return pages
def unreadable_pages(pages: dict[str, Page]) -> list[dict[str, str]]:
"""The pages whose frontmatter could not be used, as `{path, reason}`.
Reported rather than swallowed. Such a page has no `kind` and no readable
frontmatter at all, so it silently drops out of every positive `--field`
predicate - including the sweeps that exist to find pages in exactly that
state (`!sources`, `provenance=general`). Saying nothing makes it look
like a page that did not match; an empty block is excluded, because a
page can legitimately carry one.
"""
return [
{"path": key, "reason": page.frontmatter_error}
for key, page in sorted(pages.items())
if page.frontmatter_error and page.frontmatter_error != "empty frontmatter block"
]
def _sort_key(hit: SearchHit, field: str):
value = hit.as_dict().get(field)
if value is None:
# Missing values sort last in either direction rather than crashing on
# a None comparison.
return (1, "")
if isinstance(value, (int, float)):
return (0, value)
return (0, str(value).lower())
def sort_hits(hits: list[SearchHit], sort: str | None) -> list[SearchHit]:
"""Sort by a hit field. A leading `-` reverses, e.g. `--sort -modified`."""
if not sort:
return hits
descending = sort.startswith("-")
field = sort.lstrip("-")
ordered = sorted(hits, key=lambda h: _sort_key(h, field), reverse=descending)
return ordered
def run_search(
query: SearchQuery,
pages: dict[str, Page],
backends: list,
kb_dir: Path | None = None,
) -> SearchResult:
"""Answer a query. Pure: no I/O beyond whatever a backend does.
Returns the truncated hits *and* the number there were before the limit,
because the caller cannot recover the second from the first - see
`SearchResult`.
"""
filters.validate_fields(query.predicates, pages)
if query.text:
rankings = [backend.search(query, pages) for backend in backends]
hits = rankings[0] if len(rankings) == 1 else reciprocal_rank_fusion(rankings)
allowed = filters.apply_predicates(pages, query.predicates, kb_dir)
hits = [hit for hit in hits if hit.path in allowed]
else:
selected = filters.apply_predicates(pages, query.predicates, kb_dir)
hits = [
build_hit(page, key, [], query, backend="frontmatter", kb_dir=kb_dir)
for key, page in selected.items()
]
hits.sort(key=lambda h: h.title.lower())
hits = sort_hits(hits, query.sort)
total = len(hits)
return SearchResult(
hits=hits[: query.limit] if query.limit else hits,
total=total,
limit=query.limit,
)