diff --git a/app/main.py b/app/main.py index 9ea66f9..f1ac15d 100644 --- a/app/main.py +++ b/app/main.py @@ -745,62 +745,94 @@ def get_bulk_suggestions_api(): @main.post("/image//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//suggestions/reject")