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
214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
"""Allowlist semantics: accepting a suggestion adds the canonical tag to
|
|
image_tag AND to tag_allowlist; per-image removal/dismiss writes a rejection.
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import and_, delete, distinct, func, or_, select
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ...models import (
|
|
ImagePrediction,
|
|
MLSettings,
|
|
Tag,
|
|
TagAlias,
|
|
TagAllowlist,
|
|
TagSuggestionRejection,
|
|
)
|
|
from ...models.tag import image_tag
|
|
from .aliases import AliasService
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AllowlistRow:
|
|
tag_id: int
|
|
tag_name: str
|
|
tag_kind: str
|
|
min_confidence: float
|
|
applied_count: int # image_tag rows currently carrying this tag
|
|
coverage_count: int # images a sweep WOULD cover at min_confidence
|
|
|
|
|
|
class AllowlistService:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
self.aliases = AliasService(session)
|
|
|
|
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
|
|
stmt = insert(image_tag).values(
|
|
image_record_id=image_id, tag_id=tag_id, source=source
|
|
)
|
|
stmt = stmt.on_conflict_do_nothing(
|
|
index_elements=["image_record_id", "tag_id"]
|
|
)
|
|
await self.session.execute(stmt)
|
|
|
|
async def _add_to_allowlist(self, tag_id: int) -> bool:
|
|
"""Returns True if newly added (caller should kick off retro-apply)."""
|
|
exists = await self.session.get(TagAllowlist, tag_id)
|
|
if exists is not None:
|
|
return False
|
|
self.session.add(TagAllowlist(tag_id=tag_id))
|
|
await self.session.flush()
|
|
return True
|
|
|
|
async def _clear_rejection(self, image_id: int, tag_id: int):
|
|
await self.session.execute(
|
|
delete(TagSuggestionRejection)
|
|
.where(TagSuggestionRejection.image_record_id == image_id)
|
|
.where(TagSuggestionRejection.tag_id == tag_id)
|
|
)
|
|
|
|
async def accept(self, image_id: int, tag_id: int) -> bool:
|
|
"""Accept a suggestion. Returns True if the tag was newly added to
|
|
the allowlist (the API layer enqueues apply_allowlist_tags then)."""
|
|
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
|
|
await self._clear_rejection(image_id, tag_id)
|
|
return await self._add_to_allowlist(tag_id)
|
|
|
|
async def add_alias_and_accept(
|
|
self,
|
|
image_id: int,
|
|
alias_string: str,
|
|
alias_category: str,
|
|
canonical_tag_id: int,
|
|
) -> bool:
|
|
await self.aliases.create(
|
|
alias_string, alias_category, canonical_tag_id
|
|
)
|
|
return await self.accept(image_id, canonical_tag_id)
|
|
|
|
async def dismiss(self, image_id: int, tag_id: int) -> None:
|
|
stmt = insert(TagSuggestionRejection).values(
|
|
image_record_id=image_id, tag_id=tag_id
|
|
)
|
|
stmt = stmt.on_conflict_do_nothing(
|
|
index_elements=["image_record_id", "tag_id"]
|
|
)
|
|
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
|
|
re-apply it on the next maintenance sweep."""
|
|
await self.session.execute(
|
|
image_tag.delete()
|
|
.where(image_tag.c.image_record_id == image_id)
|
|
.where(image_tag.c.tag_id == tag_id)
|
|
)
|
|
await self.dismiss(image_id, tag_id)
|
|
|
|
async def _store_floor(self) -> float:
|
|
return (
|
|
await self.session.execute(
|
|
select(MLSettings.tagger_store_floor).where(MLSettings.id == 1)
|
|
)
|
|
).scalar_one()
|
|
|
|
async def update_threshold(
|
|
self, tag_id: int, min_confidence: float
|
|
) -> None:
|
|
row = await self.session.get(TagAllowlist, tag_id)
|
|
if row is not None:
|
|
# An allowlist tag can't auto-apply more permissively than the
|
|
# ingest store floor — predictions below tagger_store_floor aren't
|
|
# stored, so a lower min_confidence would behave identically to the
|
|
# floor. Clamp so the stored threshold matches actual behavior
|
|
# (#764).
|
|
floor = await self._store_floor()
|
|
row.min_confidence = max(min_confidence, floor)
|
|
|
|
async def remove(self, tag_id: int) -> None:
|
|
await self.session.execute(
|
|
delete(TagAllowlist).where(TagAllowlist.tag_id == tag_id)
|
|
)
|
|
|
|
async def _coverage_match(self, tag: Tag):
|
|
"""The predicate over image_prediction rows that resolve to `tag`,
|
|
mirroring tasks.ml._confidence_for_tag's resolution: a prediction whose
|
|
raw_name equals the tag name (any category), OR an alias maps
|
|
(raw_name, category) -> this tag. Returns a SQLAlchemy boolean clause.
|
|
"""
|
|
alias_rows = (
|
|
await self.session.execute(
|
|
select(TagAlias.alias_string, TagAlias.alias_category).where(
|
|
TagAlias.canonical_tag_id == tag.id
|
|
)
|
|
)
|
|
).all()
|
|
name_clause = ImagePrediction.raw_name == tag.name
|
|
alias_clauses = [
|
|
and_(
|
|
ImagePrediction.raw_name == a,
|
|
ImagePrediction.category == c,
|
|
)
|
|
for a, c in alias_rows
|
|
]
|
|
return or_(name_clause, *alias_clauses) if alias_clauses else name_clause
|
|
|
|
async def coverage(self, tag_id: int, threshold: float) -> int:
|
|
"""How many distinct images a sweep WOULD cover for this tag at
|
|
`threshold`: images with a resolving prediction scoring >= threshold.
|
|
The gross candidate pool (NOT minus already-applied/rejected) — it's
|
|
the tuning signal for "lower the threshold and ~N more images qualify".
|
|
"""
|
|
tag = await self.session.get(Tag, tag_id)
|
|
if tag is None:
|
|
return 0
|
|
match = await self._coverage_match(tag)
|
|
stmt = select(
|
|
func.count(distinct(ImagePrediction.image_record_id))
|
|
).where(ImagePrediction.score >= threshold, match)
|
|
return (await self.session.execute(stmt)).scalar_one()
|
|
|
|
async def list_all(self) -> Sequence[AllowlistRow]:
|
|
stmt = (
|
|
select(
|
|
TagAllowlist.tag_id,
|
|
Tag.name,
|
|
Tag.kind,
|
|
TagAllowlist.min_confidence,
|
|
)
|
|
.join(Tag, Tag.id == TagAllowlist.tag_id)
|
|
.order_by(Tag.name.asc())
|
|
)
|
|
rows = (await self.session.execute(stmt)).all()
|
|
tag_ids = [r[0] for r in rows]
|
|
|
|
# Applied counts in ONE grouped query (vs N per-row counts).
|
|
applied: dict[int, int] = {}
|
|
if tag_ids:
|
|
applied = dict(
|
|
(
|
|
await self.session.execute(
|
|
select(image_tag.c.tag_id, func.count())
|
|
.where(image_tag.c.tag_id.in_(tag_ids))
|
|
.group_by(image_tag.c.tag_id)
|
|
)
|
|
).all()
|
|
)
|
|
|
|
result = []
|
|
for r in rows:
|
|
# Coverage is per-tag (alias set differs); allowlist is small.
|
|
cov = await self.coverage(r[0], r[3])
|
|
result.append(
|
|
AllowlistRow(
|
|
tag_id=r[0],
|
|
tag_name=r[1],
|
|
tag_kind=r[2].value if hasattr(r[2], "value") else str(r[2]),
|
|
min_confidence=r[3],
|
|
applied_count=applied.get(r[0], 0),
|
|
coverage_count=cov,
|
|
)
|
|
)
|
|
return result
|