179c1a9dcc
A red-✗ dismissal no longer makes the suggestion vanish. The rejected tag stays in the rail — dimmed, struck-through, with a "rejected" pill and a one-click undo (↶) in place of the ✗ — so a misclick is recoverable and the operator can see what they've said no to (operator-asked 2026-06-27). Backend: SuggestionService.for_image now KEEPS rejected tags, flagged rejected=True, sorted to the bottom of their category, instead of dropping them. New AllowlistService.undismiss + POST /suggestions/undismiss clears the TagSuggestionRejection. Rejected items are still excluded from bulk consensus (for_selection) and the type-to-add dropdown, whose jobs are unchanged. Frontend: store.dismiss flags in place (canonical tags) rather than dropping; new store.undismiss reverts. SuggestionItem renders the rejected state and swaps ✗→↶; ✓ still accepts (which clears the rejection server-side). Tests: rejected-surfaced-flagged-then-reversible (service) + undismiss endpoint idempotency (API). Completes #1134's reversible-rejection half. Heads-as-suggestion-source is the remaining piece. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
183 lines
6.9 KiB
Python
183 lines
6.9 KiB
Python
"""Suggestions API: per-image ranked suggestions + accept/alias/dismiss."""
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from ..extensions import get_session
|
|
from ..models import Tag, TagAllowlist
|
|
from ..services.ml.allowlist import AllowlistService
|
|
from ..services.ml.suggestions import SuggestionService
|
|
|
|
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
|
|
|
|
|
async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict:
|
|
"""Shape the accept/alias response. When accepting newly allowlists a tag,
|
|
include the coverage PROJECTION (at the tag's threshold) so the UI can show
|
|
a non-blocking "auto-applying to ~N images" toast — the actual apply runs
|
|
async via apply_allowlist_tags, so this is an estimate, not a post-hoc
|
|
count (#7)."""
|
|
payload = {"allowlisted": newly_added}
|
|
if newly_added:
|
|
tag = await session.get(Tag, tag_id)
|
|
row = await session.get(TagAllowlist, tag_id)
|
|
payload["tag_id"] = tag_id
|
|
payload["tag_name"] = tag.name if tag is not None else None
|
|
payload["projected_count"] = await svc.coverage(
|
|
tag_id, row.min_confidence if row is not None else 0.90,
|
|
)
|
|
return payload
|
|
|
|
|
|
@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,
|
|
}
|
|
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:
|
|
svc = AllowlistService(session)
|
|
newly_added = await svc.accept(image_id, tag_id)
|
|
payload = await _accept_payload(session, svc, newly_added, tag_id)
|
|
await session.commit()
|
|
if newly_added:
|
|
from ..tasks.ml import apply_allowlist_tags
|
|
|
|
apply_allowlist_tags.delay(tag_id=tag_id)
|
|
return jsonify(payload)
|
|
|
|
|
|
@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:
|
|
svc = AllowlistService(session)
|
|
newly_added = await svc.add_alias_and_accept(
|
|
image_id,
|
|
body["alias_string"],
|
|
body["alias_category"],
|
|
canonical_tag_id,
|
|
)
|
|
payload = await _accept_payload(
|
|
session, svc, newly_added, canonical_tag_id,
|
|
)
|
|
await session.commit()
|
|
if newly_added:
|
|
from ..tasks.ml import apply_allowlist_tags
|
|
|
|
apply_allowlist_tags.delay(tag_id=canonical_tag_id)
|
|
return jsonify(payload)
|
|
|
|
|
|
@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,
|
|
}
|
|
)
|