feat: search the management list and the MCP material tools
- GET /api/v1/bm/materials takes q: candidates over every material, in rank order, with the snippet of the student list; 503 when the engine cannot answer - bm_materials and bm_manage_list_materials take q; under MCP the snippet is plain text with ellipses where text was cut - MCPMaterialPrivacy no longer claims no file text is ever returned - README documents q, the snippet, the 503, the Search section and bm:materials-reindex
This commit is contained in:
@@ -155,6 +155,9 @@ type managedMaterialJSON struct {
|
||||
AuthorUserID *uint `json:"author_user_id"`
|
||||
Author *userRefJSON `json:"author"`
|
||||
Shares []userRefJSON `json:"shares"`
|
||||
// Snippet is the matched passage of a search result, as on the
|
||||
// student list; nil, and the key absent, without q.
|
||||
Snippet any `json:"snippet,omitempty"`
|
||||
}
|
||||
|
||||
// materialPeople are the authors and shares of a batch of materials, as
|
||||
@@ -206,27 +209,58 @@ func newManagedMaterialJSON(m models.Material, people materialPeople) managedMat
|
||||
return out
|
||||
}
|
||||
|
||||
// ManagedMaterials answers GET /materials: every material, drafts
|
||||
// included, newest first. An MCP call gets the bodies only with
|
||||
// include_body (see managedMaterialView).
|
||||
// ManagedMaterials answers GET /materials?q=&include_body=: every material,
|
||||
// drafts and private ones included, newest first. With q (2 to 100
|
||||
// characters) the engine answers candidates, which are loaded in rank order
|
||||
// with at most one snippet each; when the engine cannot answer the request
|
||||
// is a 503. An MCP call gets the bodies only with include_body (see
|
||||
// managedMaterialView) and the snippets as plain text.
|
||||
func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
|
||||
in := query(r, "q", "include_body")
|
||||
if !h.validate(w, r, in, manageMaterialsRules) {
|
||||
return
|
||||
}
|
||||
var cands *classes.MaterialCandidates
|
||||
if q := searchQuery(in); q != "" {
|
||||
c, err := h.MaterialIndex.Search(r.Context(), classes.MaterialSearch{Q: q})
|
||||
if err != nil {
|
||||
writeSearchUnavailable(w)
|
||||
return
|
||||
}
|
||||
cands = &c
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rows []models.Material
|
||||
if err := preloadMaterial(db).Order("jz_bm_materials.id DESC").Find(&rows).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
if cands == nil || len(cands.IDs) > 0 {
|
||||
q := preloadMaterial(db)
|
||||
if cands != nil {
|
||||
q = q.Where("jz_bm_materials.id IN ?", cands.IDs).Order(rankOrder(cands.IDs))
|
||||
} else {
|
||||
q = q.Order("jz_bm_materials.id DESC")
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
people, err := loadMaterialPeople(db, rows...)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
mcp := viaMCP(r)
|
||||
out := make([]any, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
out = append(out, managedMaterialView(newManagedMaterialJSON(m, people), viaMCP(r), includeBody(r)))
|
||||
row := newManagedMaterialJSON(m, people)
|
||||
if cands != nil {
|
||||
if hit, ok := cands.Hits[m.ID]; ok {
|
||||
row.Snippet = snippetValue(pickSnippet(hit, m), mcp)
|
||||
}
|
||||
}
|
||||
out = append(out, managedMaterialView(row, mcp, includeBody(r)))
|
||||
}
|
||||
writeData(w, out)
|
||||
}
|
||||
|
||||
@@ -198,3 +198,43 @@ func TestMaterialsSearchTrace(t *testing.T) {
|
||||
t.Errorf("total %d data %v, want the dry SQL's 0 (never the engine's found)", out.Meta.Total, out.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedMaterialsSearch(t *testing.T) {
|
||||
h := noDB()
|
||||
user := &bouncer.Principal{ID: 7}
|
||||
code, body := serve(t, h.ManagedMaterials, http.MethodGet, "/?q=globalizacja", user, nil)
|
||||
if code != http.StatusServiceUnavailable || body["error"] != true || body["message"] != "Search is unavailable." {
|
||||
t.Errorf("q without an index = %d %v, want 503 Search is unavailable.", code, body)
|
||||
}
|
||||
code, body = serve(t, h.ManagedMaterials, http.MethodGet, "/?q="+strings.Repeat("a", 101), user, nil)
|
||||
if code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("q over 100 characters = %d %v, want 422", code, body)
|
||||
} else if errs, _ := body["errors"].(map[string]any); errs["q"] == nil {
|
||||
t.Errorf("422 errors %v have no q message", errs)
|
||||
}
|
||||
code, body = serve(t, h.ManagedMaterials, http.MethodGet, "/?q=a&include_body=true", user, nil)
|
||||
if code != http.StatusServiceUnavailable || body["message"] != "Database unavailable." {
|
||||
t.Errorf("one-rune q = %d %v, want the no-database answer", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialSnippetMCP(t *testing.T) {
|
||||
body := "Wstęp. Proces globalizacji zmienia państwa. Koniec."
|
||||
m := models.Material{Body: &body}
|
||||
mid := api.PickSnippet(beachcomber.Hit{Highlights: []beachcomber.Snippet{{Field: "body", ArrayIndex: -1, Segments: segments("Proces ", false, "globalizacji", true, " zmienia", false)}}}, m)
|
||||
if got := api.FlattenSnippet(mid); got != "…Proces globalizacji zmienia…" {
|
||||
t.Errorf("middle = %q, want ellipses on both sides", got)
|
||||
}
|
||||
head := api.PickSnippet(beachcomber.Hit{Highlights: []beachcomber.Snippet{{Field: "body", ArrayIndex: -1, Segments: segments("Wstęp", true, ". Proces", false)}}}, m)
|
||||
if got := api.FlattenSnippet(head); got != "Wstęp. Proces…" {
|
||||
t.Errorf("head = %q, want a trailing ellipsis only", got)
|
||||
}
|
||||
tail := api.PickSnippet(beachcomber.Hit{Highlights: []beachcomber.Snippet{{Field: "body", ArrayIndex: -1, Segments: segments("państwa. ", false, "Koniec", true, ".", false)}}}, m)
|
||||
if got := api.FlattenSnippet(tail); got != "…państwa. Koniec." {
|
||||
t.Errorf("tail = %q, want a leading ellipsis only", got)
|
||||
}
|
||||
whole := api.PickSnippet(beachcomber.Hit{Highlights: []beachcomber.Snippet{{Field: "body", ArrayIndex: -1, Segments: segments(body, false)}}}, m)
|
||||
if got := api.FlattenSnippet(whole); got != body {
|
||||
t.Errorf("whole = %q, want no ellipsis", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ var (
|
||||
{Field: "per_page", Rules: lagoon.ParseRules("nullable|integer|between:1,100")},
|
||||
{Field: "q", Rules: lagoon.ParseRules("nullable|string|max:100")},
|
||||
}
|
||||
// manageMaterialsRules check the search of the management list.
|
||||
manageMaterialsRules = []lagoon.RequestRule{
|
||||
{Field: "q", Rules: lagoon.ParseRules("nullable|string|max:100")},
|
||||
}
|
||||
)
|
||||
|
||||
// query collects the named query parameters that are present into the input
|
||||
|
||||
Reference in New Issue
Block a user