diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py index 28df98e..4baca47 100644 --- a/backend/app/api/suggestions.py +++ b/backend/app/api/suggestions.py @@ -11,10 +11,11 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api") @suggestions_bp.route("/images//suggestions", methods=["GET"]) async def get_suggestions(image_id: int): - # ?min= overrides the configured per-category thresholds so the typed - # tag-input dropdown can surface EVERY stored prediction (min=0), including - # low-confidence actions/features, in canonical formatting. Omitted → the - # curated above-threshold list the Suggestions panel uses. + # ?min= overrides the per-head suggest thresholds for INCLUSION. The + # rail sends min=0 in its single per-image fetch to get EVERY head (each row + # still carries above_threshold vs its natural cut), then derives the panel + # (above_threshold) and the typed dropdown (all, filtered by text) client-side + # — no second request. Omitted → only above-threshold rows. override = None raw_min = request.args.get("min") if raw_min is not None: @@ -36,12 +37,11 @@ async def get_suggestions(image_id: int): "category": s.category, "score": round(s.score, 4), "source": s.source, - "creates_new_tag": s.creates_new_tag, - # raw model key (alias is stored under this) + whether an - # operator alias produced this suggestion — drive the - # modal's "Treat as alias"/"Remove alias" affordances. - "raw_name": s.raw_name, - "via_alias": s.via_alias, + # whether the score cleared the head's own suggest cut. + # The single min=0 fetch returns every head; the panel + # shows above_threshold, the typed dropdown shows all and + # annotates each match with its score. + "above_threshold": s.above_threshold, # operator dismissed this tag for this image — surfaced # (not dropped) so the rail can show it rejected + offer # one-click un-reject. @@ -73,26 +73,6 @@ async def accept_suggestion(image_id: int): return jsonify({"accepted": True, "tag_id": tag_id}) -@suggestions_bp.route( - "/images//suggestions/alias", methods=["POST"] -) -async def alias_suggestion(image_id: int): - body = await request.get_json() - required = {"alias_string", "alias_category", "canonical_tag_id"} - if not body or not required.issubset(body): - return jsonify({"error": f"required: {sorted(required)}"}), 400 - canonical_tag_id = body["canonical_tag_id"] - async with get_session() as session: - await AllowlistService(session).add_alias_and_accept( - image_id, - body["alias_string"], - body["alias_category"], - canonical_tag_id, - ) - await session.commit() - return jsonify({"accepted": True, "tag_id": canonical_tag_id}) - - @suggestions_bp.route( "/images//suggestions/dismiss", methods=["POST"] ) diff --git a/backend/app/services/ml/allowlist.py b/backend/app/services/ml/allowlist.py index a6bee65..1571e7a 100644 --- a/backend/app/services/ml/allowlist.py +++ b/backend/app/services/ml/allowlist.py @@ -12,13 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...models import TagSuggestionRejection from ...models.tag import image_tag -from .aliases import AliasService class AllowlistService: def __init__(self, session: AsyncSession): self.session = session - self.aliases = AliasService(session) async def _apply_image_tag(self, image_id: int, tag_id: int, source: str): stmt = insert(image_tag).values( @@ -41,18 +39,6 @@ class AllowlistService: await self._apply_image_tag(image_id, tag_id, source="ml_accepted") await self._clear_rejection(image_id, tag_id) - async def add_alias_and_accept( - self, - image_id: int, - alias_string: str, - alias_category: str, - canonical_tag_id: int, - ) -> None: - await self.aliases.create( - alias_string, alias_category, canonical_tag_id - ) - await self.accept(image_id, canonical_tag_id) - async def dismiss(self, image_id: int, tag_id: int) -> None: stmt = insert(TagSuggestionRejection).values( image_record_id=image_id, tag_id=tag_id diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index 47e4073..423d70d 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -500,13 +500,19 @@ async def score_image( session: AsyncSession, image_id: int, threshold_override: float | None = None, ) -> list[dict]: """Suggestions for one image from the trained heads: [{tag_id, name, - category, score}], ranked. A concept surfaces when its score clears the - head's own suggest_threshold — or, when threshold_override is given (the - typed-dropdown "show everything" mode), that flat floor instead (0 → every - head). System-tag heads (wip/banner/editor) instead use a flat - _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface for rejection - (still overridden by threshold_override). Empty if the image has no - embedding or no heads exist yet. + category, score, above_threshold, grounding}], ranked. A concept is INCLUDED + when its score clears the head's own suggest_threshold — or, when + threshold_override is given (the typed-dropdown "show everything" mode), that + flat floor instead (0 → every head). System-tag heads (wip/banner/editor) + instead use a flat _SYSTEM_TAG_SUGGEST_FLOOR so their false positives surface + for rejection (still overridden by threshold_override). Empty if the image has + no embedding or no heads exist yet. + + ``above_threshold`` is reported SEPARATELY from inclusion: it's always whether + the score cleared the head's NATURAL cut (suggest_threshold, or the system + floor), regardless of any override. So the single min=0 fetch returns every + head, and the caller can split panel (above_threshold) from dropdown (all) + without a second request. MAX-OVER-BAG: the image is scored as a BAG of embeddings — the whole-image vector PLUS every concept-region crop the agent embedded (same model @@ -537,21 +543,25 @@ async def score_image( winners = probs_bag.argmax(axis=0) # (H,) out = [] for i, p in enumerate(probs): - if threshold_override is not None: - cut = threshold_override - elif heads["meta"][i]["is_system"]: - # System tags surface at the flat floor (see _SYSTEM_TAG_SUGGEST_FLOOR) - # so their false positives show up for the operator to reject. - cut = _SYSTEM_TAG_SUGGEST_FLOOR - else: - cut = heads["thr"][i] + m = heads["meta"][i] + # The head's NATURAL suggest cut — system tags use the flat floor (see + # _SYSTEM_TAG_SUGGEST_FLOOR) so their false positives show up for the + # operator to reject; content heads use their own precision-tuned + # threshold. This is what "above threshold" means (drives the panel). + natural = ( + _SYSTEM_TAG_SUGGEST_FLOOR if m["is_system"] else float(heads["thr"][i]) + ) + # INCLUSION is looser under threshold_override (dropdown show-all, + # override=0): every head comes back so a low-confidence concept can still + # be typed + picked, each carrying its own above_threshold flag. + cut = threshold_override if threshold_override is not None else natural if p >= cut: - m = heads["meta"][i] out.append({ "tag_id": m["tag_id"], "name": m["name"], "category": m["category"], "score": float(p), + "above_threshold": bool(p >= natural), "grounding": bag_meta[int(winners[i])], }) out.sort(key=lambda d: d["score"], reverse=True) diff --git a/backend/app/services/ml/suggestions.py b/backend/app/services/ml/suggestions.py index eba40ad..2bd1d3a 100644 --- a/backend/app/services/ml/suggestions.py +++ b/backend/app/services/ml/suggestions.py @@ -22,22 +22,20 @@ from .heads import score_image @dataclass(frozen=True) class Suggestion: - # canonical_tag_id is None when this is a raw Camie tag with no alias and - # no existing Tag row — accepting it will create the tag. - canonical_tag_id: int | None + # Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept + # tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap + # cases are gone — a suggestion always maps to a real tag id. + canonical_tag_id: int display_name: str category: str score: float source: str # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2) - creates_new_tag: bool - # raw_name = the booru model vocab key behind this suggestion. It's the key - # an alias MUST be stored under (resolution looks up the raw key), so the - # modal needs it to author an alias correctly. None for centroid-only hits - # (no underlying prediction → nothing to alias). - raw_name: str | None = None - # via_alias = this suggestion was surfaced because an operator alias remapped - # the raw prediction to this canonical tag. Lets the UI mark it + offer undo. - via_alias: bool = False + # above_threshold = the score cleared the head's own suggest cut (or the + # system floor). The Suggestions PANEL shows only these; the typed-tag + # dropdown fetches ALL suggestions (every head, min=0) and just annotates each + # matching row with its score, so a low-confidence concept can still be typed + # and picked. CCIP character matches are always above their match threshold. + above_threshold: bool # rejected = the operator dismissed this tag for this image (a stored # TagSuggestionRejection). It stays in the list — flagged, not dropped — so # the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery, @@ -108,6 +106,7 @@ class SuggestionService: for h in hits: merged[(h["category"], h["tag_id"])] = { "name": h["name"], "score": h["score"], "source": "head", + "above_threshold": h["above_threshold"], "grounding": h.get("grounding"), } for c in ccip_hits: @@ -116,12 +115,16 @@ class SuggestionService: if ex is not None: ex["source"] = "both" ex["score"] = max(ex["score"], c["score"]) + # CCIP only returns matches above its own threshold, so a CCIP + # corroboration always makes the merged suggestion above-threshold. + ex["above_threshold"] = True # Keep the head's localized crop if it had one; else fall back to # the CCIP figure so a corroborated character still grounds (#1206). ex["grounding"] = ex.get("grounding") or c.get("grounding") else: merged[key] = { "name": c["name"], "score": c["score"], "source": "ccip", + "above_threshold": True, "grounding": c.get("grounding"), } @@ -136,7 +139,7 @@ class SuggestionService: category=cat, score=m["score"], source=m["source"], - creates_new_tag=False, + above_threshold=m["above_threshold"], rejected=tag_id in rejected, grounding=m.get("grounding"), ) @@ -157,8 +160,7 @@ class SuggestionService: was suggested for (or already applied to) >= threshold fraction of the selection AND was acceptable on >= 1 image. Confidence is the mean over images where it was suggested. Aggregated by - canonical_tag_id; creates-new (no canonical id) suggestions are - skipped (bulk Accept applies by tag id).""" + canonical_tag_id (every suggestion is a canonical tag now).""" if not image_ids: return {} threshold = min(1.0, max(0.0, threshold)) @@ -169,8 +171,6 @@ class SuggestionService: sl = await self.for_image(image_id) for category, items in sl.by_category.items(): for s in items: - if s.canonical_tag_id is None or s.creates_new_tag: - continue # for_image keeps rejected tags (flagged) for the rail; # bulk consensus must still ignore them — a tag dismissed on # an image isn't a suggestion for that image. diff --git a/frontend/src/components/modal/AliasPickerDialog.vue b/frontend/src/components/modal/AliasPickerDialog.vue deleted file mode 100644 index 0890529..0000000 --- a/frontend/src/components/modal/AliasPickerDialog.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - diff --git a/frontend/src/components/modal/SuggestionItem.vue b/frontend/src/components/modal/SuggestionItem.vue index 7a47e3c..b918d15 100644 --- a/frontend/src/components/modal/SuggestionItem.vue +++ b/frontend/src/components/modal/SuggestionItem.vue @@ -11,10 +11,6 @@ {{ suggestion.display_name }} rejected - + new - alias {{ scorePct }} - - - - Treat as alias for… - - - Remove alias - - diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue index 947bd58..903cdc6 100644 --- a/frontend/src/components/modal/TagPanel.vue +++ b/frontend/src/components/modal/TagPanel.vue @@ -17,7 +17,6 @@ ref="tagInputRef" :applied-tags="host.current?.tags || []" @pick-existing="onPickExisting" @pick-new="onPickNew" - @accept-suggestion="onAcceptSuggestion" /> @@ -134,7 +133,6 @@ async function onRemove(tagId) { // until the next modal open (operator-asked 2026-07-03). if (host.currentImageId != null) { suggestions.load(host.currentImageId) - suggestions.loadAll(host.currentImageId) } focusTagInput() } @@ -178,18 +176,6 @@ async function onPickNew(payload) { } catch (e) { errorMsg.value = e.message } } -// A suggestion picked from the autocomplete dropdown runs the SAME path as the -// Suggestions panel's Accept: the store creates the tag if it's raw, records the -// acceptance, and drops it from the panel; then we refresh the chip rail. -async function onAcceptSuggestion(s) { - errorMsg.value = null - try { - await suggestions.accept(s) - await host.reloadTags() - focusTagInput() - } catch (e) { errorMsg.value = e.message } -} - const renameDialog = ref(false) const renameTarget = ref(null) diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js index e11a6f2..523d8e5 100644 --- a/frontend/src/stores/suggestions.js +++ b/frontend/src/stores/suggestions.js @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { toast } from '../utils/toast.js' -import { ref } from 'vue' +import { computed, ref } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' import { useInflightToken } from '../composables/useInflightToken.js' @@ -18,182 +18,112 @@ export const CATEGORY_LABELS = { export const useSuggestionsStore = defineStore('suggestions', () => { const api = useApi() - const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold) - // The typed tag-input dropdown searches the model's FULL prediction set for - // the image (min=0, down to the store floor) so low-confidence actions/ - // features can be picked in canonical formatting (operator-asked 2026-06-09). - const allByCategory = ref({}) + // ONE source of truth per image: every trained head scored for this image + // (min=0), each row carrying above_threshold. The Suggestions PANEL renders + // aboveByCategory; the typed tag-input dropdown reads the full set and + // annotates matching DB-tag rows with their score. A single fetch backs both — + // every suggestion is a canonical tag now (tagging-v2, #114), so there is no + // raw-prediction / create-new / alias-remap path to reconcile. + const byCategory = ref({}) const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) let currentImageId = null - const inflightAll = useInflightToken() - // Audit 2026-06-02: this store had no inflight guard — a late - // /suggestions response from a prior image could overwrite - // byCategory while currentImageId pointed at a new one, and - // accept() dereferenced currentImageId AFTER an awaited POST so - // the subsequent /suggestions/accept could apply A's chosen tag - // to image B (and push it to the allowlist). Both fixed below - // by capturing imageId at call-time and gating writes on the token. + // Audit 2026-06-02: without an inflight guard a late /suggestions response from + // a prior image could overwrite byCategory while currentImageId points at a new + // one, and accept() could apply image A's tag to image B. Both guarded below by + // capturing imageId at call-time and gating writes on the token. const inflight = useInflightToken() + // The curated above-threshold subset the Suggestions panel shows. A rejected + // row that still scores above its cut stays (flagged) so the rejection is + // visible + reversible; below-threshold rows live only in the dropdown. + const aboveByCategory = computed(() => { + const out = {} + for (const [cat, list] of Object.entries(byCategory.value)) { + const kept = (list || []).filter(s => s.above_threshold) + if (kept.length) out[cat] = kept + } + return out + }) + async function load(imageId) { - // Cancel any in-flight load from the previous image so its late - // response can't overwrite this image's byCategory. + // Cancel any in-flight load from the previous image so its late response + // can't overwrite this image's list. inflight.cancel() currentImageId = imageId byCategory.value = {} // cleared upfront so it stays empty on error const t = inflight.claim() await run(async () => { - const body = await api.get(`/api/images/${imageId}/suggestions`) + // min=0 → every head, each flagged above_threshold; the panel + the typed + // dropdown are both derived from this one payload. + const body = await api.get(`/api/images/${imageId}/suggestions`, { + params: { min: 0 }, + }) if (!t.isCurrent()) return byCategory.value = body.by_category || {} }) } - // Load the full prediction set for the dropdown (separate inflight guard so a - // late response from a prior image can't overwrite the current one's list). - async function loadAll(imageId) { - inflightAll.cancel() - allByCategory.value = {} - const t = inflightAll.claim() - try { - const body = await api.get(`/api/images/${imageId}/suggestions`, { - params: { min: 0 }, - }) - if (!t.isCurrent()) return - allByCategory.value = body.by_category || {} - } catch { - // Dropdown is best-effort — the panel surfaces load errors. - } - } - - // A stable identity for a suggestion across the two lists (panel vs dropdown - // are separate fetches, so object identity differs): tag id when known, else - // the raw display key. + // A stable identity for a suggestion across the panel + dropdown surfaces: + // its canonical tag id (every suggestion has one now). function _keyOf(s) { - return s.canonical_tag_id != null - ? `id:${s.canonical_tag_id}` - : `raw:${s.category}:${(s.display_name || '').toLowerCase()}` + return `id:${s.canonical_tag_id}` } - // Drop an accepted/dismissed suggestion from BOTH the panel list and the - // dropdown's full list so it can't reappear in either surface. + // Drop an accepted suggestion from the list so it can't reappear. function _dropEverywhere(suggestion) { const key = _keyOf(suggestion) - const cat = suggestion.category - for (const map of [byCategory.value, allByCategory.value]) { - const list = map[cat] - if (list) map[cat] = list.filter(s => _keyOf(s) !== key) - } + const list = byCategory.value[suggestion.category] + if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key) } - // Flip the `rejected` flag on the matching suggestion in BOTH lists in place, - // so a reject/un-reject shows immediately without dropping the row (visible, + // Flip the `rejected` flag on the matching suggestion in place, so a + // reject/un-reject shows immediately without dropping the row (visible, // reversible rejection — misclick recovery, operator-asked 2026-06-27). function _setRejectedEverywhere(suggestion, value) { const key = _keyOf(suggestion) - const cat = suggestion.category - for (const map of [byCategory.value, allByCategory.value]) { - for (const s of map[cat] || []) { - if (_keyOf(s) === key) s.rejected = value - } + for (const s of byCategory.value[suggestion.category] || []) { + if (_keyOf(s) === key) s.rejected = value } } - // opts.tagId: the tag's known id, when the caller already created/resolved - // it (TagPanel's manual-add-matches-suggestion path). Passed separately — - // NOT spread onto the suggestion — because _dropEverywhere keys off the - // suggestion object, and mutating canonical_tag_id on a raw suggestion - // would stop it matching its own row in the lists. + // opts.tagId: the tag's known id when the caller already resolved it (TagPanel's + // manual-add-matches-suggestion path). Passed separately — NOT spread onto the + // suggestion — because _dropEverywhere keys off the suggestion object. async function accept(suggestion, { tagId: knownTagId = null } = {}) { - // Capture imageId so a mid-flight prev/next can't reroute the - // accept POST to a different image AND push the tag to that - // image's allowlist. + // Capture imageId so a mid-flight prev/next can't reroute the accept POST to + // a different image. const imageId = currentImageId if (imageId == null) return - // Raw tags (creates_new_tag) have no canonical_tag_id; the backend's - // accept endpoint needs a tag_id, so for raw tags we create the tag - // first via the existing /api/tags endpoint, then accept by id. - let tagId = knownTagId ?? suggestion.canonical_tag_id - if (tagId == null) { - const created = await api.post('/api/tags', { - body: { name: suggestion.display_name, kind: suggestion.category } - }) - tagId = created.id - } + const tagId = knownTagId ?? suggestion.canonical_tag_id await api.post(`/api/images/${imageId}/suggestions/accept`, { body: { tag_id: tagId } }) - // Only drop from THIS image's category list — if the user navigated, - // the new image has its own suggestions and this drop would corrupt them. + // Only drop from THIS image's list — if the user navigated, the new image + // has its own suggestions and this drop would corrupt them. if (currentImageId === imageId) { _dropEverywhere(suggestion) } _acceptToast('Tagged', suggestion.display_name) } - // One non-blocking toast for accept/alias. The accepted tag is applied to this - // image and feeds head training; head auto-apply handles propagation (earned), - // so there's no instant fan-out to project. + // One non-blocking toast for accept. The accepted tag is applied to this image + // and feeds head training; head auto-apply handles propagation (earned). function _acceptToast(verb, displayName) { toast({ text: `${verb}: ${displayName}`, type: 'success' }) } - async function aliasAccept(suggestion, canonicalTagId) { - const imageId = currentImageId - if (imageId == null) return - // The alias MUST be stored under the raw model key — resolution looks up the - // raw prediction key, not the normalized display name. Sending display_name - // (the old bug) stored an alias that never resolved, so the prediction kept - // reappearing unaliased. raw_name is null only for centroid hits, which - // can't be aliased (the UI hides the action for them). - const aliasString = suggestion.raw_name ?? suggestion.display_name - await api.post(`/api/images/${imageId}/suggestions/alias`, { - body: { - alias_string: aliasString, - alias_category: suggestion.category, - canonical_tag_id: canonicalTagId - } - }) - if (currentImageId === imageId) { - _dropEverywhere(suggestion) - } - _acceptToast('Aliased & tagged', suggestion.display_name) - } - - // Remove the alias behind an aliased suggestion (the raw prediction reverts to - // its unaliased form on reload). The canonical tag stays applied if it was - // accepted — this only undoes the model-key→tag mapping. - async function removeAlias(suggestion) { - const imageId = currentImageId - if (imageId == null || suggestion.raw_name == null) return - await api.delete( - `/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}` - ) - if (currentImageId === imageId) { - await load(imageId) - await loadAll(imageId) - } - toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' }) - } - - // Find a live (non-rejected) pending suggestion matching a manually-picked - // tag — by canonical id when known, else by (kind, name). Manually adding a - // tag the model also suggested must be indistinguishable from accepting the - // suggestion (recorded + dropped from the panel), so TagPanel routes matches - // through accept() (operator-asked 2026-07-03). Searches the full dropdown - // set first, then the panel list (loadAll is best-effort and can be empty). + // Find a live (non-rejected) suggestion matching a manually-picked tag — by + // canonical id when known, else by (kind, name). Manually adding a tag the + // model also suggested must be indistinguishable from accepting the suggestion + // (recorded + dropped from the panel), so TagPanel routes matches through + // accept() (operator-asked 2026-07-03). function findPending(kind, name, tagId = null) { const lname = (name || '').toLowerCase() - for (const map of [allByCategory.value, byCategory.value]) { - for (const list of Object.values(map)) { - for (const s of list || []) { - if (s.rejected) continue - if (tagId != null && s.canonical_tag_id === tagId) return s - if ( - s.category === kind && - (s.display_name || '').toLowerCase() === lname - ) return s - } + for (const list of Object.values(byCategory.value)) { + for (const s of list || []) { + if (s.rejected) continue + if (tagId != null && s.canonical_tag_id === tagId) return s + if (s.category === kind && (s.display_name || '').toLowerCase() === lname) return s } } return null @@ -202,14 +132,8 @@ export const useSuggestionsStore = defineStore('suggestions', () => { async function dismiss(suggestion) { const imageId = currentImageId if (imageId == null) return - // Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's - // nothing to persist a rejection against — drop them client-side as before. // Canonical tags persist a rejection and STAY in the list flagged rejected, // so the operator can see it and one-click un-reject (misclick recovery). - if (suggestion.canonical_tag_id == null) { - if (currentImageId === imageId) _dropEverywhere(suggestion) - return - } await api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: suggestion.canonical_tag_id } }) @@ -218,33 +142,31 @@ export const useSuggestionsStore = defineStore('suggestions', () => { } } - // Reject every still-unhandled suggestion in a category in one go ("confirm - // the good ones, reject the rest" — operator-asked 2026-07-06). Canonical tags - // persist a rejection and STAY flagged rejected (reversible, one-click - // un-reject); raw creates-new-tag rows drop client-side. Dispatched in parallel - // so a big section clears fast. + // Reject every still-unhandled VISIBLE (above-threshold) suggestion in a + // category in one go ("confirm the good ones, reject the rest" — operator-asked + // 2026-07-06). Only touches what the panel shows, not the low-confidence tail + // the dropdown carries. Dispatched in parallel so a big section clears fast. async function dismissRemaining(category) { const imageId = currentImageId if (imageId == null) return - const targets = (byCategory.value[category] || []).filter((s) => !s.rejected) + const targets = (byCategory.value[category] || []).filter( + (s) => s.above_threshold && !s.rejected + ) if (!targets.length) return - const canon = targets.filter((s) => s.canonical_tag_id != null) - const raw = targets.filter((s) => s.canonical_tag_id == null) - await Promise.all(canon.map((s) => + await Promise.all(targets.map((s) => api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: s.canonical_tag_id }, }) )) if (currentImageId === imageId) { - canon.forEach((s) => _setRejectedEverywhere(s, true)) - raw.forEach((s) => _dropEverywhere(s)) + targets.forEach((s) => _setRejectedEverywhere(s, true)) } } // Undo a per-image dismissal — the suggestion reverts to a live row. async function undismiss(suggestion) { const imageId = currentImageId - if (imageId == null || suggestion.canonical_tag_id == null) return + if (imageId == null) return await api.post(`/api/images/${imageId}/suggestions/undismiss`, { body: { tag_id: suggestion.canonical_tag_id } }) @@ -254,8 +176,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => { } return { - byCategory, allByCategory, loading, error, - load, loadAll, accept, aliasAccept, removeAlias, dismiss, dismissRemaining, - undismiss, findPending + byCategory, aboveByCategory, loading, error, + load, accept, dismiss, dismissRemaining, undismiss, findPending } }) diff --git a/frontend/test/suggestions.spec.js b/frontend/test/suggestions.spec.js index 929e13e..69e3dc2 100644 --- a/frontend/test/suggestions.spec.js +++ b/frontend/test/suggestions.spec.js @@ -16,25 +16,44 @@ function stubFetch(handler) { }) } +// Every suggestion is a canonical DB tag now (tagging-v2): a real id, flagged +// above/below its head's suggest threshold. No raw / creates-new / alias cases. const sugg = (over = {}) => ({ canonical_tag_id: 7, display_name: 'Ichigo', category: 'character', score: 0.9, source: 'head', - creates_new_tag: false, + above_threshold: true, rejected: false, ...over, }) -// Seed the store through its own load/loadAll so currentImageId is set the -// way the panel sets it (accept() derives the image from it). +// Seed the store through its single load() so currentImageId is set the way the +// panel sets it (accept() derives the image from it). async function seed(store, byCategory) { stubFetch(() => ({ status: 200, body: { by_category: byCategory } })) await store.load(1) - await store.loadAll(1) } +describe('suggestions store: load derives aboveByCategory', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('one fetch backs both surfaces: byCategory is the full set, aboveByCategory the panel subset', async () => { + const s = useSuggestionsStore() + await seed(s, { + general: [ + sugg({ canonical_tag_id: 1, display_name: 'sword', category: 'general' }), + sugg({ canonical_tag_id: 2, display_name: 'faint', category: 'general', above_threshold: false }), + ], + }) + expect(s.byCategory.general).toHaveLength(2) // dropdown sees all + expect(s.aboveByCategory.general).toHaveLength(1) // panel sees above-threshold only + expect(s.aboveByCategory.general[0].display_name).toBe('sword') + }) +}) + describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => { beforeEach(() => setActivePinia(createPinia())) afterEach(() => vi.restoreAllMocks()) @@ -45,15 +64,12 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo') }) - it('matches raw suggestions by kind + name, case-insensitively', async () => { + it('matches by kind + name, case-insensitively', async () => { const s = useSuggestionsStore() await seed(s, { - general: [sugg({ - canonical_tag_id: null, display_name: 'Holding Sword', - category: 'general', creates_new_tag: true, - })], + general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })], }) - expect(s.findPending('general', 'holding sword')).toBeTruthy() + expect(s.findPending('general', 'holding sword')?.canonical_tag_id).toBe(12) // Kind must match: same name under another kind is a different tag. expect(s.findPending('character', 'holding sword')).toBeNull() }) @@ -66,37 +82,31 @@ describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () }) }) -describe('suggestions store: accept with a known tag id', () => { +describe('suggestions store: accept', () => { beforeEach(() => setActivePinia(createPinia())) afterEach(() => vi.restoreAllMocks()) - it('POSTs only the accept endpoint (no tag create) and drops the row', async () => { + it('POSTs only the accept endpoint (canonical id) and drops the row', async () => { const s = useSuggestionsStore() await seed(s, { character: [sugg()] }) const calls = [] stubFetch((url, init) => { - calls.push({ url, method: init?.method }) + calls.push({ url, method: init?.method, body: init?.body }) return { status: 200, body: { accepted: true, tag_id: 7 } } }) - await s.accept(s.byCategory.character[0], { tagId: 7 }) + await s.accept(s.byCategory.character[0]) expect(calls).toHaveLength(1) expect(calls[0].url).toContain('/api/images/1/suggestions/accept') + expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 7 }) expect(s.byCategory.character).toHaveLength(0) - expect(s.allByCategory.character).toHaveLength(0) }) - it('a RAW suggestion accepted with a known tagId skips create AND still drops its row', async () => { - // TagPanel's manual-create path: the tag was just created by - // host.createAndAdd, so accept must not create again — and the drop must - // key off the original raw suggestion object, not the resolved id - // (regression: spreading canonical_tag_id onto the suggestion changed its - // identity key and left the panel row behind). + it('honors a known tagId and still drops the row by the suggestion identity', async () => { + // TagPanel's manual-add-matches-suggestion path passes the resolved tag id; + // accept sends exactly it and drops the original suggestion row (which keys + // off the suggestion object, not the passed id). const s = useSuggestionsStore() - const raw = sugg({ - canonical_tag_id: null, display_name: 'Holding Sword', - category: 'general', creates_new_tag: true, - }) - await seed(s, { general: [raw] }) + await seed(s, { general: [sugg({ canonical_tag_id: 12, display_name: 'Holding Sword', category: 'general' })] }) const calls = [] stubFetch((url, init) => { calls.push({ url, body: init?.body }) @@ -107,6 +117,5 @@ describe('suggestions store: accept with a known tag id', () => { expect(calls[0].url).toContain('/api/images/1/suggestions/accept') expect(JSON.parse(calls[0].body)).toEqual({ tag_id: 42 }) expect(s.byCategory.general).toHaveLength(0) - expect(s.allByCategory.general).toHaveLength(0) }) }) diff --git a/tests/test_api_suggestions.py b/tests/test_api_suggestions.py index 1e3355c..6e7dfef 100644 --- a/tests/test_api_suggestions.py +++ b/tests/test_api_suggestions.py @@ -52,6 +52,7 @@ async def test_get_suggestions(client, db): general = body["by_category"].get("general", []) s2 = next(x for x in general if x["canonical_tag_id"] == tag.id) assert s2["source"] == "head" + assert s2["above_threshold"] is True # ~0.73 clears the 0.5 suggest cut @pytest.mark.asyncio @@ -114,12 +115,3 @@ async def test_undismiss_reverses_rejection(client, db): f"/api/images/{img.id}/suggestions/undismiss", json={"tag_id": tag.id} ) assert resp2.status_code == 204 - - -@pytest.mark.asyncio -async def test_alias_requires_fields(client, db): - img = await _img(db) - resp = await client.post( - f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"} - ) - assert resp.status_code == 400 diff --git a/tests/test_ml_suggestions.py b/tests/test_ml_suggestions.py index babf36d..1091518 100644 --- a/tests/test_ml_suggestions.py +++ b/tests/test_ml_suggestions.py @@ -62,9 +62,8 @@ async def test_head_suggestion_surfaces_for_matching_image(db): s = general[0] assert s.canonical_tag_id == tag.id assert s.source == "head" - assert s.creates_new_tag is False - assert s.via_alias is False and s.raw_name is None assert s.score > 0.5 + assert s.above_threshold is True # ~0.73 clears the 0.5 suggest cut @pytest.mark.asyncio @@ -109,7 +108,10 @@ async def test_threshold_override_surfaces_below_cut(db): svc = SuggestionService(db) assert svc and not (await svc.for_image(img.id)).by_category.get("general") flooded = await svc.for_image(img.id, threshold_override=0.0) - assert any(s.canonical_tag_id == tag.id for s in flooded.by_category["general"]) + s = next(s for s in flooded.by_category["general"] if s.canonical_tag_id == tag.id) + # Included by the floor, but flagged below its own cut (0.5 < 0.6) — this is + # what lets the dropdown show it while the panel hides it. + assert s.above_threshold is False @pytest.mark.asyncio @@ -281,6 +283,7 @@ async def test_ccip_character_surfaces_in_rail(db): if c.canonical_tag_id == raven.id ) assert m.source == "ccip" + assert m.above_threshold is True # CCIP only returns matches above its cut # --- #1206 Step 4: on-demand grounding for ALREADY-APPLIED tag chips ---------