coverage~bigbes/sr-ht-spec3cb1c03dmcpsrv/search.go

Coverage
95.5% 42/44 statements
Δ
Blob
e6522a5
Uncovered L71-L72L140-L141
1 package mcpsrv
2
3 import (
4 "context"
5 "errors"
6 "html"
7 "strings"
8
9 "sourcecraft.dev/bigbes/sr-ht-spec/core"
10 "sourcecraft.dev/bigbes/sr-ht-spec/search"
11 )
12
13 type searchInput struct {
14 Query string `json:"query" jsonschema:"what to search for; matched over document titles and body text in both English and Russian"`
15 // Spaces is the project filter. The design's "a project is a saved filter
16 // over one global index, not a container" is this argument and nothing
17 // else, which is why it is a space list rather than a project name: a
18 // project's membership is resolved by whoever holds the project row, and
19 // what search takes is the set it resolves to.
20 Spaces []string `json:"spaces,omitempty" jsonschema:"restrict the search to these spaces, each written \"~owner/name\" as spec_list reports them. This is the project filter: a project on this service is a named set of spaces. Omit to search every space."`
21 Sections []string `json:"sections,omitempty" jsonschema:"restrict the search to these top-level sections (\"specs\", \"notes\", \"reports\"). Omit to search every section except the dated activity log; name \"log\" to search that."`
22 Limit int `json:"limit,omitempty" jsonschema:"maximum number of hits to return, clamped to 1..100"`
23 Offset int `json:"offset,omitempty" jsonschema:"how many of the top-ranked hits to skip, for paging through a large result set"`
24 }
25
26 // searchHit is one result as an agent sees it.
27 //
28 // It is search.Hit reshaped for a tool caller rather than the type itself:
29 // Snippet becomes plain text, and ID is split from Anchor so that every id
30 // reported here is one spec_read accepts.
31 type searchHit struct {
32 Space string `json:"space"`
33 // ID addresses the document in spec_read.
34 ID string `json:"id"`
35 // Path is the document's path in the space's git tree.
36 Path string `json:"path,omitempty"`
37 // Rev is the revision the document was indexed at — the approved head of
38 // its space at index time. Pass it to spec_read to pin the read to exactly
39 // what was searched.
40 Rev string `json:"rev,omitempty"`
41 // Anchor is the heading fragment a hit inside a dated activity log lands
42 // on. Empty for an ordinary document.
43 Anchor string `json:"anchor,omitempty"`
44 Title string `json:"title,omitempty"`
45 Section string `json:"section,omitempty"`
46 Score float64 `json:"score"`
47 // Snippet is the matching fragment as plain text.
48 Snippet string `json:"snippet,omitempty"`
49 }
50
51 type searchOutput struct {
52 Hits []searchHit `json:"hits"`
53 // Total is how many documents matched, not how many are in Hits.
54 Total uint64 `json:"total"`
55 }
56
57 11 func searchHandler(ctx context.Context, b Backend, in searchInput) (searchOutput, error) {
58 11 if err := requireRead(ctx); err != nil {
59 1 return searchOutput{}, err
60 1 }
61 10 text := strings.TrimSpace(in.Query)
62 10 if text == "" {
63 1 return searchOutput{}, errors.New("query must not be empty")
64 1 }
65 9 spaces, err := parseSpaceFilter(in.Spaces)
66 9 if err != nil {
67 1 return searchOutput{}, err
68 1 }
69 8 sections, err := trimAll("section", in.Sections)
70 8 if err != nil {
71 0 return searchOutput{}, err
72 0 }
73
74 8 res, err := b.Index.Search(ctx, search.Query{
75 8 Text: text,
76 8 Spaces: spaces,
77 8 Sections: sections,
78 8 Limit: clampLimit(in.Limit),
79 8 Offset: in.Offset,
80 8 })
81 8 if err != nil {
82 1 // A query that matched nothing is an empty result set and not an error,
83 1 // so every error the index can return here is the index failing. An
84 1 // agent told "no hits" by a closed index would conclude the corpus does
85 1 // not cover what it asked about.
86 1 return searchOutput{}, internalError(err, "spec_search")
87 1 }
88
89 7 out := searchOutput{Hits: make([]searchHit, 0, len(res.Hits)), Total: res.Total}
90 7 for _, h := range res.Hits {
91 2 out.Hits = append(out.Hits, searchHit{
92 2 Space: h.Space.String(),
93 2 ID: documentID(h.ID),
94 2 Path: h.Path,
95 2 Rev: h.Rev,
96 2 Anchor: h.Anchor,
97 2 Title: h.Title,
98 2 Section: h.Section,
99 2 Score: h.Score,
100 2 Snippet: plainSnippet(h.Snippet),
101 2 })
102 2 }
103 7 return out, nil
104 }
105
106 // parseSpaceFilter validates the project filter. An unparseable space is an
107 // error rather than a dropped filter term: dropping one would silently widen
108 // the search past the set the caller asked for, and a wider answer than
109 // requested is indistinguishable from a correct one.
110 //
111 // An omitted argument is every space — which the tool schema promises — and it
112 // is returned as the filter that says so. The distinction matters one layer
113 // down: "the agent named no spaces" is not the same as "the project the agent
114 // named holds no spaces", and only a filter can tell them apart.
115 9 func parseSpaceFilter(in []string) (core.SpaceFilter, error) {
116 9 if len(in) == 0 {
117 7 return core.EverythingFilter(), nil
118 7 }
119 2 refs := make([]core.SpaceRef, 0, len(in))
120 4 for _, s := range in {
121 4 ref, err := parseSpace(s)
122 4 if err != nil {
123 1 return core.SpaceFilter{}, err
124 1 }
125 3 refs = append(refs, ref)
126 }
127 1 return core.SpacesFilter(refs, nil), nil
128 }
129
130 // trimAll trims each element and refuses an empty one. search/ rejects an empty
131 // filter term outright; catching it here names the argument that carried it.
132 8 func trimAll(what string, in []string) ([]string, error) {
133 8 if len(in) == 0 {
134 7 return nil, nil
135 7 }
136 1 out := make([]string, 0, len(in))
137 1 for _, s := range in {
138 1 t := strings.TrimSpace(s)
139 1 if t == "" {
140 0 return nil, errors.New(what + " must not be empty")
141 0 }
142 1 out = append(out, t)
143 }
144 1 return out, nil
145 }
146
147 // documentID strips the entry suffix an activity-log hit carries.
148 //
149 // search/ indexes each dated entry of a log as its own document under
150 // "<page id>#<date>-<n>", so that a hit lands on the entry rather than on the
151 // whole log. That id is not a document id: spec_read would not resolve it. The
152 // entry's position is already reported separately as Anchor, so the split loses
153 // nothing and makes every id in a search result one an agent can hand straight
154 // to spec_read.
155 2 func documentID(id string) string {
156 2 if i := strings.IndexByte(id, '#'); i >= 0 {
157 1 return id[:i]
158 1 }
159 1 return id
160 }
161
162 // plainSnippet converts bleve's highlighted fragment to plain text.
163 //
164 // search.Hit.Snippet is HTML: the matched terms are wrapped in <mark> and
165 // everything around them is HTML-escaped, because the web UI renders it. A tool
166 // result is not rendered, so leaving it would show an agent literal "&amp;" and
167 // "<mark>" and invite it to copy them into prose. Both are undone exactly
168 // rather than by a general tag stripper: the only markup bleve's formatter
169 // emits is that one tag pair, so removing it and unescaping restores the
170 // document's own text byte for byte.
171 //
172 // This is coupled to search/'s choice of highlighter. If that ever stops being
173 // bleve's default HTML formatter, this must change with it.
174 2 func plainSnippet(s string) string {
175 2 if s == "" {
176 1 return ""
177 1 }
178 1 s = strings.ReplaceAll(s, "<mark>", "")
179 1 s = strings.ReplaceAll(s, "</mark>", "")
180 1 return html.UnescapeString(s)
181 }