485387ff0b
Heads + CCIP are the tag source and head auto-apply is the earned propagation.
The Camie tagger ran only to feed the allowlist bulk-apply (its ImagePrediction
rows had no other consumer), and the allowlist was a SECOND, un-earned auto-apply
path firing in parallel with heads on every accept — exactly the un-earned spray
the v2 pivot replaced. Retire both.
Behavior change: accepting a suggestion now applies the tag to THAT image only
(source='ml_accepted', a head-training positive) — it no longer allowlists +
fans the tag across the library via Camie. Propagation is heads' earned
auto-apply. (Loses instant cold-start propagation for booru-vocab tags; that was
un-earned and bypassed the precision gate.)
- tag_and_embed is now EMBED-ONLY (no Camie load/infer, no ImagePrediction
writes); backfill enqueues it for images with no embedding.
- Removed: services/ml/tagger.py, apply_allowlist_tags + helpers + daily beat +
every enqueue caller (accept/alias/merge/per-image), api/allowlist.py +
blueprint, ImagePrediction + TagAllowlist models/tables (migration 0067),
AllowlistTable.vue + allowlist store, the accept coverage-projection payload.
- AllowlistService gutted to accept/dismiss/undismiss/reject (the rejection store
the rail still needs); accept returns nothing, API returns {accepted, tag_id}.
- tag merge no longer repoints/triggers the allowlist; _keep_as_alias now keys on
ML-applied image_tag sources (incl. head_auto) instead of the allowlist.
- UI: MLBackfillCard relabelled to embedding-only; accept toast simplified;
MaintenancePanel drops the allowlist tile.
Left for a follow-up hygiene pass (now-inert, harmless): the dead settings
columns (tagger_store_floor, tagger_model_version, suggestion_threshold_*,
video_min_tag_frames), image_record.tagger_model_version, MLThresholdSliders
trim, and the Camie model download in download_models.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
208 lines
8.3 KiB
JavaScript
208 lines
8.3 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { toast } from '../utils/toast.js'
|
|
import { ref } from 'vue'
|
|
import { useApi } from '../composables/useApi.js'
|
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
|
|
|
// Category display order: people first, general last.
|
|
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
|
|
// character and general surface as suggestion categories now.
|
|
export const CATEGORY_ORDER = ['character', 'general']
|
|
export const CATEGORY_LABELS = {
|
|
character: 'Character',
|
|
general: 'General'
|
|
}
|
|
|
|
export const useSuggestionsStore = defineStore('suggestions', () => {
|
|
const api = useApi()
|
|
const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold)
|
|
// The typed tag-input dropdown searches the model's FULL prediction set for
|
|
// the image (min=0, down to the store floor) so low-confidence actions/
|
|
// features can be picked in canonical formatting (operator-asked 2026-06-09).
|
|
const allByCategory = ref({})
|
|
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
|
let currentImageId = null
|
|
const inflightAll = useInflightToken()
|
|
// Audit 2026-06-02: this store had no inflight guard — a late
|
|
// /suggestions response from a prior image could overwrite
|
|
// byCategory while currentImageId pointed at a new one, and
|
|
// accept() dereferenced currentImageId AFTER an awaited POST so
|
|
// the subsequent /suggestions/accept could apply A's chosen tag
|
|
// to image B (and push it to the allowlist). Both fixed below
|
|
// by capturing imageId at call-time and gating writes on the token.
|
|
const inflight = useInflightToken()
|
|
|
|
async function load(imageId) {
|
|
// Cancel any in-flight load from the previous image so its late
|
|
// response can't overwrite this image's byCategory.
|
|
inflight.cancel()
|
|
currentImageId = imageId
|
|
byCategory.value = {} // cleared upfront so it stays empty on error
|
|
const t = inflight.claim()
|
|
await run(async () => {
|
|
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
|
if (!t.isCurrent()) return
|
|
byCategory.value = body.by_category || {}
|
|
})
|
|
}
|
|
|
|
// Load the full prediction set for the dropdown (separate inflight guard so a
|
|
// late response from a prior image can't overwrite the current one's list).
|
|
async function loadAll(imageId) {
|
|
inflightAll.cancel()
|
|
allByCategory.value = {}
|
|
const t = inflightAll.claim()
|
|
try {
|
|
const body = await api.get(`/api/images/${imageId}/suggestions`, {
|
|
params: { min: 0 },
|
|
})
|
|
if (!t.isCurrent()) return
|
|
allByCategory.value = body.by_category || {}
|
|
} catch {
|
|
// Dropdown is best-effort — the panel surfaces load errors.
|
|
}
|
|
}
|
|
|
|
// A stable identity for a suggestion across the two lists (panel vs dropdown
|
|
// are separate fetches, so object identity differs): tag id when known, else
|
|
// the raw display key.
|
|
function _keyOf(s) {
|
|
return s.canonical_tag_id != null
|
|
? `id:${s.canonical_tag_id}`
|
|
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
|
|
}
|
|
|
|
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
|
|
// dropdown's full list so it can't reappear in either surface.
|
|
function _dropEverywhere(suggestion) {
|
|
const key = _keyOf(suggestion)
|
|
const cat = suggestion.category
|
|
for (const map of [byCategory.value, allByCategory.value]) {
|
|
const list = map[cat]
|
|
if (list) map[cat] = list.filter(s => _keyOf(s) !== key)
|
|
}
|
|
}
|
|
|
|
// Flip the `rejected` flag on the matching suggestion in BOTH lists in place,
|
|
// so a reject/un-reject shows immediately without dropping the row (visible,
|
|
// reversible rejection — misclick recovery, operator-asked 2026-06-27).
|
|
function _setRejectedEverywhere(suggestion, value) {
|
|
const key = _keyOf(suggestion)
|
|
const cat = suggestion.category
|
|
for (const map of [byCategory.value, allByCategory.value]) {
|
|
for (const s of map[cat] || []) {
|
|
if (_keyOf(s) === key) s.rejected = value
|
|
}
|
|
}
|
|
}
|
|
|
|
async function accept(suggestion) {
|
|
// Capture imageId so a mid-flight prev/next can't reroute the
|
|
// accept POST to a different image AND push the tag to that
|
|
// image's allowlist.
|
|
const imageId = currentImageId
|
|
if (imageId == null) return
|
|
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
|
|
// accept endpoint needs a tag_id, so for raw tags we create the tag
|
|
// first via the existing /api/tags endpoint, then accept by id.
|
|
let tagId = suggestion.canonical_tag_id
|
|
if (tagId == null) {
|
|
const created = await api.post('/api/tags', {
|
|
body: { name: suggestion.display_name, kind: suggestion.category }
|
|
})
|
|
tagId = created.id
|
|
}
|
|
await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
|
body: { tag_id: tagId }
|
|
})
|
|
// Only drop from THIS image's category list — if the user navigated,
|
|
// the new image has its own suggestions and this drop would corrupt them.
|
|
if (currentImageId === imageId) {
|
|
_dropEverywhere(suggestion)
|
|
}
|
|
_acceptToast('Tagged', suggestion.display_name)
|
|
}
|
|
|
|
// One non-blocking toast for accept/alias. The accepted tag is applied to this
|
|
// image and feeds head training; head auto-apply handles propagation (earned),
|
|
// so there's no instant fan-out to project.
|
|
function _acceptToast(verb, displayName) {
|
|
toast({ text: `${verb}: ${displayName}`, type: 'success' })
|
|
}
|
|
|
|
async function aliasAccept(suggestion, canonicalTagId) {
|
|
const imageId = currentImageId
|
|
if (imageId == null) return
|
|
// The alias MUST be stored under the raw model key — resolution looks up the
|
|
// raw prediction key, not the normalized display name. Sending display_name
|
|
// (the old bug) stored an alias that never resolved, so the prediction kept
|
|
// reappearing unaliased. raw_name is null only for centroid hits, which
|
|
// can't be aliased (the UI hides the action for them).
|
|
const aliasString = suggestion.raw_name ?? suggestion.display_name
|
|
await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
|
body: {
|
|
alias_string: aliasString,
|
|
alias_category: suggestion.category,
|
|
canonical_tag_id: canonicalTagId
|
|
}
|
|
})
|
|
if (currentImageId === imageId) {
|
|
_dropEverywhere(suggestion)
|
|
}
|
|
_acceptToast('Aliased & tagged', suggestion.display_name)
|
|
}
|
|
|
|
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
|
// its unaliased form on reload). The canonical tag stays applied if it was
|
|
// accepted — this only undoes the model-key→tag mapping.
|
|
async function removeAlias(suggestion) {
|
|
const imageId = currentImageId
|
|
if (imageId == null || suggestion.raw_name == null) return
|
|
await api.delete(
|
|
`/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}`
|
|
)
|
|
if (currentImageId === imageId) {
|
|
await load(imageId)
|
|
await loadAll(imageId)
|
|
}
|
|
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
|
|
}
|
|
|
|
async function dismiss(suggestion) {
|
|
const imageId = currentImageId
|
|
if (imageId == null) return
|
|
// Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's
|
|
// nothing to persist a rejection against — drop them client-side as before.
|
|
// Canonical tags persist a rejection and STAY in the list flagged rejected,
|
|
// so the operator can see it and one-click un-reject (misclick recovery).
|
|
if (suggestion.canonical_tag_id == null) {
|
|
if (currentImageId === imageId) _dropEverywhere(suggestion)
|
|
return
|
|
}
|
|
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
|
body: { tag_id: suggestion.canonical_tag_id }
|
|
})
|
|
if (currentImageId === imageId) {
|
|
_setRejectedEverywhere(suggestion, true)
|
|
}
|
|
}
|
|
|
|
// Undo a per-image dismissal — the suggestion reverts to a live row.
|
|
async function undismiss(suggestion) {
|
|
const imageId = currentImageId
|
|
if (imageId == null || suggestion.canonical_tag_id == null) return
|
|
await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
|
|
body: { tag_id: suggestion.canonical_tag_id }
|
|
})
|
|
if (currentImageId === imageId) {
|
|
_setRejectedEverywhere(suggestion, false)
|
|
}
|
|
}
|
|
|
|
return {
|
|
byCategory, allByCategory, loading, error,
|
|
load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss
|
|
}
|
|
})
|