refactor(tags): unify suggestion source — one canonical DB-tag dropdown (#154) #209

Merged
bvandeusen merged 1 commits from dev into main 2026-07-10 15:52:45 -04:00
14 changed files with 227 additions and 543 deletions
+10 -30
View File
@@ -11,10 +11,11 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
async def get_suggestions(image_id: int):
# ?min=<float> overrides the configured per-category thresholds so the typed
# tag-input dropdown can surface EVERY stored prediction (min=0), including
# low-confidence actions/features, in canonical formatting. Omitted → the
# curated above-threshold list the Suggestions panel uses.
# ?min=<float> overrides the per-head suggest thresholds for INCLUSION. The
# rail sends min=0 in its single per-image fetch to get EVERY head (each row
# still carries above_threshold vs its natural cut), then derives the panel
# (above_threshold) and the typed dropdown (all, filtered by text) client-side
# — no second request. Omitted → only above-threshold rows.
override = None
raw_min = request.args.get("min")
if raw_min is not None:
@@ -36,12 +37,11 @@ async def get_suggestions(image_id: int):
"category": s.category,
"score": round(s.score, 4),
"source": s.source,
"creates_new_tag": s.creates_new_tag,
# raw model key (alias is stored under this) + whether an
# operator alias produced this suggestion — drive the
# modal's "Treat as alias"/"Remove alias" affordances.
"raw_name": s.raw_name,
"via_alias": s.via_alias,
# whether the score cleared the head's own suggest cut.
# The single min=0 fetch returns every head; the panel
# shows above_threshold, the typed dropdown shows all and
# annotates each match with its score.
"above_threshold": s.above_threshold,
# operator dismissed this tag for this image — surfaced
# (not dropped) so the rail can show it rejected + offer
# one-click un-reject.
@@ -73,26 +73,6 @@ async def accept_suggestion(image_id: int):
return jsonify({"accepted": True, "tag_id": tag_id})
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/alias", methods=["POST"]
)
async def alias_suggestion(image_id: int):
body = await request.get_json()
required = {"alias_string", "alias_category", "canonical_tag_id"}
if not body or not required.issubset(body):
return jsonify({"error": f"required: {sorted(required)}"}), 400
canonical_tag_id = body["canonical_tag_id"]
async with get_session() as session:
await AllowlistService(session).add_alias_and_accept(
image_id,
body["alias_string"],
body["alias_category"],
canonical_tag_id,
)
await session.commit()
return jsonify({"accepted": True, "tag_id": canonical_tag_id})
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"]
)
-14
View File
@@ -12,13 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ...models import TagSuggestionRejection
from ...models.tag import image_tag
from .aliases import AliasService
class AllowlistService:
def __init__(self, session: AsyncSession):
self.session = session
self.aliases = AliasService(session)
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
stmt = insert(image_tag).values(
@@ -41,18 +39,6 @@ class AllowlistService:
await self._apply_image_tag(image_id, tag_id, source="ml_accepted")
await self._clear_rejection(image_id, tag_id)
async def add_alias_and_accept(
self,
image_id: int,
alias_string: str,
alias_category: str,
canonical_tag_id: int,
) -> None:
await self.aliases.create(
alias_string, alias_category, canonical_tag_id
)
await self.accept(image_id, canonical_tag_id)
async def dismiss(self, image_id: int, tag_id: int) -> None:
stmt = insert(TagSuggestionRejection).values(
image_record_id=image_id, tag_id=tag_id
+26 -16
View File
@@ -500,13 +500,19 @@ async def score_image(
session: AsyncSession, image_id: int, threshold_override: float | None = None,
) -> list[dict]:
"""Suggestions for one image from the trained heads: [{tag_id, name,
category, score}], ranked. A concept surfaces when its score clears the
head's own suggest_threshold — or, when threshold_override is given (the
typed-dropdown "show everything" mode), that flat floor instead (0 → every
head). System-tag heads (wip/banner/editor) instead use a flat
_SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface for rejection
(still overridden by threshold_override). Empty if the image has no
embedding or no heads exist yet.
category, score, above_threshold, grounding}], ranked. A concept is INCLUDED
when its score clears the head's own suggest_threshold — or, when
threshold_override is given (the typed-dropdown "show everything" mode), that
flat floor instead (0 → every head). System-tag heads (wip/banner/editor)
instead use a flat _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface
for rejection (still overridden by threshold_override). Empty if the image has
no embedding or no heads exist yet.
``above_threshold`` is reported SEPARATELY from inclusion: it's always whether
the score cleared the head's NATURAL cut (suggest_threshold, or the system
floor), regardless of any override. So the single min=0 fetch returns every
head, and the caller can split panel (above_threshold) from dropdown (all)
without a second request.
MAX-OVER-BAG: the image is scored as a BAG of embeddings — the whole-image
vector PLUS every concept-region crop the agent embedded (same model
@@ -537,21 +543,25 @@ async def score_image(
winners = probs_bag.argmax(axis=0) # (H,)
out = []
for i, p in enumerate(probs):
if threshold_override is not None:
cut = threshold_override
elif heads["meta"][i]["is_system"]:
# System tags surface at the flat floor (see _SYSTEM_TAG_SUGGEST_FLOOR)
# so their false positives show up for the operator to reject.
cut = _SYSTEM_TAG_SUGGEST_FLOOR
else:
cut = heads["thr"][i]
m = heads["meta"][i]
# The head's NATURAL suggest cut — system tags use the flat floor (see
# _SYSTEM_TAG_SUGGEST_FLOOR) so their false positives show up for the
# operator to reject; content heads use their own precision-tuned
# threshold. This is what "above threshold" means (drives the panel).
natural = (
_SYSTEM_TAG_SUGGEST_FLOOR if m["is_system"] else float(heads["thr"][i])
)
# INCLUSION is looser under threshold_override (dropdown show-all,
# override=0): every head comes back so a low-confidence concept can still
# be typed + picked, each carrying its own above_threshold flag.
cut = threshold_override if threshold_override is not None else natural
if p >= cut:
m = heads["meta"][i]
out.append({
"tag_id": m["tag_id"],
"name": m["name"],
"category": m["category"],
"score": float(p),
"above_threshold": bool(p >= natural),
"grounding": bag_meta[int(winners[i])],
})
out.sort(key=lambda d: d["score"], reverse=True)
+17 -17
View File
@@ -22,22 +22,20 @@ from .heads import score_image
@dataclass(frozen=True)
class Suggestion:
# canonical_tag_id is None when this is a raw Camie tag with no alias and
# no existing Tag row — accepting it will create the tag.
canonical_tag_id: int | None
# Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept
# tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap
# cases are gone — a suggestion always maps to a real tag id.
canonical_tag_id: int
display_name: str
category: str
score: float
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
creates_new_tag: bool
# raw_name = the booru model vocab key behind this suggestion. It's the key
# an alias MUST be stored under (resolution looks up the raw key), so the
# modal needs it to author an alias correctly. None for centroid-only hits
# (no underlying prediction → nothing to alias).
raw_name: str | None = None
# via_alias = this suggestion was surfaced because an operator alias remapped
# the raw prediction to this canonical tag. Lets the UI mark it + offer undo.
via_alias: bool = False
# above_threshold = the score cleared the head's own suggest cut (or the
# system floor). The Suggestions PANEL shows only these; the typed-tag
# dropdown fetches ALL suggestions (every head, min=0) and just annotates each
# matching row with its score, so a low-confidence concept can still be typed
# and picked. CCIP character matches are always above their match threshold.
above_threshold: bool
# rejected = the operator dismissed this tag for this image (a stored
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
@@ -108,6 +106,7 @@ class SuggestionService:
for h in hits:
merged[(h["category"], h["tag_id"])] = {
"name": h["name"], "score": h["score"], "source": "head",
"above_threshold": h["above_threshold"],
"grounding": h.get("grounding"),
}
for c in ccip_hits:
@@ -116,12 +115,16 @@ class SuggestionService:
if ex is not None:
ex["source"] = "both"
ex["score"] = max(ex["score"], c["score"])
# CCIP only returns matches above its own threshold, so a CCIP
# corroboration always makes the merged suggestion above-threshold.
ex["above_threshold"] = True
# Keep the head's localized crop if it had one; else fall back to
# the CCIP figure so a corroborated character still grounds (#1206).
ex["grounding"] = ex.get("grounding") or c.get("grounding")
else:
merged[key] = {
"name": c["name"], "score": c["score"], "source": "ccip",
"above_threshold": True,
"grounding": c.get("grounding"),
}
@@ -136,7 +139,7 @@ class SuggestionService:
category=cat,
score=m["score"],
source=m["source"],
creates_new_tag=False,
above_threshold=m["above_threshold"],
rejected=tag_id in rejected,
grounding=m.get("grounding"),
)
@@ -157,8 +160,7 @@ class SuggestionService:
was suggested for (or already applied to) >= threshold fraction of
the selection AND was acceptable on >= 1 image. Confidence is the
mean over images where it was suggested. Aggregated by
canonical_tag_id; creates-new (no canonical id) suggestions are
skipped (bulk Accept applies by tag id)."""
canonical_tag_id (every suggestion is a canonical tag now)."""
if not image_ids:
return {}
threshold = min(1.0, max(0.0, threshold))
@@ -169,8 +171,6 @@ class SuggestionService:
sl = await self.for_image(image_id)
for category, items in sl.by_category.items():
for s in items:
if s.canonical_tag_id is None or s.creates_new_tag:
continue
# for_image keeps rejected tags (flagged) for the rail;
# bulk consensus must still ignore them — a tag dismissed on
# an image isn't a suggestion for that image.
@@ -1,72 +0,0 @@
<template>
<v-card>
<v-card-title>Treat as alias for</v-card-title>
<v-card-text>
<p class="text-caption mb-2">
Pick the existing tag the model's prediction should map to. All
future predictions of this name will resolve to your tag.
</p>
<v-autocomplete
v-model="selectedId"
v-model:menu="menuOpen"
:items="results"
:item-title="(t) => t.fandom_name ? `${t.name} — ${t.fandom_name}` : t.name"
:item-value="(t) => t.id"
:loading="loading"
label="Canonical tag"
no-filter clearable density="compact"
@update:search="onSearch"
@keydown.enter.capture="onEnter"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="$emit('cancel')">Cancel</v-btn>
<v-btn
color="primary" rounded="pill" :disabled="!selectedId"
@click="$emit('confirm', selectedId)"
>Use this tag</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.js'
const props = defineProps({ category: { type: String, required: true } })
const emit = defineEmits(['confirm', 'cancel'])
const api = useApi()
const results = ref([])
const loading = ref(false)
const selectedId = ref(null)
let debounce = null
// Enter on the closed dropdown confirms the selection instead of re-opening it.
const { menuOpen, onEnter } = useAcceptOnEnter(() => {
if (selectedId.value != null) emit('confirm', selectedId.value)
})
function onSearch(q) {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(async () => {
const query = (q || '').trim()
if (!query) { results.value = []; return }
loading.value = true
try {
// Scope the autocomplete to the prediction's category where it
// maps to a tag kind. Only 'character' surfaces as both a
// suggestion category and a tag kind now ('artist' + 'copyright'
// retired); other categories search unscoped.
const kind = props.category === 'character' ? 'character' : null
const params = { q: query, limit: 20 }
if (kind) params.kind = kind
results.value = await api.get('/api/tags/autocomplete', { params })
} finally {
loading.value = false
}
}, 200)
}
</script>
@@ -11,10 +11,6 @@
{{ suggestion.display_name }}
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
title="You rejected this for this image — un-reject to recover">rejected</span>
<span v-else-if="suggestion.creates_new_tag" class="fc-suggestion__new"
title="No matching tag yet — accepting creates it">+ new</span>
<span v-else-if="suggestion.via_alias" class="fc-suggestion__alias"
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
</span>
<span class="fc-suggestion__score">{{ scorePct }}</span>
<!-- Green / red pair (operator-asked 2026-06-28) mirrors the eval
@@ -46,40 +42,14 @@
@click="$emit('dismiss', suggestion)"
><v-icon size="16">mdi-close</v-icon></button>
</div>
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
teleported image modal — #711). Only rendered when an alias action
applies — dismiss now lives on the red ✗, so a centroid hit with no
alias option has no menu. -->
<KebabMenu
v-if="hasMenu"
class="fc-suggestion__menu" size="small" variant="outlined"
:label="`More actions for ${suggestion.display_name}`"
>
<!-- Alias is a tagger-prediction remap, so only offer it for tagger
suggestions with a raw model key that aren't already aliased.
Centroid hits (raw_name null) have nothing to alias. -->
<v-list-item
v-if="suggestion.raw_name && !suggestion.via_alias"
@click="$emit('alias', suggestion)"
>
<v-list-item-title>Treat as alias for</v-list-item-title>
</v-list-item>
<v-list-item
v-if="suggestion.via_alias"
@click="$emit('remove-alias', suggestion)"
>
<v-list-item-title>Remove alias</v-list-item-title>
</v-list-item>
</KebabMenu>
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
defineEmits(['accept', 'dismiss', 'undismiss'])
// #1206: on hover, tell the image viewer which crop produced this suggestion so
// it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere.
@@ -92,12 +62,6 @@ function onLeave () {
}
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
// Kebab now only carries alias actions: show it when this suggestion can be
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
// un-aliased). Centroid hits (no raw_name, no alias) have an empty menu → hide.
const hasMenu = computed(() =>
Boolean(props.suggestion.raw_name) || Boolean(props.suggestion.via_alias)
)
</script>
<style scoped>
@@ -119,26 +83,6 @@ const hasMenu = computed(() =>
color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-suggestion__new {
display: inline-block;
font-size: 10px; font-weight: 600;
color: rgb(var(--v-theme-accent));
background: rgba(var(--v-theme-accent), 0.12);
border: 1px solid rgb(var(--v-theme-accent), 0.4);
padding: 1px 6px; border-radius: 999px;
margin-left: 6px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.fc-suggestion__alias {
display: inline-block;
font-size: 10px; font-weight: 600;
color: rgb(var(--v-theme-on-surface-variant));
background: rgb(var(--v-theme-surface-light));
border: 1px solid rgb(var(--v-theme-surface-light));
padding: 1px 6px; border-radius: 999px;
margin-left: 6px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.fc-suggestion__score {
flex: 0 0 auto; min-width: 38px; text-align: right;
font-size: 11px;
@@ -166,10 +110,6 @@ const hasMenu = computed(() =>
background: transparent; color: rgb(var(--v-theme-on-surface-variant));
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5);
}
.fc-suggestion__menu {
flex: 0 0 auto;
}
/* Rejected state: the row stays put (recovery), dimmed + red-edged so it
reads as "handled, negative" without shouting over live suggestions. */
.fc-suggestion--rejected {
@@ -26,8 +26,6 @@
v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
:suggestion="s"
@accept="$emit('accept', $event)"
@alias="$emit('alias', $event)"
@remove-alias="$emit('remove-alias', $event)"
@dismiss="$emit('dismiss', $event)"
@undismiss="$emit('undismiss', $event)"
/>
@@ -45,7 +43,7 @@ const props = defineProps({
collapsible: { type: Boolean, default: false },
defaultOpen: { type: Boolean, default: true }
})
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss', 'reject-all'])
defineEmits(['accept', 'dismiss', 'undismiss', 'reject-all'])
// Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length)
@@ -20,48 +20,39 @@
first, so false positives are easy to spot and reject (reject-to-train
for these heads). Small set collapsible, open by default. -->
<SuggestionsCategoryGroup
v-if="store.byCategory.system && store.byCategory.system.length"
label="System" :items="store.byCategory.system"
v-if="store.aboveByCategory.system && store.aboveByCategory.system.length"
label="System" :items="store.aboveByCategory.system"
collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@accept="onAccept"
@dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('system')"
/>
<SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat"
v-show="store.byCategory[cat] && store.byCategory[cat].length"
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
v-show="store.aboveByCategory[cat] && store.aboveByCategory[cat].length"
:label="labelFor(cat)" :items="store.aboveByCategory[cat] || []"
@accept="onAccept"
@dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll(cat)"
/>
<SuggestionsCategoryGroup
v-if="store.byCategory.general && store.byCategory.general.length"
label="General" :items="store.byCategory.general"
v-if="store.aboveByCategory.general && store.aboveByCategory.general.length"
label="General" :items="store.aboveByCategory.general"
collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@accept="onAccept"
@dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('general')"
/>
</div>
<v-dialog v-model="aliasDialog" max-width="480">
<AliasPickerDialog
v-if="aliasTarget"
:category="aliasTarget.category"
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
/>
</v-dialog>
</section>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref, watch } from 'vue'
import { computed, watch } from 'vue'
import { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
import { useModalStore } from '../../stores/modal.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
import AliasPickerDialog from './AliasPickerDialog.vue'
const props = defineProps({
imageId: { type: Number, required: true },
@@ -100,13 +91,14 @@ const peopleCats = ['character']
function labelFor(c) { return CATEGORY_LABELS[c] || c }
const isEmpty = computed(() =>
Object.values(store.byCategory).every(list => !list || list.length === 0)
Object.values(store.aboveByCategory).every(list => !list || list.length === 0)
)
watch(() => props.imageId, (id) => {
if (id == null) return
store.load(id) // panel: curated, ≥ threshold
store.loadAll(id) // dropdown: full prediction set (low-confidence included)
// One fetch (min=0) backs both surfaces: the panel reads aboveByCategory, the
// typed tag-input dropdown reads the full set. No second request.
store.load(id)
}, { immediate: true })
// After a successful accept/alias-accept, refresh the modal's current
@@ -125,31 +117,6 @@ async function onAccept(s) {
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
}
}
const aliasDialog = ref(false)
const aliasTarget = ref(null)
function onAlias(s) { aliasTarget.value = s; aliasDialog.value = true }
async function onAliasConfirm(canonicalTagId) {
try {
await store.aliasAccept(aliasTarget.value, canonicalTagId)
aliasDialog.value = false
await host.reloadTags()
emit('accepted')
} catch (e) {
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
}
}
// Undo the model-key→tag mapping behind an aliased suggestion. The store
// reloads suggestions so the prediction reverts to its raw form; the applied
// canonical tag (if any) stays, so no tag-rail reload is needed.
async function onRemoveAlias(s) {
try {
await store.removeAlias(s)
} catch (e) {
toast({ text: `Remove alias failed: ${e.message}`, type: 'error' })
}
}
</script>
<style scoped>
@@ -17,7 +17,14 @@
density="compact" class="fc-tag-autocomplete__list"
>
<template v-for="(row, idx) in rows" :key="row.key">
<!-- Existing tag match (server autocomplete). -->
<!-- One unified row per DB tag (server autocomplete). Every suggestion is
a canonical tag now, so when the model ALSO scored this tag for the
image the row just carries its confidence (🔧 %) in place of the
redundant kind label the coloured leading icon already shows kind.
That means a searched tag that's also a suggestion shows its % on ONE
row, with no dedup and no flicker. Picking it emits pick-existing;
TagPanel.findPending routes a matching suggestion through accept() so
the acceptance is still recorded + it drops from the panel. -->
<v-list-item
v-if="row.type === 'hit'"
:active="idx === highlight" @click="onPickRow(row)"
@@ -32,32 +39,18 @@
<span v-if="row.hit.fandom_name" class="text-caption"> {{ row.hit.fandom_name }}</span>
</v-list-item-title>
<template #append>
<span class="text-caption">{{ row.hit.kind }}</span>
</template>
</v-list-item>
<!-- ML suggestion for THIS image that matches the typed query. Picking
it routes through the same accept path as the Suggestions panel, so
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
<v-list-item
v-else-if="row.type === 'suggestion'"
:active="idx === highlight" @click="onPickRow(row)"
class="fc-tag-autocomplete__sugg"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
{{ iconFor(row.sugg.category) }}
</v-icon>
</template>
<v-list-item-title>
{{ row.sugg.display_name }}
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
</v-list-item-title>
<template #append>
<span class="fc-tag-autocomplete__sugg-tag">
<span
v-if="row.sugg"
class="fc-tag-autocomplete__sugg-tag"
:class="{ 'fc-tag-autocomplete__sugg-tag--below': !row.sugg.above_threshold }"
:title="row.sugg.above_threshold
? 'The model suggests this tag for this image'
: 'The model scored this tag below its suggest threshold'"
>
<v-icon size="x-small">mdi-auto-fix</v-icon>
{{ scorePct(row.sugg) }}
</span>
<span v-else class="text-caption">{{ row.hit.kind }}</span>
</template>
</v-list-item>
@@ -100,11 +93,10 @@ import { useSuggestionsStore } from '../../stores/suggestions.js'
import { useInflightToken } from '../../composables/useInflightToken.js'
import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
// The host surface's applied tags (host.current?.tags). Both dropdown sections
// filter against this so a just-added tag drops out of the search the moment
// the chip rail updates, not after the next modal refresh (operator-asked
// 2026-07-03).
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
// The host surface's applied tags (host.current?.tags). The dropdown filters
// against this so a just-added tag drops out of the search the moment the chip
// rail updates, not after the next modal refresh (operator-asked 2026-07-03).
const props = defineProps({
appliedTags: { type: Array, default: () => [] },
})
@@ -238,9 +230,6 @@ const createLabel = computed(() =>
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))
const appliedNames = computed(() =>
new Set(props.appliedTags.map(t => `${t.kind}:${(t.name || '').toLowerCase()}`)),
)
// Server matches minus the image's applied tags. allowCreate/sameNameCharExists
// keep reading the RAW hits — they reason about the tag universe (does this tag
@@ -249,49 +238,29 @@ const visibleHits = computed(() =>
hits.value.filter(h => !appliedIds.value.has(h.id)),
)
// This image's suggestions that match the typed query, minus any the server
// autocomplete already returned (same name+kind) so a tag never shows twice.
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
// the threshold-filtered panel list — so a low-confidence action/feature the
// model saw can be typed and accepted in canonical formatting instead of being
// hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is
// the only filter; the threshold no longer hides anything here.
const suggestionHits = computed(() => {
const q = parsedName.value.toLowerCase()
if (!q) return []
const seen = new Set(visibleHits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
const out = []
for (const list of Object.values(suggestions.allByCategory)) {
// This image's suggestions keyed by canonical tag id, so a matching DB-tag row
// can show the model's confidence inline. Every suggestion is a canonical tag
// now (tagging-v2), so the id is the join key — no name/kind matching, no dedup,
// no flicker. Rejected suggestions are excluded: a dismissed tag shouldn't
// advertise a score in the type-to-add dropdown (un-reject lives in the panel).
const suggByTagId = computed(() => {
const m = new Map()
for (const list of Object.values(suggestions.byCategory)) {
for (const s of list || []) {
// Rejected suggestions now stay in allByCategory (flagged) so the panel
// can show + un-reject them; keep them OUT of the type-to-add dropdown,
// whose job is finding a tag to ADD (un-reject lives in the panel).
if (s.rejected) continue
// Already on the image (matched by canonical id or name+kind): nothing
// to add. Covers the window between an add and the next suggestions
// fetch, where the stale row would otherwise still be pickable.
if (s.canonical_tag_id != null && appliedIds.value.has(s.canonical_tag_id)) continue
const key = `${s.category}:${s.display_name.toLowerCase()}`
if (appliedNames.value.has(key)) continue
if (!s.display_name.toLowerCase().includes(q)) continue
if (seen.has(key)) continue
seen.add(key)
out.push(s)
if (s.canonical_tag_id != null) m.set(s.canonical_tag_id, s)
}
}
// Best matches first; cap generously so a specific typed query surfaces its
// matches even when many predictions exist, while the list stays scrollable.
out.sort((a, b) => b.score - a.score)
return out.slice(0, 20)
return m
})
// One ordered list backing both the rendered dropdown and keyboard nav, so the
// highlight index maps 1:1 to a row regardless of which section it's in.
// highlight index maps 1:1 to a row. Each hit is annotated with its suggestion
// (score) when the model scored that tag for this image.
const rows = computed(() => {
const r = visibleHits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
for (const s of suggestionHits.value) {
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
}
const r = visibleHits.value.map(h => ({
type: 'hit', key: `h${h.id}`, hit: h, sugg: suggByTagId.value.get(h.id) || null,
}))
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
return r
})
@@ -313,16 +282,9 @@ function moveHighlight (delta) {
// Dispatch a chosen dropdown row by its type.
function onPickRow (row) {
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
else { onCreate() }
}
// Picking an ML suggestion hands it to the parent, which runs the same accept
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
// drops it from the panel). reset() clears the query; the panel-drop makes it
// fall out of suggestionHits reactively.
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
function onCreate () {
const name = parsedName.value
const kind = parsedKind.value
@@ -389,14 +351,16 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px;
}
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
.fc-tag-autocomplete__sugg {
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
}
/* The model-confidence badge on a row whose tag the model also scored. Accent
when above the head's suggest threshold; muted when below (a low-confidence
match you can still type + pick). */
.fc-tag-autocomplete__sugg-tag {
display: inline-flex; align-items: center; gap: 2px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: rgb(var(--v-theme-accent));
}
.fc-tag-autocomplete__sugg-tag--below {
color: rgb(var(--v-theme-on-surface-variant));
}
</style>
@@ -17,7 +17,6 @@
ref="tagInputRef"
:applied-tags="host.current?.tags || []"
@pick-existing="onPickExisting" @pick-new="onPickNew"
@accept-suggestion="onAcceptSuggestion"
/>
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
@@ -134,7 +133,6 @@ async function onRemove(tagId) {
// until the next modal open (operator-asked 2026-07-03).
if (host.currentImageId != null) {
suggestions.load(host.currentImageId)
suggestions.loadAll(host.currentImageId)
}
focusTagInput()
}
@@ -178,18 +176,6 @@ async function onPickNew(payload) {
}
catch (e) { errorMsg.value = e.message }
}
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
// acceptance, and drops it from the panel; then we refresh the chip rail.
async function onAcceptSuggestion(s) {
errorMsg.value = null
try {
await suggestions.accept(s)
await host.reloadTags()
focusTagInput()
} catch (e) { errorMsg.value = e.message }
}
const renameDialog = ref(false)
const renameTarget = ref(null)
+73 -152
View File
@@ -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
}
})
+36 -27
View File
@@ -16,25 +16,44 @@ function stubFetch(handler) {
})
}
// Every suggestion is a canonical DB tag now (tagging-v2): a real id, flagged
// above/below its head's suggest threshold. No raw / creates-new / alias cases.
const sugg = (over = {}) => ({
canonical_tag_id: 7,
display_name: 'Ichigo',
category: 'character',
score: 0.9,
source: 'head',
creates_new_tag: false,
above_threshold: true,
rejected: false,
...over,
})
// Seed the store through its own load/loadAll so currentImageId is set the
// way the panel sets it (accept() derives the image from it).
// Seed the store through its single load() so currentImageId is set the way the
// panel sets it (accept() derives the image from it).
async function seed(store, byCategory) {
stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
await store.load(1)
await store.loadAll(1)
}
describe('suggestions store: load derives aboveByCategory', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('one fetch backs both surfaces: byCategory is the full set, aboveByCategory the panel subset', async () => {
const s = useSuggestionsStore()
await seed(s, {
general: [
sugg({ canonical_tag_id: 1, display_name: 'sword', category: 'general' }),
sugg({ canonical_tag_id: 2, display_name: 'faint', category: 'general', above_threshold: false }),
],
})
expect(s.byCategory.general).toHaveLength(2) // dropdown sees all
expect(s.aboveByCategory.general).toHaveLength(1) // panel sees above-threshold only
expect(s.aboveByCategory.general[0].display_name).toBe('sword')
})
})
describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
@@ -45,15 +64,12 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo')
})
it('matches raw suggestions by kind + name, case-insensitively', async () => {
it('matches by kind + name, case-insensitively', async () => {
const s = useSuggestionsStore()
await seed(s, {
general: [sugg({
canonical_tag_id: null, display_name: 'Holding Sword',
category: 'general', creates_new_tag: true,
})],
general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })],
})
expect(s.findPending('general', 'holding sword')).toBeTruthy()
expect(s.findPending('general', 'holding sword')?.canonical_tag_id).toBe(12)
// Kind must match: same name under another kind is a different tag.
expect(s.findPending('character', 'holding sword')).toBeNull()
})
@@ -66,37 +82,31 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', ()
})
})
describe('suggestions store: accept with a known tag id', () => {
describe('suggestions store: accept', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('POSTs only the accept endpoint (no tag create) and drops the row', async () => {
it('POSTs only the accept endpoint (canonical id) and drops the row', async () => {
const s = useSuggestionsStore()
await seed(s, { character: [sugg()] })
const calls = []
stubFetch((url, init) => {
calls.push({ url, method: init?.method })
calls.push({ url, method: init?.method, body: init?.body })
return { status: 200, body: { accepted: true, tag_id: 7 } }
})
await s.accept(s.byCategory.character[0], { tagId: 7 })
await s.accept(s.byCategory.character[0])
expect(calls).toHaveLength(1)
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 7 })
expect(s.byCategory.character).toHaveLength(0)
expect(s.allByCategory.character).toHaveLength(0)
})
it('a RAW suggestion accepted with a known tagId skips create AND still drops its row', async () => {
// TagPanel's manual-create path: the tag was just created by
// host.createAndAdd, so accept must not create again — and the drop must
// key off the original raw suggestion object, not the resolved id
// (regression: spreading canonical_tag_id onto the suggestion changed its
// identity key and left the panel row behind).
it('honors a known tagId and still drops the row by the suggestion identity', async () => {
// TagPanel's manual-add-matches-suggestion path passes the resolved tag id;
// accept sends exactly it and drops the original suggestion row (which keys
// off the suggestion object, not the passed id).
const s = useSuggestionsStore()
const raw = sugg({
canonical_tag_id: null, display_name: 'Holding Sword',
category: 'general', creates_new_tag: true,
})
await seed(s, { general: [raw] })
await seed(s, { general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })] })
const calls = []
stubFetch((url, init) => {
calls.push({ url, body: init?.body })
@@ -107,6 +117,5 @@ describe('suggestions store: accept with a known tag id', () => {
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 })
expect(s.byCategory.general).toHaveLength(0)
expect(s.allByCategory.general).toHaveLength(0)
})
})
+1 -9
View File
@@ -52,6 +52,7 @@ async def test_get_suggestions(client, db):
general = body["by_category"].get("general", [])
s2 = next(x for x in general if x["canonical_tag_id"] == tag.id)
assert s2["source"] == "head"
assert s2["above_threshold"] is True # ~0.73 clears the 0.5 suggest cut
@pytest.mark.asyncio
@@ -114,12 +115,3 @@ async def test_undismiss_reverses_rejection(client, db):
f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
)
assert resp2.status_code == 204
@pytest.mark.asyncio
async def test_alias_requires_fields(client, db):
img = await _img(db)
resp = await client.post(
f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"}
)
assert resp.status_code == 400
+6 -3
View File
@@ -62,9 +62,8 @@ async def test_head_suggestion_surfaces_for_matching_image(db):
s = general[0]
assert s.canonical_tag_id == tag.id
assert s.source == "head"
assert s.creates_new_tag is False
assert s.via_alias is False and s.raw_name is None
assert s.score > 0.5
assert s.above_threshold is True # ~0.73 clears the 0.5 suggest cut
@pytest.mark.asyncio
@@ -109,7 +108,10 @@ async def test_threshold_override_surfaces_below_cut(db):
svc = SuggestionService(db)
assert svc and not (await svc.for_image(img.id)).by_category.get("general")
flooded = await svc.for_image(img.id, threshold_override=0.0)
assert any(s.canonical_tag_id == tag.id for s in flooded.by_category["general"])
s = next(s for s in flooded.by_category["general"] if s.canonical_tag_id == tag.id)
# Included by the floor, but flagged below its own cut (0.5 < 0.6) — this is
# what lets the dropdown show it while the panel hides it.
assert s.above_threshold is False
@pytest.mark.asyncio
@@ -281,6 +283,7 @@ async def test_ccip_character_surfaces_in_rail(db):
if c.canonical_tag_id == raven.id
)
assert m.source == "ccip"
assert m.above_threshold is True # CCIP only returns matches above its cut
# --- #1206 Step 4: on-demand grounding for ALREADY-APPLIED tag chips ---------