diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 6bf91fe..410852e 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -145,6 +145,9 @@ async def similar(): filters, _sort = _parse_filters() except (KeyError, ValueError): return jsonify({"error": "similar_to query param required"}), 400 + # Explore passes exclude_wip=1 to also drop work-in-progress from the + # rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274). + exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True") # post_id is the exclusive post-detail view — not a similarity scope. # include_hidden is a gallery-browse flag; similar() has its OWN presentation # exclusion (a similarity-quality concern, #1274), so drop it here (#141). @@ -154,7 +157,8 @@ async def similar(): async with get_session() as session: svc = GalleryService(session) try: - images = await svc.similar(image_id=similar_to, limit=limit, **scope) + images = await svc.similar( + image_id=similar_to, limit=limit, exclude_wip=exclude_wip, **scope) except ValueError as exc: return jsonify({"error": str(exc)}), 400 if images is None: diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index 2479578..c27ebd3 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -48,6 +48,10 @@ class TagKind(StrEnum): # content. `wip` is real art: only the training pipelines exclude it. SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot") PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot") +# `wip` marks real-but-unfinished art. It's kept in the gallery's own "similar" +# results (#1274), but the Explore rabbit-hole opts to hide it (exclude_wip) so a +# browse doesn't keep surfacing work-in-progress (operator, 2026-07-08). +WIP_SYSTEM_TAG = "wip" image_tag = Table( "image_tag", diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index a05e6c7..8d11a95 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -31,7 +31,7 @@ from ..models import ( Tag, TagPositiveConfirmation, ) -from ..models.tag import PRESENTATION_SYSTEM_TAGS, image_tag +from ..models.tag import PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG, image_tag from .pagination import decode_cursor, encode_cursor from .tag_query import ( fandom_join_alias, @@ -715,6 +715,7 @@ class GalleryService: platform: str | None = None, untagged: bool = False, no_artist: bool = False, date_from: datetime | None = None, date_to: datetime | None = None, + exclude_wip: bool = False, ) -> list[GalleryImage] | None: """Visual "more like this": images near `image_id`'s SigLIP embedding (pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result @@ -751,14 +752,18 @@ class GalleryService: # Presentation images (banner / editor-screenshot system tags, #128) # cluster on UI chrome rather than content, so near any one of them # they'd fill the grid. Excluded from CANDIDATES only — the anchor - # itself may be a banner, and `wip` stays surfaced (real art; only - # the training pipelines exclude it). + # itself may be a banner. `wip` stays surfaced here by default (real art; + # only the training pipelines exclude it), but the Explore rabbit-hole + # passes exclude_wip to also drop work-in-progress (operator, 2026-07-08). + excluded_system_tags = PRESENTATION_SYSTEM_TAGS + if exclude_wip: + excluded_system_tags = (*PRESENTATION_SYSTEM_TAGS, WIP_SYSTEM_TAG) presentation = ( select(image_tag.c.image_record_id) .join(Tag, Tag.id == image_tag.c.tag_id) .where( Tag.is_system.is_(True), - Tag.name.in_(PRESENTATION_SYSTEM_TAGS), + Tag.name.in_(excluded_system_tags), ) ) stmt = stmt.where( diff --git a/frontend/src/stores/explore.js b/frontend/src/stores/explore.js index 36ee046..5adb26b 100644 --- a/frontend/src/stores/explore.js +++ b/frontend/src/stores/explore.js @@ -44,7 +44,9 @@ export const useExploreStore = defineStore('explore', () => { // empty and let the view explain why (anchor.has_embedding === false). if (detail.has_embedding) { const body = await api.get('/api/gallery/similar', { - params: { similar_to: numId, limit: NEIGHBOR_LIMIT }, + // exclude_wip: keep work-in-progress out of the Explore rabbit-hole + // (the gallery's own "similar" button still shows it) — operator 2026-07-08. + params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 }, }) if (!t.isCurrent()) return neighbors.value = body.images || [] diff --git a/tests/test_gallery_similar.py b/tests/test_gallery_similar.py index 35e75d6..a54ac1e 100644 --- a/tests/test_gallery_similar.py +++ b/tests/test_gallery_similar.py @@ -98,6 +98,30 @@ async def test_similar_excludes_presentation_tagged_images(db): assert {i.id for i in res_from_banner} == {src.id, wipped.id, plain.id} +@pytest.mark.asyncio +async def test_similar_exclude_wip_drops_wip_neighbors(db): + """Explore passes exclude_wip=True to also hide work-in-progress from the + rabbit-hole (banner is always hidden; wip only when asked).""" + src = await _img(db, 1, _vec(1, 0)) + bannered = await _img(db, 2, _vec(1, 0.02)) # always hidden + wipped = await _img(db, 3, _vec(1, 0.3)) # hidden only with exclude_wip + plain = await _img(db, 4, _vec(1, 0.6)) + banner_tag = (await db.execute(select(Tag).where( + Tag.is_system.is_(True), Tag.name == "banner"))).scalar_one() + wip_tag = (await db.execute(select(Tag).where( + Tag.is_system.is_(True), Tag.name == "wip"))).scalar_one() + await db.execute(image_tag.insert().values( + image_record_id=bannered.id, tag_id=banner_tag.id, source="manual")) + await db.execute(image_tag.insert().values( + image_record_id=wipped.id, tag_id=wip_tag.id, source="manual")) + svc = GalleryService(db) + res = await svc.similar(src.id, limit=10, exclude_wip=True) + assert [i.id for i in res] == [plain.id] # wip + banner both gone + # Default (gallery "similar" button) still keeps wip (#1274). + res_default = await svc.similar(src.id, limit=10) + assert wipped.id in {i.id for i in res_default} + + @pytest.mark.asyncio async def test_similar_composes_with_tag_filter(db): src = await _img(db, 1, _vec(1, 0))