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:
2026-07-27 20:37:59 -07:00
parent 6a6f5ab8dc
commit 0ac9a91e0d
10 changed files with 1033 additions and 19 deletions
+16 -16
View File
@@ -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
+32
View File
@@ -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 }
+45
View File
@@ -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() }
+113
View File
@@ -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
}
+107
View File
@@ -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)
}
}
+412
View File
@@ -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 ""
}
+155
View File
@@ -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)
}
}
+87
View File
@@ -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
}
+65
View File
@@ -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)
}
}
+1 -3
View File
@@ -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":