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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user