"""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 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