feat(ui): confirm/keep auto-applied tags (milestone 139)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Failing after 28s
CI / integration (push) Successful in 3m39s

Auto-applied tags are provisional (they don't train the model + can be retracted
until confirmed), so surface and confirm them:
- Backend: list_for_image + get_image_with_tags now include `source` + a
  `confirmed` flag on each applied tag (via serialize_tag, image-scoped; defaulted
  for autocomplete/directory callers).
- Frontend: TagChip badges an unconfirmed auto-tag with an "auto" pill + a
  one-click Keep/confirm (✓) → POST /images/<id>/tags/<id>/confirm, which promotes
  it to a training positive and shields it from the retraction sweep; TagPanel
  reloads so the badge + button drop once confirmed.
Contract test for the source/confirmed payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 18:51:39 -04:00
parent d3984ccb0d
commit 775941609d
6 changed files with 131 additions and 7 deletions
+16 -2
View File
@@ -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)
+7
View File
@@ -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)),
}
+15 -2
View File
@@ -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)
+46 -2
View File
@@ -16,8 +16,20 @@
>mdi-shield-outline</v-icon><span
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span><span
v-if="unconfirmedAuto" class="fc-tag-chip__auto"
title="Auto-applied — provisional: it won't train the model and can be retracted until you confirm it."
>auto</span>
</v-chip>
<!-- Keep/confirm an auto-applied tag: promotes it to a training positive and
shields it from the retraction sweep (milestone 139). Only shown for
provisional (unconfirmed) auto-tags. -->
<button
v-if="unconfirmedAuto" class="fc-tag-chip__confirm" type="button"
:title="`Keep “${tag.name}” — confirm this auto-tag so it trains the model and won't be retracted`"
:aria-label="`Confirm ${tag.name}`"
@click.stop="$emit('confirm', tag)"
><v-icon size="14">mdi-check</v-icon></button>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and
@@ -43,6 +55,8 @@ import { useTagStore } from '../../stores/tags.js'
import { useApi } from '../../composables/useApi.js'
import KebabMenu from '../common/KebabMenu.vue'
const AUTO_SOURCES = ['head_auto', 'ccip_auto', 'ml_auto']
const props = defineProps({
tag: { type: Object, required: true },
// When set (the tagging panels), hovering the chip asks the backend which crop
@@ -51,11 +65,19 @@ const props = defineProps({
// the hover is inert (no injected target, or no image to ground against).
imageId: { type: Number, default: null },
})
defineEmits(['remove', 'rename', 'set-fandom', 'navigate'])
defineEmits(['remove', 'rename', 'set-fandom', 'navigate', 'confirm'])
const store = useTagStore()
const api = useApi()
// An auto-applied tag the operator hasn't confirmed yet — provisional (milestone
// 139): it doesn't train the model and the retraction sweep can drop it. Shows
// the "auto" badge + a Keep/confirm button. `source`/`confirmed` come from the
// applied-tags payload (list_for_image / get_image_with_tags).
const unconfirmedAuto = computed(() =>
AUTO_SOURCES.includes(props.tag.source) && !props.tag.confirmed
)
// #1206 Step 4: applied-tag grounding. `fcSuggestionHover` is provided by the
// image viewer / Explore host (a no-op elsewhere). Applied tags aren't scored
// live, so we fetch the winning region on demand and cache it per (image, tag).
@@ -120,4 +142,26 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
/* "auto" = provisional (auto-applied, unconfirmed). Quiet pill inside the chip. */
.fc-tag-chip__auto {
display: inline-block; vertical-align: middle; margin-left: 4px;
font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
background: rgb(var(--v-theme-surface-light));
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.3);
padding: 0 4px; border-radius: 999px;
}
/* Keep/confirm — a success-tinted check next to a provisional auto-tag. */
.fc-tag-chip__confirm {
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
border: none; background: transparent; cursor: pointer;
color: rgb(var(--v-theme-success));
}
.fc-tag-chip__confirm:hover { background: rgb(var(--v-theme-success), 0.14); }
.fc-tag-chip__confirm:focus-visible {
outline: 2px solid rgb(var(--v-theme-success)); outline-offset: 1px;
}
</style>
+15 -1
View File
@@ -6,7 +6,7 @@
v-for="tag in host.current?.tags || []"
:key="tag.id" :tag="tag" :image-id="host.currentImageId"
@remove="onRemove" @rename="openRename" @set-fandom="openSetFandom"
@navigate="onNavigate"
@navigate="onNavigate" @confirm="onConfirm"
/>
<span v-if="!host.current?.tags?.length" class="text-caption">No tags yet.</span>
</div>
@@ -65,6 +65,7 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { useModalStore } from '../../stores/modal.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import TagChip from './TagChip.vue'
@@ -83,6 +84,7 @@ const modalStore = useModalStore()
const host = props.host || modalStore
const suggestions = useSuggestionsStore()
const router = useRouter()
const api = useApi()
const errorMsg = ref(null)
const tagInputRef = ref(null)
@@ -106,6 +108,18 @@ defineExpose({ focusTagInput })
// Every tag mutation hands focus back to the input so the operator can keep
// typing the next tag without re-clicking — matches the accept-suggestion flow
// (operator-asked 2026-06-26; the Explore workspace leans on this hard).
// Confirm/keep an auto-applied tag (milestone 139): records the affirmation so
// the tag becomes a training positive AND is shielded from the retraction sweep,
// then reloads so the chip drops its "auto" badge + Keep button.
async function onConfirm(tag) {
errorMsg.value = null
try {
await api.post(`/api/images/${host.currentImageId}/tags/${tag.id}/confirm`)
await host.reloadTags()
}
catch (e) { errorMsg.value = e.message }
}
async function onRemove(tagId) {
errorMsg.value = null
try {
+32
View File
@@ -141,6 +141,38 @@ async def test_applied_tag_grounding_returns_winning_region(client, db):
assert body["grounding"]["kind"] == "concept"
@pytest.mark.asyncio
async def test_applied_tags_expose_source_and_confirmed(client, db):
# The chip UI needs each applied tag's source (auto vs manual) + confirmed
# state to badge auto-tags and offer Keep/confirm (milestone 139).
from backend.app.models import TagPositiveConfirmation
from backend.app.models.tag import image_tag
img = ImageRecord(
path="/images/srcflag.jpg", sha256="sf" * 32, size_bytes=1,
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
auto = await TagService(db).find_or_create("autotag", TagKind.general)
manual = await TagService(db).find_or_create("manualtag", TagKind.general)
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=auto.id, source="head_auto"))
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=manual.id, source="manual"))
db.add(TagPositiveConfirmation(image_record_id=img.id, tag_id=manual.id))
await db.commit()
resp = await client.get(f"/api/images/{img.id}/tags")
assert resp.status_code == 200
by_id = {t["id"]: t for t in await resp.get_json()}
assert by_id[auto.id]["source"] == "head_auto"
assert by_id[auto.id]["confirmed"] is False
assert by_id[manual.id]["source"] == "manual"
assert by_id[manual.id]["confirmed"] is True # has a confirmation row
@pytest.mark.asyncio
async def test_applied_tag_grounding_no_head(client, db):
# A tag with no head can't be localized → has_head False, grounding null; the