"""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