22cdf0f334
Switch every prediction READER off the JSON column onto the normalized
image_prediction table. Parity by construction: each reader loads the same
{raw_name: {category, confidence}} dict it consumed before (via small
_load_predictions helpers), so all downstream threshold/alias/merge/consensus
logic is byte-identical — only the data source changed.
- suggestions.SuggestionService.for_image (and for_selection via it)
- ml.apply_allowlist_tags (iterates images that have prediction rows)
- importer re-import reset deletes the image's prediction rows
The tagger_predictions JSON column is still dual-written (step 1) so it stays
valid during transition; the backfill task's NULL check still works. Removing
the JSON write + DROP column + retiring the #764 prune is the cleanup
follow-up (needs a quiesced-worker window for the DROP lock).
Tests: shared tests/_prediction_helpers.seed_predictions seeds the table;
read-path tests (suggestions, bulk consensus, allowlist apply, API) seed there
instead of ImageRecord.tagger_predictions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
4.3 KiB
Python
123 lines
4.3 KiB
Python
import pytest
|
|
|
|
from backend.app import create_app
|
|
from backend.app.models import ImageRecord, TagKind
|
|
from backend.app.models.tag import image_tag
|
|
from backend.app.services.ml.suggestions import SuggestionService
|
|
from backend.app.services.tag_service import TagService
|
|
from tests._prediction_helpers import seed_predictions
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _img(sha: str) -> ImageRecord:
|
|
return ImageRecord(
|
|
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1,
|
|
mime="image/jpeg", width=1, height=1,
|
|
origin="imported_filesystem", integrity_status="unknown",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consensus_includes_tag_over_threshold(db):
|
|
tags = TagService(db)
|
|
t = await tags.find_or_create("sword", TagKind.general)
|
|
a = _img("a" * 64)
|
|
b = _img("b" * 64)
|
|
db.add_all([a, b])
|
|
await db.flush()
|
|
await seed_predictions(db, a.id, {"sword": {"category": "general", "confidence": 0.97}})
|
|
await seed_predictions(db, b.id, {"sword": {"category": "general", "confidence": 0.95}})
|
|
res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8)
|
|
gen = res["general"]
|
|
assert any(s["canonical_tag_id"] == t.id for s in gen)
|
|
s = next(s for s in gen if s["canonical_tag_id"] == t.id)
|
|
assert s["coverage"] == 1.0
|
|
assert 0.95 <= s["confidence"] <= 0.97
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consensus_counts_already_applied_for_coverage(db):
|
|
tags = TagService(db)
|
|
t = await tags.find_or_create("sky", TagKind.general)
|
|
a = _img("c" * 64)
|
|
b = _img("d" * 64) # no prediction
|
|
db.add_all([a, b])
|
|
await db.flush()
|
|
await seed_predictions(db, a.id, {"sky": {"category": "general", "confidence": 0.96}})
|
|
# b already has the tag applied -> counts toward coverage, not confidence
|
|
await db.execute(
|
|
image_tag.insert().values(
|
|
image_record_id=b.id, tag_id=t.id, source="manual"
|
|
)
|
|
)
|
|
res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8)
|
|
s = next(s for s in res["general"] if s["canonical_tag_id"] == t.id)
|
|
assert s["coverage"] == 1.0 # 1 suggested + 1 applied / 2
|
|
assert s["confidence"] == pytest.approx(0.96, abs=1e-4)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consensus_excludes_below_threshold(db):
|
|
tags = TagService(db)
|
|
await tags.find_or_create("rare", TagKind.general)
|
|
a = _img("e" * 64)
|
|
b = _img("f" * 64)
|
|
db.add_all([a, b])
|
|
await db.flush()
|
|
await seed_predictions(db, a.id, {"rare": {"category": "general", "confidence": 0.96}})
|
|
res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8)
|
|
assert all(
|
|
s["name"] != "rare" for s in res.get("general", [])
|
|
) # coverage 0.5 < 0.8
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consensus_skips_creates_new_tag(db):
|
|
a = _img("g" * 64)
|
|
b = _img("h" * 64)
|
|
db.add_all([a, b])
|
|
await db.flush()
|
|
await seed_predictions(db, a.id, {"neverseen": {"category": "general", "confidence": 0.99}})
|
|
await seed_predictions(db, b.id, {"neverseen": {"category": "general", "confidence": 0.99}})
|
|
res = await SuggestionService(db).for_selection([a.id, b.id], threshold=0.8)
|
|
# 'neverseen' has no Tag row -> creates_new_tag -> excluded from consensus
|
|
assert all(s["name"] != "neverseen" for s in res.get("general", []))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consensus_threshold_clamped_and_empty_for_no_ids(db):
|
|
res = await SuggestionService(db).for_selection([], threshold=5.0)
|
|
assert res == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_suggestions_route(db):
|
|
|
|
tags = TagService(db)
|
|
await tags.find_or_create("sword", TagKind.general)
|
|
a = _img("i" * 64)
|
|
db.add(a)
|
|
await db.commit()
|
|
await seed_predictions(db, a.id, {"sword": {"category": "general", "confidence": 0.97}})
|
|
await db.commit()
|
|
app = create_app()
|
|
async with app.test_client() as c:
|
|
resp = await c.post(
|
|
"/api/suggestions/bulk", json={"image_ids": [a.id]}
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["evaluated"] == 1
|
|
assert body["threshold"] == 0.8
|
|
assert "suggestions" in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_suggestions_requires_ids(db):
|
|
|
|
app = create_app()
|
|
async with app.test_client() as c:
|
|
resp = await c.post("/api/suggestions/bulk", json={})
|
|
assert resp.status_code == 400
|