feat(bulk): consensus ML suggestions + frontend polish

Bulk editor now loads consensus ML suggestions across the current
selection via a new POST /api/suggestions/bulk endpoint, powered by
get_bulk_suggestions() in tag_suggestions.py. A tag is surfaced only if
it was suggested for or already applied to >= 80% of the selection;
coverage counts include images that already have the tag so a near-
universal tag isn't penalized for dropping out of suggestion lists.
Accept-only chips (no reject) match the rest of the suggestions UX.

Frontend polish in the same commit:
- Showcase session dedup (exclude seen ids, reset on second lap) and
  aspect-ratio-aware placeholder heights for better column balancing
- Modal touch-swipe navigation on the image wrapper, auto-focus of the
  tag input on desktop (>=768px), and autocomplete flip-up when the
  dropdown would cover the Suggestions section
- Settings template inline styles replaced with utility classes
- Inline series-editor script extracted to gallery-series-editor.js

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:39:47 -04:00
parent bc6b0a4a58
commit 3944605d33
9 changed files with 693 additions and 158 deletions
+30
View File
@@ -95,6 +95,8 @@ def random_images_api():
'thumb_url': url_for('main.serve_image', filename=thumb_path),
'full_url': url_for('main.serve_image', filename=full_path),
'is_video': is_video,
'width': img.width,
'height': img.height,
'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '',
'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags]
})
@@ -714,6 +716,34 @@ def get_image_suggestions(image_id):
return jsonify({'ok': True, 'suggestions': suggestions})
@main.post("/api/suggestions/bulk")
def get_bulk_suggestions_api():
"""Consensus ML suggestions across a selection of images.
Body: {"image_ids": [...], "threshold": 0.8}
Returns grouped suggestions where each tag was suggested for or already
applied to at least `threshold` fraction of the selected images.
"""
from app.services.tag_suggestions import get_bulk_suggestions
payload = request.get_json(force=True, silent=True) or {}
image_ids = payload.get('image_ids') or []
try:
image_ids = [int(x) for x in image_ids]
except (TypeError, ValueError):
return jsonify({'ok': False, 'error': 'invalid_image_ids'}), 400
if not image_ids:
return jsonify({'ok': False, 'error': 'no_image_ids'}), 400
try:
threshold = float(payload.get('threshold', 0.8))
except (TypeError, ValueError):
threshold = 0.8
threshold = max(0.0, min(1.0, threshold))
suggestions = get_bulk_suggestions(image_ids, threshold=threshold)
return jsonify({'ok': True, 'suggestions': suggestions, 'evaluated': len(image_ids), 'threshold': threshold})
@main.post("/image/<int:image_id>/suggestions/accept")
def accept_image_suggestion(image_id):
from app.models import SuggestionFeedback