From df2310bc709a4a3af8f3b27711a095f7b769d9ce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 6 Jun 2026 17:39:54 -0400 Subject: [PATCH] fix(tags): preserve acronym casing in tag normalization (DC, NSFW) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize_tag_name now only capitalizes the first letter of each word and leaves the rest of the word untouched (was lowercasing the tail, which turned DC→Dc / NSFW→Nsfw). This matches ml/tag_name._title_word, so a Camie-suggested tag keeps the exact casing the suggestion UI showed when it round-trips through POST /api/tags on Accept — addressing "auto-suggested tags must obey capitalization" and "don't mangle acronyms" in one rule. Trade-off (operator-chosen): all-caps input no longer folds to Title Case, so case-variant merging in #714 still folds the dominant lowercase-vs-Title case but leaves all-caps stylizations distinct (protecting acronyms wins). Tests updated + a new test documenting acronym preservation / non-folding. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/tag_service.py | 14 +++++++++----- tests/test_api_tags.py | 5 +++-- tests/test_tag_normalize.py | 23 +++++++++++++++++++++-- tests/test_tag_service.py | 7 ++++++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 60ffddd..bfdef07 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -17,15 +17,19 @@ log = logging.getLogger(__name__) def normalize_tag_name(name: str) -> str: - """Canonical tag form (#701): collapse whitespace + Title Case. + """Canonical tag form (#701): collapse whitespace + capitalize the first + letter of each word, PRESERVING the rest of the word. Per-word capitalize (NOT str.title(), which mangles apostrophes: - don't → Don'T). Lowercasing the word tail also folds ALL-CAPS input - (HATSUNE MIKU → Hatsune Miku); acronym casing is sacrificed, an accepted - Title-Case trade-off (operator-chosen 2026-06-06). + don't → Don'T). The word tail is left untouched so acronyms survive + (DC stays DC, NSFW stays NSFW) — operator-revised 2026-06-06: protecting + acronyms matters more than folding ALL-CAPS input. This MATCHES + ml/tag_name._title_word, so a tag suggested by the Camie tagger keeps the + exact casing the suggestion UI showed when it round-trips through the + create endpoint on Accept. """ return " ".join( - w[:1].upper() + w[1:].lower() for w in (name or "").split() + w[:1].upper() + w[1:] for w in (name or "").split() ) diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py index c630baa..617587e 100644 --- a/tests/test_api_tags.py +++ b/tests/test_api_tags.py @@ -74,13 +74,14 @@ async def test_create_tag_with_kind_prefix(client): @pytest.mark.asyncio async def test_explicit_kind_overrides_prefix_parsing(client): """If caller passes explicit kind, don't re-parse the name — - colon and prefix stay literal. #701: still Title-Cased as a single word.""" + colon and prefix stay literal. #701: first letter capitalized, tail + preserved (acronym-safe), so the 'S' in Saber survives.""" resp = await client.post( "/api/tags", json={"name": "character:Saber", "kind": "general"} ) assert resp.status_code == 201 body = await resp.get_json() - assert body["name"] == "Character:saber" + assert body["name"] == "Character:Saber" assert body["kind"] == "general" diff --git a/tests/test_tag_normalize.py b/tests/test_tag_normalize.py index b61c383..b9c8472 100644 --- a/tests/test_tag_normalize.py +++ b/tests/test_tag_normalize.py @@ -87,8 +87,9 @@ async def test_dry_run_projection_counts(db): @pytest.mark.asyncio async def test_live_merges_case_variants_and_repoints_images(db): + # lowercase vs sentence-case → both canonicalize to "Hatsune Miku" and fold. a = await _raw_tag(db, "hatsune miku", TagKind.character) - b = await _raw_tag(db, "HATSUNE MIKU", TagKind.character) + b = await _raw_tag(db, "Hatsune miku", TagKind.character) i1, i2, i3 = await _img(db), await _img(db), await _img(db) await _link(db, i1, a.id) await _link(db, i2, a.id) @@ -125,7 +126,7 @@ async def test_live_merges_case_variants_and_repoints_images(db): @pytest.mark.asyncio async def test_idempotent_second_run_is_noop(db): await _raw_tag(db, "kafka", TagKind.character) - await _raw_tag(db, "KAFKA", TagKind.character) + await _raw_tag(db, "Kafka", TagKind.character) first = await normalize_existing_tags(db, dry_run=False) assert first["groups_processed"] == 1 @@ -138,6 +139,24 @@ async def test_idempotent_second_run_is_noop(db): assert again["renamed"] == 0 +@pytest.mark.asyncio +async def test_acronym_casing_is_preserved_not_folded(db): + """Acronym-preserving rule (2026-06-06): an already-canonical acronym tag + is a no-op, and an all-caps variant does NOT fold into a Title-cased one + (protecting DC/NSFW from becoming Dc/Nsfw is the deliberate trade).""" + await _raw_tag(db, "DC", TagKind.general) + proj = await normalize_existing_tags(db, dry_run=True) + assert proj["total_changes"] == 0 # "DC" is already canonical + + # All-caps and Title-case are distinct canonical forms → not merged. + await _raw_tag(db, "Nsfw", TagKind.general) + await _raw_tag(db, "NSFW", TagKind.general) + summary = await normalize_existing_tags(db, dry_run=False) + assert summary["merged"] == 0 + assert await _count_named(db, "DC", TagKind.general) == 1 + assert await _count_named(db, "NSFW", TagKind.general) == 1 + + @pytest.mark.asyncio async def test_same_name_different_fandom_not_merged(db): f1 = await _raw_tag(db, "Bleach", TagKind.fandom) diff --git a/tests/test_tag_service.py b/tests/test_tag_service.py index 3cb8a9b..1dbbcb7 100644 --- a/tests/test_tag_service.py +++ b/tests/test_tag_service.py @@ -77,7 +77,12 @@ async def test_normalize_tag_name(): from backend.app.services.tag_service import normalize_tag_name assert normalize_tag_name("hatsune miku") == "Hatsune Miku" assert normalize_tag_name(" looking at viewer ") == "Looking At Viewer" - assert normalize_tag_name("HATSUNE MIKU") == "Hatsune Miku" + # Acronym-preserving (2026-06-06): only the first letter of each word is + # touched, so already-uppercase tails survive (DC stays DC, not Dc). + assert normalize_tag_name("DC comics") == "DC Comics" + assert normalize_tag_name("dc") == "Dc" + assert normalize_tag_name("NSFW variant") == "NSFW Variant" + assert normalize_tag_name("HATSUNE MIKU") == "HATSUNE MIKU" @pytest.mark.asyncio