search: one relevance surface, and Team's search box finally answers
The platform had three ways to ask "what is relevant": /v1/kb/search (vector),
/v1/index/indexes/:uid/search (lexical), and /v1/search-docs/* (a proxy to the
same two). Each took a different request shape and returned a different score
scale, so a caller had to know which store held the answer before it could ask.
clients/search is the one entry point. It owns no store; it composes the legs
already running and returns a single ranked set. What it adds beyond composition:
- PROVENANCE. Every hit carries which backend matched it, at what rank, with
that backend's native score. A fused ranking without this cannot be
explained or debugged.
- AN HONEST DEGRADATION CONTRACT. Every response reports every leg with one of
four DISTINCT statuses -- ok / degraded / disabled / skipped -- because
"never provisioned" and "provisioned and broken" are different operational
facts. A leg that is down yields the survivors' results plus the error, never
a silent empty. That silent empty is exactly how a vector-store credential
drift stayed invisible for five days behind a fail-empty /v1/kb/search.
clients/search/rank is the ONE rank-fusion implementation, deliberately a leaf
that knows nothing about documents so both the cross-corpus surface and the
per-corpus tiers inside clients/code can share it instead of keeping two copies
of RRF. RRF over a weighted sum because the legs score on incomparable scales
(a term-match count and a cosine similarity); ranks are comparable by
construction, so there are no per-corpus weights to mis-tune and a leg dropping
out leaves the survivors correctly ordered.
Query is the typed op (it projects to OpenAPI/MCP/CLI from one registration);
ForOrg is the same composition for a caller that established its tenant another
way. Team's transactor is the first such caller: searchFulltext answered with a
hardcoded empty result, so the SPA's search box asked and was told "no matches"
forever. It now calls ForOrg in-process -- same binary, no HTTP hop -- scoped to
the transactor token's verified org.
Also corrects clients/index's package doc, which documented a /v1/search/* surface
while the code has always registered /v1/index/*.
NOT WIRED UP YET, deliberately: clients/provisioning already registers
POST/GET /v1/search as a resource-CRUD noun and mounts first, so registering the
query surface there today would be silently shadowed (verified: first
registration wins). Resolving that means moving provisioning's resource nouns
under /v1/provisioning/*, which breaks a wire and is not mine to decide.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+16
-16
@@ -1,4 +1,4 @@
|
||||
// Package search mounts the Hanzo Cloud /v1/search/* surface: a native-Go,
|
||||
// Package index mounts the Hanzo Cloud /v1/index/* surface: a native-Go,
|
||||
// multi-tenant full-text index on Base/SQLite that speaks the Meilisearch REST
|
||||
// dialect.
|
||||
//
|
||||
@@ -14,20 +14,20 @@
|
||||
// mongoMeili Mongoose plugin. Speaking that dialect means chat points MEILI_HOST
|
||||
// at this surface and needs no client change:
|
||||
//
|
||||
// GET /v1/search/health {"status":"available"}
|
||||
// GET /v1/search/version
|
||||
// POST /v1/search/indexes {uid, primaryKey}
|
||||
// GET /v1/search/indexes/:uid
|
||||
// GET /v1/search/indexes/:uid/settings
|
||||
// PATCH /v1/search/indexes/:uid/settings {filterableAttributes}
|
||||
// POST /v1/search/indexes/:uid/documents [doc,…] add/replace
|
||||
// PUT /v1/search/indexes/:uid/documents [doc,…] update/upsert
|
||||
// GET /v1/search/indexes/:uid/documents ?limit&offset
|
||||
// GET /v1/search/indexes/:uid/documents/:id
|
||||
// DELETE /v1/search/indexes/:uid/documents/:id
|
||||
// POST /v1/search/indexes/:uid/documents/delete-batch [id,…]
|
||||
// POST /v1/search/indexes/:uid/search {q, filter, limit, offset}
|
||||
// GET /v1/search/tasks/:uid
|
||||
// GET /v1/index/health {"status":"available"}
|
||||
// GET /v1/index/version
|
||||
// POST /v1/index/indexes {uid, primaryKey}
|
||||
// GET /v1/index/indexes/:uid
|
||||
// GET /v1/index/indexes/:uid/settings
|
||||
// PATCH /v1/index/indexes/:uid/settings {filterableAttributes}
|
||||
// POST /v1/index/indexes/:uid/documents [doc,…] add/replace
|
||||
// PUT /v1/index/indexes/:uid/documents [doc,…] update/upsert
|
||||
// GET /v1/index/indexes/:uid/documents ?limit&offset
|
||||
// GET /v1/index/indexes/:uid/documents/:id
|
||||
// DELETE /v1/index/indexes/:uid/documents/:id
|
||||
// POST /v1/index/indexes/:uid/documents/delete-batch [id,…]
|
||||
// POST /v1/index/indexes/:uid/search {q, filter, limit, offset}
|
||||
// GET /v1/index/tasks/:uid
|
||||
//
|
||||
// Error bodies use Meilisearch's {message, code, type, link} shape rather than
|
||||
// cloud's, because the JS client branches on those codes — index_not_found is
|
||||
@@ -139,7 +139,7 @@ func Shutdown() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// routes registers the Meilisearch dialect under /v1/search.
|
||||
// routes registers the Meilisearch dialect under /v1/index.
|
||||
func routes(app cloud.Router, s *cloud.Service[state]) {
|
||||
// health and version are registered as ABSOLUTE paths on app, the same idiom
|
||||
// every other OwnsHealth subsystem uses (clients/esign, clients/kms). Declared
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// query.go is the lexical leg's ONE export. /v1/search (clients/search) fuses this
|
||||
// with the vector leg and reaches the SAME store the Meilisearch dialect serves —
|
||||
// IN-PROCESS, no HTTP hop. That matters twice: the fused query is an agent tool
|
||||
// call whose latency budget is a few hundred milliseconds, and a second network
|
||||
// path to the same store would be a second way to do one thing.
|
||||
|
||||
// ErrNotMounted reports that the index subsystem is not mounted in this binary.
|
||||
// The surface maps it to a DISABLED backend, never to a failed query — a
|
||||
// deployment that does not run the index simply has no lexical leg.
|
||||
var ErrNotMounted = errors.New("index: not mounted")
|
||||
|
||||
// Query runs the org-scoped lexical search over one index. org MUST come from a
|
||||
// validated principal; the store pins every row to it, so a caller can never read
|
||||
// another tenant's documents. An index that does not exist yields no rows rather
|
||||
// than an error: "nothing indexed yet" is an empty result, not a failure.
|
||||
func Query(ctx context.Context, org, uid, q string, limit, offset int) ([]json.RawMessage, error) {
|
||||
if mounted == nil {
|
||||
return nil, ErrNotMounted
|
||||
}
|
||||
return mounted.State.store.Search(ctx, org, uid, q, nil, limit, offset)
|
||||
}
|
||||
|
||||
// Ready reports whether the lexical leg can serve a query in this binary.
|
||||
func Ready() bool { return mounted != nil }
|
||||
@@ -0,0 +1,45 @@
|
||||
package knowledge
|
||||
|
||||
import "context"
|
||||
|
||||
// semantic.go is the vector leg's ONE export. /v1/search (clients/search) fuses
|
||||
// this with the lexical leg (clients/index) and must reach the SAME per-org
|
||||
// collection, the SAME embedding model, and the SAME payload filter that
|
||||
// /v1/kb/search reaches — so it calls the identical searchDoc rather than growing
|
||||
// a second retrieval path against the same store. Everything org-scoping and
|
||||
// tenant-isolating stays in index.go; this file only widens its visibility.
|
||||
|
||||
// Hit is one semantic result. It is the retrieval hit shape verbatim (a type
|
||||
// alias, not a copy) so the wire contract cannot drift between /v1/kb/search and
|
||||
// /v1/search.
|
||||
type Hit = hit
|
||||
|
||||
// SemanticReq is an org-scoped vector query. Org is set by the CALLER from a
|
||||
// validated principal — never from a client field — exactly as searchReq requires.
|
||||
type SemanticReq struct {
|
||||
Org string
|
||||
Query string
|
||||
Project string
|
||||
DocTypes []string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Semantic runs the org-scoped vector leg. It returns an error (never a silent
|
||||
// empty) when the store or the embedding gateway is unreachable, so the caller can
|
||||
// report WHICH backend failed and why: the fail-empty behaviour that hid a
|
||||
// five-day vector outage belongs to the surface's degradation contract, not here.
|
||||
func Semantic(ctx context.Context, r SemanticReq) ([]Hit, error) {
|
||||
return index().searchDoc(ctx, searchReq{
|
||||
org: r.Org,
|
||||
query: r.Query,
|
||||
limit: r.Limit,
|
||||
project: r.Project,
|
||||
doctypes: sanitizeDocTypes(r.DocTypes),
|
||||
})
|
||||
}
|
||||
|
||||
// SemanticReady reports whether the vector leg is configured (an embedding client
|
||||
// and a store endpoint). A deployment without one is DISABLED, which the surface
|
||||
// reports distinctly from DEGRADED — "never provisioned" and "provisioned and
|
||||
// broken" are different operational facts and must not share a status.
|
||||
func SemanticReady() bool { return index().enabled() }
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package rank is the ONE rank-fusion implementation in the codebase.
|
||||
//
|
||||
// It is a leaf: it imports nothing and knows nothing about documents, orgs, or
|
||||
// stores. That is deliberate. Fusion is needed at two DIFFERENT levels — across
|
||||
// the tiers inside one corpus (clients/code fuses lexical + symbolic + semantic)
|
||||
// and across corpora at the /v1/search surface — and a package that knew about
|
||||
// either level could only serve that one, which is how a codebase ends up with
|
||||
// two copies of the same algorithm drifting apart.
|
||||
//
|
||||
// Callers pass ranked KEYS and get back fused keys with provenance; mapping keys
|
||||
// to payloads stays with the caller, who is the only one who knows what a key
|
||||
// means.
|
||||
package rank
|
||||
|
||||
import "sort"
|
||||
|
||||
// K damps the contribution of deep ranks in RRF. 60 is the value from the
|
||||
// original paper and the one every mainstream implementation ships. It is a
|
||||
// constant rather than a knob because making it tunable invites per-corpus
|
||||
// fiddling, which is the thing rank fusion exists to avoid.
|
||||
const K = 60.0
|
||||
|
||||
// List is ONE ranked input — a source name and its keys in rank order. Order is
|
||||
// the entire signal: Keys[0] is that source's best hit.
|
||||
type List struct {
|
||||
Source string
|
||||
Keys []string
|
||||
// Scores optionally carries each key's native score, positionally aligned
|
||||
// with Keys. It is reported back as provenance and never used for ranking:
|
||||
// sources score on incomparable scales (a term-match count and a cosine
|
||||
// similarity), which is precisely why fusion uses ranks.
|
||||
Scores []float64
|
||||
}
|
||||
|
||||
// Origin records that one source matched one key, at what rank and native score.
|
||||
// Without it a fused ranking is unexplainable: you cannot distinguish a hit two
|
||||
// sources agreed on from one only a single source saw, and you cannot tell a
|
||||
// healthy source from one quietly returning nothing.
|
||||
type Origin struct {
|
||||
Source string
|
||||
Rank int
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Fused is one output row: the key, its fused score, and every source that
|
||||
// contributed to it.
|
||||
type Fused struct {
|
||||
Key string
|
||||
Score float64
|
||||
Origins []Origin
|
||||
}
|
||||
|
||||
// Fuse combines ranked lists into one ordered result. It is a variable, not a
|
||||
// function, so a deployment or a test can substitute a different strategy without
|
||||
// any caller changing; nil is not a valid value and callers should not set it.
|
||||
var Fuse = RRF
|
||||
|
||||
// RRF is Reciprocal Rank Fusion: score(d) = Σ 1/(K + rank) over every list
|
||||
// containing d, ranks being 1-based.
|
||||
//
|
||||
// WHY THIS AND NOT A WEIGHTED SUM. The inputs score on incomparable scales, so
|
||||
// adding them requires a normalizer and a per-source weight — tuned magic numbers
|
||||
// that are right for the corpus they were fitted on and silently wrong everywhere
|
||||
// else. RRF discards the scores and keeps only ranks, which are comparable by
|
||||
// construction. It needs no tuning, cannot be miscalibrated by a shifting score
|
||||
// distribution, and degrades gracefully when a source drops out: the survivors'
|
||||
// ranks are unchanged, so a partial answer is still correctly ordered.
|
||||
//
|
||||
// A document found by two sources outranks one found by either alone at the same
|
||||
// depth — the whole reason to run both. Ties break on first appearance so paging
|
||||
// is stable across identical queries.
|
||||
func RRF(lists []List, limit int) []Fused {
|
||||
type acc struct {
|
||||
f Fused
|
||||
order int
|
||||
}
|
||||
byKey := map[string]*acc{}
|
||||
seq := 0
|
||||
for _, l := range lists {
|
||||
for i, key := range l.Keys {
|
||||
a, ok := byKey[key]
|
||||
if !ok {
|
||||
a = &acc{f: Fused{Key: key}, order: seq}
|
||||
seq++
|
||||
byKey[key] = a
|
||||
}
|
||||
a.f.Score += 1.0 / (K + float64(i+1))
|
||||
var native float64
|
||||
if i < len(l.Scores) {
|
||||
native = l.Scores[i]
|
||||
}
|
||||
a.f.Origins = append(a.f.Origins, Origin{Source: l.Source, Rank: i + 1, Score: native})
|
||||
}
|
||||
}
|
||||
out := make([]*acc, 0, len(byKey))
|
||||
for _, a := range byKey {
|
||||
out = append(out, a)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].f.Score != out[j].f.Score {
|
||||
return out[i].f.Score > out[j].f.Score
|
||||
}
|
||||
return out[i].order < out[j].order
|
||||
})
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
fused := make([]Fused, 0, len(out))
|
||||
for _, a := range out {
|
||||
fused = append(fused, a.f)
|
||||
}
|
||||
return fused
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package rank
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRRFReinforcesAgreement is the reason hybrid search exists: a document both
|
||||
// sources found must outrank a document only one source found, even when the
|
||||
// single-source document sits at the very top of its list.
|
||||
func TestRRFReinforcesAgreement(t *testing.T) {
|
||||
out := RRF([]List{
|
||||
{Source: "index", Keys: []string{"solo-text", "agreed"}},
|
||||
{Source: "vector", Keys: []string{"solo-vec", "agreed"}},
|
||||
}, 10)
|
||||
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("want 3 fused keys, got %d: %+v", len(out), out)
|
||||
}
|
||||
if out[0].Key != "agreed" {
|
||||
t.Fatalf("a doc both sources ranked #2 must beat docs one source ranked #1; got %q first: %+v", out[0].Key, out)
|
||||
}
|
||||
// 2/(60+2) vs 1/(60+1)
|
||||
if want := 2.0 / 62.0; out[0].Score != want {
|
||||
t.Fatalf("fused score = %v, want %v", out[0].Score, want)
|
||||
}
|
||||
if len(out[0].Origins) != 2 {
|
||||
t.Fatalf("agreed doc must carry BOTH origins, got %+v", out[0].Origins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRRFProvenance proves every fused row can explain itself: which source, at
|
||||
// which rank, with that source's native score. Without this a hybrid ranking is
|
||||
// undebuggable.
|
||||
func TestRRFProvenance(t *testing.T) {
|
||||
out := RRF([]List{
|
||||
{Source: "vector", Keys: []string{"a", "b"}, Scores: []float64{0.91, 0.42}},
|
||||
}, 10)
|
||||
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(out))
|
||||
}
|
||||
o := out[0].Origins[0]
|
||||
if o.Source != "vector" || o.Rank != 1 || o.Score != 0.91 {
|
||||
t.Fatalf("origin = %+v, want {vector 1 0.91}", o)
|
||||
}
|
||||
if o2 := out[1].Origins[0]; o2.Rank != 2 || o2.Score != 0.42 {
|
||||
t.Fatalf("second origin = %+v, want rank 2 score 0.42", o2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRRFSurvivesMissingSource is the degradation invariant: when a source drops
|
||||
// out, the survivor's ORDER is unchanged. A partial answer must still be a
|
||||
// correctly ordered answer.
|
||||
func TestRRFSurvivesMissingSource(t *testing.T) {
|
||||
both := RRF([]List{
|
||||
{Source: "index", Keys: []string{"x", "y", "z"}},
|
||||
{Source: "vector", Keys: []string{"q"}},
|
||||
}, 10)
|
||||
only := RRF([]List{
|
||||
{Source: "index", Keys: []string{"x", "y", "z"}},
|
||||
}, 10)
|
||||
|
||||
order := func(f []Fused) string {
|
||||
s := ""
|
||||
for _, r := range f {
|
||||
if r.Key != "q" {
|
||||
s += r.Key
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
if order(both) != order(only) {
|
||||
t.Fatalf("losing a source reordered the survivor: %q vs %q", order(both), order(only))
|
||||
}
|
||||
if len(only) != 3 {
|
||||
t.Fatalf("single-source fusion must return all its keys, got %d", len(only))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRRFStableAndBounded pins tie-breaking and the limit, so paging an identical
|
||||
// query twice cannot shuffle rows.
|
||||
func TestRRFStableAndBounded(t *testing.T) {
|
||||
lists := []List{{Source: "index", Keys: []string{"a", "b", "c", "d"}}}
|
||||
first := RRF(lists, 2)
|
||||
second := RRF(lists, 2)
|
||||
|
||||
if len(first) != 2 {
|
||||
t.Fatalf("limit ignored: got %d rows", len(first))
|
||||
}
|
||||
for i := range first {
|
||||
if first[i].Key != second[i].Key {
|
||||
t.Fatalf("unstable ordering at %d: %q vs %q", i, first[i].Key, second[i].Key)
|
||||
}
|
||||
}
|
||||
if first[0].Key != "a" || first[1].Key != "b" {
|
||||
t.Fatalf("rank order not preserved: %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRRFEmpty proves no-input is an empty result, not a panic — the shape a
|
||||
// fully-degraded query produces.
|
||||
func TestRRFEmpty(t *testing.T) {
|
||||
if out := RRF(nil, 10); len(out) != 0 {
|
||||
t.Fatalf("want empty, got %+v", out)
|
||||
}
|
||||
if out := RRF([]List{{Source: "index"}}, 10); len(out) != 0 {
|
||||
t.Fatalf("want empty, got %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// Package search is THE search entry point: ONE surface, POST /v1/search, that
|
||||
// answers "what is RELEVANT" over a tenant's own data.
|
||||
//
|
||||
// It owns no store. It is a composition of the two retrieval STORES the platform
|
||||
// already runs — the lexical index (clients/index, hanzoai/index) and the vector
|
||||
// index (clients/knowledge, hanzoai/vector) — fused into one ranked result set.
|
||||
// That is the whole point: before this, a caller had to know which of
|
||||
// /v1/kb/search, /v1/index/indexes/:uid/search and /v1/search-docs/* held the
|
||||
// answer, and got a different request shape and a different score scale from each.
|
||||
//
|
||||
// WHAT BELONGS HERE. A query whose honest answer has a SCORE. A query whose
|
||||
// honest answer has a TRUTH VALUE — the definition of a symbol, the callers of a
|
||||
// function, a dependency edge — belongs to /v1/code (clients/code) and must not be
|
||||
// forced through a relevance-ranked shape: a definition is not 0.87 relevant, it
|
||||
// either is the definition or it is not.
|
||||
//
|
||||
// NOT HERE: /v1/websearch/search. That searches the PUBLIC WEB, not the
|
||||
// customer's data. It has a different tenancy model (no org-scoped corpus), a
|
||||
// different cost model (per-call to an external provider) and a different failure
|
||||
// mode. It stays separate — do not fold it in.
|
||||
//
|
||||
// DEGRADATION IS THE CONTRACT. Every response names every backend it consulted
|
||||
// and that backend's status. A leg that is down produces results from the
|
||||
// surviving legs plus an explicit `degraded` entry carrying the error — never a
|
||||
// silent empty. This is not a nicety: a silent empty is exactly how a vector-store
|
||||
// credential drift went unnoticed for five days behind a fail-empty
|
||||
// /v1/kb/search.
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/index"
|
||||
"github.com/hanzoai/cloud/clients/knowledge"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/search/rank"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Backend names. One constant per leg so the wire value is declared once and the
|
||||
// provenance a client reads always matches the status it reads.
|
||||
const (
|
||||
BackendIndex = "index" // lexical, clients/index
|
||||
BackendVector = "vector" // semantic, clients/knowledge → hanzoai/vector
|
||||
)
|
||||
|
||||
// Backend statuses — four DISTINCT operational facts, never collapsed:
|
||||
// - ok the leg ran and answered.
|
||||
// - degraded the leg is configured but FAILED. Carries the error.
|
||||
// - disabled the leg is not provisioned in this deployment. Not a fault.
|
||||
// - skipped the caller's mode excluded the leg. Not a fault.
|
||||
const (
|
||||
StatusOK = "ok"
|
||||
StatusDegraded = "degraded"
|
||||
StatusDisabled = "disabled"
|
||||
StatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
// Search modes. `auto` is the default and resolves to hybrid when both legs are
|
||||
// available, else to whichever leg is.
|
||||
const (
|
||||
ModeAuto = "auto"
|
||||
ModeText = "text"
|
||||
ModeSemantic = "semantic"
|
||||
ModeHybrid = "hybrid"
|
||||
)
|
||||
|
||||
// defaultIndex is the lexical index a query uses when the caller names none.
|
||||
const defaultIndex = "kb"
|
||||
|
||||
// Request is the ONE query shape. There is deliberately no `org` field: the tenant
|
||||
// is the validated principal, so a caller can never search another org by asking.
|
||||
type Request struct {
|
||||
// Query is the natural-language or keyword query. Required.
|
||||
Query string `json:"query"`
|
||||
// Mode selects the legs: auto (default) | text | semantic | hybrid.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
// Project narrows to one project scope within the org.
|
||||
Project string `json:"project,omitempty"`
|
||||
// DocTypes restricts the semantic leg to a subset of indexed knowledge types.
|
||||
DocTypes []string `json:"doctypes,omitempty"`
|
||||
// Index names the lexical index to query. Defaults to "kb".
|
||||
Index string `json:"index,omitempty"`
|
||||
// Limit bounds the FUSED result set (default 10, max 50).
|
||||
Limit int `json:"limit,omitempty"`
|
||||
// Offset pages the fused result set.
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
// Match is PROVENANCE: one backend's contribution to one result. A fused ranking
|
||||
// without this is undebuggable — you cannot distinguish a hit both legs agreed on
|
||||
// from a hit only one leg saw, nor tell a healthy leg from one quietly returning
|
||||
// nothing.
|
||||
type Match struct {
|
||||
Backend string `json:"backend"`
|
||||
Rank int `json:"rank"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// Result is one fused hit. Score is the FUSED score (see fuse.go); each backend's
|
||||
// native score stays in Matched, because the two are different things and
|
||||
// flattening them loses the ability to explain a ranking.
|
||||
type Result struct {
|
||||
ID string `json:"id"`
|
||||
Corpus string `json:"corpus"`
|
||||
DocType string `json:"doctype,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
Matched []Match `json:"matched"`
|
||||
}
|
||||
|
||||
// BackendStatus reports one leg's outcome. It is present for EVERY leg on EVERY
|
||||
// response, including the ones that were skipped, so a client never has to infer
|
||||
// from absence.
|
||||
type BackendStatus struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Hits int `json:"hits"`
|
||||
TookMS int64 `json:"took_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Response is the ONE result shape.
|
||||
type Response struct {
|
||||
// Status is the query's overall honesty signal:
|
||||
// ok every consulted leg answered.
|
||||
// partial at least one leg failed; Hits holds the survivors' results.
|
||||
// unavailable every consulted leg failed; Hits is empty AND that is stated.
|
||||
Status string `json:"status"`
|
||||
// Mode is the mode actually used after `auto` resolution.
|
||||
Mode string `json:"mode"`
|
||||
// Hits is the fused, ranked result set.
|
||||
Hits []Result `json:"hits"`
|
||||
// Backends is the per-leg report. Always populated.
|
||||
Backends []BackendStatus `json:"backends"`
|
||||
TookMS int64 `json:"took_ms"`
|
||||
}
|
||||
|
||||
// Mount wires the surface. Every route is a typed op, so it projects to OpenAPI,
|
||||
// MCP tools and the generated CLI from the SAME registration — a Router without
|
||||
// the op registry cannot carry it, and the mount fails loudly rather than
|
||||
// registering routes no projection would know about.
|
||||
func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("search.Mount: nil app")
|
||||
}
|
||||
z := cloud.ZipApp(app)
|
||||
if z == nil {
|
||||
return fmt.Errorf("search.Mount: %T does not expose the typed-op registry", app)
|
||||
}
|
||||
b := cloud.NewBase(deps, "search")
|
||||
log = b.Log
|
||||
app.Use(cloud.Bridge())
|
||||
zip.Post(z, "/v1/search", Query,
|
||||
zip.WithOperationID("search"),
|
||||
zip.WithSummary("Hybrid search over the org's own corpora"),
|
||||
zip.WithTags("search"))
|
||||
b.Log.Info("search surface mounted", "vector", knowledge.SemanticReady(), "index", index.Ready())
|
||||
return nil
|
||||
}
|
||||
|
||||
// log is the surface's logger, set at mount. Degradation is logged as well as
|
||||
// returned: the response tells the CALLER, the log tells the OPERATOR, and the
|
||||
// five-day outage happened because neither was told.
|
||||
var log luxlog.Logger
|
||||
|
||||
// Query is the typed op behind POST /v1/search. It does exactly two things the
|
||||
// in-process entry point must not do: resolve the tenant from the validated
|
||||
// principal, and refuse when there is none. Everything else is ForOrg.
|
||||
func Query(ctx context.Context, in *Request) (*Response, error) {
|
||||
c, ok := cloud.Request(ctx)
|
||||
if !ok {
|
||||
return nil, zip.ErrForbidden("valid principal required")
|
||||
}
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return nil, zip.ErrForbidden("valid principal required")
|
||||
}
|
||||
return ForOrg(ctx, org, in)
|
||||
}
|
||||
|
||||
// ForOrg is the composition itself, for callers that have ALREADY established the
|
||||
// tenant by some other means than an HTTP principal — notably the Team transactor,
|
||||
// which runs in this same binary and holds a session whose workspace is its org.
|
||||
// Such a caller gets the identical fused answer with no HTTP hop and no second
|
||||
// retrieval path.
|
||||
//
|
||||
// org MUST be a tenant the caller has authenticated. This function does not and
|
||||
// cannot check that; it is the caller's boundary, exactly as it is for every other
|
||||
// in-process store API in the codebase.
|
||||
func ForOrg(ctx context.Context, org string, in *Request) (*Response, error) {
|
||||
if strings.TrimSpace(org) == "" {
|
||||
return nil, zip.ErrForbidden("valid principal required")
|
||||
}
|
||||
if in == nil || strings.TrimSpace(in.Query) == "" {
|
||||
return nil, zip.ErrBadRequest("query is required")
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
// Each leg is asked for the full window (offset+limit) because fusion reorders
|
||||
// across legs — paging after fusion is the only correct order of operations.
|
||||
window := limit + in.Offset
|
||||
|
||||
start := time.Now()
|
||||
mode := resolveMode(in.Mode)
|
||||
wantText := mode == ModeText || mode == ModeHybrid
|
||||
wantVec := mode == ModeSemantic || mode == ModeHybrid
|
||||
|
||||
// lists feeds fusion (ranks only); payload maps a fused key back to the row to
|
||||
// return. Splitting them is what lets rank/ stay a leaf that knows nothing
|
||||
// about documents.
|
||||
var lists []rank.List
|
||||
payload := map[string]Result{}
|
||||
backends := make([]BackendStatus, 0, 2)
|
||||
|
||||
// ---- lexical leg ----
|
||||
st := BackendStatus{Name: BackendIndex, Status: StatusSkipped}
|
||||
if wantText {
|
||||
switch {
|
||||
case !index.Ready():
|
||||
st.Status = StatusDisabled
|
||||
default:
|
||||
t0 := time.Now()
|
||||
rows, err := index.Query(ctx, org, indexUID(in.Index), in.Query, window, 0)
|
||||
st.TookMS = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
st.Status, st.Error = StatusDegraded, err.Error()
|
||||
log.Warn("search leg failed", "backend", BackendIndex, "org", org, "err", err)
|
||||
} else {
|
||||
l := lexicalList(rows, payload)
|
||||
st.Status, st.Hits = StatusOK, len(l.Keys)
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
backends = append(backends, st)
|
||||
|
||||
// ---- semantic leg ----
|
||||
st = BackendStatus{Name: BackendVector, Status: StatusSkipped}
|
||||
if wantVec {
|
||||
switch {
|
||||
case !knowledge.SemanticReady():
|
||||
st.Status = StatusDisabled
|
||||
default:
|
||||
t0 := time.Now()
|
||||
hits, err := knowledge.Semantic(ctx, knowledge.SemanticReq{
|
||||
Org: org, Query: in.Query, Project: in.Project,
|
||||
DocTypes: in.DocTypes, Limit: window,
|
||||
})
|
||||
st.TookMS = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
st.Status, st.Error = StatusDegraded, err.Error()
|
||||
log.Warn("search leg failed", "backend", BackendVector, "org", org, "err", err)
|
||||
} else {
|
||||
l := semanticList(hits, payload)
|
||||
st.Status, st.Hits = StatusOK, len(l.Keys)
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
backends = append(backends, st)
|
||||
|
||||
fused := rank.Fuse(lists, window)
|
||||
if in.Offset > 0 {
|
||||
if in.Offset >= len(fused) {
|
||||
fused = nil
|
||||
} else {
|
||||
fused = fused[in.Offset:]
|
||||
}
|
||||
}
|
||||
hits := make([]Result, 0, len(fused))
|
||||
for _, f := range fused {
|
||||
r := payload[f.Key]
|
||||
r.Score = f.Score
|
||||
for _, o := range f.Origins {
|
||||
r.Matched = append(r.Matched, Match{Backend: o.Source, Rank: o.Rank, Score: o.Score})
|
||||
}
|
||||
hits = append(hits, r)
|
||||
}
|
||||
|
||||
return &Response{
|
||||
Status: overall(backends),
|
||||
Mode: mode,
|
||||
Hits: hits,
|
||||
Backends: backends,
|
||||
TookMS: time.Since(start).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// overall folds the per-leg statuses into the response's honesty signal. A leg
|
||||
// that was skipped or is unprovisioned does not make a query partial — only a
|
||||
// CONFIGURED leg that FAILED does. When every consulted leg failed the answer is
|
||||
// `unavailable`, which a caller must not read as "no results".
|
||||
func overall(bs []BackendStatus) string {
|
||||
consulted, failed := 0, 0
|
||||
for _, b := range bs {
|
||||
switch b.Status {
|
||||
case StatusOK:
|
||||
consulted++
|
||||
case StatusDegraded:
|
||||
consulted++
|
||||
failed++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case failed == 0:
|
||||
return StatusOK
|
||||
case failed == consulted:
|
||||
return "unavailable"
|
||||
default:
|
||||
return "partial"
|
||||
}
|
||||
}
|
||||
|
||||
// resolveMode turns the requested mode into the one actually used. `auto` prefers
|
||||
// hybrid and falls back to whichever leg this deployment actually has, so a
|
||||
// single-store deployment answers instead of half-answering.
|
||||
func resolveMode(m string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(m)) {
|
||||
case ModeText:
|
||||
return ModeText
|
||||
case ModeSemantic:
|
||||
return ModeSemantic
|
||||
case ModeHybrid:
|
||||
return ModeHybrid
|
||||
default:
|
||||
switch {
|
||||
case knowledge.SemanticReady() && index.Ready():
|
||||
return ModeHybrid
|
||||
case knowledge.SemanticReady():
|
||||
return ModeSemantic
|
||||
default:
|
||||
return ModeText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func indexUID(uid string) string {
|
||||
if u := strings.TrimSpace(uid); u != "" {
|
||||
return u
|
||||
}
|
||||
return defaultIndex
|
||||
}
|
||||
|
||||
// semanticList adapts vector hits to fusion input and records each hit's payload.
|
||||
// The Key is doctype+name — the document's identity in the KB store — so the same
|
||||
// document found by both legs fuses into ONE reinforced result rather than
|
||||
// appearing twice.
|
||||
func semanticList(hits []knowledge.Hit, payload map[string]Result) rank.List {
|
||||
l := rank.List{Source: BackendVector}
|
||||
for _, h := range hits {
|
||||
key := h.DocType + "/" + h.Name
|
||||
l.Keys = append(l.Keys, key)
|
||||
l.Scores = append(l.Scores, h.Score)
|
||||
if _, seen := payload[key]; !seen {
|
||||
payload[key] = Result{
|
||||
ID: h.Name, Corpus: "kb", DocType: h.DocType,
|
||||
Title: h.Title, URL: h.URL, Project: h.Project,
|
||||
}
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// lexicalList adapts index rows to fusion input. Rows are opaque JSON documents,
|
||||
// so identity comes from the document's own id/name field. The store ranks by
|
||||
// match count and exposes no per-row score, so no score is reported: an invented
|
||||
// number here would be precision the store never had.
|
||||
func lexicalList(rows []json.RawMessage, payload map[string]Result) rank.List {
|
||||
l := rank.List{Source: BackendIndex}
|
||||
for i, raw := range rows {
|
||||
var d map[string]any
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
continue
|
||||
}
|
||||
name := firstString(d, "name", "id", "_id")
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("row-%d", i)
|
||||
}
|
||||
doctype := firstString(d, "doctype", "type")
|
||||
key := doctype + "/" + name
|
||||
l.Keys = append(l.Keys, key)
|
||||
if _, seen := payload[key]; !seen {
|
||||
payload[key] = Result{
|
||||
ID: name, Corpus: "kb", DocType: doctype,
|
||||
Title: firstString(d, "title", "name"),
|
||||
URL: firstString(d, "url"),
|
||||
Project: firstString(d, "project"),
|
||||
}
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func firstString(d map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := d[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
func mount(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), Domain: "api.test"}); err != nil {
|
||||
t.Fatalf("search.Mount: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// post issues a search with a validated principal (X-User-Id is the signal the
|
||||
// identity middleware sets only from a verified credential).
|
||||
func post(t *testing.T, app *zip.App, org string, body any, principal bool) (int, Response) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
hr := httptest.NewRequest(http.MethodPost, "/v1/search", strings.NewReader(string(b)))
|
||||
hr.Header.Set("Content-Type", "application/json")
|
||||
if org != "" {
|
||||
hr.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
if principal {
|
||||
hr.Header.Set("X-User-Id", "u_"+org)
|
||||
}
|
||||
resp, err := app.Fiber().Test(hr)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /v1/search: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var out Response
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
// TestDegradationIsExplicit is the regression test for the failure that hid a
|
||||
// five-day vector outage: with no store provisioned, the surface must still
|
||||
// answer 200 AND say, per backend, that it has nothing wired — never an
|
||||
// unqualified empty result set that a caller reads as "no matches".
|
||||
func TestDegradationIsExplicit(t *testing.T) {
|
||||
app := mount(t)
|
||||
code, out := post(t, app, "acme", Request{Query: "how do I rotate a session cookie"}, true)
|
||||
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (a retrieval outage must not fail the caller's turn)", code)
|
||||
}
|
||||
if len(out.Backends) != 2 {
|
||||
t.Fatalf("every leg must be reported on every response, got %+v", out.Backends)
|
||||
}
|
||||
for _, b := range out.Backends {
|
||||
if b.Status == StatusOK {
|
||||
t.Fatalf("no store is mounted in this test, so no leg can be ok: %+v", b)
|
||||
}
|
||||
if b.Status != StatusDisabled && b.Status != StatusDegraded && b.Status != StatusSkipped {
|
||||
t.Fatalf("unknown backend status %q", b.Status)
|
||||
}
|
||||
}
|
||||
// Unprovisioned is NOT the same fact as broken, and must not be reported as
|
||||
// a failed query.
|
||||
if out.Status != StatusOK {
|
||||
t.Fatalf("with both legs merely unprovisioned the query itself did not fail; status = %q", out.Status)
|
||||
}
|
||||
if out.Hits == nil {
|
||||
t.Fatal("hits must serialize as [] not null")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverallSeparatesUnprovisionedFromBroken pins the four-status contract. The
|
||||
// distinction is the whole point: "never wired up" and "wired up and failing" are
|
||||
// different operational facts and an operator must be able to tell them apart.
|
||||
func TestOverallSeparatesUnprovisionedFromBroken(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []BackendStatus
|
||||
want string
|
||||
}{
|
||||
{"all ok", []BackendStatus{{Status: StatusOK}, {Status: StatusOK}}, StatusOK},
|
||||
{"unprovisioned is not a failure", []BackendStatus{{Status: StatusDisabled}, {Status: StatusSkipped}}, StatusOK},
|
||||
{"one leg down is partial", []BackendStatus{{Status: StatusOK}, {Status: StatusDegraded}}, "partial"},
|
||||
{"every consulted leg down", []BackendStatus{{Status: StatusDegraded}, {Status: StatusDegraded}}, "unavailable"},
|
||||
{"lone leg down, other unwired", []BackendStatus{{Status: StatusDegraded}, {Status: StatusDisabled}}, "unavailable"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := overall(tc.in); got != tc.want {
|
||||
t.Errorf("%s: overall = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequiresValidatedPrincipal proves the tenant boundary: a client-supplied
|
||||
// org with no validated user is the forge case and must be refused, not served.
|
||||
func TestRequiresValidatedPrincipal(t *testing.T) {
|
||||
app := mount(t)
|
||||
if code, _ := post(t, app, "victim", Request{Query: "anything"}, false); code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 for an unvalidated principal", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyQueryRejected — an empty query is a client error, not an empty result.
|
||||
func TestEmptyQueryRejected(t *testing.T) {
|
||||
app := mount(t)
|
||||
if code, _ := post(t, app, "acme", Request{Query: " "}, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeIsHonest proves an explicitly requested leg is never silently widened
|
||||
// to another, and that the mode actually used is reported back.
|
||||
func TestModeIsHonest(t *testing.T) {
|
||||
app := mount(t)
|
||||
_, out := post(t, app, "acme", Request{Query: "x", Mode: ModeText}, true)
|
||||
if out.Mode != ModeText {
|
||||
t.Fatalf("mode = %q, want %q", out.Mode, ModeText)
|
||||
}
|
||||
for _, b := range out.Backends {
|
||||
if b.Name == BackendVector && b.Status != StatusSkipped {
|
||||
t.Fatalf("mode=text must skip the vector leg, got %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLexicalListIdentity proves the adapter derives a stable cross-leg identity,
|
||||
// which is what lets the same document found by both legs fuse into one
|
||||
// reinforced row instead of appearing twice.
|
||||
func TestLexicalListIdentity(t *testing.T) {
|
||||
payload := map[string]Result{}
|
||||
rows := []json.RawMessage{
|
||||
json.RawMessage(`{"doctype":"kb-page","name":"runbook","title":"Runbook"}`),
|
||||
json.RawMessage(`{"id":"orphan"}`),
|
||||
json.RawMessage(`not json`),
|
||||
}
|
||||
l := lexicalList(rows, payload)
|
||||
if len(l.Keys) != 2 {
|
||||
t.Fatalf("want 2 usable rows (bad JSON skipped), got %v", l.Keys)
|
||||
}
|
||||
if l.Keys[0] != "kb-page/runbook" {
|
||||
t.Fatalf("key = %q, want kb-page/runbook", l.Keys[0])
|
||||
}
|
||||
if payload["kb-page/runbook"].Title != "Runbook" {
|
||||
t.Fatalf("payload not captured: %+v", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package team
|
||||
|
||||
// fulltext.go answers the SPA's `searchFulltext` RPC.
|
||||
//
|
||||
// It used to return a hardcoded empty result — the client's search box worked,
|
||||
// asked, and was told "no matches" forever. The answer now comes from the ONE
|
||||
// retrieval composition (clients/search), called IN-PROCESS: the transactor and
|
||||
// the search surface are the same binary, so a second HTTP hop would add latency
|
||||
// and a second failure mode to reach a package already linked in.
|
||||
//
|
||||
// TENANT. The org is s.org — the transactor token's VERIFIED extra.org, never a
|
||||
// client field — so a workspace can only ever search its own org's knowledge.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/search"
|
||||
)
|
||||
|
||||
// maxFulltextLimit bounds what one RPC can pull back, mirroring the surface's own
|
||||
// cap so the two cannot disagree about what "too much" means.
|
||||
const maxFulltextLimit = 50
|
||||
|
||||
// searchFulltext runs the workspace's query through the fused retrieval path and
|
||||
// renders the result in the shape the client's reviver expects: {docs, total}.
|
||||
//
|
||||
// DEGRADATION IS NOT AN ERROR HERE. When a leg is down the surface still answers
|
||||
// with whatever the survivors found; this returns those docs. Only a hard failure
|
||||
// (no tenant, malformed params) yields an empty result — and it stays a 200-shaped
|
||||
// RPC reply either way, because a search outage must not break the client's
|
||||
// session.
|
||||
func (s *session) searchFulltext(id int64, params []json.RawMessage) []byte {
|
||||
q, limit := parseFulltextParams(params)
|
||||
if q == "" {
|
||||
return s.result(id, map[string]any{"docs": []any{}, "total": 0})
|
||||
}
|
||||
res, err := search.ForOrg(context.Background(), s.org, &search.Request{Query: q, Limit: limit})
|
||||
if err != nil || res == nil {
|
||||
return s.result(id, map[string]any{"docs": []any{}, "total": 0})
|
||||
}
|
||||
docs := make([]map[string]any, 0, len(res.Hits))
|
||||
for _, h := range res.Hits {
|
||||
docs = append(docs, map[string]any{
|
||||
"id": h.ID,
|
||||
"_class": h.DocType,
|
||||
"title": h.Title,
|
||||
"doctype": h.DocType,
|
||||
"project": h.Project,
|
||||
"url": h.URL,
|
||||
"score": h.Score,
|
||||
})
|
||||
}
|
||||
return s.result(id, map[string]any{"docs": docs, "total": len(docs)})
|
||||
}
|
||||
|
||||
// parseFulltextParams reads the client's (query, options) pair. The client sends
|
||||
// either a bare string or Huly's {query: "..."} object, and options carry the
|
||||
// limit; anything absent or out of range falls back to a sane default rather than
|
||||
// failing the RPC.
|
||||
func parseFulltextParams(params []json.RawMessage) (string, int) {
|
||||
limit := 10
|
||||
if len(params) == 0 {
|
||||
return "", limit
|
||||
}
|
||||
var q string
|
||||
if err := json.Unmarshal(params[0], &q); err != nil {
|
||||
var obj struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := json.Unmarshal(params[0], &obj); err == nil {
|
||||
q = obj.Query
|
||||
}
|
||||
}
|
||||
if len(params) > 1 {
|
||||
var opts struct {
|
||||
Limit *int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal(params[1], &opts); err == nil && opts.Limit != nil {
|
||||
if *opts.Limit > 0 && *opts.Limit <= maxFulltextLimit {
|
||||
limit = *opts.Limit
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(q), limit
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseFulltextParams pins the two request shapes the SPA actually sends —
|
||||
// a bare string and Huly's {query} object — plus the options limit. A parser that
|
||||
// silently returned "" for the object form would reinstate the old bug (a search
|
||||
// box that always reports no matches) while looking wired up.
|
||||
func TestParseFulltextParams(t *testing.T) {
|
||||
raw := func(vals ...string) []json.RawMessage {
|
||||
out := make([]json.RawMessage, 0, len(vals))
|
||||
for _, v := range vals {
|
||||
out = append(out, json.RawMessage(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
params []json.RawMessage
|
||||
wantQ string
|
||||
wantLimit int
|
||||
}{
|
||||
{"bare string", raw(`"incident runbook"`), "incident runbook", 10},
|
||||
{"query object", raw(`{"query":"incident runbook"}`), "incident runbook", 10},
|
||||
{"with limit", raw(`"x"`, `{"limit":25}`), "x", 25},
|
||||
{"limit over cap ignored", raw(`"x"`, `{"limit":9999}`), "x", 10},
|
||||
{"negative limit ignored", raw(`"x"`, `{"limit":-3}`), "x", 10},
|
||||
{"whitespace trimmed", raw(`" padded "`), "padded", 10},
|
||||
{"no params", nil, "", 10},
|
||||
{"unusable param", raw(`12345`), "", 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
q, limit := parseFulltextParams(tc.params)
|
||||
if q != tc.wantQ || limit != tc.wantLimit {
|
||||
t.Errorf("%s: got (%q, %d), want (%q, %d)", tc.name, q, limit, tc.wantQ, tc.wantLimit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchFulltextEmptyQueryShape proves an unusable query still produces the
|
||||
// exact wire shape the client's reviver expects, rather than a null the SPA would
|
||||
// throw on.
|
||||
func TestSearchFulltextEmptyQueryShape(t *testing.T) {
|
||||
s := &session{org: "acme"}
|
||||
out := s.searchFulltext(7, nil)
|
||||
|
||||
var env struct {
|
||||
Result struct {
|
||||
Docs []any `json:"docs"`
|
||||
Total int `json:"total"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &env); err != nil {
|
||||
t.Fatalf("reply is not decodable JSON: %v (%s)", err, out)
|
||||
}
|
||||
if env.Result.Docs == nil {
|
||||
t.Fatalf("docs must serialize as [] not null: %s", out)
|
||||
}
|
||||
if env.Result.Total != 0 {
|
||||
t.Fatalf("total = %d, want 0", env.Result.Total)
|
||||
}
|
||||
}
|
||||
@@ -242,9 +242,7 @@ func (s *session) handle(payload []byte) []byte {
|
||||
case "domainRequest":
|
||||
return s.domainRequest(req.ID, req.Params)
|
||||
case "searchFulltext":
|
||||
// Full-text is served by hanzoai/search, not the SQLite data plane; until
|
||||
// that proxy lands an empty result keeps queries non-fatal.
|
||||
return s.result(req.ID, map[string]any{"docs": []any{}, "total": 0})
|
||||
return s.searchFulltext(req.ID, req.Params)
|
||||
case "loadChunk":
|
||||
return s.result(req.ID, map[string]any{"idx": 0, "docs": []any{}, "finished": true})
|
||||
case "getDomainHash":
|
||||
|
||||
Reference in New Issue
Block a user