feat(suggestions): tag-input dropdown searches the full prediction set
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m18s

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/<id>/suggestions?min=<f> 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:22:24 -04:00
parent 978f49adcc
commit c999c64cbe
6 changed files with 121 additions and 18 deletions
@@ -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.
@@ -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