feat(gallery): similar() hides presentation images (banner / editor screenshot)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Failing after 3m34s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 23:23:30 -04:00
parent 19744fa41d
commit 723f023e6a
3 changed files with 47 additions and 1 deletions
+6
View File
@@ -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,
+15 -1
View File
@@ -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,
+26
View File
@@ -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))