AI: Prevent adding labels without title or slug #5232 #5233 #5234

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-01 01:40:34 +02:00
parent 9b5ee0513b
commit bd1be3346e
2 changed files with 70 additions and 14 deletions

View File

@@ -779,23 +779,38 @@ func (m *Photo) ShouldGenerateLabels(force bool) bool {
return true
}
// AddLabels ensures classify labels exist as Label entities and attaches them to the photo, tightening uncertainty when higher confidence values arrive.
// AddLabels ensures classify labels exist as Label entities and attaches them to the photo.
// Labels are skipped when they have no usable title or carry 0% probability so that UpdateClassify
// never receives invalid input from upstream detectors.
func (m *Photo) AddLabels(labels classify.Labels) {
for _, classifyLabel := range labels {
labelEntity := FirstOrCreateLabel(NewLabel(classifyLabel.Title(), classifyLabel.Priority))
title := classifyLabel.Title()
if title == "" || txt.Slug(title) == "" {
log.Debugf("index: skipping blank label (%s)", m)
continue
}
if classifyLabel.Uncertainty >= 100 {
log.Debugf("index: skipping label %s with zero probability (%s)", title, m)
continue
}
labelEntity := FirstOrCreateLabel(NewLabel(title, classifyLabel.Priority))
if labelEntity == nil {
log.Errorf("index: label %s could not be created (%s)", clean.Log(classifyLabel.Title()), m)
log.Errorf("index: label %s could not be created (%s)", clean.Log(title), m)
continue
}
if labelEntity.Deleted() {
log.Debugf("index: skipping deleted label %s (%s)", clean.Log(classifyLabel.Title()), m)
log.Debugf("index: skipping deleted label %s (%s)", clean.Log(title), m)
continue
}
if err := labelEntity.UpdateClassify(classifyLabel); err != nil {
log.Errorf("index: failed to update label %s (%s)", clean.Log(classifyLabel.Title()), err)
log.Errorf("index: failed to update label %s (%s)", clean.Log(title), err)
}
labelSrc := classifyLabel.Source

View File

@@ -439,6 +439,15 @@ func TestPhoto_GetDetails(t *testing.T) {
}
func TestPhoto_AddLabels(t *testing.T) {
resetLabel := func(t *testing.T, photoName, labelName, src string, uncertainty int) {
t.Helper()
photo := PhotoFixtures.Get(photoName)
label := LabelFixtures.Get(labelName)
assert.NoError(t, UnscopedDb().Model(&PhotoLabel{}).
Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).
UpdateColumns(Values{"Uncertainty": uncertainty, "LabelSrc": src}).Error)
}
t.Run("Add", func(t *testing.T) {
m := PhotoFixtures.Get("19800101_000002_D640C559")
classifyLabels := classify.Labels{{Name: "cactus", Uncertainty: 30, Source: SrcManual, Priority: 5, Categories: []string{"plant"}}}
@@ -457,15 +466,6 @@ func TestPhoto_AddLabels(t *testing.T) {
assert.Equal(t, 10, m.Labels[0].Uncertainty)
assert.Equal(t, SrcManual, m.Labels[0].LabelSrc)
})
resetLabel := func(t *testing.T, photoName, labelName, src string, uncertainty int) {
t.Helper()
photo := PhotoFixtures.Get(photoName)
label := LabelFixtures.Get(labelName)
assert.NoError(t, UnscopedDb().Model(&PhotoLabel{}).
Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).
UpdateColumns(Values{"Uncertainty": uncertainty, "LabelSrc": src}).Error)
}
t.Run("OllamaReplacesLowerConfidence", func(t *testing.T) {
photoName := "Photo15"
labelName := "landscape"
@@ -514,6 +514,47 @@ func TestPhoto_AddLabels(t *testing.T) {
assert.Equal(t, 15, updated.Uncertainty)
assert.Equal(t, SrcOllama, updated.LabelSrc)
})
t.Run("SkipBlankTitle", func(t *testing.T) {
photo := PhotoFixtures.Get("Photo15")
initialLen := len(photo.Labels)
var labelCountBefore int
if err := Db().Model(&Label{}).Where("label_slug = ?", "unknown").Count(&labelCountBefore).Error; err != nil {
t.Fatalf("count before failed: %v", err)
}
classifyLabels := classify.Labels{{Name: " ", Uncertainty: 30, Source: SrcManual}}
photo.AddLabels(classifyLabels)
assert.Equal(t, initialLen, len(photo.Labels))
var labelCountAfter int
if err := Db().Model(&Label{}).Where("label_slug = ?", "unknown").Count(&labelCountAfter).Error; err != nil {
t.Fatalf("count after failed: %v", err)
}
assert.Equal(t, labelCountBefore, labelCountAfter)
})
t.Run("SkipZeroProbability", func(t *testing.T) {
photo := PhotoFixtures.Get("Photo15")
initialLen := len(photo.Labels)
labelSlug := "zero-probability"
var labelCountBefore int
if err := Db().Model(&Label{}).Where("label_slug = ?", labelSlug).Count(&labelCountBefore).Error; err != nil {
t.Fatalf("count before failed: %v", err)
}
classifyLabels := classify.Labels{{Name: "Zero Probability", Uncertainty: 100, Source: SrcManual}}
photo.AddLabels(classifyLabels)
assert.Equal(t, initialLen, len(photo.Labels))
var labelCountAfter int
if err := Db().Model(&Label{}).Where("label_slug = ?", labelSlug).Count(&labelCountAfter).Error; err != nil {
t.Fatalf("count after failed: %v", err)
}
assert.Equal(t, labelCountBefore, labelCountAfter)
})
}
func TestPhoto_Delete(t *testing.T) {