Files
FabledCurator/frontend/src/stores/suggestions.js
T
bvandeusen a444cf82d1
CI / backend-lint-and-test (push) Successful in 36s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m45s
refactor(tags): unify suggestion source — one canonical DB-tag dropdown, drop dead raw/alias machinery (#154)
Every tag suggestion is a canonical DB tag now (tagging-v2 #114: heads + CCIP
score EXISTING concept tags). The pre-heads apparatus for model-predicted tags
that didn't exist in the DB — creates_new_tag / raw_name / via_alias, the
/suggestions/alias endpoint + add_alias_and_accept, AliasPickerDialog, and the
store's aliasAccept/removeAlias — was dead and is removed.

The type-to-add dropdown was TWO row sources (server autocomplete + the image's
ML suggestions) merged with a dedup that dropped the %-bearing suggestion row
when the debounced server hit landed — the operator's "confidence % flickers
then vanishes". Now it's ONE list of DB-tag matches, each annotated with the
model's confidence (join by canonical_tag_id) when the tag was scored for this
image. No dedup, no flicker; picking a suggested tag still records acceptance
via TagPanel.findPending.

Single per-image fetch: score_image now reports above_threshold per row
(computed vs the head's own suggest cut, separate from the inclusion floor), so
the rail makes ONE min=0 request and derives the panel (above_threshold) and the
dropdown (all, text-filtered) client-side — the two /suggestions calls collapse
to one. Manual "Create 'X' as <kind>" (novel typed names) is unchanged; the
alias table + tag-side alias admin + auto-apply alias matching are untouched.

Tests: gate/serializer assertions updated (above_threshold; dropped dead-field
+ alias-endpoint checks); frontend spec seeds via the single load and covers the
byCategory/aboveByCategory split.

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

183 lines
7.6 KiB
JavaScript

import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { computed, 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()
// ONE source of truth per image: every trained head scored for this image
// (min=0), each row carrying above_threshold. The Suggestions PANEL renders
// aboveByCategory; the typed tag-input dropdown reads the full set and
// annotates matching DB-tag rows with their score. A single fetch backs both —
// every suggestion is a canonical tag now (tagging-v2, #114), so there is no
// raw-prediction / create-new / alias-remap path to reconcile.
const byCategory = ref({})
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
let currentImageId = null
// Audit 2026-06-02: without an inflight guard a late /suggestions response from
// a prior image could overwrite byCategory while currentImageId points at a new
// one, and accept() could apply image A's tag to image B. Both guarded below by
// capturing imageId at call-time and gating writes on the token.
const inflight = useInflightToken()
// The curated above-threshold subset the Suggestions panel shows. A rejected
// row that still scores above its cut stays (flagged) so the rejection is
// visible + reversible; below-threshold rows live only in the dropdown.
const aboveByCategory = computed(() => {
const out = {}
for (const [cat, list] of Object.entries(byCategory.value)) {
const kept = (list || []).filter(s => s.above_threshold)
if (kept.length) out[cat] = kept
}
return out
})
async function load(imageId) {
// Cancel any in-flight load from the previous image so its late response
// can't overwrite this image's list.
inflight.cancel()
currentImageId = imageId
byCategory.value = {} // cleared upfront so it stays empty on error
const t = inflight.claim()
await run(async () => {
// min=0 → every head, each flagged above_threshold; the panel + the typed
// dropdown are both derived from this one payload.
const body = await api.get(`/api/images/${imageId}/suggestions`, {
params: { min: 0 },
})
if (!t.isCurrent()) return
byCategory.value = body.by_category || {}
})
}
// A stable identity for a suggestion across the panel + dropdown surfaces:
// its canonical tag id (every suggestion has one now).
function _keyOf(s) {
return `id:${s.canonical_tag_id}`
}
// Drop an accepted suggestion from the list so it can't reappear.
function _dropEverywhere(suggestion) {
const key = _keyOf(suggestion)
const list = byCategory.value[suggestion.category]
if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key)
}
// Flip the `rejected` flag on the matching suggestion 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)
for (const s of byCategory.value[suggestion.category] || []) {
if (_keyOf(s) === key) s.rejected = value
}
}
// opts.tagId: the tag's known id when the caller already resolved it (TagPanel's
// manual-add-matches-suggestion path). Passed separately — NOT spread onto the
// suggestion — because _dropEverywhere keys off the suggestion object.
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.
const imageId = currentImageId
if (imageId == null) return
const tagId = knownTagId ?? suggestion.canonical_tag_id
await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId }
})
// Only drop from THIS image's 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. The accepted tag is applied to this image
// and feeds head training; head auto-apply handles propagation (earned).
function _acceptToast(verb, displayName) {
toast({ text: `${verb}: ${displayName}`, type: 'success' })
}
// Find a live (non-rejected) 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).
function findPending(kind, name, tagId = null) {
const lname = (name || '').toLowerCase()
for (const list of Object.values(byCategory.value)) {
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
// 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).
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id }
})
if (currentImageId === imageId) {
_setRejectedEverywhere(suggestion, true)
}
}
// Reject every still-unhandled VISIBLE (above-threshold) suggestion in a
// category in one go ("confirm the good ones, reject the rest" — operator-asked
// 2026-07-06). Only touches what the panel shows, not the low-confidence tail
// the dropdown carries. Dispatched in parallel so a big section clears fast.
async function dismissRemaining(category) {
const imageId = currentImageId
if (imageId == null) return
const targets = (byCategory.value[category] || []).filter(
(s) => s.above_threshold && !s.rejected
)
if (!targets.length) return
await Promise.all(targets.map((s) =>
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: s.canonical_tag_id },
})
))
if (currentImageId === imageId) {
targets.forEach((s) => _setRejectedEverywhere(s, true))
}
}
// Undo a per-image dismissal — the suggestion reverts to a live row.
async function undismiss(suggestion) {
const imageId = currentImageId
if (imageId == 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, aboveByCategory, loading, error,
load, accept, dismiss, dismissRemaining, undismiss, findPending
}
})