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. # 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 = Table(
"image_tag", "image_tag",
Base.metadata, Base.metadata,
+15 -1
View File
@@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag 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 .pagination import decode_cursor, encode_cursor
from .tag_query import ( from .tag_query import (
fandom_join_alias, fandom_join_alias,
@@ -693,9 +693,23 @@ class GalleryService:
eff = _effective_date_col() eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
stmt = _outer_join_primary_post(stmt) 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( stmt = stmt.where(
ImageRecord.siglip_embedding.is_not(None), ImageRecord.siglip_embedding.is_not(None),
ImageRecord.id != image_id, ImageRecord.id != image_id,
ImageRecord.id.not_in(presentation),
) )
stmt = _apply_scope( stmt = _apply_scope(
stmt, tag_ids=tag_ids, post_id=None, 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 from datetime import UTC, datetime, timedelta
import pytest import pytest
from sqlalchemy import select
from backend.app.models import ImageRecord, Tag, TagKind from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag 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 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 @pytest.mark.asyncio
async def test_similar_composes_with_tag_filter(db): async def test_similar_composes_with_tag_filter(db):
src = await _img(db, 1, _vec(1, 0)) src = await _img(db, 1, _vec(1, 0))