diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 9f7a0e6..fc0e924 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -13,6 +13,19 @@ from ..models.tag_allowlist import TagAllowlist from ..models.tag_reference_embedding import TagReferenceEmbedding +def normalize_tag_name(name: str) -> str: + """Canonical tag form (#701): collapse whitespace + Title Case. + + 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). + """ + return " ".join( + w[:1].upper() + w[1:].lower() for w in (name or "").split() + ) + + class TagValidationError(ValueError): """Raised when tag construction breaks the kind/fandom rules.""" @@ -71,7 +84,7 @@ class TagService: - if fandom_id is set, the referenced tag must exist and have kind == TagKind.fandom """ - name = name.strip() + name = normalize_tag_name(name) if not name: raise TagValidationError("Tag name cannot be empty") @@ -93,13 +106,17 @@ class TagService: # inserts; without the savepoint the outer transaction would # poison and the calling request crashes. Mirrors # importer._get_or_create. + # Case-insensitive match (#701): a normalized (Title Case) input must + # find an existing differently-cased tag instead of forking a duplicate. stmt = ( select(Tag) - .where(Tag.name == name) + .where(func.lower(Tag.name) == name.lower()) .where(Tag.kind == kind) .where( Tag.fandom_id.is_(None) if fandom_id is None else Tag.fandom_id == fandom_id ) + .order_by(Tag.id) + .limit(1) ) existing = (await self.session.execute(stmt)).scalar_one_or_none() if existing: @@ -242,16 +259,18 @@ class TagService: with an existing tag of the same (kind, fandom_id) — the API turns that into a 409 merge hint. """ - new_name = new_name.strip() + new_name = normalize_tag_name(new_name) if not new_name: raise TagValidationError("Tag name cannot be empty") tag = await self.session.get(Tag, tag_id) if tag is None: raise TagValidationError(f"Tag {tag_id} not found") + # Case-insensitive clash (#701) — colliding with a differently-cased tag + # is still a merge, not a silent fork. clash_stmt = ( select(Tag) - .where(Tag.name == new_name) + .where(func.lower(Tag.name) == new_name.lower()) .where(Tag.kind == tag.kind) .where( Tag.fandom_id.is_(None) @@ -259,6 +278,8 @@ class TagService: else Tag.fandom_id == tag.fandom_id ) .where(Tag.id != tag_id) + .order_by(Tag.id) + .limit(1) ) clash = (await self.session.execute(clash_stmt)).scalar_one_or_none() if clash is not None: diff --git a/tests/test_tag_service.py b/tests/test_tag_service.py index a8ceb16..d427ffd 100644 --- a/tests/test_tag_service.py +++ b/tests/test_tag_service.py @@ -59,6 +59,19 @@ async def test_character_with_fandom(db): assert char.fandom_id == fandom.id +@pytest.mark.asyncio +async def test_find_or_create_titlecases_and_dedups_case_insensitive(db): + """#701: tags normalize to Title Case + collapsed whitespace on create, and a + differently-cased entry finds the existing tag instead of forking.""" + svc = TagService(db) + t1 = await svc.find_or_create("hatsune miku", TagKind.character) + assert t1.name == "Hatsune Miku" + t2 = await svc.find_or_create("HATSUNE miku", TagKind.character) + assert t2.id == t1.id # case + whitespace variant → same tag, no fork + g = await svc.find_or_create(" looking at viewer ", TagKind.general) + assert g.name == "Looking At Viewer" + + @pytest.mark.asyncio async def test_character_same_name_different_fandoms(db): svc = TagService(db)