chore(tags): remove orphaned cluster tag-gaps route + service method
The cluster tag-gap feature's only UI (Explore's TagGapPanel) was removed in the 3-pane rework, leaving the backend that fed it with no caller. Surgical removal: - drop the POST /api/images/cluster/tag-gaps route (cluster_tag_gaps) - drop BulkTagService.tag_gaps (+ the now-unused `import math`) - drop the tag_gaps tests (test_bulk_tag_service, test_api_bulk_tags) BulkTagService's common_tags / bulk_add / bulk_remove stay — they still back the gallery bulk editor. Pure deletion, no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -320,27 +320,6 @@ async def common_tags():
|
|||||||
return jsonify({"tags": 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"])
|
@tags_bp.route("/images/bulk/tags", methods=["POST"])
|
||||||
async def bulk_add_tag():
|
async def bulk_add_tag():
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Bulk tag operations over a set of images (Core, set-based, atomic)."""
|
"""Bulk tag operations over a set of images (Core, set-based, atomic)."""
|
||||||
|
|
||||||
import math
|
|
||||||
|
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -15,85 +13,6 @@ class BulkTagService:
|
|||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
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]:
|
async def common_tags(self, image_ids: list[int]) -> list[dict]:
|
||||||
"""Tags present on EVERY image in image_ids."""
|
"""Tags present on EVERY image in image_ids."""
|
||||||
if not image_ids:
|
if not image_ids:
|
||||||
|
|||||||
@@ -87,42 +87,3 @@ async def test_bulk_remove_route_requires_ids(client):
|
|||||||
"/api/images/bulk/tags/remove", json={"tag_id": 1}
|
"/api/images/bulk/tags/remove", json={"tag_id": 1}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
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]]
|
|
||||||
|
|||||||
@@ -120,59 +120,3 @@ async def test_bulk_remove_rejection_is_idempotent(db):
|
|||||||
# Second remove: nothing to delete, rejection already present, no error.
|
# Second remove: nothing to delete, rejection already present, no error.
|
||||||
removed = await svc.bulk_remove([i1], tag.id)
|
removed = await svc.bulk_remove([i1], tag.id)
|
||||||
assert removed == 0
|
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) == []
|
|
||||||
|
|||||||
Reference in New Issue
Block a user