f8105046dc
Explore's neighbour grid (/api/gallery/similar → gallery_service.similar) now takes an Explore-only exclude_wip flag that drops `wip` system-tagged images from the candidates, alongside the banner/editor presentation tags. The gallery's own "similar" button is unchanged (keeps wip, #1274) — only the Explore store passes exclude_wip=1. The anchor itself may still be a WIP; only neighbours are filtered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
8.0 KiB
Python
208 lines
8.0 KiB
Python
"""Phase-3 visual "more like this" — pgvector cosine ranking over the
|
|
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
|
|
from backend.app.services.gallery_service import GalleryService
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _vec(*head):
|
|
"""A 1152-dim embedding with the given leading values, rest zero. Cosine
|
|
distance to _vec(1,0) grows as the 2nd component grows, so callers can
|
|
order fixtures deterministically by direction."""
|
|
v = [0.0] * 1152
|
|
for i, x in enumerate(head):
|
|
v[i] = float(x)
|
|
return v
|
|
|
|
|
|
async def _img(db, n, emb):
|
|
rec = ImageRecord(
|
|
path=f"/images/sim/{n}.jpg", sha256=f"e{n:063d}",
|
|
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
|
origin="imported_filesystem", integrity_status="unknown",
|
|
siglip_embedding=emb,
|
|
)
|
|
base = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
|
|
rec.created_at = base - timedelta(minutes=n)
|
|
rec.effective_date = rec.created_at
|
|
db.add(rec)
|
|
await db.flush()
|
|
return rec
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_similar_ranks_nearest_first(db):
|
|
src = await _img(db, 1, _vec(1, 0))
|
|
near = await _img(db, 2, _vec(1, 0.05)) # almost same direction
|
|
mid = await _img(db, 3, _vec(1, 1)) # 45°
|
|
far = await _img(db, 4, _vec(0, 1)) # orthogonal
|
|
svc = GalleryService(db)
|
|
res = await svc.similar(src.id, limit=10)
|
|
assert [i.id for i in res] == [near.id, mid.id, far.id] # self excluded
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_similar_excludes_null_embeddings(db):
|
|
src = await _img(db, 1, _vec(1, 0))
|
|
have = await _img(db, 2, _vec(1, 0.1))
|
|
await _img(db, 3, None) # un-embedded (e.g. a video) → excluded
|
|
svc = GalleryService(db)
|
|
res = await svc.similar(src.id, limit=10)
|
|
assert [i.id for i in res] == [have.id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_similar_source_without_embedding_returns_empty(db):
|
|
src = await _img(db, 1, None)
|
|
await _img(db, 2, _vec(1, 0))
|
|
svc = GalleryService(db)
|
|
assert await svc.similar(src.id, limit=10) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_similar_missing_source_returns_none(db):
|
|
svc = GalleryService(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_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))
|
|
await _img(db, 2, _vec(1, 0.02)) # nearest, but untagged
|
|
tagged = await _img(db, 3, _vec(1, 0.6)) # farther, but carries the tag
|
|
tag = Tag(name="t", kind=TagKind.general)
|
|
db.add(tag)
|
|
await db.flush()
|
|
await db.execute(image_tag.insert().values(
|
|
image_record_id=tagged.id, tag_id=tag.id, source="manual"))
|
|
svc = GalleryService(db)
|
|
res = await svc.similar(src.id, limit=10, tag_ids=[tag.id])
|
|
assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_similar_collapses_near_duplicate_phashes(db):
|
|
# The reported failure: a reposted image fills the whole neighbour grid.
|
|
# A wall of same-pHash reposts must collapse to at most one, and the
|
|
# genuinely distinct images must still come through.
|
|
src = await _img(db, 1, _vec(1, 0))
|
|
dupes = []
|
|
for n in range(2, 7): # 5 near-identical reposts
|
|
r = await _img(db, n, _vec(1, 0.01 * n))
|
|
r.phash = "ffffffffffffffff" # identical perceptual hash
|
|
dupes.append(r)
|
|
distinct_a = await _img(db, 7, _vec(1, 1))
|
|
distinct_a.phash = "0000000000000000"
|
|
distinct_b = await _img(db, 8, _vec(0, 1))
|
|
distinct_b.phash = "0f0f0f0f0f0f0f0f"
|
|
await db.flush()
|
|
|
|
res = await GalleryService(db).similar(src.id, limit=10)
|
|
ids = [i.id for i in res]
|
|
assert sum(1 for d in dupes if d.id in ids) <= 1 # repost wall collapsed
|
|
assert distinct_a.id in ids and distinct_b.id in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_similar_respects_limit(db):
|
|
src = await _img(db, 1, _vec(1, 0))
|
|
for n in range(2, 7):
|
|
await _img(db, n, _vec(1, 0.1 * n))
|
|
svc = GalleryService(db)
|
|
res = await svc.similar(src.id, limit=2)
|
|
assert len(res) == 2
|
|
|
|
|
|
# --- API ---
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_similar_endpoint(client, db):
|
|
src = await _img(db, 1, _vec(1, 0))
|
|
near = await _img(db, 2, _vec(1, 0.05))
|
|
await db.commit()
|
|
resp = await client.get(f"/api/gallery/similar?similar_to={src.id}&limit=10")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert [i["id"] for i in body["images"]] == [near.id]
|
|
assert body["next_cursor"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_similar_404_when_source_missing(client):
|
|
resp = await client.get("/api/gallery/similar?similar_to=99999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_similar_requires_param(client):
|
|
resp = await client.get("/api/gallery/similar")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_image_detail_reports_has_embedding(client, db):
|
|
embedded = await _img(db, 1, _vec(1, 0))
|
|
plain = await _img(db, 2, None)
|
|
await db.commit()
|
|
e = await (await client.get(f"/api/gallery/image/{embedded.id}")).get_json()
|
|
p = await (await client.get(f"/api/gallery/image/{plain.id}")).get_json()
|
|
assert e["has_embedding"] is True
|
|
assert p["has_embedding"] is False
|