feat(gallery): similar() hides presentation images (banner / editor screenshot)
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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user