fix(tags): Title-Case operator-entered tags at create endpoint only (plan #701)
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 24s
CI / integration (push) Successful in 3m2s
CI / lint (push) Successful in 2s

normalize_tag_name (per-word capitalize + whitespace collapse) is applied in
the POST /api/tags handler so operator-entered tags get clean display casing.
It is NOT applied in the shared find_or_create / rename paths — those are used
by the ML tagger and allowlist matching, which must preserve the booru
vocabulary's original casing (Title-Casing it broke apply_allowlist matching).

find_or_create / rename keep case-insensitive lookup + clash detection so a
differently-cased entry dedups onto the existing tag instead of forking.

Tests updated to expect Title-Cased create output (sunset→Sunset,
character:Saber→Character:saber, http://example.com→Http://example.com) and a
dedicated normalize_tag_name unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:57:29 -04:00
parent 62cca64dce
commit 23f452021f
4 changed files with 37 additions and 17 deletions
+6
View File
@@ -14,6 +14,7 @@ from ..services.tag_service import (
TagMergeConflict, TagMergeConflict,
TagService, TagService,
TagValidationError, TagValidationError,
normalize_tag_name,
) )
from ..utils.tag_prefix import parse_kind_prefix from ..utils.tag_prefix import parse_kind_prefix
@@ -141,6 +142,11 @@ async def create_tag():
fandom_id = body.get("fandom_id") fandom_id = body.get("fandom_id")
# #701: Title-Case operator-entered tags. Only here (the explicit create
# endpoint), NOT in the shared find_or_create — the ML tagger uses that path
# and must keep the booru vocabulary's casing for allowlist matching.
name = normalize_tag_name(name)
async with get_session() as session: async with get_session() as session:
svc = TagService(session) svc = TagService(session)
try: try:
+7 -3
View File
@@ -84,7 +84,11 @@ 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 = normalize_tag_name(name) # NOTE: case is NOT normalized here — find_or_create is the shared path
# the ML tagger / allowlist also use, and Title-Casing the booru
# vocabulary breaks allowlist matching. Display-casing (Title Case) is
# applied at the user-entry layer (api/tags create_tag) only (#701).
name = name.strip()
if not name: if not name:
raise TagValidationError("Tag name cannot be empty") raise TagValidationError("Tag name cannot be empty")
@@ -259,14 +263,14 @@ 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 = normalize_tag_name(new_name) new_name = new_name.strip()
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 # Case-insensitive clash (#701) — renaming onto a differently-cased tag
# is still a merge, not a silent fork. # is still a merge, not a silent fork.
clash_stmt = ( clash_stmt = (
select(Tag) select(Tag)
+8 -6
View File
@@ -52,11 +52,12 @@ async def test_create_tag_missing_name_400(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tag_name_only_defaults_to_general(client): async def test_create_tag_name_only_defaults_to_general(client):
"""IR-style: name without kind and without `kind:` prefix → general.""" """IR-style: name without kind and without `kind:` prefix → general.
#701: operator-entered names are Title-Cased at the create endpoint."""
resp = await client.post("/api/tags", json={"name": "sunset"}) resp = await client.post("/api/tags", json={"name": "sunset"})
assert resp.status_code == 201 assert resp.status_code == 201
body = await resp.get_json() body = await resp.get_json()
assert body["name"] == "sunset" assert body["name"] == "Sunset"
assert body["kind"] == "general" assert body["kind"] == "general"
@@ -73,21 +74,22 @@ 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.""" colon and prefix stay literal. #701: still Title-Cased as a single word."""
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"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unknown_prefix_kept_literal(client): async def test_unknown_prefix_kept_literal(client):
"""`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name.""" """`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name.
#701: Title-Cased as one word (no internal whitespace to split on)."""
resp = await client.post("/api/tags", json={"name": "http://example.com"}) resp = await client.post("/api/tags", json={"name": "http://example.com"})
assert resp.status_code == 201 assert resp.status_code == 201
body = await resp.get_json() body = await resp.get_json()
assert body["name"] == "http://example.com" assert body["name"] == "Http://example.com"
assert body["kind"] == "general" assert body["kind"] == "general"
+16 -8
View File
@@ -60,16 +60,24 @@ async def test_character_with_fandom(db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_find_or_create_titlecases_and_dedups_case_insensitive(db): async def test_find_or_create_dedups_case_insensitive(db):
"""#701: tags normalize to Title Case + collapsed whitespace on create, and a """#701: find_or_create matches case-insensitively so a differently-cased
differently-cased entry finds the existing tag instead of forking.""" entry finds the existing tag instead of forking. (Display Title-Casing is
applied at the create API, not here — this path is shared with the ML
tagger, which keeps the booru vocabulary's casing.)"""
svc = TagService(db) svc = TagService(db)
t1 = await svc.find_or_create("hatsune miku", TagKind.character) t1 = await svc.find_or_create("hatsune miku", TagKind.character)
assert t1.name == "Hatsune Miku" assert t1.name == "hatsune miku" # stored as given; not recased here
t2 = await svc.find_or_create("HATSUNE miku", TagKind.character) t2 = await svc.find_or_create("HATSUNE MIKU", TagKind.character)
assert t2.id == t1.id # case + whitespace variant → same tag, no fork assert t2.id == t1.id # case 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_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(" looking at viewer ") == "Looking At Viewer"
assert normalize_tag_name("HATSUNE MIKU") == "Hatsune Miku"
@pytest.mark.asyncio @pytest.mark.asyncio