From 23f452021f03fff262af3cd204a65f06ece044d9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 6 Jun 2026 15:57:29 -0400 Subject: [PATCH] fix(tags): Title-Case operator-entered tags at create endpoint only (plan #701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/tags.py | 6 ++++++ backend/app/services/tag_service.py | 10 +++++++--- tests/test_api_tags.py | 14 ++++++++------ tests/test_tag_service.py | 24 ++++++++++++++++-------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 94bddff..288ade6 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -14,6 +14,7 @@ from ..services.tag_service import ( TagMergeConflict, TagService, TagValidationError, + normalize_tag_name, ) from ..utils.tag_prefix import parse_kind_prefix @@ -141,6 +142,11 @@ async def create_tag(): 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: svc = TagService(session) try: diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index fc0e924..b26132b 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -84,7 +84,11 @@ class TagService: - if fandom_id is set, the referenced tag must exist and have 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: 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 that into a 409 merge hint. """ - new_name = normalize_tag_name(new_name) + new_name = new_name.strip() 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 + # Case-insensitive clash (#701) — renaming onto a differently-cased tag # is still a merge, not a silent fork. clash_stmt = ( select(Tag) diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py index c4ead6e..c630baa 100644 --- a/tests/test_api_tags.py +++ b/tests/test_api_tags.py @@ -52,11 +52,12 @@ async def test_create_tag_missing_name_400(client): @pytest.mark.asyncio 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"}) assert resp.status_code == 201 body = await resp.get_json() - assert body["name"] == "sunset" + assert body["name"] == "Sunset" assert body["kind"] == "general" @@ -73,21 +74,22 @@ async def test_create_tag_with_kind_prefix(client): @pytest.mark.asyncio async def test_explicit_kind_overrides_prefix_parsing(client): """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( "/api/tags", json={"name": "character:Saber", "kind": "general"} ) assert resp.status_code == 201 body = await resp.get_json() - assert body["name"] == "character:Saber" + assert body["name"] == "Character:saber" assert body["kind"] == "general" @pytest.mark.asyncio 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"}) assert resp.status_code == 201 body = await resp.get_json() - assert body["name"] == "http://example.com" + assert body["name"] == "Http://example.com" assert body["kind"] == "general" diff --git a/tests/test_tag_service.py b/tests/test_tag_service.py index d427ffd..3cb8a9b 100644 --- a/tests/test_tag_service.py +++ b/tests/test_tag_service.py @@ -60,16 +60,24 @@ async def test_character_with_fandom(db): @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.""" +async def test_find_or_create_dedups_case_insensitive(db): + """#701: find_or_create matches case-insensitively so a differently-cased + 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) 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" + assert t1.name == "hatsune miku" # stored as given; not recased here + t2 = await svc.find_or_create("HATSUNE MIKU", TagKind.character) + assert t2.id == t1.id # case variant → same tag, no fork + + +@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