feat: index the text of attached PDFs

- jz_bm_material_file_texts caches the pdftotext output per file
- the River job jz.bm.extract_material_text extracts and resyncs
- the management multipart attach queues it inside its transaction
- file_texts in the document and a file snippet with its name
This commit is contained in:
Jakub Zych
2026-11-08 20:11:19 +01:00
parent dd13795e6f
commit 412821ec56
21 changed files with 747 additions and 24 deletions

View File

@@ -9,6 +9,7 @@ import (
"git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// Material search: the engine (beachcomber, Typesense) answers candidate ids
@@ -66,17 +67,20 @@ type snippetJSON struct {
}
// pickSnippet returns the snippet of m from hit: the first body snippet,
// mapped one to one. It is nil when the hit has none. Call it only for a
// row the SQL re-gate returned.
func pickSnippet(hit beachcomber.Hit, m models.Material) *snippetJSON {
// mapped one to one, or else the first file_texts snippet, with the name of
// the file at its array index in files (the IndexedFiles of m, in document
// order; nil when the index does not resolve). A file text is long and is
// not loaded for the page, so a file snippet is always cut on both sides.
// It is nil when the hit has neither. Call it only for a row the SQL
// re-gate returned.
func pickSnippet(hit beachcomber.Hit, m models.Material, files []models.IndexedFile) *snippetJSON {
for _, sn := range hit.Highlights {
if sn.Field != "body" || len(sn.Segments) == 0 {
continue
}
out := &snippetJSON{Source: "body", Segments: make([]segmentJSON, 0, len(sn.Segments))}
out := &snippetJSON{Source: "body", Segments: snippetSegments(sn)}
var joined strings.Builder
for _, seg := range sn.Segments {
out.Segments = append(out.Segments, segmentJSON{Text: seg.Text, Hit: seg.Match})
joined.WriteString(seg.Text)
}
body := ""
@@ -88,9 +92,66 @@ func pickSnippet(hit beachcomber.Hit, m models.Material) *snippetJSON {
out.Trail = !strings.HasSuffix(body, text)
return out
}
for _, sn := range hit.Highlights {
if sn.Field != fileTextsField || len(sn.Segments) == 0 {
continue
}
out := &snippetJSON{Source: "file", Lead: true, Trail: true, Segments: snippetSegments(sn)}
if sn.ArrayIndex >= 0 && sn.ArrayIndex < len(files) {
name := files[sn.ArrayIndex].FileName
out.FileName = &name
}
return out
}
return nil
}
// fileTextsField is the document field of the attached files' texts.
const fileTextsField = "file_texts"
// snippetSegments maps the engine's segments one to one.
func snippetSegments(sn beachcomber.Snippet) []segmentJSON {
out := make([]segmentJSON, 0, len(sn.Segments))
for _, seg := range sn.Segments {
out = append(out, segmentJSON{Text: seg.Text, Hit: seg.Match})
}
return out
}
// hasFileSnippet reports whether hit carries a file_texts snippet and no
// body one, so its snippet needs the material's file names.
func hasFileSnippet(hit beachcomber.Hit) bool {
file := false
for _, sn := range hit.Highlights {
if len(sn.Segments) == 0 {
continue
}
switch sn.Field {
case "body":
return false
case fileTextsField:
file = true
}
}
return file
}
// snippetFiles loads, once per page, the indexed files (names only) of the
// rows whose snippet comes from a file. It reads nothing when no row needs
// it. ids are rows SQL returned.
func snippetFiles(db *gorm.DB, ids []uint, hits map[uint]beachcomber.Hit) (map[uint][]models.IndexedFile, error) {
var need []uint
for _, id := range ids {
if hit, ok := hits[id]; ok && hasFileSnippet(hit) {
need = append(need, id)
}
}
if len(need) == 0 {
return nil, nil
}
return models.IndexedFiles(db, need, false)
}
// flattenSnippet is a snippet as plain text, for MCP: the segment texts
// joined, with an ellipsis where text was cut.
func flattenSnippet(s *snippetJSON) string {