576df2cddd
Files changed: - .gitea/workflows/ci.yml - CHANGES.md - README.md - VERSION - instructions/mcp-read-server.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/api.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/search.py - tools/chemenu/commands/types_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/errors.py - tools/chemenu/frontmatter_io.py - tools/chemenu/lint_core.py - tools/chemenu/mcp/__init__.py - tools/chemenu/mcp/__main__.py - tools/chemenu/mcp/server.py - tools/chemenu/page.py - tools/chemenu/search/filters.py - tools/chemenu/search/registry.py - tools/chemenu/search/ripgrep.py - tools/chemenu/search/service.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_api.py - tools/chemenu/tests/test_corpus_cache.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_frontmatter_io.py - tools/chemenu/tests/test_instructions_cmd.py - tools/chemenu/tests/test_mcp_server.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_search.py - tools/chemenu/type_resolver.py - tools/chemenu/types_core.py - tools/requirements-mcp.txt
181 lines
6.1 KiB
Python
181 lines
6.1 KiB
Python
"""`wikitool search` - find pages without reading `kb/index.md`.
|
|
|
|
This command exists to make retrieval cheap. Before it, the documented way to
|
|
find a page was to read the whole generated index; at a few hundred pages that
|
|
is tens of thousands of tokens spent to learn three filenames. A search returns
|
|
the same pointers for a fraction of it.
|
|
|
|
Two halves, deliberately kept separate:
|
|
|
|
- Text search is answered by a pluggable backend (`rg` today) - see
|
|
`chemenu/search/`.
|
|
- Frontmatter predicates (`--field`) are evaluated here, in-process, on the
|
|
structured YAML rather than on its rendering. With no text at all this is a
|
|
pure structured query, which is how "systems below 0.6 confidence, oldest
|
|
first" is asked without a second command.
|
|
|
|
Scope is `kb/` only. `instructions/` is discovered through
|
|
`wikitool instructions list`, because a procedure is found by what it is *for*
|
|
(its description), not by keywords in its body.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import typer
|
|
|
|
from chemenu.commands._util import fail, today_iso
|
|
from chemenu.search import filters
|
|
from chemenu.search.filters import PredicateError
|
|
from chemenu.search.registry import UnknownBackend, resolve
|
|
from chemenu.search.ripgrep import RipgrepFailed, RipgrepMissing
|
|
from chemenu.search.service import (
|
|
load_pages_by_path,
|
|
run_search,
|
|
sort_hits,
|
|
unreadable_pages,
|
|
)
|
|
from chemenu.search.types import Predicate, SearchHit, SearchQuery
|
|
|
|
# Re-exported so `from chemenu.commands.search import run_search` keeps
|
|
# resolving. The core lives in `chemenu/search/service.py`, which imports no
|
|
# CLI machinery; this module is the terminal adapter over it.
|
|
__all__ = [
|
|
"load_pages_by_path",
|
|
"run_search",
|
|
"sort_hits",
|
|
"unreadable_pages",
|
|
"render_table",
|
|
"search_command",
|
|
]
|
|
|
|
TITLE_WIDTH = 34
|
|
SUMMARY_WIDTH = 84
|
|
|
|
|
|
def _truncate(text: str, width: int) -> str:
|
|
text = " ".join(text.split())
|
|
return text if len(text) <= width else text[: width - 1] + "\u2026"
|
|
|
|
|
|
def render_table(hits: list[SearchHit], show_matches: bool) -> str:
|
|
if not hits:
|
|
return "No matches."
|
|
lines = []
|
|
for hit in hits:
|
|
kind = hit.kind or "?"
|
|
if hit.subtype:
|
|
kind = f"{kind}/{hit.subtype}"
|
|
lines.append(
|
|
f"{hit.score:6.1f} {_truncate(hit.title, TITLE_WIDTH):<{TITLE_WIDTH}} "
|
|
f"{kind:<18} {_truncate(hit.summary, SUMMARY_WIDTH)}"
|
|
)
|
|
if show_matches:
|
|
for match in hit.matches:
|
|
lines.append(f" {hit.path}:{match.line}: {_truncate(match.text, 100)}")
|
|
lines.append("")
|
|
lines.append(f"{len(hits)} result(s).")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def search_command(
|
|
text: str = typer.Argument(
|
|
None,
|
|
help="Text to search for. Omit it to run a pure frontmatter query.",
|
|
),
|
|
field: list[str] = typer.Option(
|
|
None,
|
|
"--field",
|
|
"-f",
|
|
help="Frontmatter predicate, repeatable (AND). Forms: field=value, "
|
|
"field~substring, 'field>=value', 'field:*' (present), '!field' (absent).",
|
|
),
|
|
kind: str = typer.Option(None, "--kind", help="Shorthand for --field kind=<value>."),
|
|
subtype: str = typer.Option(None, "--subtype", help="Shorthand for --field subtype=<value>."),
|
|
collection: str = typer.Option(
|
|
None, "--collection", help="Shorthand for --field collection=<value>."
|
|
),
|
|
tag: str = typer.Option(None, "--tag", help="Shorthand for --field tags=<value>."),
|
|
regex: bool = typer.Option(
|
|
False, "--regex", help="Treat the query as a regex. Off by default: terms are literal."
|
|
),
|
|
limit: int = typer.Option(20, "--limit", help="Maximum number of results. 0 for no limit."),
|
|
sort: str = typer.Option(
|
|
None, "--sort", help="Sort by a result field; prefix with '-' to reverse, e.g. -confidence."
|
|
),
|
|
backend: str = typer.Option(
|
|
None,
|
|
"--backend",
|
|
help="Search backend(s), comma-separated. Default 'rg' (or $WIKITOOL_SEARCH_BACKEND).",
|
|
),
|
|
show_matches: bool = typer.Option(
|
|
False, "--matches", help="Print the matching lines under each result."
|
|
),
|
|
json_out: bool = typer.Option(False, "--json", help="Print the results as JSON."),
|
|
):
|
|
"""Search kb/ by text, by frontmatter, or by both."""
|
|
raw_predicates = list(field or [])
|
|
for value, name in ((kind, "kind"), (subtype, "subtype"), (collection, "collection")):
|
|
if value:
|
|
raw_predicates.append(f"{name}={value}")
|
|
if tag:
|
|
raw_predicates.append(f"tags={tag}")
|
|
|
|
if not text and not raw_predicates:
|
|
fail("Nothing to search for: give a query, or at least one --field predicate.")
|
|
|
|
try:
|
|
predicates: tuple[Predicate, ...] = tuple(
|
|
filters.parse_predicate(raw) for raw in raw_predicates
|
|
)
|
|
except PredicateError as exc:
|
|
fail(str(exc))
|
|
|
|
try:
|
|
backends = resolve(backend)
|
|
except UnknownBackend as exc:
|
|
fail(str(exc))
|
|
|
|
query = SearchQuery(
|
|
text=text,
|
|
predicates=predicates,
|
|
regex=regex,
|
|
limit=limit,
|
|
sort=sort,
|
|
)
|
|
|
|
pages = load_pages_by_path()
|
|
try:
|
|
hits = run_search(query, pages, backends)
|
|
except PredicateError as exc:
|
|
fail(str(exc))
|
|
except RipgrepMissing as exc:
|
|
fail(str(exc))
|
|
except RipgrepFailed as exc:
|
|
fail(str(exc))
|
|
|
|
unreadable = unreadable_pages(pages)
|
|
|
|
if json_out:
|
|
payload = {
|
|
"generated": today_iso(),
|
|
"query": text,
|
|
"predicates": [p.render() for p in predicates],
|
|
"backend": ",".join(b.name for b in backends),
|
|
"count": len(hits),
|
|
"results": [hit.as_dict() for hit in hits],
|
|
# Always present, usually empty. A caller that has to look for the
|
|
# key to learn whether it should worry will not look.
|
|
"unreadable": unreadable,
|
|
}
|
|
typer.echo(json.dumps(payload, indent=2))
|
|
return
|
|
|
|
typer.echo(render_table(hits, show_matches))
|
|
for entry in unreadable:
|
|
typer.echo(
|
|
f"WARN unreadable frontmatter: {entry['path']} ({entry['reason']}) - "
|
|
"this page cannot match any --field predicate",
|
|
err=True,
|
|
)
|