From c999c64cbed4eb8584ee8ee75e37f5d9b4a925e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 9 Jun 2026 20:22:24 -0400 Subject: [PATCH] feat(suggestions): tag-input dropdown searches the full prediction set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed dropdown sourced the threshold-filtered panel list (>= 0.70 general), so low-confidence actions/features the model DID predict never appeared — forcing hand-typed custom tags instead of accepting the model's canonical formatting. Add a threshold override: SuggestionService.for_image(threshold_override=) and GET /images//suggestions?min= surface EVERY stored prediction (down to the 0.05 store floor), alias-resolved and normalized, still excluding applied/rejected and unsurfaced categories. The suggestions store gains allByCategory + loadAll (min=0); the dropdown searches that full set (cap 20), while the Suggestions panel stays curated at the configured threshold. Accept/dismiss drop from both lists. Operator-asked 2026-06-09. Test: a 0.30 general prediction is hidden by default but surfaced with threshold_override=0.0; unsurfaced categories still excluded. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/suggestions.py | 15 ++++- backend/app/services/ml/suggestions.py | 23 +++++++- .../src/components/modal/SuggestionsPanel.vue | 6 +- .../src/components/modal/TagAutocomplete.vue | 12 +++- frontend/src/stores/suggestions.js | 56 +++++++++++++++---- tests/test_ml_suggestions.py | 27 +++++++++ 6 files changed, 121 insertions(+), 18 deletions(-) diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py index 25bd2fe..61fbbb7 100644 --- a/backend/app/api/suggestions.py +++ b/backend/app/api/suggestions.py @@ -11,8 +11,21 @@ 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. + override = None + raw_min = request.args.get("min") + if raw_min is not None: + try: + override = min(1.0, max(0.0, float(raw_min))) + except ValueError: + return jsonify({"error": "min must be a float in [0,1]"}), 400 async with get_session() as session: - sl = await SuggestionService(session).for_image(image_id) + sl = await SuggestionService(session).for_image( + image_id, threshold_override=override + ) return jsonify( { "by_category": { diff --git a/backend/app/services/ml/suggestions.py b/backend/app/services/ml/suggestions.py index 9e570a8..b53e198 100644 --- a/backend/app/services/ml/suggestions.py +++ b/backend/app/services/ml/suggestions.py @@ -48,16 +48,33 @@ class SuggestionService: await self.session.execute(select(MLSettings).where(MLSettings.id == 1)) ).scalar_one() - def _threshold_for(self, s: MLSettings, category: str) -> float: + def _threshold_for( + self, s: MLSettings, category: str, override: float | None = None, + ) -> float: # 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired; # both fall through to the 1.01 "never surfaces" default like any # unsurfaced category. + # override (the typed-dropdown "show everything the model saw" mode) + # applies to the surfaced categories only — unsurfaced ones are already + # skipped before the threshold check, so they can't leak in. + if override is not None: + return override return { "character": s.suggestion_threshold_character, "general": s.suggestion_threshold_general, }.get(category, 1.01) - async def for_image(self, image_id: int) -> SuggestionList: + async def for_image( + self, image_id: int, *, threshold_override: float | None = None, + ) -> SuggestionList: + """Ranked suggestions for one image. + + threshold_override surfaces EVERY stored tagger prediction (down to the + ingest STORE_FLOOR) regardless of the configured per-category suggestion + thresholds — backs the tag-input dropdown's "search all of the model's + predictions, including low-confidence ones, in the canonical formatting" + mode (operator-asked 2026-06-09). The Suggestions panel still calls with + no override so it stays the curated above-threshold list.""" img = await self.session.get(ImageRecord, image_id) if img is None: return SuggestionList() @@ -96,7 +113,7 @@ class SuggestionService: if category not in SURFACED_CATEGORIES: continue conf = float(p.get("confidence", 0.0)) - if conf < self._threshold_for(settings, category): + if conf < self._threshold_for(settings, category, threshold_override): continue display = normalize_tag_name(name) if display is None: diff --git a/frontend/src/components/modal/SuggestionsPanel.vue b/frontend/src/components/modal/SuggestionsPanel.vue index 8b61677..142d3f6 100644 --- a/frontend/src/components/modal/SuggestionsPanel.vue +++ b/frontend/src/components/modal/SuggestionsPanel.vue @@ -62,7 +62,11 @@ const isEmpty = computed(() => Object.values(store.byCategory).every(list => !list || list.length === 0) ) -watch(() => props.imageId, (id) => { if (id != null) store.load(id) }, { immediate: true }) +watch(() => props.imageId, (id) => { + if (id == null) return + store.load(id) // panel: curated, ≥ threshold + store.loadAll(id) // dropdown: full prediction set (low-confidence included) +}, { immediate: true }) // After a successful accept/alias-accept, refresh the modal's current // tag list so TagPanel's chip rail reflects the newly-attached tag. diff --git a/frontend/src/components/modal/TagAutocomplete.vue b/frontend/src/components/modal/TagAutocomplete.vue index 87c1a20..ba6fe25 100644 --- a/frontend/src/components/modal/TagAutocomplete.vue +++ b/frontend/src/components/modal/TagAutocomplete.vue @@ -190,12 +190,17 @@ function scorePct (s) { return `${Math.round(s.score * 100)}%` } // This image's suggestions that match the typed query, minus any the server // autocomplete already returned (same name+kind) so a tag never shows twice. +// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT +// the threshold-filtered panel list — so a low-confidence action/feature the +// model saw can be typed and accepted in canonical formatting instead of being +// hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is +// the only filter; the threshold no longer hides anything here. const suggestionHits = computed(() => { const q = parsedName.value.toLowerCase() if (!q) return [] const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`)) const out = [] - for (const list of Object.values(suggestions.byCategory)) { + for (const list of Object.values(suggestions.allByCategory)) { for (const s of list || []) { const key = `${s.category}:${s.display_name.toLowerCase()}` if (!s.display_name.toLowerCase().includes(q)) continue @@ -204,9 +209,10 @@ const suggestionHits = computed(() => { out.push(s) } } - // Best matches first; cap so the dropdown stays scannable. + // Best matches first; cap generously so a specific typed query surfaces its + // matches even when many predictions exist, while the list stays scrollable. out.sort((a, b) => b.score - a.score) - return out.slice(0, 6) + return out.slice(0, 20) }) // One ordered list backing both the rendered dropdown and keyboard nav, so the diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js index 6f7bb78..4209570 100644 --- a/frontend/src/stores/suggestions.js +++ b/frontend/src/stores/suggestions.js @@ -16,9 +16,14 @@ export const CATEGORY_LABELS = { export const useSuggestionsStore = defineStore('suggestions', () => { const api = useApi() - const byCategory = ref({}) // { category: [suggestion, ...] } + 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({}) 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 @@ -42,10 +47,41 @@ export const useSuggestionsStore = defineStore('suggestions', () => { }) } - function _drop(category, predicate) { - const list = byCategory.value[category] - if (!list) return - byCategory.value[category] = list.filter(s => !predicate(s)) + // 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. + function _keyOf(s) { + return s.canonical_tag_id != null + ? `id:${s.canonical_tag_id}` + : `raw:${s.category}:${(s.display_name || '').toLowerCase()}` + } + + // Drop an accepted/dismissed suggestion from BOTH the panel list and the + // dropdown's full list so it can't reappear in either surface. + 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) + } } async function accept(suggestion) { @@ -70,7 +106,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => { // 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. if (currentImageId === imageId) { - _drop(suggestion.category, s => s === suggestion) + _dropEverywhere(suggestion) } toast({ text: `Tagged: ${suggestion.display_name}`, @@ -89,7 +125,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => { } }) if (currentImageId === imageId) { - _drop(suggestion.category, s => s === suggestion) + _dropEverywhere(suggestion) } toast({ text: `Aliased & tagged: ${suggestion.display_name}`, @@ -109,12 +145,12 @@ export const useSuggestionsStore = defineStore('suggestions', () => { }) } if (currentImageId === imageId) { - _drop(suggestion.category, s => s === suggestion) + _dropEverywhere(suggestion) } } return { - byCategory, loading, error, - load, accept, aliasAccept, dismiss + byCategory, allByCategory, loading, error, + load, loadAll, accept, aliasAccept, dismiss } }) diff --git a/tests/test_ml_suggestions.py b/tests/test_ml_suggestions.py index be1f4fa..9486ce8 100644 --- a/tests/test_ml_suggestions.py +++ b/tests/test_ml_suggestions.py @@ -44,6 +44,33 @@ async def test_threshold_filters_low_confidence_general(db): assert "Lowconf" not in names +@pytest.mark.asyncio +async def test_threshold_override_surfaces_low_confidence(db): + # The typed-dropdown "show everything the model saw" mode: threshold_override + # surfaces stored predictions below the configured threshold (in canonical + # formatting) so they can be picked instead of hand-typed (2026-06-09). + img = _img( + "d" * 64, + { + "lowconf": {"category": "general", "confidence": 0.30}, + "sword": {"category": "general", "confidence": 0.97}, + }, + ) + db.add(img) + await db.flush() + sl = await SuggestionService(db).for_image(img.id, threshold_override=0.0) + names = [s.display_name for s in sl.by_category.get("general", [])] + assert "Sword" in names + assert "Lowconf" in names # below the configured threshold, surfaced anyway + + # Unsurfaced categories are still excluded even with the override. + img2 = _img("e" * 64, {"safe": {"category": "rating", "confidence": 0.99}}) + db.add(img2) + await db.flush() + sl2 = await SuggestionService(db).for_image(img2.id, threshold_override=0.0) + assert "rating" not in sl2.by_category + + @pytest.mark.asyncio async def test_unsurfaced_category_dropped(db): img = _img(