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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user