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:
Jakub Zych
2026-11-08 19:53:26 +01:00
parent ffd19342b2
commit dd13795e6f
7 changed files with 126 additions and 17 deletions

View File

@@ -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)
}
}