feat(ml): read suggestions + allowlist from image_prediction (#768 step 2)
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>
This commit is contained in:
@@ -9,7 +9,7 @@ from backend.app.services.tag_service import TagService
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _img(sha: str, predictions: dict) -> ImageRecord:
|
||||
def _img(sha: str) -> ImageRecord:
|
||||
return ImageRecord(
|
||||
path=f"/images/{sha}.jpg",
|
||||
sha256=sha,
|
||||
@@ -19,24 +19,34 @@ def _img(sha: str, predictions: dict) -> ImageRecord:
|
||||
height=1,
|
||||
origin="imported_filesystem",
|
||||
integrity_status="unknown",
|
||||
tagger_predictions=predictions,
|
||||
)
|
||||
|
||||
|
||||
async def _seed_img(db, sha: str, predictions: dict) -> ImageRecord:
|
||||
"""#768: create an image + seed its predictions into image_prediction
|
||||
(the read path's source), returning the flushed record."""
|
||||
from tests._prediction_helpers import seed_predictions
|
||||
|
||||
img = _img(sha)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
await seed_predictions(db, img.id, predictions)
|
||||
return img
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_threshold_filters_low_confidence_general(db):
|
||||
# Default general threshold is 0.50 (alembic 0029 lowered it from
|
||||
# 0.95). Use 0.30/0.60 to keep the test asserting threshold behavior
|
||||
# rather than the exact cutoff number.
|
||||
img = _img(
|
||||
img = await _seed_img(
|
||||
db,
|
||||
"a" * 64,
|
||||
{
|
||||
"lowconf": {"category": "general", "confidence": 0.30},
|
||||
"sword": {"category": "general", "confidence": 0.97},
|
||||
},
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
names = [s.display_name for s in sl.by_category.get("general", [])]
|
||||
# display_name is normalized (tag_name.normalize) before surfacing.
|
||||
@@ -49,36 +59,34 @@ async def test_threshold_override_surfaces_low_confidence(db):
|
||||
# The typed-dropdown "show everything the model saw" mode: threshold_override
|
||||
# surfaces stored predictions below the configured threshold (in canonical
|
||||
# formatting) so they can be picked instead of hand-typed (2026-06-09).
|
||||
img = _img(
|
||||
img = await _seed_img(
|
||||
db,
|
||||
"d" * 64,
|
||||
{
|
||||
"lowconf": {"category": "general", "confidence": 0.30},
|
||||
"sword": {"category": "general", "confidence": 0.97},
|
||||
},
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
sl = await SuggestionService(db).for_image(img.id, threshold_override=0.0)
|
||||
names = [s.display_name for s in sl.by_category.get("general", [])]
|
||||
assert "Sword" in names
|
||||
assert "Lowconf" in names # below the configured threshold, surfaced anyway
|
||||
|
||||
# Unsurfaced categories are still excluded even with the override.
|
||||
img2 = _img("e" * 64, {"safe": {"category": "rating", "confidence": 0.99}})
|
||||
db.add(img2)
|
||||
await db.flush()
|
||||
img2 = await _seed_img(
|
||||
db, "e" * 64, {"safe": {"category": "rating", "confidence": 0.99}}
|
||||
)
|
||||
sl2 = await SuggestionService(db).for_image(img2.id, threshold_override=0.0)
|
||||
assert "rating" not in sl2.by_category
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsurfaced_category_dropped(db):
|
||||
img = _img(
|
||||
img = await _seed_img(
|
||||
db,
|
||||
"b" * 64,
|
||||
{"safe": {"category": "rating", "confidence": 0.99}},
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
assert "rating" not in sl.by_category
|
||||
|
||||
@@ -88,12 +96,11 @@ async def test_alias_resolution(db):
|
||||
tags = TagService(db)
|
||||
canonical = await tags.find_or_create("Sasuke Uchiha", TagKind.character)
|
||||
await AliasService(db).create("uchiha_sasuke", "character", canonical.id)
|
||||
img = _img(
|
||||
img = await _seed_img(
|
||||
db,
|
||||
"c" * 64,
|
||||
{"uchiha_sasuke": {"category": "character", "confidence": 0.96}},
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
chars = sl.by_category["character"]
|
||||
assert len(chars) == 1
|
||||
@@ -104,12 +111,11 @@ async def test_alias_resolution(db):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_tag_creates_new(db):
|
||||
img = _img(
|
||||
img = await _seed_img(
|
||||
db,
|
||||
"d" * 64,
|
||||
{"brand_new_tag": {"category": "character", "confidence": 0.96}},
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
chars = sl.by_category["character"]
|
||||
# display_name is the normalized Camie name (underscores -> spaces,
|
||||
@@ -123,12 +129,11 @@ async def test_raw_tag_creates_new(db):
|
||||
async def test_applied_tag_not_suggested(db):
|
||||
tags = TagService(db)
|
||||
tag = await tags.find_or_create("alreadyhere", TagKind.character)
|
||||
img = _img(
|
||||
img = await _seed_img(
|
||||
db,
|
||||
"e" * 64,
|
||||
{"alreadyhere": {"category": "character", "confidence": 0.96}},
|
||||
)
|
||||
db.add(img)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
image_tag.insert().values(
|
||||
image_record_id=img.id, tag_id=tag.id, source="manual"
|
||||
|
||||
Reference in New Issue
Block a user