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

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