diff --git a/alembic/versions/0029_drop_artist_copyright_ml_thresholds.py b/alembic/versions/0029_drop_artist_copyright_ml_thresholds.py new file mode 100644 index 0000000..e6c044b --- /dev/null +++ b/alembic/versions/0029_drop_artist_copyright_ml_thresholds.py @@ -0,0 +1,71 @@ +"""drop artist + copyright ml thresholds; lower general default to 0.50 + +Revision ID: 0029 +Revises: 0028 +Create Date: 2026-06-01 + +Operator-flagged 2026-06-01: the view modal's Suggestions panel hides +most general-category predictions because the default threshold is +0.95. Lowering the default to 0.50 (matches character) so general +suggestions surface more aggressively; the value remains tunable in +Settings → ML. + +Same change retires two ML suggestion categories whose Tag.kind +surfaces are unused: + +- `artist`: retired in FC-2d-vii-c — artist identity is acquisition- + derived (image_record.artist_id), never ML-inferred. The threshold + column was a leftover from before that retirement. +- `copyright`: retired 2026-06-01 — the app uses `fandom` for the + franchise/copyright concept (per TagsView.vue's doc comment); no + Tag rows of kind=copyright exist, and the threshold column never + fed anything user-visible. + +Both columns are dropped from ml_settings; the existing row's +suggestion_threshold_general value is bumped from 0.95 to 0.50 iff +it's still at the old default, so deployed installs pick up the new +UX without overriding any operator tuning. +""" +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +revision: str = "0029" +down_revision: Union[str, None] = "0028" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Bump the general threshold for installs still at the old default. + op.execute(text( + "UPDATE ml_settings " + "SET suggestion_threshold_general = 0.50 " + "WHERE id = 1 AND suggestion_threshold_general = 0.95" + )) + op.drop_column("ml_settings", "suggestion_threshold_artist") + op.drop_column("ml_settings", "suggestion_threshold_copyright") + + +def downgrade() -> None: + # Restore the columns with their prior defaults. The bump from + # 0.95 → 0.50 isn't reversible without remembering whether the + # operator had explicitly set 0.95 (unlikely — that was just the + # default) so we leave the current general value as-is. + from sqlalchemy import Column, Float + + op.add_column( + "ml_settings", + Column( + "suggestion_threshold_artist", + Float, nullable=False, server_default="0.30", + ), + ) + op.add_column( + "ml_settings", + Column( + "suggestion_threshold_copyright", + Float, nullable=False, server_default="0.50", + ), + ) diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py index a346175..574e6f5 100644 --- a/backend/app/api/ml_admin.py +++ b/backend/app/api/ml_admin.py @@ -9,9 +9,7 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml") _EDITABLE = ( - "suggestion_threshold_artist", "suggestion_threshold_character", - "suggestion_threshold_copyright", "suggestion_threshold_general", "centroid_similarity_threshold", "min_reference_images", @@ -28,9 +26,7 @@ async def get_settings(): ).scalar_one() return jsonify( { - "suggestion_threshold_artist": s.suggestion_threshold_artist, "suggestion_threshold_character": s.suggestion_threshold_character, - "suggestion_threshold_copyright": s.suggestion_threshold_copyright, "suggestion_threshold_general": s.suggestion_threshold_general, "centroid_similarity_threshold": s.centroid_similarity_threshold, "min_reference_images": s.min_reference_images, diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 5ad33ad..de4e476 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -15,17 +15,14 @@ class MLSettings(Base): __table_args__ = (CheckConstraint("id = 1", name="singleton"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) - suggestion_threshold_artist: Mapped[float] = mapped_column( - Float, nullable=False, default=0.30 - ) suggestion_threshold_character: Mapped[float] = mapped_column( Float, nullable=False, default=0.50 ) - suggestion_threshold_copyright: Mapped[float] = mapped_column( - Float, nullable=False, default=0.50 - ) + # Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that + # 0.95 hid most general suggestions. Operator-tunable via Settings → + # ML if too noisy. suggestion_threshold_general: Mapped[float] = mapped_column( - Float, nullable=False, default=0.95 + Float, nullable=False, default=0.50 ) centroid_similarity_threshold: Mapped[float] = mapped_column( Float, nullable=False, default=0.55 diff --git a/backend/app/services/ml/suggestions.py b/backend/app/services/ml/suggestions.py index 0b8d805..7c71b0d 100644 --- a/backend/app/services/ml/suggestions.py +++ b/backend/app/services/ml/suggestions.py @@ -48,11 +48,11 @@ class SuggestionService: ).scalar_one() def _threshold_for(self, s: MLSettings, category: str) -> float: - # 'artist' intentionally absent (FC-2d-vii-c) — falls through to - # the 1.01 "never surfaces" default like any unsurfaced category. + # 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired; + # both fall through to the 1.01 "never surfaces" default like any + # unsurfaced category. return { "character": s.suggestion_threshold_character, - "copyright": s.suggestion_threshold_copyright, "general": s.suggestion_threshold_general, }.get(category, 1.01) diff --git a/backend/app/services/ml/tagger.py b/backend/app/services/ml/tagger.py index 5277b44..ddf17b9 100644 --- a/backend/app/services/ml/tagger.py +++ b/backend/app/services/ml/tagger.py @@ -38,10 +38,13 @@ STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05")) # The categories FC-2b surfaces in the UI. Others (meta/rating/year) are # still stored but the suggestion service filters them out. -# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived -# (image_record.artist_id), never ML-inferred. Raw predictions are still -# stored at STORE_FLOOR but artist never surfaces. -SURFACED_CATEGORIES = {"character", "copyright", "general"} +# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived +# (image_record.artist_id), never ML-inferred. 'copyright' retired +# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is +# this app's franchise/series concept (per TagsView.vue's doc comment). +# Raw predictions for both categories still get stored at STORE_FLOOR but +# don't surface in suggestions. +SURFACED_CATEGORIES = {"character", "general"} # ImageNet preprocessing constants (per Camie v2 onnx_inference.py). _IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) diff --git a/frontend/src/components/modal/AliasPickerDialog.vue b/frontend/src/components/modal/AliasPickerDialog.vue index 2552d31..37fae6f 100644 --- a/frontend/src/components/modal/AliasPickerDialog.vue +++ b/frontend/src/components/modal/AliasPickerDialog.vue @@ -48,10 +48,11 @@ function onSearch(q) { if (!query) { results.value = []; return } loading.value = true try { - // Scope the autocomplete to the prediction's category where it maps - // to a tag kind. 'copyright' has no tag kind; search unscoped there. - const kind = ['artist', 'character'].includes(props.category) - ? props.category : null + // Scope the autocomplete to the prediction's category where it + // maps to a tag kind. Only 'character' surfaces as both a + // suggestion category and a tag kind now ('artist' + 'copyright' + // retired); other categories search unscoped. + const kind = props.category === 'character' ? 'character' : null const params = { q: query, limit: 20 } if (kind) params.kind = kind results.value = await api.get('/api/tags/autocomplete', { params }) diff --git a/frontend/src/components/modal/SuggestionsPanel.vue b/frontend/src/components/modal/SuggestionsPanel.vue index 0ca5cea..68e8040 100644 --- a/frontend/src/components/modal/SuggestionsPanel.vue +++ b/frontend/src/components/modal/SuggestionsPanel.vue @@ -22,7 +22,7 @@ @@ -47,7 +47,10 @@ import AliasPickerDialog from './AliasPickerDialog.vue' const props = defineProps({ imageId: { type: Number, required: true } }) const store = useSuggestionsStore() -const peopleCats = ['artist', 'character', 'copyright'] +// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as +// suggestion categories. Only 'character' remains as a people-style +// category alongside the general bucket. +const peopleCats = ['character'] function labelFor(c) { return CATEGORY_LABELS[c] || c } const isEmpty = computed(() => diff --git a/frontend/src/components/modal/TagAutocomplete.vue b/frontend/src/components/modal/TagAutocomplete.vue index e074488..9767af2 100644 --- a/frontend/src/components/modal/TagAutocomplete.vue +++ b/frontend/src/components/modal/TagAutocomplete.vue @@ -1,6 +1,7 @@