Merge pull request 'refactor(tags): unify suggestion source — one canonical DB-tag dropdown (#154)' (#209) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m44s

This commit was merged in pull request #209.
This commit is contained in:
2026-07-10 15:52:44 -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"]) @suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
async def get_suggestions(image_id: int): async def get_suggestions(image_id: int):
# ?min=<float> overrides the configured per-category thresholds so the typed # ?min=<float> overrides the per-head suggest thresholds for INCLUSION. The
# tag-input dropdown can surface EVERY stored prediction (min=0), including # rail sends min=0 in its single per-image fetch to get EVERY head (each row
# low-confidence actions/features, in canonical formatting. Omitted → the # still carries above_threshold vs its natural cut), then derives the panel
# curated above-threshold list the Suggestions panel uses. # (above_threshold) and the typed dropdown (all, filtered by text) client-side
# — no second request. Omitted → only above-threshold rows.
override = None override = None
raw_min = request.args.get("min") raw_min = request.args.get("min")
if raw_min is not None: if raw_min is not None:
@@ -36,12 +37,11 @@ async def get_suggestions(image_id: int):
"category": s.category, "category": s.category,
"score": round(s.score, 4), "score": round(s.score, 4),
"source": s.source, "source": s.source,
"creates_new_tag": s.creates_new_tag, # whether the score cleared the head's own suggest cut.
# raw model key (alias is stored under this) + whether an # The single min=0 fetch returns every head; the panel
# operator alias produced this suggestion — drive the # shows above_threshold, the typed dropdown shows all and
# modal's "Treat as alias"/"Remove alias" affordances. # annotates each match with its score.
"raw_name": s.raw_name, "above_threshold": s.above_threshold,
"via_alias": s.via_alias,
# operator dismissed this tag for this image — surfaced # operator dismissed this tag for this image — surfaced
# (not dropped) so the rail can show it rejected + offer # (not dropped) so the rail can show it rejected + offer
# one-click un-reject. # one-click un-reject.
@@ -73,26 +73,6 @@ async def accept_suggestion(image_id: int):
return jsonify({"accepted": True, "tag_id": tag_id}) 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( @suggestions_bp.route(
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"] "/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 import TagSuggestionRejection
from ...models.tag import image_tag from ...models.tag import image_tag
from .aliases import AliasService
class AllowlistService: class AllowlistService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
self.aliases = AliasService(session)
async def _apply_image_tag(self, image_id: int, tag_id: int, source: str): async def _apply_image_tag(self, image_id: int, tag_id: int, source: str):
stmt = insert(image_tag).values( 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._apply_image_tag(image_id, tag_id, source="ml_accepted")
await self._clear_rejection(image_id, tag_id) 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: async def dismiss(self, image_id: int, tag_id: int) -> None:
stmt = insert(TagSuggestionRejection).values( stmt = insert(TagSuggestionRejection).values(
image_record_id=image_id, tag_id=tag_id 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, session: AsyncSession, image_id: int, threshold_override: float | None = None,
) -> list[dict]: ) -> list[dict]:
"""Suggestions for one image from the trained heads: [{tag_id, name, """Suggestions for one image from the trained heads: [{tag_id, name,
category, score}], ranked. A concept surfaces when its score clears the category, score, above_threshold, grounding}], ranked. A concept is INCLUDED
head's own suggest_threshold — or, when threshold_override is given (the when its score clears the head's own suggest_threshold — or, when
typed-dropdown "show everything" mode), that flat floor instead (0 → every threshold_override is given (the typed-dropdown "show everything" mode), that
head). System-tag heads (wip/banner/editor) instead use a flat flat floor instead (0 → every head). System-tag heads (wip/banner/editor)
_SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface for rejection instead use a flat _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface
(still overridden by threshold_override). Empty if the image has no for rejection (still overridden by threshold_override). Empty if the image has
embedding or no heads exist yet. 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 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 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,) winners = probs_bag.argmax(axis=0) # (H,)
out = [] out = []
for i, p in enumerate(probs): for i, p in enumerate(probs):
if threshold_override is not None: m = heads["meta"][i]
cut = threshold_override # The head's NATURAL suggest cut — system tags use the flat floor (see
elif heads["meta"][i]["is_system"]: # _SYSTEM_TAG_SUGGEST_FLOOR) so their false positives show up for the
# System tags surface at the flat floor (see _SYSTEM_TAG_SUGGEST_FLOOR) # operator to reject; content heads use their own precision-tuned
# so their false positives show up for the operator to reject. # threshold. This is what "above threshold" means (drives the panel).
cut = _SYSTEM_TAG_SUGGEST_FLOOR natural = (
else: _SYSTEM_TAG_SUGGEST_FLOOR if m["is_system"] else float(heads["thr"][i])
cut = 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: if p >= cut:
m = heads["meta"][i]
out.append({ out.append({
"tag_id": m["tag_id"], "tag_id": m["tag_id"],
"name": m["name"], "name": m["name"],
"category": m["category"], "category": m["category"],
"score": float(p), "score": float(p),
"above_threshold": bool(p >= natural),
"grounding": bag_meta[int(winners[i])], "grounding": bag_meta[int(winners[i])],
}) })
out.sort(key=lambda d: d["score"], reverse=True) 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) @dataclass(frozen=True)
class Suggestion: class Suggestion:
# canonical_tag_id is None when this is a raw Camie tag with no alias and # Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept
# no existing Tag row — accepting it will create the tag. # tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap
canonical_tag_id: int | None # cases are gone — a suggestion always maps to a real tag id.
canonical_tag_id: int
display_name: str display_name: str
category: str category: str
score: float score: float
source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2) source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
creates_new_tag: bool # above_threshold = the score cleared the head's own suggest cut (or the
# raw_name = the booru model vocab key behind this suggestion. It's the key # system floor). The Suggestions PANEL shows only these; the typed-tag
# an alias MUST be stored under (resolution looks up the raw key), so the # dropdown fetches ALL suggestions (every head, min=0) and just annotates each
# modal needs it to author an alias correctly. None for centroid-only hits # matching row with its score, so a low-confidence concept can still be typed
# (no underlying prediction → nothing to alias). # and picked. CCIP character matches are always above their match threshold.
raw_name: str | None = None above_threshold: bool
# 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
# rejected = the operator dismissed this tag for this image (a stored # rejected = the operator dismissed this tag for this image (a stored
# TagSuggestionRejection). It stays in the list — flagged, not dropped — so # TagSuggestionRejection). It stays in the list — flagged, not dropped — so
# the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery, # the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
@@ -108,6 +106,7 @@ class SuggestionService:
for h in hits: for h in hits:
merged[(h["category"], h["tag_id"])] = { merged[(h["category"], h["tag_id"])] = {
"name": h["name"], "score": h["score"], "source": "head", "name": h["name"], "score": h["score"], "source": "head",
"above_threshold": h["above_threshold"],
"grounding": h.get("grounding"), "grounding": h.get("grounding"),
} }
for c in ccip_hits: for c in ccip_hits:
@@ -116,12 +115,16 @@ class SuggestionService:
if ex is not None: if ex is not None:
ex["source"] = "both" ex["source"] = "both"
ex["score"] = max(ex["score"], c["score"]) 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 # Keep the head's localized crop if it had one; else fall back to
# the CCIP figure so a corroborated character still grounds (#1206). # the CCIP figure so a corroborated character still grounds (#1206).
ex["grounding"] = ex.get("grounding") or c.get("grounding") ex["grounding"] = ex.get("grounding") or c.get("grounding")
else: else:
merged[key] = { merged[key] = {
"name": c["name"], "score": c["score"], "source": "ccip", "name": c["name"], "score": c["score"], "source": "ccip",
"above_threshold": True,
"grounding": c.get("grounding"), "grounding": c.get("grounding"),
} }
@@ -136,7 +139,7 @@ class SuggestionService:
category=cat, category=cat,
score=m["score"], score=m["score"],
source=m["source"], source=m["source"],
creates_new_tag=False, above_threshold=m["above_threshold"],
rejected=tag_id in rejected, rejected=tag_id in rejected,
grounding=m.get("grounding"), grounding=m.get("grounding"),
) )
@@ -157,8 +160,7 @@ class SuggestionService:
was suggested for (or already applied to) >= threshold fraction of was suggested for (or already applied to) >= threshold fraction of
the selection AND was acceptable on >= 1 image. Confidence is the the selection AND was acceptable on >= 1 image. Confidence is the
mean over images where it was suggested. Aggregated by mean over images where it was suggested. Aggregated by
canonical_tag_id; creates-new (no canonical id) suggestions are canonical_tag_id (every suggestion is a canonical tag now)."""
skipped (bulk Accept applies by tag id)."""
if not image_ids: if not image_ids:
return {} return {}
threshold = min(1.0, max(0.0, threshold)) threshold = min(1.0, max(0.0, threshold))
@@ -169,8 +171,6 @@ class SuggestionService:
sl = await self.for_image(image_id) sl = await self.for_image(image_id)
for category, items in sl.by_category.items(): for category, items in sl.by_category.items():
for s in 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; # for_image keeps rejected tags (flagged) for the rail;
# bulk consensus must still ignore them — a tag dismissed on # bulk consensus must still ignore them — a tag dismissed on
# an image isn't a suggestion for that image. # 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 }} {{ suggestion.display_name }}
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag" <span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
title="You rejected this for this image — un-reject to recover">rejected</span> 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>
<span class="fc-suggestion__score">{{ scorePct }}</span> <span class="fc-suggestion__score">{{ scorePct }}</span>
<!-- Green / red pair (operator-asked 2026-06-28) mirrors the eval <!-- Green / red pair (operator-asked 2026-06-28) mirrors the eval
@@ -46,40 +42,14 @@
@click="$emit('dismiss', suggestion)" @click="$emit('dismiss', suggestion)"
><v-icon size="16">mdi-close</v-icon></button> ><v-icon size="16">mdi-close</v-icon></button>
</div> </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> </div>
</template> </template>
<script setup> <script setup>
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ suggestion: { type: Object, required: true } }) 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 // #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. // 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)}%`) 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> </script>
<style scoped> <style scoped>
@@ -119,26 +83,6 @@ const hasMenu = computed(() =>
color: rgb(var(--v-theme-on-surface)); color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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 { .fc-suggestion__score {
flex: 0 0 auto; min-width: 38px; text-align: right; flex: 0 0 auto; min-width: 38px; text-align: right;
font-size: 11px; font-size: 11px;
@@ -166,10 +110,6 @@ const hasMenu = computed(() =>
background: transparent; color: rgb(var(--v-theme-on-surface-variant)); background: transparent; color: rgb(var(--v-theme-on-surface-variant));
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5); 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 /* Rejected state: the row stays put (recovery), dimmed + red-edged so it
reads as "handled, negative" without shouting over live suggestions. */ reads as "handled, negative" without shouting over live suggestions. */
.fc-suggestion--rejected { .fc-suggestion--rejected {
@@ -26,8 +26,6 @@
v-for="(s, i) in items" :key="`${s.display_name}-${i}`" v-for="(s, i) in items" :key="`${s.display_name}-${i}`"
:suggestion="s" :suggestion="s"
@accept="$emit('accept', $event)" @accept="$emit('accept', $event)"
@alias="$emit('alias', $event)"
@remove-alias="$emit('remove-alias', $event)"
@dismiss="$emit('dismiss', $event)" @dismiss="$emit('dismiss', $event)"
@undismiss="$emit('undismiss', $event)" @undismiss="$emit('undismiss', $event)"
/> />
@@ -45,7 +43,7 @@ const props = defineProps({
collapsible: { type: Boolean, default: false }, collapsible: { type: Boolean, default: false },
defaultOpen: { type: Boolean, default: true } 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. // Still-unhandled suggestions (not yet rejected) — how many "Reject rest" clears.
const rejectableCount = computed(() => props.items.filter((s) => !s.rejected).length) 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 first, so false positives are easy to spot and reject (reject-to-train
for these heads). Small set collapsible, open by default. --> for these heads). Small set collapsible, open by default. -->
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-if="store.byCategory.system && store.byCategory.system.length" v-if="store.aboveByCategory.system && store.aboveByCategory.system.length"
label="System" :items="store.byCategory.system" label="System" :items="store.aboveByCategory.system"
collapsible :default-open="true" collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias" @accept="onAccept"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('system')" @reject-all="onRejectAll('system')"
/> />
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-for="cat in peopleCats" :key="cat" v-for="cat in peopleCats" :key="cat"
v-show="store.byCategory[cat] && store.byCategory[cat].length" v-show="store.aboveByCategory[cat] && store.aboveByCategory[cat].length"
:label="labelFor(cat)" :items="store.byCategory[cat] || []" :label="labelFor(cat)" :items="store.aboveByCategory[cat] || []"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias" @accept="onAccept"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll(cat)" @reject-all="onRejectAll(cat)"
/> />
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-if="store.byCategory.general && store.byCategory.general.length" v-if="store.aboveByCategory.general && store.aboveByCategory.general.length"
label="General" :items="store.byCategory.general" label="General" :items="store.aboveByCategory.general"
collapsible :default-open="true" collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias" @accept="onAccept"
@dismiss="onDismiss" @undismiss="onUndismiss" @dismiss="onDismiss" @undismiss="onUndismiss"
@reject-all="onRejectAll('general')" @reject-all="onRejectAll('general')"
/> />
</div> </div>
<v-dialog v-model="aliasDialog" max-width="480">
<AliasPickerDialog
v-if="aliasTarget"
:category="aliasTarget.category"
@confirm="onAliasConfirm" @cancel="aliasDialog = false"
/>
</v-dialog>
</section> </section>
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js' 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 { useSuggestionsStore, CATEGORY_LABELS } from '../../stores/suggestions.js'
import { useModalStore } from '../../stores/modal.js' import { useModalStore } from '../../stores/modal.js'
import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue' import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
import AliasPickerDialog from './AliasPickerDialog.vue'
const props = defineProps({ const props = defineProps({
imageId: { type: Number, required: true }, imageId: { type: Number, required: true },
@@ -100,13 +91,14 @@ const peopleCats = ['character']
function labelFor(c) { return CATEGORY_LABELS[c] || c } function labelFor(c) { return CATEGORY_LABELS[c] || c }
const isEmpty = computed(() => 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) => { watch(() => props.imageId, (id) => {
if (id == null) return if (id == null) return
store.load(id) // panel: curated, ≥ threshold // One fetch (min=0) backs both surfaces: the panel reads aboveByCategory, the
store.loadAll(id) // dropdown: full prediction set (low-confidence included) // typed tag-input dropdown reads the full set. No second request.
store.load(id)
}, { immediate: true }) }, { immediate: true })
// After a successful accept/alias-accept, refresh the modal's current // 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' }) 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> </script>
<style scoped> <style scoped>
@@ -17,7 +17,14 @@
density="compact" class="fc-tag-autocomplete__list" density="compact" class="fc-tag-autocomplete__list"
> >
<template v-for="(row, idx) in rows" :key="row.key"> <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-list-item
v-if="row.type === 'hit'" v-if="row.type === 'hit'"
:active="idx === highlight" @click="onPickRow(row)" :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> <span v-if="row.hit.fandom_name" class="text-caption"> {{ row.hit.fandom_name }}</span>
</v-list-item-title> </v-list-item-title>
<template #append> <template #append>
<span class="text-caption">{{ row.hit.kind }}</span> <span
</template> v-if="row.sugg"
</v-list-item> class="fc-tag-autocomplete__sugg-tag"
:class="{ 'fc-tag-autocomplete__sugg-tag--below': !row.sugg.above_threshold }"
<!-- ML suggestion for THIS image that matches the typed query. Picking :title="row.sugg.above_threshold
it routes through the same accept path as the Suggestions panel, so ? 'The model suggests this tag for this image'
it's recorded + drops out of the panel (operator-asked 2026-06-07). --> : 'The model scored this tag below its suggest threshold'"
<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">
<v-icon size="x-small">mdi-auto-fix</v-icon> <v-icon size="x-small">mdi-auto-fix</v-icon>
{{ scorePct(row.sugg) }} {{ scorePct(row.sugg) }}
</span> </span>
<span v-else class="text-caption">{{ row.hit.kind }}</span>
</template> </template>
</v-list-item> </v-list-item>
@@ -100,11 +93,10 @@ import { useSuggestionsStore } from '../../stores/suggestions.js'
import { useInflightToken } from '../../composables/useInflightToken.js' import { useInflightToken } from '../../composables/useInflightToken.js'
import FandomPicker from './FandomPicker.vue' import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel']) const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
// The host surface's applied tags (host.current?.tags). Both dropdown sections // The host surface's applied tags (host.current?.tags). The dropdown filters
// filter against this so a just-added tag drops out of the search the moment // against this so a just-added tag drops out of the search the moment the chip
// the chip rail updates, not after the next modal refresh (operator-asked // rail updates, not after the next modal refresh (operator-asked 2026-07-03).
// 2026-07-03).
const props = defineProps({ const props = defineProps({
appliedTags: { type: Array, default: () => [] }, appliedTags: { type: Array, default: () => [] },
}) })
@@ -238,9 +230,6 @@ const createLabel = computed(() =>
function scorePct (s) { return `${Math.round(s.score * 100)}%` } function scorePct (s) { return `${Math.round(s.score * 100)}%` }
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id))) 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 // Server matches minus the image's applied tags. allowCreate/sameNameCharExists
// keep reading the RAW hits — they reason about the tag universe (does this tag // 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)), hits.value.filter(h => !appliedIds.value.has(h.id)),
) )
// This image's suggestions that match the typed query, minus any the server // This image's suggestions keyed by canonical tag id, so a matching DB-tag row
// autocomplete already returned (same name+kind) so a tag never shows twice. // can show the model's confidence inline. Every suggestion is a canonical tag
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT // now (tagging-v2), so the id is the join key — no name/kind matching, no dedup,
// the threshold-filtered panel list — so a low-confidence action/feature the // no flicker. Rejected suggestions are excluded: a dismissed tag shouldn't
// model saw can be typed and accepted in canonical formatting instead of being // advertise a score in the type-to-add dropdown (un-reject lives in the panel).
// hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is const suggByTagId = computed(() => {
// the only filter; the threshold no longer hides anything here. const m = new Map()
const suggestionHits = computed(() => { for (const list of Object.values(suggestions.byCategory)) {
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)) {
for (const s of list || []) { 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 if (s.rejected) continue
// Already on the image (matched by canonical id or name+kind): nothing if (s.canonical_tag_id != null) m.set(s.canonical_tag_id, s)
// 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)
} }
} }
// Best matches first; cap generously so a specific typed query surfaces its return m
// matches even when many predictions exist, while the list stays scrollable.
out.sort((a, b) => b.score - a.score)
return out.slice(0, 20)
}) })
// One ordered list backing both the rendered dropdown and keyboard nav, so the // 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 rows = computed(() => {
const r = visibleHits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h })) const r = visibleHits.value.map(h => ({
for (const s of suggestionHits.value) { type: 'hit', key: `h${h.id}`, hit: h, sugg: suggByTagId.value.get(h.id) || null,
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s }) }))
}
if (allowCreate.value) r.push({ type: 'create', key: 'create' }) if (allowCreate.value) r.push({ type: 'create', key: 'create' })
return r return r
}) })
@@ -313,16 +282,9 @@ function moveHighlight (delta) {
// Dispatch a chosen dropdown row by its type. // Dispatch a chosen dropdown row by its type.
function onPickRow (row) { function onPickRow (row) {
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() } if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
else { onCreate() } 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 () { function onCreate () {
const name = parsedName.value const name = parsedName.value
const kind = parsedKind.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: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; border-radius: 6px;
} }
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */ /* The model-confidence badge on a row whose tag the model also scored. Accent
.fc-tag-autocomplete__sugg { when above the head's suggest threshold; muted when below (a low-confidence
border-left: 2px solid rgb(var(--v-theme-accent), 0.5); match you can still type + pick). */
}
.fc-tag-autocomplete__sugg-tag { .fc-tag-autocomplete__sugg-tag {
display: inline-flex; align-items: center; gap: 2px; display: inline-flex; align-items: center; gap: 2px;
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
color: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-accent));
} }
.fc-tag-autocomplete__sugg-tag--below {
color: rgb(var(--v-theme-on-surface-variant));
}
</style> </style>
@@ -17,7 +17,6 @@
ref="tagInputRef" ref="tagInputRef"
:applied-tags="host.current?.tags || []" :applied-tags="host.current?.tags || []"
@pick-existing="onPickExisting" @pick-new="onPickNew" @pick-existing="onPickExisting" @pick-new="onPickNew"
@accept-suggestion="onAcceptSuggestion"
/> />
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable> <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). // until the next modal open (operator-asked 2026-07-03).
if (host.currentImageId != null) { if (host.currentImageId != null) {
suggestions.load(host.currentImageId) suggestions.load(host.currentImageId)
suggestions.loadAll(host.currentImageId)
} }
focusTagInput() focusTagInput()
} }
@@ -178,18 +176,6 @@ async function onPickNew(payload) {
} }
catch (e) { errorMsg.value = e.message } 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 renameDialog = ref(false)
const renameTarget = ref(null) const renameTarget = ref(null)
+73 -152
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js' import { toast } from '../utils/toast.js'
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js' import { useInflightToken } from '../composables/useInflightToken.js'
@@ -18,182 +18,112 @@ export const CATEGORY_LABELS = {
export const useSuggestionsStore = defineStore('suggestions', () => { export const useSuggestionsStore = defineStore('suggestions', () => {
const api = useApi() const api = useApi()
const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold) // ONE source of truth per image: every trained head scored for this image
// The typed tag-input dropdown searches the model's FULL prediction set for // (min=0), each row carrying above_threshold. The Suggestions PANEL renders
// the image (min=0, down to the store floor) so low-confidence actions/ // aboveByCategory; the typed tag-input dropdown reads the full set and
// features can be picked in canonical formatting (operator-asked 2026-06-09). // annotates matching DB-tag rows with their score. A single fetch backs both —
const allByCategory = ref({}) // 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' }) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
let currentImageId = null let currentImageId = null
const inflightAll = useInflightToken() // Audit 2026-06-02: without an inflight guard a late /suggestions response from
// Audit 2026-06-02: this store had no inflight guard — a late // a prior image could overwrite byCategory while currentImageId points at a new
// /suggestions response from a prior image could overwrite // one, and accept() could apply image A's tag to image B. Both guarded below by
// byCategory while currentImageId pointed at a new one, and // capturing imageId at call-time and gating writes on the token.
// 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() 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) { async function load(imageId) {
// Cancel any in-flight load from the previous image so its late // Cancel any in-flight load from the previous image so its late response
// response can't overwrite this image's byCategory. // can't overwrite this image's list.
inflight.cancel() inflight.cancel()
currentImageId = imageId currentImageId = imageId
byCategory.value = {} // cleared upfront so it stays empty on error byCategory.value = {} // cleared upfront so it stays empty on error
const t = inflight.claim() const t = inflight.claim()
await run(async () => { 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 if (!t.isCurrent()) return
byCategory.value = body.by_category || {} byCategory.value = body.by_category || {}
}) })
} }
// Load the full prediction set for the dropdown (separate inflight guard so a // A stable identity for a suggestion across the panel + dropdown surfaces:
// late response from a prior image can't overwrite the current one's list). // its canonical tag id (every suggestion has one now).
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) { function _keyOf(s) {
return s.canonical_tag_id != null return `id:${s.canonical_tag_id}`
? `id:${s.canonical_tag_id}`
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
} }
// Drop an accepted/dismissed suggestion from BOTH the panel list and the // Drop an accepted suggestion from the list so it can't reappear.
// dropdown's full list so it can't reappear in either surface.
function _dropEverywhere(suggestion) { function _dropEverywhere(suggestion) {
const key = _keyOf(suggestion) const key = _keyOf(suggestion)
const cat = suggestion.category const list = byCategory.value[suggestion.category]
for (const map of [byCategory.value, allByCategory.value]) { if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key)
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, // Flip the `rejected` flag on the matching suggestion in place, so a
// so a reject/un-reject shows immediately without dropping the row (visible, // reject/un-reject shows immediately without dropping the row (visible,
// reversible rejection — misclick recovery, operator-asked 2026-06-27). // reversible rejection — misclick recovery, operator-asked 2026-06-27).
function _setRejectedEverywhere(suggestion, value) { function _setRejectedEverywhere(suggestion, value) {
const key = _keyOf(suggestion) const key = _keyOf(suggestion)
const cat = suggestion.category for (const s of byCategory.value[suggestion.category] || []) {
for (const map of [byCategory.value, allByCategory.value]) { if (_keyOf(s) === key) s.rejected = 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 // opts.tagId: the tag's known id when the caller already resolved it (TagPanel's
// it (TagPanel's manual-add-matches-suggestion path). Passed separately — // manual-add-matches-suggestion path). Passed separately — NOT spread onto the
// NOT spread onto the suggestion — because _dropEverywhere keys off the // suggestion — because _dropEverywhere keys off the suggestion object.
// 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 } = {}) { async function accept(suggestion, { tagId: knownTagId = null } = {}) {
// Capture imageId so a mid-flight prev/next can't reroute the // Capture imageId so a mid-flight prev/next can't reroute the accept POST to
// accept POST to a different image AND push the tag to that // a different image.
// image's allowlist.
const imageId = currentImageId const imageId = currentImageId
if (imageId == null) return if (imageId == null) return
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's const tagId = knownTagId ?? suggestion.canonical_tag_id
// 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`, { await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId } body: { tag_id: tagId }
}) })
// Only drop from THIS image's category list — if the user navigated, // Only drop from THIS image's list — if the user navigated, the new image
// the new image has its own suggestions and this drop would corrupt them. // has its own suggestions and this drop would corrupt them.
if (currentImageId === imageId) { if (currentImageId === imageId) {
_dropEverywhere(suggestion) _dropEverywhere(suggestion)
} }
_acceptToast('Tagged', suggestion.display_name) _acceptToast('Tagged', suggestion.display_name)
} }
// One non-blocking toast for accept/alias. The accepted tag is applied to this // One non-blocking toast for accept. The accepted tag is applied to this image
// image and feeds head training; head auto-apply handles propagation (earned), // and feeds head training; head auto-apply handles propagation (earned).
// so there's no instant fan-out to project.
function _acceptToast(verb, displayName) { function _acceptToast(verb, displayName) {
toast({ text: `${verb}: ${displayName}`, type: 'success' }) toast({ text: `${verb}: ${displayName}`, type: 'success' })
} }
async function aliasAccept(suggestion, canonicalTagId) { // Find a live (non-rejected) suggestion matching a manually-picked tag — by
const imageId = currentImageId // canonical id when known, else by (kind, name). Manually adding a tag the
if (imageId == null) return // model also suggested must be indistinguishable from accepting the suggestion
// The alias MUST be stored under the raw model key — resolution looks up the // (recorded + dropped from the panel), so TagPanel routes matches through
// raw prediction key, not the normalized display name. Sending display_name // accept() (operator-asked 2026-07-03).
// (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) { function findPending(kind, name, tagId = null) {
const lname = (name || '').toLowerCase() const lname = (name || '').toLowerCase()
for (const map of [allByCategory.value, byCategory.value]) { for (const list of Object.values(byCategory.value)) {
for (const list of Object.values(map)) { for (const s of list || []) {
for (const s of list || []) { if (s.rejected) continue
if (s.rejected) continue if (tagId != null && s.canonical_tag_id === tagId) return s
if (tagId != null && s.canonical_tag_id === tagId) return s if (s.category === kind && (s.display_name || '').toLowerCase() === lname) return s
if (
s.category === kind &&
(s.display_name || '').toLowerCase() === lname
) return s
}
} }
} }
return null return null
@@ -202,14 +132,8 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
async function dismiss(suggestion) { async function dismiss(suggestion) {
const imageId = currentImageId const imageId = currentImageId
if (imageId == null) return 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, // 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). // 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`, { await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id } 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 // Reject every still-unhandled VISIBLE (above-threshold) suggestion in a
// the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags // category in one go ("confirm the good ones, reject the rest" — operator-asked
// persist a rejection and STAY flagged rejected (reversible, one-click // 2026-07-06). Only touches what the panel shows, not the low-confidence tail
// un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel // the dropdown carries. Dispatched in parallel so a big section clears fast.
// so a big section clears fast.
async function dismissRemaining(category) { async function dismissRemaining(category) {
const imageId = currentImageId const imageId = currentImageId
if (imageId == null) return 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 if (!targets.length) return
const canon = targets.filter((s) => s.canonical_tag_id != null) await Promise.all(targets.map((s) =>
const raw = targets.filter((s) => s.canonical_tag_id == null)
await Promise.all(canon.map((s) =>
api.post(`/api/images/${imageId}/suggestions/dismiss`, { api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: s.canonical_tag_id }, body: { tag_id: s.canonical_tag_id },
}) })
)) ))
if (currentImageId === imageId) { if (currentImageId === imageId) {
canon.forEach((s) => _setRejectedEverywhere(s, true)) targets.forEach((s) => _setRejectedEverywhere(s, true))
raw.forEach((s) => _dropEverywhere(s))
} }
} }
// Undo a per-image dismissal — the suggestion reverts to a live row. // Undo a per-image dismissal — the suggestion reverts to a live row.
async function undismiss(suggestion) { async function undismiss(suggestion) {
const imageId = currentImageId const imageId = currentImageId
if (imageId == null || suggestion.canonical_tag_id == null) return if (imageId == null) return
await api.post(`/api/images/${imageId}/suggestions/undismiss`, { await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
body: { tag_id: suggestion.canonical_tag_id } body: { tag_id: suggestion.canonical_tag_id }
}) })
@@ -254,8 +176,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
return { return {
byCategory, allByCategory, loading, error, byCategory, aboveByCategory, loading, error,
load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining, load, accept, dismiss, dismissRemaining, undismiss, findPending
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 = {}) => ({ const sugg = (over = {}) => ({
canonical_tag_id: 7, canonical_tag_id: 7,
display_name: 'Ichigo', display_name: 'Ichigo',
category: 'character', category: 'character',
score: 0.9, score: 0.9,
source: 'head', source: 'head',
creates_new_tag: false, above_threshold: true,
rejected: false, rejected: false,
...over, ...over,
}) })
// Seed the store through its own load/loadAll so currentImageId is set the // Seed the store through its single load() so currentImageId is set the way the
// way the panel sets it (accept() derives the image from it). // panel sets it (accept() derives the image from it).
async function seed(store, byCategory) { async function seed(store, byCategory) {
stubFetch(() => ({ status: 200, body: { by_category: byCategory } })) stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
await store.load(1) 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)', () => { describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
beforeEach(() => setActivePinia(createPinia())) beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks()) 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') 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() const s = useSuggestionsStore()
await seed(s, { await seed(s, {
general: [sugg({ general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })],
canonical_tag_id: null, display_name: 'Holding Sword',
category: 'general', creates_new_tag: true,
})],
}) })
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. // Kind must match: same name under another kind is a different tag.
expect(s.findPending('character', 'holding sword')).toBeNull() 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())) beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks()) 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() const s = useSuggestionsStore()
await seed(s, { character: [sugg()] }) await seed(s, { character: [sugg()] })
const calls = [] const calls = []
stubFetch((url, init) => { 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 } } 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).toHaveLength(1)
expect(calls[0].url).toContain('/api/images/1/suggestions/accept') 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.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 () => { it('honors a known tagId and still drops the row by the suggestion identity', async () => {
// TagPanel's manual-create path: the tag was just created by // TagPanel's manual-add-matches-suggestion path passes the resolved tag id;
// host.createAndAdd, so accept must not create again — and the drop must // accept sends exactly it and drops the original suggestion row (which keys
// key off the original raw suggestion object, not the resolved id // off the suggestion object, not the passed id).
// (regression: spreading canonical_tag_id onto the suggestion changed its
// identity key and left the panel row behind).
const s = useSuggestionsStore() const s = useSuggestionsStore()
const raw = sugg({ await seed(s, { general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })] })
canonical_tag_id: null, display_name: 'Holding Sword',
category: 'general', creates_new_tag: true,
})
await seed(s, { general: [raw] })
const calls = [] const calls = []
stubFetch((url, init) => { stubFetch((url, init) => {
calls.push({ url, body: init?.body }) 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(calls[0].url).toContain('/api/images/1/suggestions/accept')
expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 }) expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 })
expect(s.byCategory.general).toHaveLength(0) 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", []) general = body["by_category"].get("general", [])
s2 = next(x for x in general if x["canonical_tag_id"] == tag.id) s2 = next(x for x in general if x["canonical_tag_id"] == tag.id)
assert s2["source"] == "head" assert s2["source"] == "head"
assert s2["above_threshold"] is True # ~0.73 clears the 0.5 suggest cut
@pytest.mark.asyncio @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} f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id}
) )
assert resp2.status_code == 204 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] s = general[0]
assert s.canonical_tag_id == tag.id assert s.canonical_tag_id == tag.id
assert s.source == "head" 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.score > 0.5
assert s.above_threshold is True # ~0.73 clears the 0.5 suggest cut
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -109,7 +108,10 @@ async def test_threshold_override_surfaces_below_cut(db):
svc = SuggestionService(db) svc = SuggestionService(db)
assert svc and not (await svc.for_image(img.id)).by_category.get("general") 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) 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 @pytest.mark.asyncio
@@ -281,6 +283,7 @@ async def test_ccip_character_surfaces_in_rail(db):
if c.canonical_tag_id == raven.id if c.canonical_tag_id == raven.id
) )
assert m.source == "ccip" 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 --------- # --- #1206 Step 4: on-demand grounding for ALREADY-APPLIED tag chips ---------