Files
FabledCurator/tests/test_ml_suggestions.py
T
bvandeusen 291b90803d
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m22s
fix(test): match rejected suggestion by id, not display casing
test_rejected_tag_surfaced_flagged_then_reversible asserted "Rejectme" but an
existing tag keeps its stored name ("rejectme"), so the suggestion's
display_name is lowercase. Match by canonical_tag_id instead (casing-robust).
The feature was correct — only the assertion was wrong (run 1595 integration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-28 10:38:15 -04:00

189 lines
6.8 KiB
Python

import pytest
from backend.app.models import ImageRecord, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.ml.aliases import AliasService
from backend.app.services.ml.allowlist import AllowlistService
from backend.app.services.ml.suggestions import SuggestionService
from backend.app.services.tag_service import TagService
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",
)
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 = await _seed_img(
db,
"a" * 64,
{
"lowconf": {"category": "general", "confidence": 0.30},
"sword": {"category": "general", "confidence": 0.97},
},
)
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.
assert "Sword" in names
assert "Lowconf" not in names
@pytest.mark.asyncio
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 = await _seed_img(
db,
"d" * 64,
{
"lowconf": {"category": "general", "confidence": 0.30},
"sword": {"category": "general", "confidence": 0.97},
},
)
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 = 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 = await _seed_img(
db,
"b" * 64,
{"safe": {"category": "rating", "confidence": 0.99}},
)
sl = await SuggestionService(db).for_image(img.id)
assert "rating" not in sl.by_category
@pytest.mark.asyncio
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 = await _seed_img(
db,
"c" * 64,
{"uchiha_sasuke": {"category": "character", "confidence": 0.96}},
)
sl = await SuggestionService(db).for_image(img.id)
chars = sl.by_category["character"]
assert len(chars) == 1
assert chars[0].display_name == "Sasuke Uchiha"
assert chars[0].canonical_tag_id == canonical.id
assert chars[0].creates_new_tag is False
# Surfaced via an alias on the raw model key — the UI marks it + offers undo.
assert chars[0].via_alias is True
assert chars[0].raw_name == "uchiha_sasuke"
@pytest.mark.asyncio
async def test_raw_tag_creates_new(db):
img = await _seed_img(
db,
"d" * 64,
{"brand_new_tag": {"category": "character", "confidence": 0.96}},
)
sl = await SuggestionService(db).for_image(img.id)
chars = sl.by_category["character"]
# display_name is the normalized Camie name (underscores -> spaces,
# title-cased), not the raw vocab key.
assert chars[0].display_name == "Brand New Tag"
assert chars[0].creates_new_tag is True
# Not aliased, but the raw key is carried so the modal can author one.
assert chars[0].via_alias is False
assert chars[0].raw_name == "brand_new_tag"
assert chars[0].canonical_tag_id is None
@pytest.mark.asyncio
async def test_applied_tag_not_suggested(db):
tags = TagService(db)
tag = await tags.find_or_create("alreadyhere", TagKind.character)
img = await _seed_img(
db,
"e" * 64,
{"alreadyhere": {"category": "character", "confidence": 0.96}},
)
await db.execute(
image_tag.insert().values(
image_record_id=img.id, tag_id=tag.id, source="manual"
)
)
sl = await SuggestionService(db).for_image(img.id)
assert "character" not in sl.by_category or not sl.by_category["character"]
@pytest.mark.asyncio
async def test_rejected_tag_surfaced_flagged_then_reversible(db):
# A dismissed suggestion is NOT dropped: it stays in the list flagged
# rejected=True so the rail can show it + offer one-click un-reject
# (visible, reversible rejection — operator-asked 2026-06-27). A live
# suggestion sorts ahead of the rejected one.
tags = TagService(db)
rejected_tag = await tags.find_or_create("rejectme", TagKind.general)
img = await _seed_img(
db,
"f" * 64,
{
"rejectme": {"category": "general", "confidence": 0.96},
"keepme": {"category": "general", "confidence": 0.90},
},
)
await AllowlistService(db).dismiss(img.id, rejected_tag.id)
sl = await SuggestionService(db).for_image(img.id)
general = sl.by_category["general"]
# Match by id, not display casing (an existing tag keeps its stored name).
rej = next(s for s in general if s.canonical_tag_id == rejected_tag.id)
assert rej.rejected is True
live = [s for s in general if not s.rejected]
assert live, "the un-rejected 'keepme' suggestion should still surface"
# Live suggestions sort ahead of rejected ones regardless of score.
assert general[-1].canonical_tag_id == rejected_tag.id
# Un-reject reverts it to a live suggestion.
await AllowlistService(db).undismiss(img.id, rejected_tag.id)
sl2 = await SuggestionService(db).for_image(img.id)
rej2 = next(
s for s in sl2.by_category["general"]
if s.canonical_tag_id == rejected_tag.id
)
assert rej2.rejected is False