From 723f023e6af63f735fa2b8f763b30bcbcde9bc27 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 2 Jul 2026 23:23:30 -0400 Subject: [PATCH] feat(gallery): similar() hides presentation images (banner / editor screenshot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of milestone #128. Presentation-tagged images cluster on UI chrome rather than content, so near any one of them they fill the whole more-like-this grid. Excluded from candidates in the ONE whole-image similarity surface (gallery similar mode, explore walk, and RelatedStrip all ride GalleryService.similar) — the anchor itself may be a banner, and wip stays surfaced: only the training pipelines exclude it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- backend/app/models/tag.py | 6 ++++++ backend/app/services/gallery_service.py | 16 ++++++++++++++- tests/test_gallery_similar.py | 26 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index fe02dcd..2479578 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -43,6 +43,12 @@ class TagKind(StrEnum): # to keep historic tag rows queryable. +# The seeded system tags (migration 0075). PRESENTATION tags additionally +# hide from whole-image similarity results — they cluster on UI chrome, not +# content. `wip` is real art: only the training pipelines exclude it. +SYSTEM_TAG_NAMES = ("wip", "banner", "editor screenshot") +PRESENTATION_SYSTEM_TAGS = ("banner", "editor screenshot") + image_tag = Table( "image_tag", Base.metadata, diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index e385459..4cde351 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag -from ..models.tag import image_tag +from ..models.tag import PRESENTATION_SYSTEM_TAGS, image_tag from .pagination import decode_cursor, encode_cursor from .tag_query import ( fandom_join_alias, @@ -693,9 +693,23 @@ class GalleryService: eff = _effective_date_col() stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = _outer_join_primary_post(stmt) + # 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). + 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), + ) + ) stmt = stmt.where( ImageRecord.siglip_embedding.is_not(None), ImageRecord.id != image_id, + ImageRecord.id.not_in(presentation), ) stmt = _apply_scope( stmt, tag_ids=tag_ids, post_id=None, diff --git a/tests/test_gallery_similar.py b/tests/test_gallery_similar.py index ea9b355..35e75d6 100644 --- a/tests/test_gallery_similar.py +++ b/tests/test_gallery_similar.py @@ -4,6 +4,7 @@ precomputed SigLIP image embeddings. No query-time ML inference.""" from datetime import UTC, datetime, timedelta import pytest +from sqlalchemy import select from backend.app.models import ImageRecord, Tag, TagKind from backend.app.models.tag import image_tag @@ -72,6 +73,31 @@ async def test_similar_missing_source_returns_none(db): assert await svc.similar(99999, limit=10) is None +@pytest.mark.asyncio +async def test_similar_excludes_presentation_tagged_images(db): + """banner / editor-screenshot system tags (#128) hide from similar + RESULTS — they cluster on UI chrome, not content. A banner anchor still + gets results; `wip`-tagged images stay in (real art).""" + src = await _img(db, 1, _vec(1, 0)) + bannered = await _img(db, 2, _vec(1, 0.02)) # nearest, but a banner + wipped = await _img(db, 3, _vec(1, 0.3)) + 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) + assert [i.id for i in res] == [wipped.id, plain.id] + # A presentation-tagged ANCHOR still answers — only candidates hide. + res_from_banner = await svc.similar(bannered.id, limit=10) + assert {i.id for i in res_from_banner} == {src.id, wipped.id, plain.id} + + @pytest.mark.asyncio async def test_similar_composes_with_tag_filter(db): src = await _img(db, 1, _vec(1, 0))