fix(tags): preserve acronym casing in tag normalization (DC, NSFW)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 17:39:54 -04:00
parent 9374f63953
commit df2310bc70
4 changed files with 39 additions and 10 deletions
+9 -5
View File
@@ -17,15 +17,19 @@ log = logging.getLogger(__name__)
def normalize_tag_name(name: str) -> str: 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: Per-word capitalize (NOT str.title(), which mangles apostrophes:
don't → Don'T). Lowercasing the word tail also folds ALL-CAPS input don't → Don'T). The word tail is left untouched so acronyms survive
(HATSUNE MIKU → Hatsune Miku); acronym casing is sacrificed, an accepted (DC stays DC, NSFW stays NSFW) — operator-revised 2026-06-06: protecting
Title-Case trade-off (operator-chosen 2026-06-06). 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( 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()
) )
+3 -2
View File
@@ -74,13 +74,14 @@ async def test_create_tag_with_kind_prefix(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_explicit_kind_overrides_prefix_parsing(client): async def test_explicit_kind_overrides_prefix_parsing(client):
"""If caller passes explicit kind, don't re-parse the name — """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( resp = await client.post(
"/api/tags", json={"name": "character:Saber", "kind": "general"} "/api/tags", json={"name": "character:Saber", "kind": "general"}
) )
assert resp.status_code == 201 assert resp.status_code == 201
body = await resp.get_json() body = await resp.get_json()
assert body["name"] == "Character:saber" assert body["name"] == "Character:Saber"
assert body["kind"] == "general" assert body["kind"] == "general"
+21 -2
View File
@@ -87,8 +87,9 @@ async def test_dry_run_projection_counts(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_live_merges_case_variants_and_repoints_images(db): 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) 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) i1, i2, i3 = await _img(db), await _img(db), await _img(db)
await _link(db, i1, a.id) await _link(db, i1, a.id)
await _link(db, i2, 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 @pytest.mark.asyncio
async def test_idempotent_second_run_is_noop(db): 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) await _raw_tag(db, "Kafka", TagKind.character)
first = await normalize_existing_tags(db, dry_run=False) first = await normalize_existing_tags(db, dry_run=False)
assert first["groups_processed"] == 1 assert first["groups_processed"] == 1
@@ -138,6 +139,24 @@ async def test_idempotent_second_run_is_noop(db):
assert again["renamed"] == 0 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 @pytest.mark.asyncio
async def test_same_name_different_fandom_not_merged(db): async def test_same_name_different_fandom_not_merged(db):
f1 = await _raw_tag(db, "Bleach", TagKind.fandom) f1 = await _raw_tag(db, "Bleach", TagKind.fandom)
+6 -1
View File
@@ -77,7 +77,12 @@ async def test_normalize_tag_name():
from backend.app.services.tag_service import 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("hatsune miku") == "Hatsune Miku"
assert normalize_tag_name(" looking at viewer ") == "Looking At Viewer" 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 @pytest.mark.asyncio