~bigbes/sr-ht-spec

ref: 2928bf9d79d9ae999a27c74ee5811693489a3a4e sr-ht-spec/mcpsrv/search.go -rw-r--r-- 6.4 KiB
2928bf9d — bigbes feat(cmd): specsrht space create/list 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
package mcpsrv

import (
	"context"
	"errors"
	"html"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"
)

type searchInput struct {
	Query string `json:"query" jsonschema:"what to search for; matched over document titles and body text in both English and Russian"`
	// Spaces is the project filter. The design's "a project is a saved filter
	// over one global index, not a container" is this argument and nothing
	// else, which is why it is a space list rather than a project name: a
	// project's membership is resolved by whoever holds the project row, and
	// what search takes is the set it resolves to.
	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."`
	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."`
	Limit    int      `json:"limit,omitempty" jsonschema:"maximum number of hits to return, clamped to 1..100"`
	Offset   int      `json:"offset,omitempty" jsonschema:"how many of the top-ranked hits to skip, for paging through a large result set"`
}

// searchHit is one result as an agent sees it.
//
// It is search.Hit reshaped for a tool caller rather than the type itself:
// Snippet becomes plain text, and ID is split from Anchor so that every id
// reported here is one spec_read accepts.
type searchHit struct {
	Space string `json:"space"`
	// ID addresses the document in spec_read.
	ID string `json:"id"`
	// Path is the document's path in the space's git tree.
	Path string `json:"path,omitempty"`
	// Rev is the revision the document was indexed at — the approved head of
	// its space at index time. Pass it to spec_read to pin the read to exactly
	// what was searched.
	Rev string `json:"rev,omitempty"`
	// Anchor is the heading fragment a hit inside a dated activity log lands
	// on. Empty for an ordinary document.
	Anchor  string  `json:"anchor,omitempty"`
	Title   string  `json:"title,omitempty"`
	Section string  `json:"section,omitempty"`
	Score   float64 `json:"score"`
	// Snippet is the matching fragment as plain text.
	Snippet string `json:"snippet,omitempty"`
}

type searchOutput struct {
	Hits []searchHit `json:"hits"`
	// Total is how many documents matched, not how many are in Hits.
	Total uint64 `json:"total"`
}

func searchHandler(ctx context.Context, b Backend, in searchInput) (searchOutput, error) {
	text := strings.TrimSpace(in.Query)
	if text == "" {
		return searchOutput{}, errors.New("query must not be empty")
	}
	spaces, err := parseSpaceFilter(in.Spaces)
	if err != nil {
		return searchOutput{}, err
	}
	sections, err := trimAll("section", in.Sections)
	if err != nil {
		return searchOutput{}, err
	}

	res, err := b.Index.Search(ctx, search.Query{
		Text:     text,
		Spaces:   spaces,
		Sections: sections,
		Limit:    clampLimit(in.Limit),
		Offset:   in.Offset,
	})
	if err != nil {
		return searchOutput{}, err
	}

	out := searchOutput{Hits: make([]searchHit, 0, len(res.Hits)), Total: res.Total}
	for _, h := range res.Hits {
		out.Hits = append(out.Hits, searchHit{
			Space:   h.Space.String(),
			ID:      documentID(h.ID),
			Path:    h.Path,
			Rev:     h.Rev,
			Anchor:  h.Anchor,
			Title:   h.Title,
			Section: h.Section,
			Score:   h.Score,
			Snippet: plainSnippet(h.Snippet),
		})
	}
	return out, nil
}

// parseSpaceFilter validates the project filter. An unparseable space is an
// error rather than a dropped filter term: dropping one would silently widen
// the search past the set the caller asked for, and a wider answer than
// requested is indistinguishable from a correct one.
//
// An omitted argument is every space — which the tool schema promises — and it
// is returned as the filter that says so. The distinction matters one layer
// down: "the agent named no spaces" is not the same as "the project the agent
// named holds no spaces", and only a filter can tell them apart.
func parseSpaceFilter(in []string) (core.SpaceFilter, error) {
	if len(in) == 0 {
		return core.EverythingFilter(), nil
	}
	refs := make([]core.SpaceRef, 0, len(in))
	for _, s := range in {
		ref, err := parseSpace(s)
		if err != nil {
			return core.SpaceFilter{}, err
		}
		refs = append(refs, ref)
	}
	return core.SpacesFilter(refs, nil), nil
}

// trimAll trims each element and refuses an empty one. search/ rejects an empty
// filter term outright; catching it here names the argument that carried it.
func trimAll(what string, in []string) ([]string, error) {
	if len(in) == 0 {
		return nil, nil
	}
	out := make([]string, 0, len(in))
	for _, s := range in {
		t := strings.TrimSpace(s)
		if t == "" {
			return nil, errors.New(what + " must not be empty")
		}
		out = append(out, t)
	}
	return out, nil
}

// documentID strips the entry suffix an activity-log hit carries.
//
// search/ indexes each dated entry of a log as its own document under
// "<page id>#<date>-<n>", so that a hit lands on the entry rather than on the
// whole log. That id is not a document id: spec_read would not resolve it. The
// entry's position is already reported separately as Anchor, so the split loses
// nothing and makes every id in a search result one an agent can hand straight
// to spec_read.
func documentID(id string) string {
	if i := strings.IndexByte(id, '#'); i >= 0 {
		return id[:i]
	}
	return id
}

// plainSnippet converts bleve's highlighted fragment to plain text.
//
// search.Hit.Snippet is HTML: the matched terms are wrapped in <mark> and
// everything around them is HTML-escaped, because the web UI renders it. A tool
// result is not rendered, so leaving it would show an agent literal "&amp;" and
// "<mark>" and invite it to copy them into prose. Both are undone exactly
// rather than by a general tag stripper: the only markup bleve's formatter
// emits is that one tag pair, so removing it and unescaping restores the
// document's own text byte for byte.
//
// This is coupled to search/'s choice of highlighter. If that ever stops being
// bleve's default HTML formatter, this must change with it.
func plainSnippet(s string) string {
	if s == "" {
		return ""
	}
	s = strings.ReplaceAll(s, "<mark>", "")
	s = strings.ReplaceAll(s, "</mark>", "")
	return html.UnescapeString(s)
}