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
|
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):
|
class TagValidationError(ValueError):
|
||||||
"""Raised when tag construction breaks the kind/fandom rules."""
|
"""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
|
- if fandom_id is set, the referenced tag must exist and have
|
||||||
kind == TagKind.fandom
|
kind == TagKind.fandom
|
||||||
"""
|
"""
|
||||||
name = name.strip()
|
name = normalize_tag_name(name)
|
||||||
if not name:
|
if not name:
|
||||||
raise TagValidationError("Tag name cannot be empty")
|
raise TagValidationError("Tag name cannot be empty")
|
||||||
|
|
||||||
@@ -93,13 +106,17 @@ class TagService:
|
|||||||
# inserts; without the savepoint the outer transaction would
|
# inserts; without the savepoint the outer transaction would
|
||||||
# poison and the calling request crashes. Mirrors
|
# poison and the calling request crashes. Mirrors
|
||||||
# importer._get_or_create.
|
# 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 = (
|
stmt = (
|
||||||
select(Tag)
|
select(Tag)
|
||||||
.where(Tag.name == name)
|
.where(func.lower(Tag.name) == name.lower())
|
||||||
.where(Tag.kind == kind)
|
.where(Tag.kind == kind)
|
||||||
.where(
|
.where(
|
||||||
Tag.fandom_id.is_(None) if fandom_id is None else Tag.fandom_id == fandom_id
|
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()
|
existing = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -242,16 +259,18 @@ class TagService:
|
|||||||
with an existing tag of the same (kind, fandom_id) — the API turns
|
with an existing tag of the same (kind, fandom_id) — the API turns
|
||||||
that into a 409 merge hint.
|
that into a 409 merge hint.
|
||||||
"""
|
"""
|
||||||
new_name = new_name.strip()
|
new_name = normalize_tag_name(new_name)
|
||||||
if not new_name:
|
if not new_name:
|
||||||
raise TagValidationError("Tag name cannot be empty")
|
raise TagValidationError("Tag name cannot be empty")
|
||||||
tag = await self.session.get(Tag, tag_id)
|
tag = await self.session.get(Tag, tag_id)
|
||||||
if tag is None:
|
if tag is None:
|
||||||
raise TagValidationError(f"Tag {tag_id} not found")
|
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 = (
|
clash_stmt = (
|
||||||
select(Tag)
|
select(Tag)
|
||||||
.where(Tag.name == new_name)
|
.where(func.lower(Tag.name) == new_name.lower())
|
||||||
.where(Tag.kind == tag.kind)
|
.where(Tag.kind == tag.kind)
|
||||||
.where(
|
.where(
|
||||||
Tag.fandom_id.is_(None)
|
Tag.fandom_id.is_(None)
|
||||||
@@ -259,6 +278,8 @@ class TagService:
|
|||||||
else Tag.fandom_id == tag.fandom_id
|
else Tag.fandom_id == tag.fandom_id
|
||||||
)
|
)
|
||||||
.where(Tag.id != tag_id)
|
.where(Tag.id != tag_id)
|
||||||
|
.order_by(Tag.id)
|
||||||
|
.limit(1)
|
||||||
)
|
)
|
||||||
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
|
clash = (await self.session.execute(clash_stmt)).scalar_one_or_none()
|
||||||
if clash is not None:
|
if clash is not None:
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ async def test_character_with_fandom(db):
|
|||||||
assert char.fandom_id == fandom.id
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_character_same_name_different_fandoms(db):
|
async def test_character_same_name_different_fandoms(db):
|
||||||
svc = TagService(db)
|
svc = TagService(db)
|
||||||
|
|||||||
Reference in New Issue
Block a user