From 0257a79a1527532911dfaaa09a1c43403ca37cf0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Apr 2026 20:30:49 -0400 Subject: [PATCH] fix(suggestions): resolve bare WD14 character names to existing "(Fandom)" tags WD14 emits bare names (e.g. "Ruby Rose") while curated character/fandom tags carry a fandom suffix ("Ruby Rose (RWBY)"). Two gaps caused a duplicate, fandom-less tag to be created when users accepted a WD14 suggestion for an image that already had the curated version: - Suggestion compute: bare character/copyright suggestions are now suppressed when the image already carries a `Name (Fandom)` variant, so the duplicate chip no longer surfaces. - Accept endpoint: if the exact-name lookup misses for character/fandom kinds, we try a `Name (%)`-scoped lookup and attach the existing tag only when exactly one candidate matches. Zero or multiple matches fall through to prior behavior to avoid silently guessing across ambiguous fandoms. Co-Authored-By: Claude Opus 4.7 --- app/main.py | 24 +++++++++++++++++++++--- app/services/tag_suggestions.py | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 9c82f34..4d5278d 100644 --- a/app/main.py +++ b/app/main.py @@ -767,9 +767,27 @@ def accept_image_suggestion(image_id): tag = Tag.query.filter_by(name=tag_name).first() if tag is None: kind = _WD14_CATEGORY_TO_KIND.get(category) # None for 'general' - tag = Tag(name=tag_name, kind=kind) - db.session.add(tag) - db.session.flush() + # WD14 emits bare names ("Ruby Rose") while curated character/fandom tags + # carry a fandom suffix ("Ruby Rose (RWBY)"). Try to auto-resolve a bare + # suggestion to an existing fandom-scoped tag — but only when exactly one + # candidate exists; zero/multiple falls through to creating a new tag so + # we don't silently guess across ambiguous matches (e.g. same name in + # multiple fandoms). + if kind in ('character', 'fandom'): + candidates = ( + Tag.query + .filter(Tag.kind == kind) + .filter(Tag.name.ilike(f'{tag_name} (%)')) + .limit(2) + .all() + ) + if len(candidates) == 1: + tag = candidates[0] + tag_name = tag.name # feedback row logs the resolved name + if tag is None: + tag = Tag(name=tag_name, kind=kind) + db.session.add(tag) + db.session.flush() if tag not in image.tags: image.tags.append(tag) diff --git a/app/services/tag_suggestions.py b/app/services/tag_suggestions.py index 1cea1a8..5e156a2 100644 --- a/app/services/tag_suggestions.py +++ b/app/services/tag_suggestions.py @@ -8,6 +8,7 @@ The result is grouped by category and annotated with whether each tag already exists in the Tag table (the modal dims disallowed auto-creations). """ from __future__ import annotations +import re from typing import Iterable from sqlalchemy import select @@ -106,6 +107,21 @@ def _existing_tag_names() -> set[str]: return {row[0] for row in db.session.query(Tag.name).all()} +# Matches `Name (Fandom)` — used to recognize that an applied character/copyright +# tag covers a bare WD14 suggestion like `Name`. +_FANDOM_SUFFIX_RE = re.compile(r'^(.+?) \([^()]+\)$') + + +def _fandom_suffix_prefixes(names: Iterable[str]) -> set[str]: + """Extract the `Name` portion from any `Name (Fandom)` entries in `names`.""" + out: set[str] = set() + for n in names: + m = _FANDOM_SUFFIX_RE.match(n) + if m: + out.add(m.group(1)) + return out + + def _wd14_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]: wd14_ver = cfg.get('wd14_model_version', _DEFAULTS['wd14_model_version']) preds = ( @@ -113,6 +129,11 @@ def _wd14_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict] .filter_by(image_id=image_id, model_version=wd14_ver) .all() ) + # Bare names covered by an already-applied `Name (Fandom)` tag. WD14 emits + # bare names (e.g. "Ruby Rose") even when the curated tag is fandom-scoped + # ("Ruby Rose (RWBY)"); without this, the same character resurfaces as a + # duplicate suggestion and accepting it creates a second, fandom-less tag. + applied_fandom_prefixes = _fandom_suffix_prefixes(already) out: list[dict] = [] for p in preds: threshold = _category_threshold(p.tag_category, cfg) @@ -121,6 +142,8 @@ def _wd14_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict] canonical = _canonicalize_wd14_name(p.tag_name, p.tag_category) if canonical in already: continue + if p.tag_category in ('character', 'copyright') and canonical in applied_fandom_prefixes: + continue out.append({ 'name': canonical, 'category': p.tag_category,