refactor(main): accept_image_suggestion 0/1/N candidate resolution

Replaces the ilike-suffix fallback with explicit bare-name character
matching: 0 candidates -> create null-fandom, 1 -> attach, N -> 409
with candidate list for frontend disambiguation. Accepts explicit
tag_id to complete the disambiguation round-trip.

Preserves existing behavior on the accept side:
  - defensive _canonicalize_wd14_name on the boundary
  - SuggestionFeedback row logged on every accepted decision
    (including the explicit_tag_id path). Ambiguous 409 does not
    log — no decision has been made yet.
  - conditional centroid recompute enqueue for eligible kinds

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 15:09:54 -04:00
parent d9081917ba
commit bdd69c0a07
+90 -45
View File
@@ -745,62 +745,94 @@ def get_bulk_suggestions_api():
@main.post("/image/<int:image_id>/suggestions/accept")
def accept_image_suggestion(image_id):
"""
Accept a single tag suggestion for an image.
Post-refactor resolution:
- If `tag_id` is provided, attach that exact tag (used by the
frontend after the user resolves an ambiguous suggestion).
- Else for character category: search by (kind='character', name=bare).
0 matches -> create null-fandom tag. 1 match -> attach. N matches ->
return HTTP 409 with candidate list for the frontend to disambiguate.
- Else for fandom/general: find-or-create by (kind, name).
Records a SuggestionFeedback row on every accepted decision (including
the explicit_tag_id disambiguation path) and conditionally enqueues a
centroid recompute when enough new accepts have accumulated on an
eligible kind. The 409 ambiguous branch does NOT log feedback because
no decision has been made yet.
"""
from app.models import SuggestionFeedback
from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND, _canonicalize_wd14_name
payload = request.get_json(force=True, silent=True) or {}
tag_name = (payload.get('tag_name') or '').strip()
source = payload.get('source') or ''
category = payload.get('category') or ''
confidence = float(payload.get('confidence') or 0.0)
if not tag_name or not source or not category:
return jsonify({'ok': False, 'error': 'missing_fields'}), 400
image = ImageRecord.query.get(image_id)
if image is None:
return jsonify({'ok': False, 'error': 'image_not_found'}), 404
data = request.get_json() or {}
category = data.get("category")
name = (data.get("name") or "").strip()
explicit_tag_id = data.get("tag_id")
source = data.get("source") or ""
confidence = float(data.get("confidence") or 0.0)
# Canonicalize before lookup so an already-attached "big hair" matches a
# freshly-POSTed "big_hair" (defensive — the client should already send canonical).
tag_name = _canonicalize_wd14_name(tag_name, category)
# Defensive: clients may send raw WD14 names with underscores or off casing.
if name:
name = _canonicalize_wd14_name(name, category)
tag = Tag.query.filter_by(name=tag_name).first()
if tag is None:
kind = _WD14_CATEGORY_TO_KIND.get(category) # None for 'general'
# WD14 emits bare names ("Ruby Rose") while curated character/fandom tags
# carry a fandom suffix ("Ruby Rose (RWBY)"). Try to auto-resolve a bare
# suggestion to an existing fandom-scoped tag — but only when exactly one
# candidate exists; zero/multiple falls through to creating a new tag so
# we don't silently guess across ambiguous matches (e.g. same name in
# multiple fandoms).
if kind in ('character', 'fandom'):
candidates = (
Tag.query
.filter(Tag.kind == kind)
.filter(Tag.name.ilike(f'{tag_name} (%)'))
.limit(2)
.all()
)
if len(candidates) == 1:
tag = candidates[0]
tag_name = tag.name # feedback row logs the resolved name
img = ImageRecord.query.get_or_404(image_id)
kind = _WD14_CATEGORY_TO_KIND.get(category)
if explicit_tag_id:
tag = Tag.query.get(explicit_tag_id)
if tag is None:
tag = Tag(name=tag_name, kind=kind)
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()
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()
if tag is None:
tag = Tag(kind=effective_kind, name=name)
db.session.add(tag)
db.session.flush()
if tag not in image.tags:
image.tags.append(tag)
# Auto-apply the character's fandom if set.
fandom_tag = None
if tag.kind == "character" and tag.fandom_id:
fandom_tag = Tag.query.get(tag.fandom_id)
if tag not in img.tags:
img.tags.append(tag)
if fandom_tag and fandom_tag not in img.tags:
img.tags.append(fandom_tag)
db.session.add(SuggestionFeedback(
image_id=image_id,
tag_name=tag_name,
tag_name=tag.name,
suggestion_source=source,
confidence=confidence,
decision='accepted',
))
db.session.commit()
# Schedule centroid recompute if this tag's kind is eligible and delta is exceeded.
# Conditionally schedule centroid recompute for eligible tag kinds.
from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS
if tag.kind in ELIGIBLE_CENTROID_KINDS:
from app.models import TagReferenceEmbedding, TagSuggestionConfig
@@ -817,21 +849,34 @@ def accept_image_suggestion(image_id):
) or 0
ref = (
TagReferenceEmbedding.query
.filter_by(tag_name=tag_name, model_version=SIGLIP_VER)
.filter_by(tag_name=tag.name, model_version=SIGLIP_VER)
.first()
)
last_count = ref.reference_count if ref else 0
if current_count - last_count >= delta_threshold:
try:
recompute_centroid.apply_async(args=[tag_name], queue='ml')
recompute_centroid.apply_async(args=[tag.name], queue='ml')
except Exception:
# Don't fail the accept if enqueue fails
pass
return jsonify({
'ok': True,
'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind},
})
return jsonify(
ok=True,
tag={
"id": tag.id,
"name": tag.name,
"display_name": tag.display_name,
"kind": tag.kind,
},
fandom_tag=(
{
"id": fandom_tag.id,
"name": fandom_tag.name,
"display_name": fandom_tag.display_name,
"kind": fandom_tag.kind,
}
if fandom_tag else None
),
)
@main.post("/image/<int:image_id>/suggestions/reject")