CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m42s
CI & Build / Build & push image (push) Successful in 28s
The engine took `note_type` and `task_kind` all along. What was missing was a way to say them: the MCP tool's `content_type` knew `note`, `task` and `all`, and `/api/search` knew the same two — so an agent could not ask "has this snippet already been recorded" or "what lessons apply here" without searching everything and reading past the rest. Browse offered nine kinds from the same data. The cause is that each door kept its own map. `_FACETS` in services/knowledge is where a kind is declared, and #3161 made adding one a single edit by generating the SQL filter, the Python predicate and the door's validation from it — but the two search doors were written before that and never joined. So this adds the third dialect, `search_filters_for`, and one composition over it, `content_type_filters`, and both doors now derive instead of listing. Two names keep a meaning of their own, and the docstrings say so: `all` is no filter, and `note` is BROAD — any non-task, snippets and lessons included — where the browse facet of the same name is narrow (`note_type == 'note'`). They are left different deliberately; narrowing this one would stop returning snippets to every caller that already asks this way. An unrecognised kind is now refused rather than answered. Both doors used to fall through: the MCP tool into a filter matching no row, the route into no filter at all, so `?content_type=snippets` returned the whole corpus while looking like a narrowed search. An empty result set is a claim — "the corpus holds nothing like this" — and an agent acts on that claim by building the thing it could not find, so a typo must not be able to make it. The docstring is the agent-facing contract (#2846), and a test now holds it to the table: every kind `_FACETS` declares has to appear in it, because a filter an agent has not been told about is unreachable however well it is wired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
51 lines
2.2 KiB
Python
51 lines
2.2 KiB
Python
"""/api/search — the kind filter it offers, and what it does with a bad one.
|
|
|
|
This route used to carry its own two-value map (`note`/`task`/everything else
|
|
is `all`), which made it the third hand-kept copy of a vocabulary that lives in
|
|
one table. Both halves of #4250 are pinned here: every declared kind is
|
|
reachable, and a kind that does not exist is refused rather than widened.
|
|
"""
|
|
import pytest
|
|
|
|
from scribe.services.knowledge import FACET_TYPES, content_type_filters
|
|
|
|
|
|
def test_the_three_historical_values_still_mean_what_they_meant():
|
|
assert content_type_filters("all") == {"is_task": None}
|
|
assert content_type_filters("task")["is_task"] is True
|
|
# BROAD on purpose: any non-task, so snippets and lessons stay in scope.
|
|
assert content_type_filters("note") == {"is_task": False}
|
|
|
|
|
|
@pytest.mark.parametrize("facet", sorted(FACET_TYPES))
|
|
def test_every_declared_kind_is_reachable_through_this_door(facet):
|
|
"""The engine took `note_type` and `task_kind` all along — the route was
|
|
simply unable to say them."""
|
|
filters = content_type_filters(facet)
|
|
assert "is_task" in filters
|
|
if facet not in ("all", "note", "task"):
|
|
assert filters.get("note_type") == facet or filters.get("task_kind") == facet
|
|
|
|
|
|
def test_an_unknown_kind_is_refused_instead_of_silently_widening():
|
|
"""The old map read anything unrecognised as "no filter", so
|
|
`?content_type=snippets` returned the whole corpus while looking like a
|
|
narrowed search — the failure mode that answers a question nobody asked."""
|
|
with pytest.raises(ValueError) as err:
|
|
content_type_filters("snippets")
|
|
assert "snippets" in str(err.value)
|
|
assert "snippet" in str(err.value) # names the real ones
|
|
|
|
|
|
def test_a_doors_refusal_lists_only_what_that_door_accepts():
|
|
"""`rule` and `milestone` are the MCP tool's own searches. This route has
|
|
neither, so offering them in its error would send a caller at a parameter
|
|
that does not work here."""
|
|
with pytest.raises(ValueError) as plain:
|
|
content_type_filters("nonsense")
|
|
assert "rule" not in str(plain.value)
|
|
|
|
with pytest.raises(ValueError) as extra:
|
|
content_type_filters("nonsense", extra=("rule", "milestone"))
|
|
assert "rule" in str(extra.value) and "milestone" in str(extra.value)
|