diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 0556ed9..31aee4e 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -320,6 +320,27 @@ async def common_tags(): return jsonify({"tags": tags}) +@tags_bp.route("/images/cluster/tag-gaps", methods=["POST"]) +async def cluster_tag_gaps(): + """Cluster-consensus tag gaps for a visual neighbour set (#94 Explore): + tags on >= threshold of the images but not all. Each gap carries the + laggard image ids (minus rejections) for apply-to-cluster.""" + body = await request.get_json() + ids, err = _parse_bulk_ids(body) + if err: + return err + try: + threshold = float(body.get("threshold", 0.6)) + except (TypeError, ValueError): + threshold = 0.6 + threshold = min(1.0, max(0.0, threshold)) + async with get_session() as session: + gaps = await BulkTagService(session).tag_gaps(ids, threshold) + return jsonify( + {"gaps": gaps, "total": len(set(ids)), "threshold": threshold} + ) + + @tags_bp.route("/images/bulk/tags", methods=["POST"]) async def bulk_add_tag(): body = await request.get_json() diff --git a/backend/app/services/bulk_tag_service.py b/backend/app/services/bulk_tag_service.py index b573ef9..57d673c 100644 --- a/backend/app/services/bulk_tag_service.py +++ b/backend/app/services/bulk_tag_service.py @@ -1,5 +1,7 @@ """Bulk tag operations over a set of images (Core, set-based, atomic).""" +import math + from sqlalchemy import and_, func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession @@ -13,6 +15,85 @@ class BulkTagService: def __init__(self, session: AsyncSession): self.session = session + async def tag_gaps( + self, image_ids: list[int], threshold: float + ) -> list[dict]: + """Cluster-consensus tag gaps (#94 Explore): tags applied to at least + `threshold` fraction of the image set but NOT all of it — the + "7 of these 10 share Hatsune Miku; these 3 don't" signal. + + For each gap, `missing_image_ids` are the set members that LACK the tag + and have NOT rejected it (TagSuggestionRejection) — so apply-to-cluster + never re-proposes a tag a neighbor explicitly dismissed. A tag on every + image is "common", not a gap; a tag on fewer than 2 isn't consensus. + """ + ids = list({int(i) for i in image_ids}) + if len(ids) < 2: + return [] + n = len(ids) + min_present = max(2, math.ceil(threshold * n)) + if min_present >= n: + return [] # threshold so high only a 100%-common tag could qualify + + cnt = func.count(func.distinct(image_tag.c.image_record_id)) + gap_rows = ( + await self.session.execute( + select(Tag.id, Tag.name, Tag.kind) + .join(image_tag, image_tag.c.tag_id == Tag.id) + .where(image_tag.c.image_record_id.in_(ids)) + .group_by(Tag.id, Tag.name, Tag.kind) + .having(and_(cnt >= min_present, cnt < n)) + .order_by(cnt.desc(), Tag.name.asc()) + ) + ).all() + if not gap_rows: + return [] + gap_ids = [r.id for r in gap_rows] + + present: dict[int, set[int]] = {} + for tid, iid in ( + await self.session.execute( + select(image_tag.c.tag_id, image_tag.c.image_record_id).where( + image_tag.c.tag_id.in_(gap_ids), + image_tag.c.image_record_id.in_(ids), + ) + ) + ).all(): + present.setdefault(tid, set()).add(iid) + + rejected: dict[int, set[int]] = {} + for tid, iid in ( + await self.session.execute( + select( + TagSuggestionRejection.tag_id, + TagSuggestionRejection.image_record_id, + ).where( + TagSuggestionRejection.tag_id.in_(gap_ids), + TagSuggestionRejection.image_record_id.in_(ids), + ) + ) + ).all(): + rejected.setdefault(tid, set()).add(iid) + + id_set = set(ids) + gaps = [] + for r in gap_rows: + have = present.get(r.id, set()) + missing = id_set - have - rejected.get(r.id, set()) + if not missing: + continue # every laggard rejected it — nothing to apply + gaps.append( + { + "tag_id": r.id, + "name": r.name, + "kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind), + "present_count": len(have), + "total": n, + "missing_image_ids": sorted(missing), + } + ) + return gaps + async def common_tags(self, image_ids: list[int]) -> list[dict]: """Tags present on EVERY image in image_ids.""" if not image_ids: diff --git a/tests/test_api_bulk_tags.py b/tests/test_api_bulk_tags.py index 5c3c6c7..7f6e0db 100644 --- a/tests/test_api_bulk_tags.py +++ b/tests/test_api_bulk_tags.py @@ -87,3 +87,42 @@ async def test_bulk_remove_route_requires_ids(client): "/api/images/bulk/tags/remove", json={"tag_id": 1} ) assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_cluster_tag_gaps_requires_list(client): + resp = await client.post("/api/images/cluster/tag-gaps", json={}) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_cluster_tag_gaps_route_reports_gap(client, db): + from backend.app.models import ImageRecord, TagKind + from backend.app.services.tag_service import TagService + + tags = TagService(db) + gap = await tags.find_or_create("GapRoute", TagKind.general) + imgs = [] + for i in range(4): + rec = ImageRecord( + path=f"/tmp/gaproute_{i}.png", sha256=f"g{i:063d}", + size_bytes=1, mime="image/png", origin="uploaded", + ) + db.add(rec) + await db.flush() + imgs.append(rec.id) + for i in imgs[:3]: + await tags.add_to_image(i, gap.id) # on 3/4 → a gap at 0.6 + await db.commit() + + resp = await client.post( + "/api/images/cluster/tag-gaps", + json={"image_ids": imgs, "threshold": 0.6}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["total"] == 4 + assert body["threshold"] == 0.6 + g = next(x for x in body["gaps"] if x["tag_id"] == gap.id) + assert g["present_count"] == 3 + assert g["missing_image_ids"] == [imgs[3]] diff --git a/tests/test_bulk_tag_service.py b/tests/test_bulk_tag_service.py index cb8d1c5..4cd9024 100644 --- a/tests/test_bulk_tag_service.py +++ b/tests/test_bulk_tag_service.py @@ -120,3 +120,59 @@ async def test_bulk_remove_rejection_is_idempotent(db): # Second remove: nothing to delete, rejection already present, no error. removed = await svc.bulk_remove([i1], tag.id) assert removed == 0 + + +@pytest.mark.asyncio +async def test_tag_gaps_finds_consensus_excludes_common(db): + tags = TagService(db) + gap = await tags.find_or_create("Miku", TagKind.character) + common = await tags.find_or_create("Solo", TagKind.general) + imgs = [await _img(db) for _ in range(5)] + for i in imgs: + await tags.add_to_image(i, common.id) # on all 5 → not a gap + for i in imgs[:4]: + await tags.add_to_image(i, gap.id) # on 4/5 → a gap + svc = BulkTagService(db) + gaps = await svc.tag_gaps(imgs, threshold=0.6) + assert [g["tag_id"] for g in gaps] == [gap.id] # the common tag is excluded + g = gaps[0] + assert g["present_count"] == 4 + assert g["total"] == 5 + assert g["missing_image_ids"] == [imgs[4]] + + +@pytest.mark.asyncio +async def test_tag_gaps_excludes_rejected_laggard(db): + tags = TagService(db) + gap = await tags.find_or_create("Rin", TagKind.character) + imgs = [await _img(db) for _ in range(6)] + for i in imgs[:4]: + await tags.add_to_image(i, gap.id) # on 4/6 (min_present=ceil(3.6)=4) + # imgs[4], imgs[5] lack it; imgs[4] explicitly rejected it. + db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id)) + await db.flush() + svc = BulkTagService(db) + gaps = await svc.tag_gaps(imgs, threshold=0.6) + g = next(x for x in gaps if x["tag_id"] == gap.id) + assert g["missing_image_ids"] == [imgs[5]] # rejected laggard excluded + + +@pytest.mark.asyncio +async def test_tag_gaps_drops_tag_when_all_laggards_rejected(db): + tags = TagService(db) + gap = await tags.find_or_create("Len", TagKind.character) + imgs = [await _img(db) for _ in range(5)] + for i in imgs[:4]: + await tags.add_to_image(i, gap.id) # on 4/5, sole laggard imgs[4] + db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id)) + await db.flush() + svc = BulkTagService(db) + gaps = await svc.tag_gaps(imgs, threshold=0.6) + assert all(g["tag_id"] != gap.id for g in gaps) # nothing to apply → dropped + + +@pytest.mark.asyncio +async def test_tag_gaps_needs_at_least_two_images(db): + svc = BulkTagService(db) + assert await svc.tag_gaps([], 0.6) == [] + assert await svc.tag_gaps([await _img(db)], 0.6) == []