diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py index 25bd2fe..61fbbb7 100644 --- a/backend/app/api/suggestions.py +++ b/backend/app/api/suggestions.py @@ -11,8 +11,21 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api") @suggestions_bp.route("/images//suggestions", methods=["GET"]) async def get_suggestions(image_id: int): + # ?min= overrides the configured per-category thresholds so the typed + # tag-input dropdown can surface EVERY stored prediction (min=0), including + # low-confidence actions/features, in canonical formatting. Omitted → the + # curated above-threshold list the Suggestions panel uses. + override = None + raw_min = request.args.get("min") + if raw_min is not None: + try: + override = min(1.0, max(0.0, float(raw_min))) + except ValueError: + return jsonify({"error": "min must be a float in [0,1]"}), 400 async with get_session() as session: - sl = await SuggestionService(session).for_image(image_id) + sl = await SuggestionService(session).for_image( + image_id, threshold_override=override + ) return jsonify( { "by_category": { diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 59dc286..b189c1f 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -172,6 +172,7 @@ async def list_tags_for_image(image_id: int): "name": t.name, "kind": t.kind.value, "fandom_id": t.fandom_id, + "fandom_name": t.fandom_name, } for t in tags ] diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 9c31476..44cbe66 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -558,13 +558,26 @@ class GalleryService: record = await self.session.get(ImageRecord, image_id) if record is None: return None + # Self-join Tag to resolve a character's fandom NAME (not just id) so the + # modal chip can label it without an N+1 — mirrors list_for_image. + fandom_alias = Tag.__table__.alias("fandom_lookup") tag_stmt = ( - select(Tag) - .join(image_tag, image_tag.c.tag_id == Tag.id) + select( + Tag.id, + Tag.name, + Tag.kind, + Tag.fandom_id, + fandom_alias.c.name.label("fandom_name"), + ) + .select_from( + Tag.__table__ + .join(image_tag, image_tag.c.tag_id == Tag.id) + .outerjoin(fandom_alias, Tag.fandom_id == fandom_alias.c.id) + ) .where(image_tag.c.image_record_id == image_id) .order_by(Tag.kind.asc(), Tag.name.asc()) ) - tags = (await self.session.execute(tag_stmt)).scalars().all() + tags = (await self.session.execute(tag_stmt)).all() # Fetch the canonical post.post_date for this image (if any) so # the modal can show "Posted on " alongside import date. posted_at = None @@ -608,6 +621,7 @@ class GalleryService: "name": t.name, "kind": t.kind.value if hasattr(t.kind, "value") else t.kind, "fandom_id": t.fandom_id, + "fandom_name": t.fandom_name, } for t in tags ], diff --git a/backend/app/services/ml/suggestions.py b/backend/app/services/ml/suggestions.py index 9e570a8..b53e198 100644 --- a/backend/app/services/ml/suggestions.py +++ b/backend/app/services/ml/suggestions.py @@ -48,16 +48,33 @@ class SuggestionService: await self.session.execute(select(MLSettings).where(MLSettings.id == 1)) ).scalar_one() - def _threshold_for(self, s: MLSettings, category: str) -> float: + def _threshold_for( + self, s: MLSettings, category: str, override: float | None = None, + ) -> float: # '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. + # override (the typed-dropdown "show everything the model saw" mode) + # applies to the surfaced categories only — unsurfaced ones are already + # skipped before the threshold check, so they can't leak in. + if override is not None: + return override return { "character": s.suggestion_threshold_character, "general": s.suggestion_threshold_general, }.get(category, 1.01) - async def for_image(self, image_id: int) -> SuggestionList: + async def for_image( + self, image_id: int, *, threshold_override: float | None = None, + ) -> SuggestionList: + """Ranked suggestions for one image. + + threshold_override surfaces EVERY stored tagger prediction (down to the + ingest STORE_FLOOR) regardless of the configured per-category suggestion + thresholds — backs the tag-input dropdown's "search all of the model's + predictions, including low-confidence ones, in the canonical formatting" + mode (operator-asked 2026-06-09). The Suggestions panel still calls with + no override so it stays the curated above-threshold list.""" img = await self.session.get(ImageRecord, image_id) if img is None: return SuggestionList() @@ -96,7 +113,7 @@ class SuggestionService: if category not in SURFACED_CATEGORIES: continue conf = float(p.get("confidence", 0.0)) - if conf < self._threshold_for(settings, category): + if conf < self._threshold_for(settings, category, threshold_override): continue display = normalize_tag_name(name) if display is None: diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 11192a4..335afc3 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -225,14 +225,29 @@ class TagService: ) ) - async def list_for_image(self, image_id: int) -> Sequence[Tag]: + async def list_for_image(self, image_id: int) -> Sequence: + """Tags on an image, ordered (kind, name). Each row carries the fandom's + NAME (not just fandom_id) via a self-join on Tag, so the UI can label a + character chip with its fandom without an N+1 (mirrors the + autocomplete/directory resolution).""" + fandom_alias = Tag.__table__.alias("fandom_lookup") stmt = ( - select(Tag) - .join(image_tag, image_tag.c.tag_id == Tag.id) + select( + Tag.id, + Tag.name, + Tag.kind, + Tag.fandom_id, + fandom_alias.c.name.label("fandom_name"), + ) + .select_from( + Tag.__table__ + .join(image_tag, image_tag.c.tag_id == Tag.id) + .outerjoin(fandom_alias, Tag.fandom_id == fandom_alias.c.id) + ) .where(image_tag.c.image_record_id == image_id) .order_by(Tag.kind.asc(), Tag.name.asc()) ) - return (await self.session.execute(stmt)).scalars().all() + return (await self.session.execute(stmt)).all() async def _keep_as_alias(self, tag_id: int) -> bool: """A merged-away tag's old name must survive as an alias iff the ML diff --git a/frontend/src/components/TopNav.vue b/frontend/src/components/TopNav.vue index 3800d8a..b37514e 100644 --- a/frontend/src/components/TopNav.vue +++ b/frontend/src/components/TopNav.vue @@ -15,7 +15,7 @@ where they fold into the hamburger menu on the right. -->