From 5c3f8ebd703912a6a37182bd17f31ed2ccbfe587 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 12 Jun 2026 13:05:58 -0400 Subject: [PATCH] fix(aliases): store modal alias under raw model key + make aliases visible/manageable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline bug: aliases created from the modal NEVER resolved. Create sent the normalized display name ('Sword', 'Uchiha Sasuke') while resolution keys on the raw booru model key ('sword', 'uchiha_sasuke', case-sensitive) — so the mapping was stored under a key nothing looks up, and the prediction kept reappearing unaliased. The raw key wasn't even in the /suggestions response, so the modal couldn't send it. - Suggestion now carries raw_name (the model key an alias must use) and via_alias (surfaced via an operator alias); both serialized by the API. - Modal alias-create sends raw_name, not display_name (the fix). Aliased suggestions show an 'alias' badge and a 'Remove alias' action; 'Treat as alias for…' is hidden for centroid hits (no model key) and already-aliased rows. - Tag-side management: TagCard ⋮ → 'Aliases…' opens a dialog listing the model keys that fold into a tag, with remove (GET /api/tags//aliases + AliasService.list_for_tag). Creation stays in the modal suggestion flow. Tests: full API round-trip locking the raw-key contract (raw_name exposed → alias authored with it → resolves + via_alias on a later image); list_for_tag (service + API); via_alias/raw_name on the existing service suggestion tests. No migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/suggestions.py | 5 + backend/app/api/tags.py | 20 ++++ backend/app/services/ml/aliases.py | 25 +++++ backend/app/services/ml/suggestions.py | 19 ++++ .../components/discovery/TagAliasesDialog.vue | 99 +++++++++++++++++++ frontend/src/components/discovery/TagCard.vue | 6 ++ .../src/components/modal/SuggestionItem.vue | 28 +++++- .../modal/SuggestionsCategoryGroup.vue | 3 +- .../src/components/modal/SuggestionsPanel.vue | 17 +++- frontend/src/stores/suggestions.js | 26 ++++- frontend/src/views/TagsView.vue | 19 +++- tests/test_api_aliases.py | 21 ++++ tests/test_api_suggestions.py | 63 ++++++++++++ tests/test_ml_aliases.py | 15 +++ tests/test_ml_suggestions.py | 6 ++ 15 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/discovery/TagAliasesDialog.vue diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py index 61fbbb7..a8a9cf4 100644 --- a/backend/app/api/suggestions.py +++ b/backend/app/api/suggestions.py @@ -37,6 +37,11 @@ async def get_suggestions(image_id: int): "score": round(s.score, 4), "source": s.source, "creates_new_tag": s.creates_new_tag, + # raw model key (alias is stored under this) + whether an + # operator alias produced this suggestion — drive the + # modal's "Treat as alias"/"Remove alias" affordances. + "raw_name": s.raw_name, + "via_alias": s.via_alias, } for s in items ] diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 3ab53a9..0556ed9 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -8,6 +8,7 @@ from ..extensions import get_session from ..models import Tag, TagKind from ..models.tag_allowlist import TagAllowlist from ..services.bulk_tag_service import BulkTagService +from ..services.ml.aliases import AliasService from ..services.series_match_service import SeriesMatchService from ..services.series_service import SeriesError, SeriesService from ..services.tag_directory_service import TagDirectoryService @@ -200,6 +201,25 @@ async def get_tag(tag_id: int): ) +@tags_bp.route("/tags//aliases", methods=["GET"]) +async def list_tag_aliases(tag_id: int): + """Model keys that fold into this tag (tag-side alias view). Remove via the + shared DELETE /api/aliases//.""" + async with get_session() as session: + if await session.get(Tag, tag_id) is None: + return jsonify({"error": "tag not found"}), 404 + rows = await AliasService(session).list_for_tag(tag_id) + return jsonify( + [ + { + "alias_string": r.alias_string, + "alias_category": r.alias_category, + } + for r in rows + ] + ) + + @tags_bp.route("/tags/", methods=["PATCH"]) async def update_tag(tag_id: int): """Rename and/or re-fandom a tag. Body may carry `name` and/or diff --git a/backend/app/services/ml/aliases.py b/backend/app/services/ml/aliases.py index 479e378..c119c36 100644 --- a/backend/app/services/ml/aliases.py +++ b/backend/app/services/ml/aliases.py @@ -81,6 +81,31 @@ class AliasService: .where(TagAlias.alias_category == alias_category) ) + async def list_for_tag(self, canonical_tag_id: int) -> Sequence[AliasRow]: + """Aliases that resolve TO this tag — drives the tag-side 'Aliases' + view (see/remove the model keys that fold into a tag).""" + stmt = ( + select( + TagAlias.alias_string, + TagAlias.alias_category, + TagAlias.canonical_tag_id, + Tag.name, + ) + .join(Tag, Tag.id == TagAlias.canonical_tag_id) + .where(TagAlias.canonical_tag_id == canonical_tag_id) + .order_by(TagAlias.alias_string.asc()) + ) + rows = (await self.session.execute(stmt)).all() + return [ + AliasRow( + alias_string=r[0], + alias_category=r[1], + canonical_tag_id=r[2], + canonical_tag_name=r[3], + ) + for r in rows + ] + async def list_all(self) -> Sequence[AliasRow]: stmt = ( select( diff --git a/backend/app/services/ml/suggestions.py b/backend/app/services/ml/suggestions.py index ca60c30..7b0f996 100644 --- a/backend/app/services/ml/suggestions.py +++ b/backend/app/services/ml/suggestions.py @@ -31,6 +31,14 @@ class Suggestion: score: float source: str # 'tagger' | 'centroid' | 'both' creates_new_tag: bool + # raw_name = the booru model vocab key behind this suggestion. It's the key + # an alias MUST be stored under (resolution looks up the raw key), so the + # modal needs it to author an alias correctly. None for centroid-only hits + # (no underlying prediction → nothing to alias). + raw_name: str | None = None + # via_alias = this suggestion was surfaced because an operator alias remapped + # the raw prediction to this canonical tag. Lets the UI mark it + offer undo. + via_alias: bool = False @dataclass @@ -161,6 +169,11 @@ class SuggestionService: if existing.source != sug.source else existing.source, creates_new_tag=existing.creates_new_tag, + # Keep the alias identity from `existing`: the tagger pass + # (which carries raw_name / via_alias) runs before centroid + # augmentation, so it's always the first writer for a key. + raw_name=existing.raw_name, + via_alias=existing.via_alias, ) for raw, display, category, conf in candidates: @@ -177,6 +190,8 @@ class SuggestionService: score=conf, source="tagger", creates_new_tag=False, + raw_name=raw, + via_alias=True, ), ) else: @@ -208,6 +223,8 @@ class SuggestionService: score=conf, source="tagger", creates_new_tag=False, + raw_name=raw, + via_alias=False, ), ) else: @@ -220,6 +237,8 @@ class SuggestionService: score=conf, source="tagger", creates_new_tag=True, + raw_name=raw, + via_alias=False, ), ) diff --git a/frontend/src/components/discovery/TagAliasesDialog.vue b/frontend/src/components/discovery/TagAliasesDialog.vue new file mode 100644 index 0000000..c9bd385 --- /dev/null +++ b/frontend/src/components/discovery/TagAliasesDialog.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/components/discovery/TagCard.vue b/frontend/src/components/discovery/TagCard.vue index 81e5376..1d1a0b1 100644 --- a/frontend/src/components/discovery/TagCard.vue +++ b/frontend/src/components/discovery/TagCard.vue @@ -53,6 +53,11 @@ prepend-icon="mdi-book-open-page-variant" @click="$emit('set-fandom', card)" /> + + new + alias {{ scorePct }} - + + Treat as alias for… + + Remove alias + Dismiss for this image @@ -40,7 +54,7 @@ import { computed } from 'vue' import KebabMenu from '../common/KebabMenu.vue' const props = defineProps({ suggestion: { type: Object, required: true } }) -defineEmits(['accept', 'alias', 'dismiss']) +defineEmits(['accept', 'alias', 'remove-alias', 'dismiss']) const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) @@ -74,6 +88,16 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) margin-left: 6px; text-transform: uppercase; letter-spacing: 0.04em; } +.fc-suggestion__alias { + display: inline-block; + font-size: 10px; font-weight: 600; + color: rgb(var(--v-theme-on-surface-variant)); + background: rgb(var(--v-theme-surface-light)); + border: 1px solid rgb(var(--v-theme-surface-light)); + padding: 1px 6px; border-radius: 999px; + margin-left: 6px; + text-transform: uppercase; letter-spacing: 0.04em; +} .fc-suggestion__score { flex: 0 0 auto; min-width: 38px; text-align: right; font-size: 11px; diff --git a/frontend/src/components/modal/SuggestionsCategoryGroup.vue b/frontend/src/components/modal/SuggestionsCategoryGroup.vue index c6c6611..c2b35bc 100644 --- a/frontend/src/components/modal/SuggestionsCategoryGroup.vue +++ b/frontend/src/components/modal/SuggestionsCategoryGroup.vue @@ -16,6 +16,7 @@ :suggestion="s" @accept="$emit('accept', $event)" @alias="$emit('alias', $event)" + @remove-alias="$emit('remove-alias', $event)" @dismiss="$emit('dismiss', $event)" /> @@ -32,7 +33,7 @@ const props = defineProps({ collapsible: { type: Boolean, default: false }, defaultOpen: { type: Boolean, default: true } }) -defineEmits(['accept', 'alias', 'dismiss']) +defineEmits(['accept', 'alias', 'remove-alias', 'dismiss']) const open = ref(props.collapsible ? props.defaultOpen : true) diff --git a/frontend/src/components/modal/SuggestionsPanel.vue b/frontend/src/components/modal/SuggestionsPanel.vue index 142d3f6..8b0ce45 100644 --- a/frontend/src/components/modal/SuggestionsPanel.vue +++ b/frontend/src/components/modal/SuggestionsPanel.vue @@ -17,13 +17,15 @@ v-for="cat in peopleCats" :key="cat" v-show="store.byCategory[cat] && store.byCategory[cat].length" :label="labelFor(cat)" :items="store.byCategory[cat] || []" - @accept="onAccept" @alias="onAlias" @dismiss="store.dismiss" + @accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias" + @dismiss="store.dismiss" /> @@ -98,6 +100,17 @@ async function onAliasConfirm(canonicalTagId) { toast({ text: `Alias failed: ${e.message}`, type: 'error' }) } } + +// Undo the model-key→tag mapping behind an aliased suggestion. The store +// reloads suggestions so the prediction reverts to its raw form; the applied +// canonical tag (if any) stays, so no tag-rail reload is needed. +async function onRemoveAlias(s) { + try { + await store.removeAlias(s) + } catch (e) { + toast({ text: `Remove alias failed: ${e.message}`, type: 'error' }) + } +}