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
+14 -1
View File
@@ -11,8 +11,21 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
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:
sl = await SuggestionService(session).for_image(image_id)
sl = await SuggestionService(session).for_image(
image_id, threshold_override=override
)
return jsonify(
{
"by_category": {
+20 -3
View File
@@ -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: