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 = (
|
_EDITABLE = (
|
||||||
"suggestion_threshold_artist",
|
|
||||||
"suggestion_threshold_character",
|
"suggestion_threshold_character",
|
||||||
"suggestion_threshold_copyright",
|
|
||||||
"suggestion_threshold_general",
|
"suggestion_threshold_general",
|
||||||
"centroid_similarity_threshold",
|
"centroid_similarity_threshold",
|
||||||
"min_reference_images",
|
"min_reference_images",
|
||||||
@@ -28,9 +26,7 @@ async def get_settings():
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"suggestion_threshold_artist": s.suggestion_threshold_artist,
|
|
||||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
"suggestion_threshold_character": s.suggestion_threshold_character,
|
||||||
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
|
|
||||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
"suggestion_threshold_general": s.suggestion_threshold_general,
|
||||||
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||||
"min_reference_images": s.min_reference_images,
|
"min_reference_images": s.min_reference_images,
|
||||||
|
|||||||
@@ -15,17 +15,14 @@ class MLSettings(Base):
|
|||||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
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(
|
suggestion_threshold_character: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.50
|
Float, nullable=False, default=0.50
|
||||||
)
|
)
|
||||||
suggestion_threshold_copyright: Mapped[float] = mapped_column(
|
# Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that
|
||||||
Float, nullable=False, default=0.50
|
# 0.95 hid most general suggestions. Operator-tunable via Settings →
|
||||||
)
|
# ML if too noisy.
|
||||||
suggestion_threshold_general: Mapped[float] = mapped_column(
|
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(
|
centroid_similarity_threshold: Mapped[float] = mapped_column(
|
||||||
Float, nullable=False, default=0.55
|
Float, nullable=False, default=0.55
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ class SuggestionService:
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
||||||
# 'artist' intentionally absent (FC-2d-vii-c) — falls through to
|
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
||||||
# the 1.01 "never surfaces" default like any unsurfaced category.
|
# both fall through to the 1.01 "never surfaces" default like any
|
||||||
|
# unsurfaced category.
|
||||||
return {
|
return {
|
||||||
"character": s.suggestion_threshold_character,
|
"character": s.suggestion_threshold_character,
|
||||||
"copyright": s.suggestion_threshold_copyright,
|
|
||||||
"general": s.suggestion_threshold_general,
|
"general": s.suggestion_threshold_general,
|
||||||
}.get(category, 1.01)
|
}.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
|
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
|
||||||
# still stored but the suggestion service filters them out.
|
# still stored but the suggestion service filters them out.
|
||||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
|
||||||
# (image_record.artist_id), never ML-inferred. Raw predictions are still
|
# (image_record.artist_id), never ML-inferred. 'copyright' retired
|
||||||
# stored at STORE_FLOOR but artist never surfaces.
|
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
|
||||||
SURFACED_CATEGORIES = {"character", "copyright", "general"}
|
# 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 preprocessing constants (per Camie v2 onnx_inference.py).
|
||||||
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
_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 }
|
if (!query) { results.value = []; return }
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// Scope the autocomplete to the prediction's category where it maps
|
// Scope the autocomplete to the prediction's category where it
|
||||||
// to a tag kind. 'copyright' has no tag kind; search unscoped there.
|
// maps to a tag kind. Only 'character' surfaces as both a
|
||||||
const kind = ['artist', 'character'].includes(props.category)
|
// suggestion category and a tag kind now ('artist' + 'copyright'
|
||||||
? props.category : null
|
// retired); other categories search unscoped.
|
||||||
|
const kind = props.category === 'character' ? 'character' : null
|
||||||
const params = { q: query, limit: 20 }
|
const params = { q: query, limit: 20 }
|
||||||
if (kind) params.kind = kind
|
if (kind) params.kind = kind
|
||||||
results.value = await api.get('/api/tags/autocomplete', { params })
|
results.value = await api.get('/api/tags/autocomplete', { params })
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<SuggestionsCategoryGroup
|
<SuggestionsCategoryGroup
|
||||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||||
label="General" :items="store.byCategory.general"
|
label="General" :items="store.byCategory.general"
|
||||||
collapsible :default-open="false"
|
collapsible :default-open="true"
|
||||||
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
|
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -47,7 +47,10 @@ import AliasPickerDialog from './AliasPickerDialog.vue'
|
|||||||
const props = defineProps({ imageId: { type: Number, required: true } })
|
const props = defineProps({ imageId: { type: Number, required: true } })
|
||||||
const store = useSuggestionsStore()
|
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 }
|
function labelFor(c) { return CATEGORY_LABELS[c] || c }
|
||||||
|
|
||||||
const isEmpty = computed(() =>
|
const isEmpty = computed(() =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-tag-autocomplete">
|
<div class="fc-tag-autocomplete">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
ref="inputRef"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
placeholder="Add tag (or kind:name — character/fandom/series)"
|
placeholder="Add tag (or kind:name — character/fandom/series)"
|
||||||
density="compact" hide-details
|
density="compact" hide-details
|
||||||
@@ -52,13 +53,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
import { useTagStore } from '../../stores/tags.js'
|
||||||
import FandomPicker from './FandomPicker.vue'
|
import FandomPicker from './FandomPicker.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||||
const store = useTagStore()
|
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
|
// Single text input; no kind dropdown. Client-side mirror of the
|
||||||
// backend's parse_kind_prefix lives below — kept in sync with
|
// backend's parse_kind_prefix lives below — kept in sync with
|
||||||
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
|
// 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'
|
import { useMLStore } from '../../stores/ml.js'
|
||||||
|
|
||||||
const store = useMLStore()
|
const store = useMLStore()
|
||||||
|
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
||||||
|
// suggestion categories; their threshold rows are gone.
|
||||||
const fields = [
|
const fields = [
|
||||||
{ key: 'suggestion_threshold_artist', label: 'Artist' },
|
|
||||||
{ key: 'suggestion_threshold_character', label: 'Character' },
|
{ key: 'suggestion_threshold_character', label: 'Character' },
|
||||||
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
|
|
||||||
{ key: 'suggestion_threshold_general', label: 'General' },
|
{ key: 'suggestion_threshold_general', label: 'General' },
|
||||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { ref } from 'vue'
|
|||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
|
||||||
// Category display order: people/sources first, general last.
|
// Category display order: people first, general last.
|
||||||
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
|
// '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 = {
|
export const CATEGORY_LABELS = {
|
||||||
artist: 'Artist',
|
|
||||||
character: 'Character',
|
character: 'Character',
|
||||||
copyright: 'Copyright',
|
|
||||||
general: 'General'
|
general: 'General'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ async def test_get_and_patch_settings(client):
|
|||||||
resp = await client.get("/api/ml/settings")
|
resp = await client.get("/api/ml/settings")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = await resp.get_json()
|
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(
|
resp = await client.patch(
|
||||||
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ def test_threshold_for_artist_is_unsurfaced():
|
|||||||
|
|
||||||
class _S:
|
class _S:
|
||||||
suggestion_threshold_character = 0.5
|
suggestion_threshold_character = 0.5
|
||||||
suggestion_threshold_copyright = 0.5
|
|
||||||
suggestion_threshold_general = 0.5
|
suggestion_threshold_general = 0.5
|
||||||
|
|
||||||
svc = SuggestionService.__new__(SuggestionService)
|
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(), "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():
|
def test_surfaced_categories():
|
||||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
|
||||||
# (image_record.artist_id), never ML-inferred.
|
# derived (image_record.artist_id), never ML-inferred.
|
||||||
assert SURFACED_CATEGORIES == {"character", "copyright", "general"}
|
# 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 "artist" not in SURFACED_CATEGORIES
|
||||||
|
assert "copyright" not in SURFACED_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
def test_store_floor_is_low():
|
def test_store_floor_is_low():
|
||||||
|
|||||||
Reference in New Issue
Block a user