From 2347f5eaa2166117dd1c1cbdd2c3406a4b07062e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 20:02:07 -0400 Subject: [PATCH] fix(suggestions): rejected tags stay rejected per image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-image rejection filter to get_suggestions so once the user clicks ✕ on an auto-accepted chip (or rejects a regular suggestion via the existing ✕ button), that tag name stops reappearing in suggestions — including auto_accept candidates. Without this filter, the undo flow was cosmetic: the user's ✕ removed the tag from this image and logged a SuggestionFeedback decision='rejected' row, but the next modal load re-ran auto-accept against the same unchanged WD14 predictions and re-attached the same tag. Rinse, repeat. Implementation: _rejected_names_for_image(image_id) reads distinct tag_names from suggestion_feedback where decision='rejected' for this image. get_suggestions filters merged results against that set alongside the existing blocklist filter. Both auto-accept candidates and the three core categories respect the filter. Tested on image 222 at threshold=0.9: first load auto-applied 23 general tags; rejected '1girl'; second load auto_accepted.count=0, general suggestions had 10 new items, '1girl' absent from both. Co-Authored-By: Claude Opus 4.7 --- app/services/tag_suggestions.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/services/tag_suggestions.py b/app/services/tag_suggestions.py index 4de9922..36ae260 100644 --- a/app/services/tag_suggestions.py +++ b/app/services/tag_suggestions.py @@ -114,6 +114,27 @@ def _blocklisted_names() -> set[str]: return {row[0] for row in db.session.query(TagSuggestionBlocklistEntry.name).all()} +def _rejected_names_for_image(image_id: int) -> set[str]: + """Tag names the user has rejected for this specific image. Includes + both explicit ✕ rejections on raw suggestions and the ✕/⊘ undo actions + on auto-accepted tags (both go through /suggestions/reject which writes + decision='rejected' rows). Once rejected for an image, a name stays + suppressed for that image so auto-accept doesn't re-apply it on the + next modal open.""" + from app.models import SuggestionFeedback + rows = ( + db.session + .query(SuggestionFeedback.tag_name) + .filter( + SuggestionFeedback.image_id == image_id, + SuggestionFeedback.decision == 'rejected', + ) + .distinct() + .all() + ) + return {r[0] for r in rows} + + def _wd14_suggestions(image_id: int, cfg: dict, already: set[str]) -> list[dict]: @@ -247,6 +268,14 @@ def get_suggestions( if blocklist: merged = [s for s in merged if s['name'] not in blocklist] + # Drop names the user has already rejected for THIS image. Without this, + # auto-accept re-applies the same WD14 prediction every time the modal + # loads, and the user's ✕ click on an auto-accepted chip is immediately + # undone on the next view. + per_image_rejected = _rejected_names_for_image(image_id) + if per_image_rejected: + merged = [s for s in merged if s['name'] not in per_image_rejected] + if existing_all is None: existing_all = _existing_tag_names()