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:
@@ -95,6 +95,25 @@ async def test_dismiss(client, db):
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_undismiss_reverses_rejection(client, db):
|
||||
img = await _img(db, {})
|
||||
tag = await TagService(db).find_or_create("UndismissMe", TagKind.general)
|
||||
await db.commit()
|
||||
await client.post(
|
||||
f"/api/images/{img.id}/suggestions/dismiss", json={"tag_id": tag.id}
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
# Idempotent: un-rejecting again (nothing to clear) is still a 204.
|
||||
resp2 = await client.post(
|
||||
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
|
||||
)
|
||||
assert resp2.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_requires_fields(client, db):
|
||||
img = await _img(db, {})
|
||||
|
||||
@@ -3,6 +3,7 @@ import pytest
|
||||
from backend.app.models import ImageRecord, TagKind
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.ml.aliases import AliasService
|
||||
from backend.app.services.ml.allowlist import AllowlistService
|
||||
from backend.app.services.ml.suggestions import SuggestionService
|
||||
from backend.app.services.tag_service import TagService
|
||||
|
||||
@@ -147,3 +148,36 @@ async def test_applied_tag_not_suggested(db):
|
||||
)
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
assert "character" not in sl.by_category or not sl.by_category["character"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_tag_surfaced_flagged_then_reversible(db):
|
||||
# A dismissed suggestion is NOT dropped: it stays in the list flagged
|
||||
# rejected=True so the rail can show it + offer one-click un-reject
|
||||
# (visible, reversible rejection — operator-asked 2026-06-27). A live
|
||||
# suggestion sorts ahead of the rejected one.
|
||||
tags = TagService(db)
|
||||
rejected_tag = await tags.find_or_create("rejectme", TagKind.general)
|
||||
img = await _seed_img(
|
||||
db,
|
||||
"f" * 64,
|
||||
{
|
||||
"rejectme": {"category": "general", "confidence": 0.96},
|
||||
"keepme": {"category": "general", "confidence": 0.90},
|
||||
},
|
||||
)
|
||||
await AllowlistService(db).dismiss(img.id, rejected_tag.id)
|
||||
|
||||
sl = await SuggestionService(db).for_image(img.id)
|
||||
general = sl.by_category["general"]
|
||||
by_name = {s.display_name: s for s in general}
|
||||
assert by_name["Rejectme"].rejected is True
|
||||
assert by_name["Keepme"].rejected is False
|
||||
# Live suggestions sort ahead of rejected ones regardless of score.
|
||||
assert general[-1].display_name == "Rejectme"
|
||||
|
||||
# Un-reject reverts it to a live suggestion.
|
||||
await AllowlistService(db).undismiss(img.id, rejected_tag.id)
|
||||
sl2 = await SuggestionService(db).for_image(img.id)
|
||||
by_name2 = {s.display_name: s for s in sl2.by_category["general"]}
|
||||
assert by_name2["Rejectme"].rejected is False
|
||||
|
||||
Reference in New Issue
Block a user