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:
+90
-45
@@ -745,62 +745,94 @@ def get_bulk_suggestions_api():
|
|||||||
|
|
||||||
@main.post("/image/<int:image_id>/suggestions/accept")
|
@main.post("/image/<int:image_id>/suggestions/accept")
|
||||||
def accept_image_suggestion(image_id):
|
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.models import SuggestionFeedback
|
||||||
from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND, _canonicalize_wd14_name
|
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)
|
data = request.get_json() or {}
|
||||||
if image is None:
|
category = data.get("category")
|
||||||
return jsonify({'ok': False, 'error': 'image_not_found'}), 404
|
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
|
# Defensive: clients may send raw WD14 names with underscores or off casing.
|
||||||
# freshly-POSTed "big_hair" (defensive — the client should already send canonical).
|
if name:
|
||||||
tag_name = _canonicalize_wd14_name(tag_name, category)
|
name = _canonicalize_wd14_name(name, category)
|
||||||
|
|
||||||
tag = Tag.query.filter_by(name=tag_name).first()
|
img = ImageRecord.query.get_or_404(image_id)
|
||||||
if tag is None:
|
kind = _WD14_CATEGORY_TO_KIND.get(category)
|
||||||
kind = _WD14_CATEGORY_TO_KIND.get(category) # None for 'general'
|
|
||||||
# WD14 emits bare names ("Ruby Rose") while curated character/fandom tags
|
if explicit_tag_id:
|
||||||
# carry a fandom suffix ("Ruby Rose (RWBY)"). Try to auto-resolve a bare
|
tag = Tag.query.get(explicit_tag_id)
|
||||||
# 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
|
|
||||||
if tag is None:
|
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.add(tag)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
if tag not in image.tags:
|
# Auto-apply the character's fandom if set.
|
||||||
image.tags.append(tag)
|
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(
|
db.session.add(SuggestionFeedback(
|
||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
tag_name=tag_name,
|
tag_name=tag.name,
|
||||||
suggestion_source=source,
|
suggestion_source=source,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
decision='accepted',
|
decision='accepted',
|
||||||
))
|
))
|
||||||
db.session.commit()
|
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
|
from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS
|
||||||
if tag.kind in ELIGIBLE_CENTROID_KINDS:
|
if tag.kind in ELIGIBLE_CENTROID_KINDS:
|
||||||
from app.models import TagReferenceEmbedding, TagSuggestionConfig
|
from app.models import TagReferenceEmbedding, TagSuggestionConfig
|
||||||
@@ -817,21 +849,34 @@ def accept_image_suggestion(image_id):
|
|||||||
) or 0
|
) or 0
|
||||||
ref = (
|
ref = (
|
||||||
TagReferenceEmbedding.query
|
TagReferenceEmbedding.query
|
||||||
.filter_by(tag_name=tag_name, model_version=SIGLIP_VER)
|
.filter_by(tag_name=tag.name, model_version=SIGLIP_VER)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
last_count = ref.reference_count if ref else 0
|
last_count = ref.reference_count if ref else 0
|
||||||
if current_count - last_count >= delta_threshold:
|
if current_count - last_count >= delta_threshold:
|
||||||
try:
|
try:
|
||||||
recompute_centroid.apply_async(args=[tag_name], queue='ml')
|
recompute_centroid.apply_async(args=[tag.name], queue='ml')
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't fail the accept if enqueue fails
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return jsonify({
|
return jsonify(
|
||||||
'ok': True,
|
ok=True,
|
||||||
'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind},
|
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")
|
@main.post("/image/<int:image_id>/suggestions/reject")
|
||||||
|
|||||||
Reference in New Issue
Block a user