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
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:
@@ -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"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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 ---------
|
||||||
|
|||||||
Reference in New Issue
Block a user