feat(bulk): SuggestionService.for_selection consensus + POST /api/suggestions/bulk
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,3 +85,35 @@ async def dismiss_suggestion(image_id: int):
|
||||
await AllowlistService(session).dismiss(image_id, body["tag_id"])
|
||||
await session.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
@suggestions_bp.route("/suggestions/bulk", methods=["POST"])
|
||||
async def bulk_suggestions():
|
||||
body = await request.get_json()
|
||||
if not body or "image_ids" not in body:
|
||||
return jsonify({"error": "image_ids required"}), 400
|
||||
raw = body["image_ids"]
|
||||
if not isinstance(raw, list) or not raw:
|
||||
return jsonify({"error": "image_ids must be a non-empty list"}), 400
|
||||
try:
|
||||
ids = [int(x) for x in raw]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "image_ids must be integers"}), 400
|
||||
if len(ids) > 200:
|
||||
return jsonify({"error": "selection too large (max 200)"}), 400
|
||||
try:
|
||||
threshold = float(body.get("threshold", 0.8))
|
||||
except (TypeError, ValueError):
|
||||
threshold = 0.8
|
||||
threshold = min(1.0, max(0.0, threshold))
|
||||
async with get_session() as session:
|
||||
suggestions = await SuggestionService(session).for_selection(
|
||||
ids, threshold=threshold
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"suggestions": suggestions,
|
||||
"evaluated": len(ids),
|
||||
"threshold": threshold,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -197,3 +197,77 @@ class SuggestionService:
|
||||
for cat in result.by_category:
|
||||
result.by_category[cat].sort(key=lambda s: s.score, reverse=True)
|
||||
return result
|
||||
|
||||
async def for_selection(
|
||||
self,
|
||||
image_ids: list[int],
|
||||
threshold: float = 0.8,
|
||||
top_k: int = 10,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Consensus suggestions across image_ids. A tag is included iff it
|
||||
was suggested for (or already applied to) >= threshold fraction of
|
||||
the selection AND was acceptable on >= 1 image. Confidence is the
|
||||
mean over images where it was suggested. Aggregated by
|
||||
canonical_tag_id; creates-new (no canonical id) suggestions are
|
||||
skipped (bulk Accept applies by tag id)."""
|
||||
if not image_ids:
|
||||
return {}
|
||||
threshold = min(1.0, max(0.0, threshold))
|
||||
total = len(image_ids)
|
||||
|
||||
stats: dict[int, dict] = {}
|
||||
for image_id in image_ids:
|
||||
sl = await self.for_image(image_id)
|
||||
for category, items in sl.by_category.items():
|
||||
for s in items:
|
||||
if s.canonical_tag_id is None or s.creates_new_tag:
|
||||
continue
|
||||
st = stats.get(s.canonical_tag_id)
|
||||
if st is None:
|
||||
st = {
|
||||
"tag_id": s.canonical_tag_id,
|
||||
"name": s.display_name,
|
||||
"category": category,
|
||||
"source": s.source,
|
||||
"suggested_count": 0,
|
||||
"sum_score": 0.0,
|
||||
}
|
||||
stats[s.canonical_tag_id] = st
|
||||
st["suggested_count"] += 1
|
||||
st["sum_score"] += s.score
|
||||
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(
|
||||
image_tag.c.image_record_id, image_tag.c.tag_id
|
||||
).where(image_tag.c.image_record_id.in_(image_ids))
|
||||
)
|
||||
).all()
|
||||
applied_by_tag: dict[int, set[int]] = {}
|
||||
for iid, tid in rows:
|
||||
applied_by_tag.setdefault(tid, set()).add(iid)
|
||||
|
||||
result: dict[str, list[dict]] = {}
|
||||
for st in stats.values():
|
||||
existing_count = len(applied_by_tag.get(st["tag_id"], set()))
|
||||
covered = st["suggested_count"] + existing_count
|
||||
coverage = covered / total
|
||||
if coverage < threshold or st["suggested_count"] < 1:
|
||||
continue
|
||||
result.setdefault(st["category"], []).append(
|
||||
{
|
||||
"canonical_tag_id": st["tag_id"],
|
||||
"name": st["name"],
|
||||
"category": st["category"],
|
||||
"confidence": round(
|
||||
st["sum_score"] / st["suggested_count"], 4
|
||||
),
|
||||
"coverage": round(coverage, 4),
|
||||
"covered_count": covered,
|
||||
"source": st["source"],
|
||||
}
|
||||
)
|
||||
for cat in result:
|
||||
result[cat].sort(key=lambda x: x["confidence"], reverse=True)
|
||||
result[cat] = result[cat][:top_k]
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user