feat(ml): normalize Camie suggestion names to human-readable
Camie's booru-style vocab strings (`uchiha_sasuke_(naruto)`, `#unicus_(idolmaster)`, `1000-nen_ikiteru_(vocaloid)`, `:/`) were surfacing raw in SuggestionsPanel — and worse, the SAME raw string was written to tag.name on Accept, polluting the DB with `underscored_lowercase` names that don't match the operator's "Title Case" tag convention. Add backend/app/services/ml/tag_name.py with a single normalize() applying nine rules (strip leading junk #/./+/;/~/_/ws, drop trailing _(disambiguator) blocks iteratively, strip wrapping quotes, underscores to spaces, space after colon, title-case each word's first char, preserve hyphens/apostrophes/digits, drop entries with no letters). Wire into SuggestionService.for_image: - raw Camie key kept for alias_map lookup (alias rows are hand-curated against raw keys; don't disturb) - display_name = normalize(raw); None means drop the candidate - existing-tag lookup widened to case-insensitive match against BOTH raw and normalized forms so legacy underscore-named Tag rows accepted before this change still surface as "existing" not "+ new"
This commit is contained in:
@@ -39,8 +39,9 @@ async def test_threshold_filters_low_confidence_general(db):
|
||||
await db.flush()
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
names = [s.display_name for s in sl.by_category.get("general", [])]
|
||||
assert "sword" in names
|
||||
assert "lowconf" not in names
|
||||
# display_name is normalized (tag_name.normalize) before surfacing.
|
||||
assert "Sword" in names
|
||||
assert "Lowconf" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -84,7 +85,9 @@ async def test_raw_tag_creates_new(db):
|
||||
await db.flush()
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
chars = sl.by_category["character"]
|
||||
assert chars[0].display_name == "brand_new_tag"
|
||||
# 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
|
||||
assert chars[0].canonical_tag_id is None
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
|
||||
from backend.app.services.ml.tag_name import normalize
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
# Rule 4: underscores -> spaces; rule 7: title case
|
||||
("light_purple_hair", "Light Purple Hair"),
|
||||
("no_pants", "No Pants"),
|
||||
("year_2005", "Year 2005"),
|
||||
# Single-word still title-cased
|
||||
("sword", "Sword"),
|
||||
# Rule 3: drop trailing _(disambiguator)
|
||||
("uchiha_sasuke_(naruto)", "Uchiha Sasuke"),
|
||||
("apple_(fruit)", "Apple"),
|
||||
("kirby_(series)", "Kirby"),
|
||||
# Repeated trailing disambig blocks
|
||||
("foo_(bar)_(baz)", "Foo"),
|
||||
# Rule 1: leading junk chars
|
||||
("#unicus_(idolmaster)", "Unicus"),
|
||||
(".52_gal_(splatoon)", "52 Gal"),
|
||||
("+_+_smile_(emote)", "Smile"),
|
||||
# Rule 5: space after colon
|
||||
("nier:automata", "Nier: Automata"),
|
||||
# Already-spaced colon left alone
|
||||
("nier: automata", "Nier: Automata"),
|
||||
# Rule 6: hyphens preserved
|
||||
("1000-nen_ikiteru_(vocaloid)", "1000-nen Ikiteru"),
|
||||
("well-known_face", "Well-known Face"),
|
||||
# Rule 2: wrapping quotes
|
||||
('"pile_em_up"_(genshin_impact)', "Pile Em Up"),
|
||||
("'foo_bar'", "Foo Bar"),
|
||||
# Rule 8: emoticons -> None
|
||||
(":/", None),
|
||||
(";)", None),
|
||||
("+_+", None),
|
||||
("^_^", None),
|
||||
# Empty / whitespace-only
|
||||
("", None),
|
||||
(" ", None),
|
||||
("___", None),
|
||||
# Apostrophe inside word — preserved, not title-cased
|
||||
("it's_okay", "It's Okay"),
|
||||
# Digit-only still surfaces (year tags)
|
||||
("2005", "2005"),
|
||||
# Multi-space collapse
|
||||
("foo___bar", "Foo Bar"),
|
||||
],
|
||||
)
|
||||
def test_normalize(raw, expected):
|
||||
assert normalize(raw) == expected
|
||||
Reference in New Issue
Block a user