diff --git a/AGENTS.md b/AGENTS.md index 2b5dd3b..1fbc433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,6 +251,13 @@ tools/wikitool search --field entity_type=system --field '!sources' `search` is read-only and exempt from the iteration budget. +**It is also exhaustive, so do not grep `kb/` yourself.** `search` *is* a `rg` run over `kb/`, +enriched with each hit's frontmatter and ranked; a grep of your own can therefore surface no +page it missed, only the generated files it deliberately excludes - `kb/index.md`, `kb/log.md`, +`kb/provenance.md`, every `INDEX.md` - which invariant 1 forbids acting on anyway. Each hit +carries the page's full path and full title, so it can be opened and passed to the commands +that take a title. A result cut short by `--limit` says so and names the total. + ## Gates Four limits are enforced in code rather than by instruction, because a prompt-level limit is diff --git a/CHANGES.md b/CHANGES.md index d9a9185..f38b15a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 6.0.0-beta.7 - 2026-09-15 - types/type-spec.schema.yaml enforced against real type-spec frontmatter +## 6.0.0-beta.8 - 2026-09-15 - search: Pfad und Titel vollstaendig, Trunkierung sichtbar **Author:** Torben Nehmer @@ -80,6 +80,7 @@ concern - readable here, never shipped as something to parse. - docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest - Control-Plane-Sprache universell: Achse ist das Publikum, kein Instanz-Schalter - types/type-spec.schema.yaml enforced against real type-spec frontmatter +- search: Pfad und Titel vollstaendig, Trunkierung sichtbar **Low impact** - gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform @@ -342,6 +343,60 @@ Verifiziert: `tools/wikitool docs verify`/`instructions verify` gruen, 1263 Test alle Type-Specs dieses Repos validieren gegen ihr eigenes Schema; ein Type-Spec mit einem dem Schema unbekannten Feld wird gemeldet, mit Dateiname und Feldname in der Meldung). +### search: Pfad und Titel vollstaendig, Trunkierung sichtbar + +Gemeldet wurde eine Sitzung, die nach `wikitool search` zusaetzlich `grep -rl` ueber `kb/` +laufen liess. Der Grep war redundant - `search` *ist* ein `rg`-Lauf ueber `kb/` und kann keine +Seite verfehlen, die ein Grep findet -, aber die Ausgabe gab ihr drei Gruende dafuer, und die +sind der eigentliche Befund. + +Die Tabelle nannte **keinen Pfad**, obwohl `wiki-query` verlangt, nur die Seiten zu lesen, auf +die die Suche zeigt. Sie kappte ausserdem den **Titel** auf 34 Zeichen - im gemeldeten +Transkript vier von fuenf Treffern -, und der Titel ist nach Invariante 2 der einzige +Identifier einer Seite und das Argument, das `touch`, `xref add` und `cite add` nehmen. Die +Sitzung hatte also weder etwas zum Oeffnen noch etwas zum Weiterreichen; `grep -rl` lieferte +genau beides. + +Drittens war `N result(s).` die *gekappte* Zahl: `run_search` gab nur die beschnittene Liste +zurueck, also konnte kein Adapter die Gesamtzahl melden, und `20 result(s).` auf einer Anfrage +mit 182 Treffern war von einem vollstaendigen Ergebnis nicht zu unterscheiden. Eine +Vollstaendigkeitsaussage, zu der die Ausgabe nicht berechtigt war - der staerkste denkbare +Anlass, ihr zu misstrauen. + +Die Zeile hat jetzt die Form `score | kind/subtype | titel | pfad | summary`, ohne +Spaltenauffuellung. Titel und Pfad werden nie gekappt; die Summary ist das einzige verlustige +Feld und steht deshalb am Ende, wo ein `|` in Prosa beim Trennen mit `maxsplit=4` folgenlos +bleibt (ein `|` im Titel schliesst die Wikilink-Syntax ohnehin aus). JSON als Default-Ausgabe +wurde erwogen und verworfen: ein Treffer ist flach, JSON kostet dafuer ein Vielfaches an Tokens, +und `search` existiert dafuer, Retrieval billig zu machen - der Fehler war ein fehlendes Feld, +kein Parse-Problem. Wer Struktur braucht, hat `--json`, `api.search` und MCP. + +`run_search` gibt jetzt ein `SearchResult` mit Treffern, Gesamtzahl und Limit zurueck. Die +Tabelle schreibt `50 of 182 result(s) - raise --limit (0 for all) or narrow the query.`, das +JSON traegt `total`/`truncated`/`limit` neben `count`, dessen Bedeutung unveraendert bleibt +(`len(results)`), und `api.search` sowie der MCP-`search`-Tool tragen dieselben Felder. Das +Default-Limit steigt von 20 auf 50 und liegt als eine Konstante `DEFAULT_LIMIT` statt als drei +Literale in drei Adaptern: gekappt wurden bisher vor allem die *strukturellen* Sweeps +(`--field '!sources'`), die alphabetisch und nicht nach Relevanz sortiert sind, wo die Kappung +also eine beliebige Scheibe der Antwort wegwirft statt ihres schwaechsten Endes. Sichtbar zu +sein ist es, was ein endliches Default ueberhaupt erst unbedenklich macht. + +`AGENTS.md` § Routing traegt die Regel an genau einer Stelle - `search` ist erschoepfend, ein +eigener Grep ueber `kb/` fuegt nur die generierten Dateien hinzu, die Invariante 1 ohnehin +verbietet; `tools/CONTRACT.md` traegt daneben nur den Mechanismus. + +Grenzuebertritt-Frage geprueft und verneint: kein Flag entfernt oder umbenannt, keine +Umgebungsvariable, keine maschinengelesene Datei in ihrer Form veraendert, JSON rein additiv. +Die Tabelle liest ein Agent, kein Skript, und ihre Aenderung verlangt keiner Instanz Handarbeit +ab. + +Verifiziert: `docs verify`/`instructions verify` gruen, 1272 Tests gruen (9 neu: Pfad vorhanden; +Titel und Pfad ungekappt bei langem Titel; eine Trefferzeile zerfaellt trotz `|` in der Prosa in +ihre fuenf Felder; ein gekapptes Ergebnis nennt die Gesamtzahl, ein ungekapptes behauptet +nichts; `--limit 0` gilt nie als gekappt; die Gesamtzahl ueberlebt das Limit in `run_search`; +`api.search` meldet dasselbe; alle drei Adapter teilen ein Default-Limit - der MCP-Golden-Test +haelt die neuen Felder zwischen CLI und Server zusammen). + ## 5.1.0 - 2026-09-12 - changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung **Author:** Torben Nehmer diff --git a/VERSION b/VERSION index 09deee8..4808b68 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.0.0-beta.7 +6.0.0-beta.8 diff --git a/instructions/wiki-query/SKILL.md b/instructions/wiki-query/SKILL.md index 1f74fb0..73087c1 100644 --- a/instructions/wiki-query/SKILL.md +++ b/instructions/wiki-query/SKILL.md @@ -34,15 +34,18 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn ```bash tools/wikitool search "backup" --kind entity --subtype system tools/wikitool search --field entity_type=system --field '!sources' --sort -modified - tools/wikitool search --field tags=k8s --limit 30 - tools/wikitool search "Longhorn" --matches # show the matching lines + tools/wikitool search --field tags=k8s --limit 0 # a sweep: every match, not the first 50 + tools/wikitool search "Longhorn" --matches # show the matching lines ``` `search` is read-only and exempt from the iteration budget, so searching again is always - cheaper than reading more. + cheaper than reading more. A result that hit `--limit` says so and names the total, so read + the last line before treating a list as the whole answer - and do not grep `kb/` yourself, + per AGENTS.md § Routing. -3. **Read only the pages the search points at**, then follow their `related:` and `sources:` - entries. Check `kb/sources/` when the question is about what a specific source said. +3. **Read only the pages the search points at** - each hit carries its full path - then follow + their `related:` and `sources:` entries. Check `kb/sources/` when the question is about what + a specific source said. 4. **Answer and cite.** Name the wiki pages the answer came from, and the sources behind them. Hedge to what those sources carry, not to a number - see diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index e976ee5..5c0dc1e 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -124,7 +124,7 @@ tools/wikitool --help | Command | Purpose | |---------|---------| | `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report .md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing | -| `search [""] [--field ...] [--kind/--subtype/--collection/--tag ] [--regex] [--limit N] [--sort [-]] [--backend ] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** | +| `search [""] [--field ...] [--kind/--subtype/--collection/--tag ] [--regex] [--limit N] [--sort [-]] [--backend ] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** | ### Provenance diff --git a/tools/chemenu/api.py b/tools/chemenu/api.py index 6384fb7..d6ccad2 100644 --- a/tools/chemenu/api.py +++ b/tools/chemenu/api.py @@ -38,7 +38,7 @@ from chemenu.lint_core import run_lint from chemenu.search import filters from chemenu.search.registry import resolve from chemenu.search.service import run_search, unreadable_pages -from chemenu.search.types import Predicate, SearchQuery +from chemenu.search.types import DEFAULT_LIMIT, Predicate, SearchQuery from chemenu.types_core import describe_type, list_types # Distinguishes "the caller did not pass a revision" from "the caller passed @@ -120,7 +120,7 @@ class Corpus: text: Optional[str] = None, predicates: Iterable[str] = (), regex: bool = False, - limit: int = 20, + limit: int = DEFAULT_LIMIT, sort: Optional[str] = None, backend: Optional[str] = None, ) -> dict[str, Any]: @@ -142,13 +142,18 @@ class Corpus: with self._rooted(): pages, revision = self._cache.load() - hits = run_search(query, pages, backends, self.kb_dir) + result = run_search(query, pages, backends, self.kb_dir) return self._stamp({ "query": text, "predicates": [p.render() for p in parsed], "backend": ",".join(b.name for b in backends), - "count": len(hits), - "results": [hit.as_dict() for hit in hits], + # Same shape the CLI's `--json` prints: `count` is what came back, + # `total` is how many matched before `limit` cut it. + "count": len(result.hits), + "total": result.total, + "truncated": result.truncated, + "limit": result.limit, + "results": [hit.as_dict() for hit in result.hits], "unreadable": unreadable_pages(pages), }, revision) diff --git a/tools/chemenu/commands/search.py b/tools/chemenu/commands/search.py index 4bda04d..31a3a97 100644 --- a/tools/chemenu/commands/search.py +++ b/tools/chemenu/commands/search.py @@ -35,7 +35,7 @@ from chemenu.search.service import ( sort_hits, unreadable_pages, ) -from chemenu.search.types import Predicate, SearchHit, SearchQuery +from chemenu.search.types import DEFAULT_LIMIT, Predicate, SearchQuery, SearchResult # Re-exported so `from chemenu.commands.search import run_search` keeps # resolving. The core lives in `chemenu/search/service.py`, which imports no @@ -49,32 +49,76 @@ __all__ = [ "search_command", ] -TITLE_WIDTH = 34 SUMMARY_WIDTH = 84 +# One hit per line, ` | `-separated, in the order score, kind, title, path, +# summary. Three properties are load-bearing and should survive any edit here: +# +# 1. **The path is present.** It was not, and the instructions that drive this +# command tell an agent to "read only the pages the search points at" - which +# it could not do, because nothing here pointed anywhere. What a session did +# instead was run `grep -rl` over `kb/` for the filenames, a second search +# that can find no page this one missed (the backend *is* `rg` over `kb/`). +# 2. **Title and path are never truncated.** The title is the wiki's only +# identifier for a page (AGENTS.md invariant 2) and the argument `xref add`, +# `cite add` and `touch` all take; a title clipped to a column width is not +# one. The old fixed 34-char field clipped four of five hits in the report +# that prompted this. Only the summary is lossy, which is why it goes last. +# 3. **The separator is unambiguous.** A `|` cannot occur in a title - the +# wikilink syntax reserves it, so a page carrying one could not be linked at +# all - and a `|` in the summary is harmless, because the summary is the +# final field: split on " | " with maxsplit=4 and prose cannot shift a +# column. +# +# Column padding is gone with the widths: it aligned the table for an eye, and +# the reader here is an agent that pays for the spaces by the token. +SEPARATOR = " | " + 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: +def _count_line(result: SearchResult) -> str: + """The last line: how many hits, and whether that is all of them. + + A bare `N result(s).` reads as the whole answer, so it is only used when it + is one. A capped search says what it capped, which is the number the caller + would otherwise have to run a second, unlimited search to learn. + """ + if not result.truncated: + return f"{len(result.hits)} result(s)." + return ( + f"{len(result.hits)} of {result.total} result(s) - " + f"raise --limit (0 for all) or narrow the query." + ) + + +def render_table(result: SearchResult, show_matches: bool) -> str: + if not result.hits: return "No matches." lines = [] - for hit in hits: + for hit in result.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)}" + SEPARATOR.join( + ( + f"{hit.score:.1f}", + kind, + hit.title, + hit.path, + _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(f" {hit.path}:{match.line}: {_truncate(match.text, 100)}") lines.append("") - lines.append(f"{len(hits)} result(s).") + lines.append(_count_line(result)) return "\n".join(lines) @@ -99,7 +143,11 @@ def search_command( 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."), + limit: int = typer.Option( + DEFAULT_LIMIT, + "--limit", + help="Maximum number of results. 0 for no limit. A capped result says so.", + ), sort: str = typer.Option( None, "--sort", help="Sort by a result field; prefix with '-' to reverse, e.g. -modified." ), @@ -146,7 +194,7 @@ def search_command( pages = load_pages_by_path() try: - hits = run_search(query, pages, backends) + result = run_search(query, pages, backends) except PredicateError as exc: fail(str(exc)) except RipgrepMissing as exc: @@ -162,8 +210,15 @@ def search_command( "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], + # `count` keeps its meaning - how many results are in this payload - + # so a consumer written against the old shape reads the same number + # it always did. `total`/`truncated`/`limit` are what it could not + # ask before. + "count": len(result.hits), + "total": result.total, + "truncated": result.truncated, + "limit": result.limit, + "results": [hit.as_dict() for hit in result.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, @@ -171,7 +226,7 @@ def search_command( typer.echo(json.dumps(payload, indent=2)) return - typer.echo(render_table(hits, show_matches)) + typer.echo(render_table(result, show_matches)) for entry in unreadable: typer.echo( f"WARN unreadable frontmatter: {entry['path']} ({entry['reason']}) - " diff --git a/tools/chemenu/mcp/server.py b/tools/chemenu/mcp/server.py index 887023d..75a12ee 100644 --- a/tools/chemenu/mcp/server.py +++ b/tools/chemenu/mcp/server.py @@ -56,6 +56,7 @@ from mcp.server.mcpserver.exceptions import ToolError from chemenu import config, upload from chemenu.api import Corpus from chemenu.errors import ChemenuError +from chemenu.search.types import DEFAULT_LIMIT from chemenu.telemetry import policy SERVER_NAME = "chemenu" @@ -158,14 +159,16 @@ def build_server( "Find pages in kb/ by text, by frontmatter, or by both. Returns " "title, path, kind and summary per hit, so a result can be judged " "without fetching the page. Prefer this over listing files: the " - "answer is a few hundred tokens instead of a whole index." + "answer is a few hundred tokens instead of a whole index. " + "'count' is how many hits came back and 'total' how many matched; " + "when 'truncated' is true, raise 'limit' (0 for all) to see the rest." ), ) def search( query: str | None = None, predicates: list[str] | None = None, regex: bool = False, - limit: int = 20, + limit: int = DEFAULT_LIMIT, sort: str | None = None, ) -> dict[str, Any]: """Search the wiki. diff --git a/tools/chemenu/search/service.py b/tools/chemenu/search/service.py index 324f10c..86ba758 100644 --- a/tools/chemenu/search/service.py +++ b/tools/chemenu/search/service.py @@ -28,7 +28,7 @@ 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 +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]: @@ -92,8 +92,13 @@ def run_search( pages: dict[str, Page], backends: list, kb_dir: Path | None = None, -) -> list[SearchHit]: - """Answer a query. Pure: no I/O beyond whatever a backend does.""" +) -> 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: @@ -110,4 +115,9 @@ def run_search( hits.sort(key=lambda h: h.title.lower()) hits = sort_hits(hits, query.sort) - return hits[: query.limit] if query.limit else hits + total = len(hits) + return SearchResult( + hits=hits[: query.limit] if query.limit else hits, + total=total, + limit=query.limit, + ) diff --git a/tools/chemenu/search/types.py b/tools/chemenu/search/types.py index 590e28f..33d997d 100644 --- a/tools/chemenu/search/types.py +++ b/tools/chemenu/search/types.py @@ -4,6 +4,18 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Optional +# How many hits a caller gets when it asks for no particular number. Defined +# once, here, because three adapters offer the same knob - the CLI's `--limit`, +# `api.search(limit=...)` and the MCP `search` tool - and three literals is how +# they start disagreeing about what "the default search" returns. +# +# 50 rather than a smaller number because the queries that actually hit the cap +# are the *structured* sweeps (`--field '!sources'`), which are ordered +# alphabetically rather than by relevance: truncating those throws away an +# arbitrary slice of the answer rather than its weakest tail. A capped result +# is only safe at all because it now says so - see `SearchResult.truncated`. +DEFAULT_LIMIT = 50 + @dataclass(frozen=True) class Predicate: @@ -30,7 +42,7 @@ class SearchQuery: text: Optional[str] = None predicates: tuple[Predicate, ...] = () regex: bool = False - limit: int = 20 + limit: int = DEFAULT_LIMIT sort: Optional[str] = None @@ -77,3 +89,34 @@ class SearchHit: "backend": self.backend, "matches": [m.as_dict() for m in self.matches], } + + +@dataclass(frozen=True) +class SearchResult: + """The hits a caller gets back, plus how many there were before the limit. + + `run_search` used to return the truncated list alone, which made the + truncation impossible to report: every adapter counted `len(hits)` and + printed it as the answer, so `20 result(s).` on a query matching 182 pages + was indistinguishable from a query that really matched twenty. That is a + completeness claim none of them were in a position to make, and the only + way to find out was to ask again with `--limit 0` - a second full search to + learn a number the first one already knew. + + So the total travels with the hits. Nothing here decides how to say it; + that belongs to each adapter (`render_table`, the `--json` payload, + `api.search`). + """ + + hits: list[SearchHit] + total: int + limit: int + + @property + def truncated(self) -> bool: + """Whether the limit actually cut something off. + + `limit=0` means "no limit", so it never truncates however large the + corpus is. + """ + return bool(self.limit) and self.total > len(self.hits) diff --git a/tools/chemenu/tests/test_api.py b/tools/chemenu/tests/test_api.py index 3aa0235..7e9e661 100644 --- a/tools/chemenu/tests/test_api.py +++ b/tools/chemenu/tests/test_api.py @@ -47,6 +47,21 @@ def test_a_corpus_can_be_named_and_is_the_one_that_answers(foreign_corpus): assert result["results"][0]["path"] == "kb/entities/Peregrine.md" +def test_a_capped_answer_says_how_much_it_left_out(foreign_corpus): + """`count` keeps meaning "what is in this payload", so a consumer written + against the old shape reads the number it always did; `total` is what it + could not ask before, and without it a truncated answer is indistinguishable + from a complete one.""" + corpus = Corpus(foreign_corpus) + + whole = corpus.search("Peregrine") + assert (whole["count"], whole["total"], whole["truncated"]) == (1, 1, False) + assert whole["count"] == len(whole["results"]) + + capped = corpus.search("Peregrine", limit=0) + assert capped["truncated"] is False, "limit 0 means no limit, so it caps nothing" + + def test_no_path_of_this_checkout_is_read_while_a_foreign_root_is_set(foreign_corpus): """The acceptance criterion, asserted rather than argued. diff --git a/tools/chemenu/tests/test_mcp_server.py b/tools/chemenu/tests/test_mcp_server.py index 25ffdd3..c58c6b4 100644 --- a/tools/chemenu/tests/test_mcp_server.py +++ b/tools/chemenu/tests/test_mcp_server.py @@ -125,6 +125,24 @@ def test_the_four_tools_are_there_and_nothing_that_writes(corpus): assert names.isdisjoint({"new", "touch", "xref", "cite", "publish", "migrate", "rm"}) +def test_all_three_adapters_offer_the_same_default_limit(corpus): + """The CLI's `--limit`, `api.search(limit=...)` and this tool are three + knobs on one search, and three literals is how they start disagreeing about + what "the default search" returns. They read one constant.""" + import inspect + + from chemenu.api import Corpus + from chemenu.commands.search import search_command + from chemenu.search.types import DEFAULT_LIMIT + + server = build_server(corpus, check_trace=False) + tool = next(t for t in asyncio.run(server.list_tools()) if t.name == "search") + + assert tool.input_schema["properties"]["limit"]["default"] == DEFAULT_LIMIT + assert inspect.signature(Corpus.search).parameters["limit"].default == DEFAULT_LIMIT + assert inspect.signature(search_command).parameters["limit"].default.default == DEFAULT_LIMIT + + def test_no_tool_writes_anything_into_the_corpus_or_git(corpus): server = build_server(corpus, check_trace=False) before = _tree(corpus) @@ -176,7 +194,8 @@ def test_the_wire_format_is_the_clis_json_form(corpus): # `generated` is the CLI's date stamp and `commit`/`as_of` are the server's # revision stamp - two answers to "when", neither of them a finding. What # has to match is everything that describes the *corpus*. - shared = ("query", "predicates", "backend", "count", "results", "unreadable") + shared = ("query", "predicates", "backend", "count", "total", "truncated", "limit", + "results", "unreadable") assert {key: served[key] for key in shared} == {key: from_cli[key] for key in shared} diff --git a/tools/chemenu/tests/test_search.py b/tools/chemenu/tests/test_search.py index 031900e..6101600 100644 --- a/tools/chemenu/tests/test_search.py +++ b/tools/chemenu/tests/test_search.py @@ -17,7 +17,14 @@ from chemenu.search.filters import PredicateError, parse_predicate from chemenu.search.fuse import reciprocal_rank_fusion from chemenu.search.registry import UnknownBackend, resolve from chemenu.search.ripgrep import RipgrepBackend, build_argv -from chemenu.search.types import Match, Predicate, SearchHit, SearchQuery +from chemenu.search.types import ( + DEFAULT_LIMIT, + Match, + Predicate, + SearchHit, + SearchQuery, + SearchResult, +) from chemenu.frontmatter_io import write_page @@ -32,8 +39,12 @@ def backend(kb_dir: Path, tmp_path: Path): @pytest.fixture -def search(kb_dir: Path, pages): - """Run a query against the fixture kb rather than the real one.""" +def search_result(kb_dir: Path, pages): + """Run a query against the fixture kb and return the whole `SearchResult`. + + For the tests that care about the limit and the total; most only want the + hits, and use `search` below. + """ def _search(query: SearchQuery, backends=()): return run_search(query, pages, list(backends), kb_dir) @@ -41,6 +52,16 @@ def search(kb_dir: Path, pages): return _search +@pytest.fixture +def search(search_result): + """The hits alone, for tests whose subject is ranking or filtering.""" + + def _search(query: SearchQuery, backends=()): + return search_result(query, backends).hits + + return _search + + def _titles(hits): return [hit.title for hit in hits] @@ -211,6 +232,20 @@ def test_limit_and_sort(search): assert len(search(_q("kind=entity", limit=2))) == 2 +def test_the_total_survives_the_limit_so_no_second_search_is_needed(search_result): + """The caller cannot recover the total from a truncated list, and asking + again with `--limit 0` is a second full search to learn a number the first + one already had.""" + capped = search_result(_q("kind=entity", limit=2)) + + assert len(capped.hits) == 2 + assert capped.total == 3 + assert capped.truncated is True + + whole = search_result(_q("kind=entity")) + assert (whole.total, whole.truncated) == (3, False) + + def test_sort_puts_missing_values_last(): hits = [ SearchHit(title="b", path="b", modified=None), @@ -240,18 +275,79 @@ def test_resolve_defaults_to_rg_and_rejects_unknown(): # --- output ----------------------------------------------------------------- +def _result(hits, total=None, limit=DEFAULT_LIMIT): + return SearchResult(hits=hits, total=total if total is not None else len(hits), limit=limit) + + def test_render_table_is_compact_and_reports_the_count(): hit = SearchHit(title="aurora", path="kb/x.md", kind="entity", subtype="system", summary="Server hosting DocStore", score=8.0, matches=[Match(3, "DocStore")]) - out = render_table([hit], show_matches=False) + out = render_table(_result([hit]), show_matches=False) assert "aurora" in out and "entity/system" in out assert "kb/x.md:3" not in out assert "1 result(s)." in out - assert "kb/x.md:3" in render_table([hit], show_matches=True) + assert "kb/x.md:3" in render_table(_result([hit]), show_matches=True) def test_render_table_says_so_when_nothing_matched(): - assert render_table([], show_matches=False) == "No matches." + assert render_table(_result([]), show_matches=False) == "No matches." + + +def test_every_hit_carries_the_path_so_the_page_can_be_opened(): + """The reason this format changed: the instructions tell an agent to read + the pages the search points at, and the table pointed nowhere. A session + that needed filenames ran `grep -rl` over kb/ for them - a second search + that can find no page this one missed.""" + hit = SearchHit(title="aurora", path="kb/entities/systems/aurora.md", kind="entity") + assert "kb/entities/systems/aurora.md" in render_table(_result([hit]), show_matches=False) + + +def test_title_and_path_are_never_truncated_only_the_summary_is(): + """A clipped title is not an identifier. It is what `xref add`, `cite add` + and `touch` take as an argument, and the old fixed 34-char column cut four + of five hits in the report that prompted this.""" + title = "Source - Pelletofenkondensator und Verkabelung Recherche" + path = f"kb/sources/llm-sessions/{title}.md" + hit = SearchHit(title=title, path=path, kind="source", summary="x" * 400) + line = render_table(_result([hit]), show_matches=False).splitlines()[0] + + assert title in line + assert path in line + assert "…" in line, "the summary is still the one lossy field" + + +def test_a_hit_line_parses_into_its_five_fields_even_with_prose_pipes(): + """The separator has to survive a summary that contains one. It does, + because the summary is last: a `|` there cannot shift a column. A `|` in a + title is impossible - the wikilink syntax reserves it.""" + hit = SearchHit(title="aurora", path="kb/x.md", kind="entity", subtype="system", + summary="Runs `a | b` nightly", score=8.0) + line = render_table(_result([hit]), show_matches=False).splitlines()[0] + score, kind, title, path, summary = line.split(" | ", 4) + + assert (score, kind, title, path) == ("8.0", "entity/system", "aurora", "kb/x.md") + assert summary == "Runs `a | b` nightly" + + +def test_a_capped_result_says_what_it_capped(): + """`20 result(s).` on a query matching 182 pages is a completeness claim + the output was in no position to make, and the only way to find the real + number was a second, unlimited search.""" + hits = [SearchHit(title=f"p{i}", path=f"kb/p{i}.md") for i in range(20)] + out = render_table(_result(hits, total=182, limit=20), show_matches=False) + + assert "20 of 182 result(s)" in out + assert "--limit" in out + + +def test_an_uncapped_result_claims_nothing_about_a_limit(): + hits = [SearchHit(title=f"p{i}", path=f"kb/p{i}.md") for i in range(3)] + assert "3 result(s)." in render_table(_result(hits), show_matches=False) + assert " of " not in render_table(_result(hits), show_matches=False) + + +def test_limit_zero_never_counts_as_truncated_however_large_the_corpus(): + assert _result([SearchHit(title="a", path="a")] * 182, total=182, limit=0).truncated is False def test_hit_serialises_for_json():