feat(suggestions): tag-input dropdown searches the full prediction set
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:
@@ -11,8 +11,21 @@ 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
|
||||||
|
# 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:
|
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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"by_category": {
|
"by_category": {
|
||||||
|
|||||||
@@ -48,16 +48,33 @@ class SuggestionService:
|
|||||||
await self.session.execute(select(MLSettings).where(MLSettings.id == 1))
|
await self.session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||||
).scalar_one()
|
).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;
|
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
||||||
# both fall through to the 1.01 "never surfaces" default like any
|
# both fall through to the 1.01 "never surfaces" default like any
|
||||||
# unsurfaced category.
|
# 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 {
|
return {
|
||||||
"character": s.suggestion_threshold_character,
|
"character": s.suggestion_threshold_character,
|
||||||
"general": s.suggestion_threshold_general,
|
"general": s.suggestion_threshold_general,
|
||||||
}.get(category, 1.01)
|
}.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)
|
img = await self.session.get(ImageRecord, image_id)
|
||||||
if img is None:
|
if img is None:
|
||||||
return SuggestionList()
|
return SuggestionList()
|
||||||
@@ -96,7 +113,7 @@ class SuggestionService:
|
|||||||
if category not in SURFACED_CATEGORIES:
|
if category not in SURFACED_CATEGORIES:
|
||||||
continue
|
continue
|
||||||
conf = float(p.get("confidence", 0.0))
|
conf = float(p.get("confidence", 0.0))
|
||||||
if conf < self._threshold_for(settings, category):
|
if conf < self._threshold_for(settings, category, threshold_override):
|
||||||
continue
|
continue
|
||||||
display = normalize_tag_name(name)
|
display = normalize_tag_name(name)
|
||||||
if display is None:
|
if display is None:
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ const isEmpty = computed(() =>
|
|||||||
Object.values(store.byCategory).every(list => !list || list.length === 0)
|
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
|
// After a successful accept/alias-accept, refresh the modal's current
|
||||||
// tag list so TagPanel's chip rail reflects the newly-attached tag.
|
// 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
|
// 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.
|
// 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 suggestionHits = computed(() => {
|
||||||
const q = parsedName.value.toLowerCase()
|
const q = parsedName.value.toLowerCase()
|
||||||
if (!q) return []
|
if (!q) return []
|
||||||
const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
||||||
const out = []
|
const out = []
|
||||||
for (const list of Object.values(suggestions.byCategory)) {
|
for (const list of Object.values(suggestions.allByCategory)) {
|
||||||
for (const s of list || []) {
|
for (const s of list || []) {
|
||||||
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
||||||
if (!s.display_name.toLowerCase().includes(q)) continue
|
if (!s.display_name.toLowerCase().includes(q)) continue
|
||||||
@@ -204,9 +209,10 @@ const suggestionHits = computed(() => {
|
|||||||
out.push(s)
|
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)
|
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
|
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ 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, ...] }
|
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' })
|
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||||
let currentImageId = null
|
let currentImageId = null
|
||||||
|
const inflightAll = useInflightToken()
|
||||||
// Audit 2026-06-02: this store had no inflight guard — a late
|
// Audit 2026-06-02: this store had no inflight guard — a late
|
||||||
// /suggestions response from a prior image could overwrite
|
// /suggestions response from a prior image could overwrite
|
||||||
// byCategory while currentImageId pointed at a new one, and
|
// byCategory while currentImageId pointed at a new one, and
|
||||||
@@ -42,10 +47,41 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function _drop(category, predicate) {
|
// Load the full prediction set for the dropdown (separate inflight guard so a
|
||||||
const list = byCategory.value[category]
|
// late response from a prior image can't overwrite the current one's list).
|
||||||
if (!list) return
|
async function loadAll(imageId) {
|
||||||
byCategory.value[category] = list.filter(s => !predicate(s))
|
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) {
|
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,
|
// 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.
|
// the new image has its own suggestions and this drop would corrupt them.
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
toast({
|
toast({
|
||||||
text: `Tagged: ${suggestion.display_name}`,
|
text: `Tagged: ${suggestion.display_name}`,
|
||||||
@@ -89,7 +125,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
toast({
|
toast({
|
||||||
text: `Aliased & tagged: ${suggestion.display_name}`,
|
text: `Aliased & tagged: ${suggestion.display_name}`,
|
||||||
@@ -109,12 +145,12 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
byCategory, loading, error,
|
byCategory, allByCategory, loading, error,
|
||||||
load, accept, aliasAccept, dismiss
|
load, loadAll, accept, aliasAccept, dismiss
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ async def test_threshold_filters_low_confidence_general(db):
|
|||||||
assert "Lowconf" not in names
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_unsurfaced_category_dropped(db):
|
async def test_unsurfaced_category_dropped(db):
|
||||||
img = _img(
|
img = _img(
|
||||||
|
|||||||
Reference in New Issue
Block a user