feat(modal): autofocus tag input, expand general suggestions, retire copyright/artist categories
Four coupled operator-asked changes to the view modal (Scribe plan #509): 1. **Autofocus tag entry on modal open** — TagAutocomplete grabs focus in onMounted/nextTick so the caret is in the input the moment the modal renders. No click needed to start typing. 2. **General suggestions expanded by default** — SuggestionsPanel's general-category group now mounts with `:default-open="true"`. Operator can collapse if too noisy, but the v1 frame shows them. 3. **Lower general threshold default 0.95 → 0.50** — MLSettings. suggestion_threshold_general default matches character. Alembic 0029 also bumps the existing singleton row's value if it's still at the old 0.95. Operator can re-tune from Settings → ML. 4. **Retire `copyright` + `artist` as ML suggestion categories** — neither feeds a Tag.kind (`artist` retired in FC-2d-vii-c, never really existed as a copyright tag-kind). They were surfaced in the suggestions pipeline + threshold settings UI but had no follow- through. Drop from SURFACED_CATEGORIES, suggestions._threshold_for, ml_admin GET/PATCH allowlist, MLSettings columns (alembic 0029 drops the two columns), frontend CATEGORY_ORDER + CATEGORY_LABELS, SuggestionsPanel.peopleCats, AliasPickerDialog kind-check, and MLThresholdSliders rows. Out of scope (intentional): `tag_kind` Postgres enum still includes `artist` for historic Tag row queryability (per the model comment); no operator pain reported, no enum-shrink needed. Tests: - test_surfaced_categories asserts {character, general}, excludes artist + copyright. - test_threshold_for_artist_is_unsurfaced extended to cover copyright. - test_get_and_patch_settings asserts new 0.50 default and the absent artist + copyright keys in the GET payload.
This commit is contained in:
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||
label="General" :items="store.byCategory.general"
|
||||
collapsible :default-open="false"
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
|
||||
/>
|
||||
</template>
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="fc-tag-autocomplete">
|
||||
<v-text-field
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
placeholder="Add tag (or kind:name — character/fandom/series)"
|
||||
density="compact" hide-details
|
||||
@@ -52,13 +53,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import FandomPicker from './FandomPicker.vue'
|
||||
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
const store = useTagStore()
|
||||
|
||||
// Autofocus on modal open so the operator can type the moment the view
|
||||
// modal renders, no extra click required (operator-asked 2026-06-01).
|
||||
// Vuetify's v-text-field exposes .focus() on the component instance;
|
||||
// nextTick waits for the modal's mount to finish so the inner <input>
|
||||
// element exists.
|
||||
const inputRef = ref(null)
|
||||
onMounted(() => { nextTick(() => inputRef.value?.focus?.()) })
|
||||
|
||||
// Single text input; no kind dropdown. Client-side mirror of the
|
||||
// backend's parse_kind_prefix lives below — kept in sync with
|
||||
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
|
||||
|
||||
@@ -22,10 +22,10 @@ import { reactive, watch } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
|
||||
const store = useMLStore()
|
||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
||||
// suggestion categories; their threshold rows are gone.
|
||||
const fields = [
|
||||
{ key: 'suggestion_threshold_artist', label: 'Artist' },
|
||||
{ key: 'suggestion_threshold_character', label: 'Character' },
|
||||
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
|
||||
{ key: 'suggestion_threshold_general', label: 'General' },
|
||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
||||
]
|
||||
|
||||
@@ -4,12 +4,12 @@ import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
|
||||
// Category display order: people/sources first, general last.
|
||||
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
|
||||
// Category display order: people first, general last.
|
||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
|
||||
// character and general surface as suggestion categories now.
|
||||
export const CATEGORY_ORDER = ['character', 'general']
|
||||
export const CATEGORY_LABELS = {
|
||||
artist: 'Artist',
|
||||
character: 'Character',
|
||||
copyright: 'Copyright',
|
||||
general: 'General'
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ async def test_get_and_patch_settings(client):
|
||||
resp = await client.get("/api/ml/settings")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["suggestion_threshold_general"] == pytest.approx(0.95)
|
||||
# Default lowered 0.95 → 0.50 on 2026-06-01 (alembic 0029) — 0.95
|
||||
# hid most general suggestions in the view modal.
|
||||
assert body["suggestion_threshold_general"] == pytest.approx(0.50)
|
||||
# Retired threshold columns must not appear in the payload.
|
||||
assert "suggestion_threshold_artist" not in body
|
||||
assert "suggestion_threshold_copyright" not in body
|
||||
|
||||
resp = await client.patch(
|
||||
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
||||
|
||||
@@ -19,9 +19,9 @@ def test_threshold_for_artist_is_unsurfaced():
|
||||
|
||||
class _S:
|
||||
suggestion_threshold_character = 0.5
|
||||
suggestion_threshold_copyright = 0.5
|
||||
suggestion_threshold_general = 0.5
|
||||
|
||||
svc = SuggestionService.__new__(SuggestionService)
|
||||
# 'artist' must fall through to the 1.01 "never surfaces" default
|
||||
# 'artist' and 'copyright' both retired — fall through to 1.01
|
||||
assert svc._threshold_for(_S(), "artist") == 1.01
|
||||
assert svc._threshold_for(_S(), "copyright") == 1.01
|
||||
|
||||
@@ -17,10 +17,13 @@ from backend.app.services.ml.tagger import (
|
||||
|
||||
|
||||
def test_surfaced_categories():
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred.
|
||||
assert SURFACED_CATEGORIES == {"character", "copyright", "general"}
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
|
||||
# derived (image_record.artist_id), never ML-inferred.
|
||||
# 2026-06-01: 'copyright' retired — fandom serves as the franchise/
|
||||
# copyright concept; operator doesn't use a separate copyright kind.
|
||||
assert SURFACED_CATEGORIES == {"character", "general"}
|
||||
assert "artist" not in SURFACED_CATEGORIES
|
||||
assert "copyright" not in SURFACED_CATEGORIES
|
||||
|
||||
|
||||
def test_store_floor_is_low():
|
||||
|
||||
Reference in New Issue
Block a user