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
+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 {