feat(search): the agent's search can ask for every kind the corpus has (#4250)
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
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
This commit is contained in:
@@ -11,6 +11,7 @@ import time
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services.access import owner_names_for
|
||||
from scribe.services.knowledge import content_type_filters
|
||||
from scribe.services.text import MATCHED_PASSAGE, excerpt_fields
|
||||
from scribe.services.embeddings import (
|
||||
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_milestones, semantic_search_notes,
|
||||
@@ -28,6 +29,18 @@ from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summ
|
||||
_EXCERPT_CHARS = 1000
|
||||
|
||||
|
||||
# The kinds `content_type` accepts are DERIVED from the facet table, not listed
|
||||
# again here — that table is where a kind is declared (#3161), and a second
|
||||
# hand-kept copy in this module is precisely how the agent's door came to offer
|
||||
# two kinds while browse offered nine (#4250). `content_type_filters` carries
|
||||
# the mapping, the 'all'/'note' special cases and the refusal.
|
||||
#
|
||||
# These two do not reach `semantic_search_notes` at all: they have their own
|
||||
# search and their own result shape, so they are dispatched before the mapping
|
||||
# and passed in only so the refusal message lists everything THIS door takes.
|
||||
_OWN_SEARCH = ("rule", "milestone")
|
||||
|
||||
|
||||
async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
|
||||
"""Rules by meaning — a separate result shape because a rule IS different.
|
||||
|
||||
@@ -170,8 +183,28 @@ async def search(
|
||||
|
||||
Args:
|
||||
q: search query string.
|
||||
content_type: 'all' (default), 'note' (notes only), 'task' (tasks
|
||||
only), or 'rule' (RULES only — the operator's standing
|
||||
content_type: which kind of record to search. 'all' (default) spans
|
||||
every note and task.
|
||||
|
||||
THE BROAD TWO: 'note' is any non-task record — it still includes
|
||||
snippets, lessons and processes, so it means "knowledge, not work
|
||||
items". 'task' is any task whatever its kind.
|
||||
|
||||
THE SPECIFIC KINDS, each narrowing to one: 'snippet' (recorded
|
||||
prior art — reach for this BEFORE writing a helper, rather than
|
||||
searching 'all' and reading past the issues), 'lesson' (a
|
||||
transferable insight, the kind that exists to be recalled by
|
||||
situation), 'process' (a stored procedure the operator saved),
|
||||
'issue' (corrective work — "has this already been reported?"),
|
||||
'spike' (a time-boxed investigation, whose output is an answer),
|
||||
'work', and 'plan' (retired; the ~90 legacy plan-tasks).
|
||||
|
||||
An unrecognised value is REFUSED with the list of valid ones
|
||||
rather than quietly returning nothing: an empty result set is a
|
||||
claim that the corpus holds nothing, and a typo must not be able
|
||||
to make that claim.
|
||||
|
||||
Or 'rule' (RULES only — the operator's standing
|
||||
instructions, searchable by meaning since milestone 307).
|
||||
Reach for 'rule' when you want to know whether a standing
|
||||
instruction covers something: "is there a rule about release
|
||||
@@ -225,11 +258,12 @@ async def search(
|
||||
return await _search_rules(uid, q, limit, project_id)
|
||||
if content_type == "milestone":
|
||||
return await _search_milestones(uid, q, limit, project_id)
|
||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
||||
filters = content_type_filters(content_type, extra=_OWN_SEARCH)
|
||||
is_task = filters.get("is_task")
|
||||
t0 = time.perf_counter()
|
||||
report: dict = {}
|
||||
raw = await semantic_search_notes(
|
||||
uid, q, limit=limit, is_task=is_task,
|
||||
uid, q, limit=limit, **filters,
|
||||
project_id=project_id or None,
|
||||
system_id=system_id or None,
|
||||
# A LESSON is reachable from any project (milestone 385). The kind
|
||||
|
||||
Reference in New Issue
Block a user