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
+106 -3
View File
@@ -197,7 +197,13 @@ def _merge(wd14: list[dict], embedding: list[dict]) -> list[dict]:
return list(by_name.values())
def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
def get_suggestions(
image_id: int,
top_k_per_category: int = 10,
*,
cfg: dict | None = None,
existing_all: set[str] | None = None,
) -> dict:
"""Return suggestions grouped by category for one image.
Shape:
@@ -207,11 +213,15 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
'general': [...],
}
Ordered by confidence desc within each category. 'meta' and 'rating' are omitted.
The optional `cfg` and `existing_all` kwargs let callers that loop over many
images amortize the config fetch and the Tag-table scan.
"""
if ImageRecord.query.get(image_id) is None:
return {'character': [], 'copyright': [], 'general': []}
cfg = _config()
if cfg is None:
cfg = _config()
already = _existing_tag_names_for_image(image_id)
merged = _merge(
@@ -219,7 +229,8 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
_embedding_tag_suggestions(image_id, cfg, already),
)
existing_all = _existing_tag_names()
if existing_all is None:
existing_all = _existing_tag_names()
grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
for s in merged:
@@ -233,3 +244,95 @@ def get_suggestions(image_id: int, top_k_per_category: int = 10) -> dict:
grouped[cat] = grouped[cat][:top_k_per_category]
return grouped
def get_bulk_suggestions(
image_ids: list[int],
threshold: float = 0.8,
top_k_per_category: int = 10,
) -> dict:
"""Return consensus suggestions across multiple selected images.
A tag is included only if it was suggested for (or already applied to)
at least `threshold` fraction of the selected images. Confidence returned
is the mean over the images where it was actually suggested; images that
already have the tag contribute to coverage but not to confidence.
Shape matches get_suggestions(): {'character': [...], 'copyright': [...], 'general': [...]}.
Each item also carries 'coverage' (float in [0, 1]) and 'covered_count' (int)
so the UI can display how broad the consensus is.
"""
if not image_ids:
return {'character': [], 'copyright': [], 'general': []}
cfg = _config()
existing_all = _existing_tag_names()
total = len(image_ids)
# Batched lookup: which selected images already have which tags. Used so a
# tag that's already on most of the selection isn't penalized just because
# it drops out of those images' suggestion lists.
existing_by_image: dict[int, set[str]] = {iid: set() for iid in image_ids}
rows = (
db.session.query(image_tags.c.image_id, Tag.name)
.join(Tag, Tag.id == image_tags.c.tag_id)
.filter(image_tags.c.image_id.in_(image_ids))
.all()
)
for iid, name in rows:
existing_by_image.setdefault(iid, set()).add(name)
# Aggregate suggested-count and confidence per tag name.
# suggested_count and existing_count are disjoint because get_suggestions()
# excludes tags the image already has.
tag_stats: dict[str, dict] = {}
for image_id in image_ids:
grouped = get_suggestions(
image_id,
top_k_per_category=top_k_per_category,
cfg=cfg,
existing_all=existing_all,
)
for category, items in grouped.items():
for item in items:
name = item['name']
stat = tag_stats.get(name)
if stat is None:
stat = {
'name': name,
'category': category,
'suggested_count': 0,
'sum_conf': 0.0,
'source': item['source'],
}
tag_stats[name] = stat
stat['suggested_count'] += 1
stat['sum_conf'] += item['confidence']
# Only tags that appeared somewhere as suggestions are candidates. Tags
# already universally applied don't appear here — correctly, since there's
# nothing for Accept to do.
result: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
for stat in tag_stats.values():
existing_count = sum(
1 for iid in image_ids if stat['name'] in existing_by_image.get(iid, set())
)
covered_count = stat['suggested_count'] + existing_count
coverage = covered_count / total
if coverage < threshold:
continue
mean_conf = stat['sum_conf'] / stat['suggested_count']
result[stat['category']].append({
'name': stat['name'],
'category': stat['category'],
'confidence': mean_conf,
'coverage': coverage,
'covered_count': covered_count,
'source': stat['source'],
'exists_in_db': stat['name'] in existing_all,
})
for cat in result:
result[cat].sort(key=lambda x: x['confidence'], reverse=True)
result[cat] = result[cat][:top_k_per_category]
return result