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

@@ -0,0 +1,18 @@
package models
import "time"
// MaterialFileText is the text extracted from one PDF attached to a
// material. An empty Text means the file was extracted and held none (a
// scan or a broken PDF). Truncated is set when the text was cut at the
// extraction cap. The row goes with its file (foreign-key cascade).
type MaterialFileText struct {
FileID uint `gorm:"column:file_id;primaryKey;autoIncrement:false"`
MaterialID uint `gorm:"column:material_id"`
Text string `gorm:"column:text"`
Truncated bool `gorm:"column:truncated"`
ExtractedAt time.Time `gorm:"column:extracted_at"`
}
// TableName is the file text table.
func (MaterialFileText) TableName() string { return "jz_bm_material_file_texts" }

View File

@@ -58,7 +58,71 @@ func (m *Material) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[stri
if err != nil {
return nil, err
}
return MaterialDocument(*m, course, meetingNumber, tags, nil), nil
files, err := IndexedFiles(db, []uint{m.ID}, true)
if err != nil {
return nil, err
}
texts := make([]string, 0, len(files[m.ID]))
for _, f := range files[m.ID] {
texts = append(texts, f.Text)
}
return MaterialDocument(*m, course, meetingNumber, tags, texts), nil
}
// IndexedFile is one entry of a material document's file_texts: the file,
// its name and, when asked for, its extracted text.
type IndexedFile struct {
FileID uint
FileName string
Text string
}
// IndexedFiles returns, per material id, the files whose text the search
// document holds, in document order: the current public files of the
// material's files field that have a non-empty extracted text, by
// sort_order (nulls last) and id. It is the one query that decides what
// file_texts holds, so a snippet's array index maps back to a file through
// it. A detached file is no longer a current file, so its text drops out
// even before the cascade removes it. The text is read only when withText.
func IndexedFiles(db *gorm.DB, materialIDs []uint, withText bool) (map[uint][]IndexedFile, error) {
out := map[uint][]IndexedFile{}
if len(materialIDs) == 0 {
return out, nil
}
ids := make([]string, 0, len(materialIDs))
for _, id := range materialIDs {
ids = append(ids, strconv.FormatUint(uint64(id), 10))
}
cols := "system_files.id AS file_id, system_files.file_name, system_files.attachment_id"
if withText {
cols += ", t.text"
}
var rows []struct {
FileID uint
FileName string
AttachmentID string
Text string
}
err := db.Table("system_files").
Select(cols).
Joins("JOIN jz_bm_material_file_texts t ON t.file_id = system_files.id").
Where("system_files.attachment_type = ? AND system_files.field = ? AND system_files.is_public", Material{}.MorphName(), MaterialFilesField).
Where("system_files.attachment_id IN ?", ids).
Where("t.text <> ''").
Order("system_files.attachment_id, system_files.sort_order NULLS LAST, system_files.id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
n, err := strconv.ParseUint(r.AttachmentID, 10, 64)
if err != nil {
continue
}
id := uint(n)
out[id] = append(out[id], IndexedFile{FileID: r.FileID, FileName: r.FileName, Text: r.Text})
}
return out, nil
}
// MaterialDocument is the search document of m with its course, meeting

View File

@@ -102,6 +102,16 @@ func TestMaterialDocument(t *testing.T) {
t.Errorf("file_texts = %v", doc["file_texts"])
}
// File texts keep their order (the IndexedFiles order); an empty list
// leaves the key out.
ordered := models.MaterialDocument(m, course, 3, tags, []string{"pierwszy plik", "drugi plik", "trzeci plik"})
if got, _ := ordered["file_texts"].([]string); !slices.Equal(got, []string{"pierwszy plik", "drugi plik", "trzeci plik"}) {
t.Errorf("file_texts = %v, want the three texts in order", ordered["file_texts"])
}
if _, ok := models.MaterialDocument(m, course, 3, tags, []string{})["file_texts"]; ok {
t.Error("an empty file text list carries file_texts")
}
// No code, no course id, no meeting, a draft, no files.
bare := models.MaterialDocument(models.Material{ID: 5, Title: "Szkic", Type: "inne"}, &models.Course{Name: "Teoria"}, 0, nil, nil)
if bare["course"] != "Teoria" {