"""Suggestion actions: accept applies the canonical tag to an image (which feeds head training); dismiss / reject record a per-image rejection. (The Camie allowlist bulk-apply was retired #1189 — heads + CCIP are the tag source, and head auto-apply is the earned propagation. Accept no longer allowlists or fans a tag out across the library.) """ from sqlalchemy import delete from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from ...models import TagSuggestionRejection from ...models.tag import image_tag from .aliases import AliasService 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 ).on_conflict_do_nothing( index_elements=["image_record_id", "tag_id"] ) await self.session.execute(stmt) 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) -> None: """Apply the accepted tag to this image (source='ml_accepted', a head training positive) and clear any prior rejection.""" await self._apply_image_tag(image_id, tag_id, source="ml_accepted") await self._clear_rejection(image_id, tag_id) async def add_alias_and_accept( self, image_id: int, alias_string: str, alias_category: str, canonical_tag_id: int, ) -> None: await self.aliases.create( alias_string, alias_category, canonical_tag_id ) 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 ).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 head auto-apply won't re-apply it.""" 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)