"""Suggestions API: per-image ranked suggestions + accept/alias/dismiss.""" from quart import Blueprint, jsonify, request from ..extensions import get_session from ..services.ml.allowlist import AllowlistService from ..services.ml.suggestions import SuggestionService suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api") @suggestions_bp.route("/images//suggestions", methods=["GET"]) async def get_suggestions(image_id: int): # ?min= overrides the configured per-category thresholds so the typed # tag-input dropdown can surface EVERY stored prediction (min=0), including # low-confidence actions/features, in canonical formatting. Omitted → the # curated above-threshold list the Suggestions panel uses. override = None raw_min = request.args.get("min") if raw_min is not None: try: override = min(1.0, max(0.0, float(raw_min))) except ValueError: return jsonify({"error": "min must be a float in [0,1]"}), 400 async with get_session() as session: sl = await SuggestionService(session).for_image( image_id, threshold_override=override ) return jsonify( { "by_category": { cat: [ { "canonical_tag_id": s.canonical_tag_id, "display_name": s.display_name, "category": s.category, "score": round(s.score, 4), "source": s.source, "creates_new_tag": s.creates_new_tag, # raw model key (alias is stored under this) + whether an # operator alias produced this suggestion — drive the # modal's "Treat as alias"/"Remove alias" affordances. "raw_name": s.raw_name, "via_alias": s.via_alias, # operator dismissed this tag for this image — surfaced # (not dropped) so the rail can show it rejected + offer # one-click un-reject. "rejected": s.rejected, # the crop region that produced this tag (#1206) — # {bbox,kind,detector} or null (whole-image won). Drives # the hover→overlay highlight. "grounding": s.grounding, } for s in items ] for cat, items in sl.by_category.items() } } ) @suggestions_bp.route( "/images//suggestions/accept", methods=["POST"] ) async def accept_suggestion(image_id: int): body = await request.get_json() if not body or "tag_id" not in body: return jsonify({"error": "tag_id required"}), 400 tag_id = body["tag_id"] async with get_session() as session: await AllowlistService(session).accept(image_id, tag_id) await session.commit() return jsonify({"accepted": True, "tag_id": tag_id}) @suggestions_bp.route( "/images//suggestions/alias", methods=["POST"] ) async def alias_suggestion(image_id: int): body = await request.get_json() required = {"alias_string", "alias_category", "canonical_tag_id"} if not body or not required.issubset(body): return jsonify({"error": f"required: {sorted(required)}"}), 400 canonical_tag_id = body["canonical_tag_id"] async with get_session() as session: await AllowlistService(session).add_alias_and_accept( image_id, body["alias_string"], body["alias_category"], canonical_tag_id, ) await session.commit() return jsonify({"accepted": True, "tag_id": canonical_tag_id}) @suggestions_bp.route( "/images//suggestions/dismiss", methods=["POST"] ) async def dismiss_suggestion(image_id: int): body = await request.get_json() if not body or "tag_id" not in body: return jsonify({"error": "tag_id required"}), 400 async with get_session() as session: await AllowlistService(session).dismiss(image_id, body["tag_id"]) await session.commit() return "", 204 @suggestions_bp.route( "/images//suggestions/undismiss", methods=["POST"] ) async def undismiss_suggestion(image_id: int): """Reverse a per-image dismissal (reject-recovery). Idempotent — undoing a tag that isn't rejected is a no-op delete.""" body = await request.get_json() if not body or "tag_id" not in body: return jsonify({"error": "tag_id required"}), 400 async with get_session() as session: await AllowlistService(session).undismiss(image_id, body["tag_id"]) await session.commit() return "", 204 @suggestions_bp.route("/suggestions/bulk", methods=["POST"]) async def bulk_suggestions(): body = await request.get_json() if not body or "image_ids" not in body: return jsonify({"error": "image_ids required"}), 400 raw = body["image_ids"] if not isinstance(raw, list) or not raw: return jsonify({"error": "image_ids must be a non-empty list"}), 400 try: ids = [int(x) for x in raw] except (TypeError, ValueError): return jsonify({"error": "image_ids must be integers"}), 400 if len(ids) > 200: return jsonify({"error": "selection too large (max 200)"}), 400 try: threshold = float(body.get("threshold", 0.8)) except (TypeError, ValueError): threshold = 0.8 threshold = min(1.0, max(0.0, threshold)) async with get_session() as session: suggestions = await SuggestionService(session).for_selection( ids, threshold=threshold ) return jsonify( { "suggestions": suggestions, "evaluated": len(ids), "threshold": threshold, } )