5c3f8ebd70
The headline bug: aliases created from the modal NEVER resolved. Create
sent the normalized display name ('Sword', 'Uchiha Sasuke') while
resolution keys on the raw booru model key ('sword', 'uchiha_sasuke',
case-sensitive) — so the mapping was stored under a key nothing looks up,
and the prediction kept reappearing unaliased. The raw key wasn't even in
the /suggestions response, so the modal couldn't send it.
- Suggestion now carries raw_name (the model key an alias must use) and
via_alias (surfaced via an operator alias); both serialized by the API.
- Modal alias-create sends raw_name, not display_name (the fix). Aliased
suggestions show an 'alias' badge and a 'Remove alias' action; 'Treat as
alias for…' is hidden for centroid hits (no model key) and already-aliased
rows.
- Tag-side management: TagCard ⋮ → 'Aliases…' opens a dialog listing the
model keys that fold into a tag, with remove (GET /api/tags/<id>/aliases +
AliasService.list_for_tag). Creation stays in the modal suggestion flow.
Tests: full API round-trip locking the raw-key contract (raw_name exposed →
alias authored with it → resolves + via_alias on a later image);
list_for_tag (service + API); via_alias/raw_name on the existing service
suggestion tests. No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
5.1 KiB
Python
150 lines
5.1 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.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"]
|