diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 4cde351..94e949f 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -22,7 +22,15 @@ from sqlalchemy import Select, and_, distinct, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased -from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag +from ..models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + Source, + Tag, + TagPositiveConfirmation, +) from ..models.tag import PRESENTATION_SYSTEM_TAGS, image_tag from .pagination import decode_cursor, encode_cursor from .tag_query import ( @@ -731,8 +739,14 @@ class GalleryService: # Self-join Tag to resolve a character's fandom NAME (not just id) so the # modal chip can label it without an N+1 (shared tag_query helpers). fandom_alias = fandom_join_alias() + # source drives the auto-applied badge; confirmed = operator affirmed the + # tag (positive + retraction-shielded, milestone 139). + confirmed = exists().where( + TagPositiveConfirmation.image_record_id == image_id, + TagPositiveConfirmation.tag_id == Tag.id, + ).label("confirmed") tag_stmt = ( - select(*tag_columns(fandom_alias)) + select(*tag_columns(fandom_alias), image_tag.c.source, confirmed) .select_from( Tag.__table__ .join(image_tag, image_tag.c.tag_id == Tag.id) diff --git a/backend/app/services/tag_query.py b/backend/app/services/tag_query.py index 13186dc..a60340e 100644 --- a/backend/app/services/tag_query.py +++ b/backend/app/services/tag_query.py @@ -91,4 +91,11 @@ def serialize_tag(row) -> dict: "fandom_id": row.fandom_id, "fandom_name": row.fandom_name, "is_system": bool(getattr(row, "is_system", False)), + # Applied-tag context: only the image-scoped selects (list_for_image / + # get_image_with_tags) provide these; autocomplete / directory don't → + # default. `source` drives the auto-applied badge; `confirmed` = the + # operator affirmed the tag (a training positive, shielded from the + # retraction sweep — milestone 139). + "source": getattr(row, "source", None), + "confirmed": bool(getattr(row, "confirmed", False)), } diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 98e1d9c..b6fe1d6 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -9,7 +9,14 @@ from sqlalchemy import and_, case, exists, func, select, text, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession -from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag +from ..models import ( + HeadMetric, + Tag, + TagHead, + TagKind, + TagPositiveConfirmation, + image_tag, +) from .db_helpers import get_or_create from .tag_query import fandom_join_alias, tag_columns @@ -288,8 +295,14 @@ class TagService: character chip with its fandom without an N+1 (mirrors the autocomplete/directory resolution).""" fandom_alias = fandom_join_alias() + # source drives the auto-applied badge; confirmed = operator affirmed the + # tag (positive + retraction-shielded, milestone 139). + confirmed = exists().where( + TagPositiveConfirmation.image_record_id == image_id, + TagPositiveConfirmation.tag_id == Tag.id, + ).label("confirmed") stmt = ( - select(*tag_columns(fandom_alias)) + select(*tag_columns(fandom_alias), image_tag.c.source, confirmed) .select_from( Tag.__table__ .join(image_tag, image_tag.c.tag_id == Tag.id) diff --git a/frontend/src/components/modal/TagChip.vue b/frontend/src/components/modal/TagChip.vue index 6437255..7cf2f59 100644 --- a/frontend/src/components/modal/TagChip.vue +++ b/frontend/src/components/modal/TagChip.vue @@ -16,8 +16,20 @@ >mdi-shield-outline + >→auto + +