feat: ML tag suggestions, character/fandom integrity, underscores, modal polish

Consolidated merge of feat/tag-suggestions branch. Original 64-commit history
was lost to git-object corruption in a Nextcloud-synced checkout; this single
commit captures the equivalent diff.

Includes:
- pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker
  container, Celery tasks, suggestion service, accept/reject endpoints + modal
  UI with green/red chip buttons)
- Character/fandom integrity: title-case normalization on every write path,
  fandom-id backfill, maintenance task + settings button, migrations g26041901
  + h26041901 to canonicalize legacy rows with case-only duplicate merging
- Tag-underscores + modal polish: WD14 name canonicalization at emit + accept
  + add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge
  across character/fandom/NULL kinds, suggestion-accept refresh parity via
  awaited loadTags, persistent chip tint
This commit is contained in:
2026-04-19 19:50:58 -04:00
parent 69b3ddcbd0
commit 0f35a0c484
37 changed files with 8642 additions and 30 deletions
+49
View File
@@ -0,0 +1,49 @@
"""Normalization helpers for Tag display names.
Keeps the first letter of every whitespace-separated word upper-case and
preserves internal punctuation. Called from every character/fandom write
path so typos like "misty" don't accumulate alongside "Misty".
"""
def underscores_to_spaces(s: str) -> str:
"""Replace every underscore with a space.
Used by general/null-kind write paths and as a pre-step inside
normalize_display_name so WD14's 'misty_ketchum' and a hand-typed
'Misty Ketchum' collapse onto the same tag name.
Examples:
"big_hair" -> "big hair"
"long_blue_hair" -> "long blue hair"
"" -> ""
"""
return s.replace('_', ' ') if s else s
def normalize_display_name(s: str) -> str:
"""Title-case each whitespace-separated word. Preserves internal punctuation.
Underscores are converted to spaces first so WD14-style names normalize
identically to hand-typed names.
Examples:
"misty" -> "Misty"
"misty ketchum" -> "Misty Ketchum"
"misty_ketchum" -> "Misty Ketchum"
"o'brien" -> "O'brien"
"mary-kate" -> "Mary-kate"
"" -> ""
" " -> " " (only whitespace — returned unchanged)
"""
if not s:
return s
s = underscores_to_spaces(s)
parts = s.split(' ')
out: list[str] = []
for p in parts:
if p == '':
out.append(p)
else:
out.append(p[:1].upper() + p[1:])
return ' '.join(out)