c999c64cbe
The typed dropdown sourced the threshold-filtered panel list (>= 0.70 general), so low-confidence actions/features the model DID predict never appeared — forcing hand-typed custom tags instead of accepting the model's canonical formatting. Add a threshold override: SuggestionService.for_image(threshold_override=) and GET /images/<id>/suggestions?min=<f> surface EVERY stored prediction (down to the 0.05 store floor), alias-resolved and normalized, still excluding applied/rejected and unsurfaced categories. The suggestions store gains allByCategory + loadAll (min=0); the dropdown searches that full set (cap 20), while the Suggestions panel stays curated at the configured threshold. Accept/dismiss drop from both lists. Operator-asked 2026-06-09. Test: a 0.30 general prediction is hidden by default but surfaced with threshold_override=0.0; unsurfaced categories still excluded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
4.6 KiB
Python
133 lines
4.6 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,
|
|
}
|
|
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:
|
|
newly_added = await AllowlistService(session).accept(image_id, tag_id)
|
|
await session.commit()
|
|
if newly_added:
|
|
from ..tasks.ml import apply_allowlist_tags
|
|
|
|
apply_allowlist_tags.delay(tag_id=tag_id)
|
|
return "", 204
|
|
|
|
|
|
@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
|
|
async with get_session() as session:
|
|
newly_added = await AllowlistService(session).add_alias_and_accept(
|
|
image_id,
|
|
body["alias_string"],
|
|
body["alias_category"],
|
|
body["canonical_tag_id"],
|
|
)
|
|
await session.commit()
|
|
if newly_added:
|
|
from ..tasks.ml import apply_allowlist_tags
|
|
|
|
apply_allowlist_tags.delay(tag_id=body["canonical_tag_id"])
|
|
return "", 204
|
|
|
|
|
|
@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("/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,
|
|
}
|
|
)
|