From 2b69540ecc0bd250cd8c916b6f5a4f56c78bbe5b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 6 Jun 2026 15:42:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(tags):=20Title-Case=20normalization=20on?= =?UTF-8?q?=20create/rename=20=E2=80=94=20#701=20(core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags now normalize to Title Case + collapsed whitespace at the central TagService.find_or_create (and rename) — so manual entry, the create API, and anything routed through find_or_create produce the canonical form. The lookup is case-insensitive, so a differently-cased entry finds the existing tag instead of forking a case-variant duplicate ('hatsune miku' / 'HATSUNE MIKU' → one tag). normalize_tag_name uses per-word capitalize (not str.title(), which mangles apostrophes) and folds ALL-CAPS input. Existing tags keep their current casing until touched — a retro-normalize maintenance pass (Title-Case + merge case-collisions) is the follow-up to convert the back-catalog. Test: create title-cases + collapses whitespace; case/whitespace variants dedupe to one tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/services/tag_service.py | 29 +++++++++++++++++++++++++---- tests/test_tag_service.py | 13 +++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) 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)