feat(tags): Title-Case normalization on create/rename — #701 (core)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user