Files
FabledCurator/frontend/src/stores/suggestions.js
T
bvandeusen 437bf4d37a
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 3m36s
CI / backend-lint-and-test (push) Successful in 30s
feat(suggestions): group wip/banner/editor under a separate 'system' category
System tags are kind=general, so their suggestions previously landed in the
General group. Give them their own 'system' suggestion category so the operator
reviews them apart from content tags: _current_heads maps is_system heads to
category 'system' (still trained as general heads, still gated by the 0.65
floor). Frontend: CATEGORY_ORDER/LABELS gain 'system'; SuggestionsPanel renders
a 'System' group first (small, collapsible, open — false positives easy to spot
and reject); the typed-dropdown shows the shield icon for system entries. Safe:
system-tag suggestions always carry a canonical_tag_id, so the create-by-kind
path (which would send 'system' as a TagKind) is never hit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-03 22:00:49 -04:00

239 lines
9.9 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: system (hygiene flags) first for quick review, then
// people, general last. 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01)
// retired. 'system' groups the wip/banner/editor hygiene tags apart from content
// tags (they're kind=general on the backend but surface under their own category).
export const CATEGORY_ORDER = ['system', 'character', 'general']
export const CATEGORY_LABELS = {
system: 'System',
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
}
}
}
// opts.tagId: the tag's known id, when the caller already created/resolved
// it (TagPanel's manual-add-matches-suggestion path). Passed separately —
// NOT spread onto the suggestion — because _dropEverywhere keys off the
// suggestion object, and mutating canonical_tag_id on a raw suggestion
// would stop it matching its own row in the lists.
async function accept(suggestion, { tagId: knownTagId = null } = {}) {
// 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 = knownTagId ?? 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' })
}
// Find a live (non-rejected) pending suggestion matching a manually-picked
// tag — by canonical id when known, else by (kind, name). Manually adding a
// tag the model also suggested must be indistinguishable from accepting the
// suggestion (recorded + dropped from the panel), so TagPanel routes matches
// through accept() (operator-asked 2026-07-03). Searches the full dropdown
// set first, then the panel list (loadAll is best-effort and can be empty).
function findPending(kind, name, tagId = null) {
const lname = (name || '').toLowerCase()
for (const map of [allByCategory.value, byCategory.value]) {
for (const list of Object.values(map)) {
for (const s of list || []) {
if (s.rejected) continue
if (tagId != null && s.canonical_tag_id === tagId) return s
if (
s.category === kind &&
(s.display_name || '').toLowerCase() === lname
) return s
}
}
}
return null
}
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,
findPending
}
})