409724b981
score_image now keeps the ARGMAX beside the max-over-bag: which bag row won each
head. The region query also selects bbox/kind/detector_version, a parallel
bag_meta maps each row → its region (None for the whole-image vector), and every
hit gains grounding {bbox,kind,detector} (null when the global vector won). Threaded
through SuggestionService (new Suggestion.grounding field) → /api/.../suggestions
payload. This is the data the #1206 hover-overlay draws. CCIP-only hits ground null
for now (figure grounding = step 2). Tests: winning crop grounds the tag with its
bbox+kind; whole-image win → grounding None.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
154 lines
5.9 KiB
Python
154 lines
5.9 KiB
Python
"""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/<int:image_id>/suggestions", methods=["GET"])
|
|
async def get_suggestions(image_id: int):
|
|
# ?min=<float> 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/<int:image_id>/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/<int:image_id>/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/<int:image_id>/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/<int:image_id>/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,
|
|
}
|
|
)
|