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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { toast } from '../utils/toast.js'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||
@@ -18,182 +18,112 @@ export const CATEGORY_LABELS = {
|
||||
|
||||
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({})
|
||||
// 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
|
||||
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.
|
||||
// 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 byCategory.
|
||||
// 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 () => {
|
||||
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
||||
// 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 || {}
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// A stable identity for a suggestion across the panel + dropdown surfaces:
|
||||
// its canonical tag id (every suggestion has one now).
|
||||
function _keyOf(s) {
|
||||
return s.canonical_tag_id != null
|
||||
? `id:${s.canonical_tag_id}`
|
||||
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
|
||||
return `id:${s.canonical_tag_id}`
|
||||
}
|
||||
|
||||
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
|
||||
// dropdown's full list so it can't reappear in either surface.
|
||||
// Drop an accepted suggestion from the list so it can't reappear.
|
||||
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)
|
||||
}
|
||||
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 BOTH lists in place,
|
||||
// so a reject/un-reject shows immediately without dropping the row (visible,
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
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 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.
|
||||
// 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 AND push the tag to that
|
||||
// image's allowlist.
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
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 category list — if the user navigated,
|
||||
// the new image has its own suggestions and this drop would corrupt them.
|
||||
// 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/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.
|
||||
// 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' })
|
||||
}
|
||||
|
||||
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).
|
||||
// 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 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
|
||||
}
|
||||
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
|
||||
@@ -202,14 +132,8 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
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 }
|
||||
})
|
||||
@@ -218,33 +142,31 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Reject every still-unhandled suggestion in a category in one go ("confirm
|
||||
// the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags
|
||||
// persist a rejection and STAY flagged rejected (reversible, one-click
|
||||
// un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel
|
||||
// so a big section clears fast.
|
||||
// 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.rejected)
|
||||
const targets = (byCategory.value[category] || []).filter(
|
||||
(s) => s.above_threshold && !s.rejected
|
||||
)
|
||||
if (!targets.length) return
|
||||
const canon = targets.filter((s) => s.canonical_tag_id != null)
|
||||
const raw = targets.filter((s) => s.canonical_tag_id == null)
|
||||
await Promise.all(canon.map((s) =>
|
||||
await Promise.all(targets.map((s) =>
|
||||
api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
||||
body: { tag_id: s.canonical_tag_id },
|
||||
})
|
||||
))
|
||||
if (currentImageId === imageId) {
|
||||
canon.forEach((s) => _setRejectedEverywhere(s, true))
|
||||
raw.forEach((s) => _dropEverywhere(s))
|
||||
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 || suggestion.canonical_tag_id == null) return
|
||||
if (imageId == null) return
|
||||
await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
|
||||
body: { tag_id: suggestion.canonical_tag_id }
|
||||
})
|
||||
@@ -254,8 +176,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
byCategory, allByCategory, loading, error,
|
||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining,
|
||||
undismiss, findPending
|
||||
byCategory, aboveByCategory, loading, error,
|
||||
load, accept, dismiss, dismissRemaining, undismiss, findPending
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user