feat(suggestions): visible, reversible rejection in the modal rail
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
This commit is contained in:
@@ -61,6 +61,10 @@ async def get_suggestions(image_id: int):
|
||||
# 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
|
||||
]
|
||||
@@ -131,6 +135,21 @@ async def dismiss_suggestion(image_id: int):
|
||||
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()
|
||||
|
||||
@@ -89,6 +89,12 @@ class AllowlistService:
|
||||
)
|
||||
await self.session.execute(stmt)
|
||||
|
||||
async def undismiss(self, image_id: int, tag_id: int) -> None:
|
||||
"""Undo a per-image dismissal — drop the TagSuggestionRejection so the
|
||||
suggestion reverts to a live (un-rejected) state. Backs the rail's
|
||||
one-click reject-recovery (operator-asked 2026-06-27)."""
|
||||
await self._clear_rejection(image_id, tag_id)
|
||||
|
||||
async def reject_applied_tag(self, image_id: int, tag_id: int) -> None:
|
||||
"""Operator removed an applied tag from an image. Remove the
|
||||
image_tag row AND record a rejection so the allowlist won't
|
||||
|
||||
@@ -39,6 +39,11 @@ class Suggestion:
|
||||
# via_alias = this suggestion was surfaced because an operator alias remapped
|
||||
# the raw prediction to this canonical tag. Lets the UI mark it + offer undo.
|
||||
via_alias: bool = False
|
||||
# rejected = the operator dismissed this tag for this image (a stored
|
||||
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
|
||||
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
|
||||
# operator-asked 2026-06-27) instead of silently vanishing or re-suggesting.
|
||||
rejected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -174,12 +179,15 @@ class SuggestionService:
|
||||
# augmentation, so it's always the first writer for a key.
|
||||
raw_name=existing.raw_name,
|
||||
via_alias=existing.via_alias,
|
||||
# rejected is a property of the tag_id, so both writers for a
|
||||
# key agree — preserve it through the higher-score rebuild.
|
||||
rejected=existing.rejected,
|
||||
)
|
||||
|
||||
for raw, display, category, conf in candidates:
|
||||
canonical = alias_map.get((raw, category))
|
||||
if canonical is not None:
|
||||
if canonical.id in applied or canonical.id in rejected:
|
||||
if canonical.id in applied:
|
||||
continue
|
||||
_merge(
|
||||
canonical.id,
|
||||
@@ -192,6 +200,7 @@ class SuggestionService:
|
||||
creates_new_tag=False,
|
||||
raw_name=raw,
|
||||
via_alias=True,
|
||||
rejected=canonical.id in rejected,
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -209,10 +218,7 @@ class SuggestionService:
|
||||
)
|
||||
).scalars().first()
|
||||
if existing_tag is not None:
|
||||
if (
|
||||
existing_tag.id in applied
|
||||
or existing_tag.id in rejected
|
||||
):
|
||||
if existing_tag.id in applied:
|
||||
continue
|
||||
_merge(
|
||||
existing_tag.id,
|
||||
@@ -225,6 +231,7 @@ class SuggestionService:
|
||||
creates_new_tag=False,
|
||||
raw_name=raw,
|
||||
via_alias=False,
|
||||
rejected=existing_tag.id in rejected,
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -247,7 +254,7 @@ class SuggestionService:
|
||||
for hit in hits:
|
||||
if hit.similarity < settings.centroid_similarity_threshold:
|
||||
continue
|
||||
if hit.tag_id in applied or hit.tag_id in rejected:
|
||||
if hit.tag_id in applied:
|
||||
continue
|
||||
tag = await self.session.get(Tag, hit.tag_id)
|
||||
if tag is None:
|
||||
@@ -263,6 +270,7 @@ class SuggestionService:
|
||||
score=hit.similarity,
|
||||
source="centroid",
|
||||
creates_new_tag=False,
|
||||
rejected=hit.tag_id in rejected,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -270,7 +278,9 @@ class SuggestionService:
|
||||
for sug in merged.values():
|
||||
result.by_category.setdefault(sug.category, []).append(sug)
|
||||
for cat in result.by_category:
|
||||
result.by_category[cat].sort(key=lambda s: s.score, reverse=True)
|
||||
# Live suggestions first (by score), rejected ones sink to the
|
||||
# bottom of the category — visible for recovery, out of the way.
|
||||
result.by_category[cat].sort(key=lambda s: (s.rejected, -s.score))
|
||||
return result
|
||||
|
||||
async def for_selection(
|
||||
@@ -297,6 +307,11 @@ class SuggestionService:
|
||||
for s in items:
|
||||
if s.canonical_tag_id is None or s.creates_new_tag:
|
||||
continue
|
||||
# for_image now keeps rejected tags (flagged) for the rail;
|
||||
# bulk consensus must still ignore them — a tag dismissed on
|
||||
# an image isn't a suggestion for that image.
|
||||
if s.rejected:
|
||||
continue
|
||||
st = stats.get(s.canonical_tag_id)
|
||||
if st is None:
|
||||
st = {
|
||||
|
||||
Reference in New Issue
Block a user