feat(explore): cluster-consensus tag-gaps service + route (#94a)
Cluster C, milestone #94. BulkTagService.tag_gaps(image_ids, threshold) finds tags applied to >= threshold fraction of a visual neighbour set but not all of it (the '7 of 10 share Miku; these 3 don't' signal). Each gap carries the laggard image ids minus any TagSuggestionRejection rows, so apply-to-cluster never re-proposes a tag a neighbour dismissed. 100%-common tags and <2-image sets are excluded. New POST /api/images/cluster/tag-gaps. Tests: consensus found / common excluded / missing ids; rejected laggard excluded from missing; tag dropped when all laggards rejected; <2 images empty; route shape + bad input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
@@ -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]]
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
Reference in New Issue
Block a user