fix(suggestions): parse (Fandom) suffix on accept + heal legacy rows

Two-part fix for the reported bug where accepting a character
suggestion named 'Jinx (League Of Legends)' created a single
malformed character tag with fandom_id=NULL and the whole suffix
embedded in the name, and never attached the fandom tag to the
image.

Root cause: WD14 predictions store names like
'jinx_(league_of_legends)'. app.services.tag_suggestions
._canonicalize_wd14_name transforms that to 'Jinx (League Of
Legends)' for character/copyright categories. The suggestion chip
renders that canonicalized string. On ✓, accept_image_suggestion
looked for an existing character with name = 'Jinx (League Of
Legends)' — post-refactor no such row exists (names are bare) —
fell into the 0-candidate branch, and created a fresh malformed
character.

Going forward (main.py):
  accept_image_suggestion's character branch now splits a
  '(Fandom)' suffix off the incoming name before the 0/1/N lookup.
  If it splits, find-or-create the fandom tag, then find-or-create
  a character with (kind='character', name=bare, fandom_id=<f>).
  If no suffix, unchanged 0/1/N behavior.

Backfill for already-corrupted rows (tasks/maintenance.py):
  New _heal_malformed_character_names pass runs before the
  existing sync-fandoms-to-images logic. Finds
  (kind='character', fandom_id IS NULL, name LIKE '% (%)%'),
  parses the suffix via the same regex, and either promotes the
  row to (name=bare, fandom_id=<f>) or merges into an existing
  canonical character. tag_reference_embedding rows (string PK)
  are cascade-renamed alongside. Same auto-merge semantics as
  migration j26042101 Phase 2.

Implementation is all raw SQL to sidestep an ORM autoflush
ordering quirk: ORM-level fandom INSERT + subsequent tag UPDATE
referencing its fresh id hit a transient FK violation in worker
task context. Raw SQL with an explicit commit after fandom
insert avoids the ambiguity.

Verified locally:
  - Fresh accept of 'CanonicalChar (Canonical Fandom)' against a
    pre-existing canonical character: resolved to the canonical
    row, fandom attached to image.
  - Seeded malformed 'LegacyJinx (League Of Legends)' character
    with fandom_id=NULL attached to an image: sync task healed it
    to bare 'LegacyJinx' + fandom_id, attached the fandom row to
    image_tags, final counts {healed:1, skipped:0,
    links_added:1}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 10:49:23 -04:00
parent 9276b31b99
commit 007592827c
2 changed files with 187 additions and 36 deletions
+45 -20
View File
@@ -872,27 +872,52 @@ def accept_image_suggestion(image_id):
if tag is None:
return jsonify(ok=False, error="tag_not_found"), 404
elif kind == "character":
candidates = Tag.query.filter_by(kind="character", name=name).all()
if len(candidates) == 1:
tag = candidates[0]
elif len(candidates) == 0:
tag = Tag(kind="character", name=name, fandom_id=None)
db.session.add(tag)
db.session.flush()
# WD14 emits character names in 'bare_(fandom)' form. After canonical
# case/underscore handling the incoming name may look like
# 'Soraka (League Of Legends)'. Before the post-refactor 0/1/N
# lookup, split the '(Fandom)' suffix and resolve the fandom to a
# fandom_id — otherwise the lookup misses every existing bare-name
# character and we'd create a new character whose entire name is
# 'Soraka (League Of Legends)' with fandom_id=NULL.
import re
fandom_suffix_match = re.match(r'^(.+?) \(([^()]+)\)$', name)
if fandom_suffix_match:
bare_name = fandom_suffix_match.group(1).strip()
suffix_fandom_name = fandom_suffix_match.group(2).strip()
f_tag = _ensure_fandom_tag(suffix_fandom_name)
candidates = (
Tag.query
.filter_by(kind="character", name=bare_name, fandom_id=f_tag.id)
.all()
)
if len(candidates) == 1:
tag = candidates[0]
else:
tag = Tag(kind="character", name=bare_name, fandom_id=f_tag.id)
db.session.add(tag)
db.session.flush()
else:
return jsonify(
ok=False,
error="ambiguous",
candidates=[
{
"id": c.id,
"display_name": c.display_name,
"fandom_id": c.fandom_id,
"fandom_name": c.fandom.name if c.fandom else None,
}
for c in candidates
],
), 409
candidates = Tag.query.filter_by(kind="character", name=name).all()
if len(candidates) == 1:
tag = candidates[0]
elif len(candidates) == 0:
tag = Tag(kind="character", name=name, fandom_id=None)
db.session.add(tag)
db.session.flush()
else:
return jsonify(
ok=False,
error="ambiguous",
candidates=[
{
"id": c.id,
"display_name": c.display_name,
"fandom_id": c.fandom_id,
"fandom_name": c.fandom.name if c.fandom else None,
}
for c in candidates
],
), 409
else:
effective_kind = kind if kind else "user"
tag = Tag.query.filter_by(kind=effective_kind, name=name).first()