Skip to content

feat(search): add POST /v1/search with dirctl search parity - #2005

Open
akijakya wants to merge 5 commits into
mainfrom
feat/api-post-search
Open

feat(search): add POST /v1/search with dirctl search parity#2005
akijakya wants to merge 5 commits into
mainfrom
feat/api-post-search

Conversation

@akijakya

@akijakya akijakya commented Aug 11, 2026

Copy link
Copy Markdown
Member

Closes #1905

Summary

Adds POST /v1/search to the AI Catalog gateway: free text in, relevance-ranked CatalogEntry results out. The acceptance bar is parity with dirctl search "<query>" — the same query against the same node returns the same records in the same order through either path.

Commits, grouped by the risk they carry:

  1. refactor(nlsearch) — move the existing fan-out into utils/nlsearch and make ranking deterministic. Touches dirctl search.
  2. feat(search) — the endpoint itself. New surface only.
  3. fix(search), feat(search), fix(search) — review follow-ups: query length enforcement, projectable-candidate restriction, rejecting unusable queries, and counting the query limit in characters rather than bytes.

Rebased onto main after #2023

POST /v1/extract merged first, and both endpoints live on the same service, so this branch was rebased onto it. Three things came out of that and are worth knowing while reviewing:

  • Both RPCs coexist in the proto, and the bindings were regenerated from the merged file rather than hand-resolved — a fresh task api:gen produces no diff.
  • fakeExtractor is now shared. It arrived on main with the ExtractTaxonomy tests; the search tests reuse it instead of declaring a second one in the same package.
  • WithExtractor's doc names both extractor-backed RPCs, and the //nolint:unused that guarded the ext field while it had no consumer is gone — main removed it when extract became the first one.

Heads-up: this changes dirctl search

The natural-language search algorithm is not new and is not changing. dirctl search "some phrase" already decomposed the phrase into signals, queried each one independently, and ranked the union by how many signals matched — that is what its --help has always described. The first commit moves that code from cli/cmd/search into utils/nlsearch so the gateway can run the same implementation.

One behavior does change for the CLI: tie ordering is now stable.

The scorer accumulated results in goroutine-completion order and then sorted only by hit count, so records with equal scores came back in a different order on every run. Printing results once, nobody notices. For a paginated caller it is a correctness bug — an unstable order lets a record appear on two pages, or on none. Ranking is now a total order: hit count, then summed signal score, then CID.

So the CLI's results are the same set, in the same relevance tiers, with ties no longer shuffling between invocations.

How the search works

The extractor turns one phrase into several signals. "review my python code" might yield two skills, a domain, and a keyword. Each is queried independently and concurrently, and the union is ranked by how many signals matched:

code_review        → {a, b}
static_analysis    → {b}
software_dev       → {b, c}
python (name∪desc) → {c, d}
                     ↓ union + count
b:3   c:2   a:1   d:1

The union matters. ANDing the signals — requiring every guess the extractor made to be simultaneously correct — usually returns nothing; that is why a node holding four cloud-tagged records could answer "cloud computing" with an empty list. Ranking, not exclusion, is what separates a record matching four signals from one matching a single signal.

Cost: N concurrent queries per request rather than one, where N is the signal count (roughly 4–10 at the default two tiers). On the gateway these are in-process rather than gRPC round trips, and each is capped at 500 candidates.

Deliberate choices

  • No filter parameter. dirctl search applies no facets to a natural-language query, so accepting them here would break parity. Structured filtering stays on GET /v1/agents?filter=. This also removes the facet-versus-pagination problem the earlier design ran into.
  • No displayName fallback on empty extraction. The endpoint was originally specified with a substring fallback; it is dropped. It answers a different question than the one asked and would return hits where dirctl search returns none, breaking the parity this endpoint exists for. [Feature]: Backend POST /v1/search — natural-language search with dirctl parity #1905 has been updated to match.
  • An unusable query is rejected, not answered with an empty page. When the extractor derives no signals, INVALID_ARGUMENT is returned with the advice the CLI gives. Zero results cannot distinguish "nothing in the catalog matches" from "the query was not understood", and only the second is fixed by rephrasing.
  • Candidates are restricted to catalog-projectable records. GetRecordCIDs searches every record, but GetCatalogEntries only projects records carrying a known catalog module. Without the restriction, unprojectable records occupied page slots and inflated total_count, then vanished at hydration. Exact CID-set parity with the CLI is not achievable here — it returns raw CIDs for any record, this returns CatalogEntry — so parity holds over the projectable universe, made explicit in the query rather than appearing as records quietly disappearing.
  • Query length is enforced in the handler, in characters. The service registers no protovalidate interceptor, so the proto's max_len: 1024 was not applied at runtime; enforced in code as ListAgents does with filterMaxLen. Counted with utf8.RuneCountInString, matching how protovalidate reads max_len and how ExtractTaxonomy enforces the same limit — a byte count would have rejected multi-byte queries at roughly a third of the documented length.
  • A failing signal degrades recall, not the request. It is logged and the remaining signals still contribute, so one unhealthy query does not turn into a 500.
  • total_count is candidates found, bounded by the per-signal fan-out cap — not a whole-index count.
  • 503 when no extractor is configured, so callers can distinguish "not set up" from "no matches".

Testing

  • 6 unit tests on the shared fan-out: union rather than intersection, hit-count ranking, keyword NAME∪DESCRIPTION dedup (one keyword counts once), per-signal error isolation, limit defaulting and override, and determinism asserted across 25 identical runs
  • 11 handler tests: rank order preserved through hydration, pagination disjoint and complete, offset past the end, unusable-query rejection, projectable-candidate restriction, 503 paths, invalid requests including over-length queries, search failure degrading rather than erroring, and a full-length multi-byte query being accepted
  • All 6 modules build; task lint 0 issues (Go and Helm); task test 0 failures
  • The refactor commit builds and passes tests standalone, so the split is bisectable

Not covered: relevance quality against a seeded catalog. The tests pin the mechanism, not whether the taxonomy matches are good.

Follow-ups

@akijakya akijakya self-assigned this Aug 11, 2026
@akijakya
akijakya requested a review from a team as a code owner August 11, 2026 15:13
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

The latest Buf updates on your PR. Results from workflow Buf CI / verify-proto (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed⏩ skipped⏩ skipped✅ passedAug 14, 2026, 1:14 PM

@github-actions github-actions Bot added the size/L Denotes a PR that changes 1000-1999 lines label Aug 11, 2026
@akijakya
akijakya requested a balanced review from Copilot August 11, 2026 15:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds natural-language catalog search with shared CLI/gateway ranking and pagination.

Changes:

  • Centralizes concurrent fan-out and deterministic scoring.
  • Adds POST /v1/search and gateway handling.
  • Adds unit tests for ranking, failures, and pagination.

Reviewed changes

Copilot reviewed 8 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
utils/nlsearch/fanout.go Implements shared fan-out and ranking.
utils/nlsearch/fanout_test.go Tests shared search behavior.
server/controller/ai_finder.go Adds search database and extractor dependencies.
server/controller/ai_finder_test.go Extends the fake catalog database.
server/controller/ai_finder_search.go Implements the search endpoint.
server/controller/ai_finder_search_test.go Tests endpoint behavior.
proto/agntcy/dir/catalog/v1/ai_finder_service.proto Defines the search API.
cli/cmd/search/nlsearch.go Migrates CLI search to shared ranking.
api/catalog/v1/ai_finder_service.pb.gw.go Adds generated HTTP routing.
api/catalog/v1/ai_finder_service.pb.go Adds generated search messages.
api/catalog/v1/ai_finder_service_grpc.pb.go Adds generated gRPC bindings.
Files not reviewed (3)
  • api/catalog/v1/ai_finder_service.pb.go: Generated file
  • api/catalog/v1/ai_finder_service.pb.gw.go: Generated file
  • api/catalog/v1/ai_finder_service_grpc.pb.go: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread server/controller/ai_finder_search.go Outdated
Comment thread server/controller/ai_finder_search.go
Comment thread server/controller/ai_finder_search.go
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 58.54701% with 97 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
api/catalog/v1/ai_finder_service.pb.gw.go 0.0% 50 Missing and 1 partial ⚠️
cli/cmd/search/nlsearch.go 0.0% 35 Missing ⚠️
server/controller/ai_finder_search.go 88.8% 5 Missing and 4 partials ⚠️
utils/nlsearch/fanout.go 97.0% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

…nistic

Moves the natural-language fan-out and scoring out of cli/cmd/search into
utils/nlsearch, behind a Searcher interface that abstracts how a caller reaches
the search layer. The CLI keeps issuing SearchCIDs RPCs; a second caller can
query a database in-process and get identical results, which is what lets the
API endpoint match `dirctl search` by construction rather than by convention.

The algorithm is unchanged: one query per extracted signal, keyword signals
fanned out to NAME and DESCRIPTION and deduplicated, the union ranked by how
many signals matched each record.

Tie ordering does change. The scorer accumulated results in goroutine
completion order and then sorted only by hit count, so equal-scoring records
came back in a different order between runs. That is invisible when printing
results once, but it breaks any paginated caller, where an unstable order lets
records repeat or disappear between pages. Ranking is now a total order: hit
count, then summed signal score, then CID.

Per-signal failures are returned rather than printed, so each caller reports
them its own way; the CLI keeps warning on stderr.

Signed-off-by: András Jáky <ajaky@cisco.com>
Adds a natural-language search endpoint to the AI Catalog gateway. It answers
free text with relevance-ranked catalog entries, returning the same records in
the same order as `dirctl search "<query>"` for the same query on the same
node: both run the shared nlsearch fan-out over the same extracted signals,
the CLI over gRPC and the gateway in-process.

The request deliberately has no filter field. `dirctl search` applies no facets
to a natural-language query, so accepting them here would make the two paths
disagree; structured filtering stays on ListAgents. Ranking covers the whole
candidate set before paging, so a page is a slice of an already-ordered list
and total_count is exact for the candidates found.

A failing signal degrades recall rather than the request: it is logged and the
remaining signals still contribute. With no extractor configured the RPC
returns UNAVAILABLE (HTTP 503); an empty extraction returns an empty page.

Closes #1905

Signed-off-by: András Jáky <ajaky@cisco.com>
Enforce the proto's max_len=1024 on the query in code. The service registers
no protovalidate interceptor, so the declared constraint was not applied at
runtime and an arbitrarily long string could reach the extractor. Mirrors how
ListAgents enforces filterMaxLen.

Restrict fan-out candidates to records carrying a known catalog module, the
same filter GetCatalogEntries applies when projecting entries. GetRecordCIDs
searches every record, so the ranking could include records the catalog cannot
represent: they consumed page slots and inflated total_count, then silently
disappeared at hydration, yielding short pages. The endpoint returns
CatalogEntry, so a record with no catalog projection is not a candidate.

Signed-off-by: András Jáky <ajaky@cisco.com>
When the extractor derives no signals there is nothing to search on. Returning
an empty page made that indistinguishable from "the catalog holds no match",
even though only one of the two is fixed by rephrasing. SearchAgents now
returns INVALID_ARGUMENT with the same advice `dirctl search` gives, so a
client can tell the user what to do.

Also drops the display-name substring fallback the endpoint was originally
specified with: it would answer a different question than the one asked and
return hits where `dirctl search` returns none, which is the parity this
endpoint exists to provide.

Signed-off-by: András Jáky <ajaky@cisco.com>
protovalidate interprets a string's max_len as a character count, and
ExtractTaxonomy — which landed on main with the same 1024 limit — enforces it
with utf8.RuneCountInString. SearchAgents used len(), so a multi-byte query
was rejected at roughly a third of the documented limit, and the two sibling
endpoints disagreed about what their identical constraint meant.

Signed-off-by: András Jáky <ajaky@cisco.com>
@akijakya
akijakya force-pushed the feat/api-post-search branch from 3406747 to 7ed3973 Compare August 14, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Denotes a PR that changes 1000-1999 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Backend POST /v1/search — natural-language search with dirctl parity

3 participants