Files
FabledCurator/backend/app/api/suggestions.py
T
bvandeusen e206778a5c
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Failing after 3m24s
feat(allowlist): coverage projection + applied-count + post-accept projection (#7a/#7b)
Cluster B, milestone #99. Backend for the allowlist tuning dashboard.

#7a: AllowlistService.coverage(tag_id, threshold) counts distinct images with
a prediction resolving to the tag (raw_name==tag.name OR (raw_name,category) in
the tag's aliases) scoring >= threshold — the gross candidate pool, mirroring
tasks.ml._confidence_for_tag resolution. list_all now carries applied_count
(grouped image_tag count) + coverage_count (at the row's threshold). New
GET /api/tags/<id>/allowlist/coverage?threshold= for the live what-if number.

#7b: /suggestions/accept + /alias return {allowlisted, tag_id, tag_name,
projected_count} (projection at the tag's threshold) instead of 204, so the UI
can show a non-blocking 'auto-applying to ~N images' toast. Apply still runs
async via apply_allowlist_tags — projected_count is an estimate.

Tests: coverage by threshold (direct + alias-with-category), list applied vs
coverage, coverage route (explicit/default/bad threshold), accept/alias payload
(newly-allowlisted vs already-on-list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
2026-06-23 01:34:21 -04:00

164 lines
6.1 KiB
Python

"""Suggestions API: per-image ranked suggestions + accept/alias/dismiss."""
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import Tag, TagAllowlist
from ..services.ml.allowlist import AllowlistService
from ..services.ml.suggestions import SuggestionService
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
async def _accept_payload(session, svc, newly_added: bool, tag_id: int) -> dict:
"""Shape the accept/alias response. When accepting newly allowlists a tag,
include the coverage PROJECTION (at the tag's threshold) so the UI can show
a non-blocking "auto-applying to ~N images" toast — the actual apply runs
async via apply_allowlist_tags, so this is an estimate, not a post-hoc
count (#7)."""
payload = {"allowlisted": newly_added}
if newly_added:
tag = await session.get(Tag, tag_id)
row = await session.get(TagAllowlist, tag_id)
payload["tag_id"] = tag_id
payload["tag_name"] = tag.name if tag is not None else None
payload["projected_count"] = await svc.coverage(
tag_id, row.min_confidence if row is not None else 0.90,
)
return payload
@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, threshold_override=override
)
return jsonify(
{
"by_category": {
cat: [
{
"canonical_tag_id": s.canonical_tag_id,
"display_name": s.display_name,
"category": s.category,
"score": round(s.score, 4),
"source": s.source,
"creates_new_tag": s.creates_new_tag,
# raw model key (alias is stored under this) + whether an
# operator alias produced this suggestion — drive the
# modal's "Treat as alias"/"Remove alias" affordances.
"raw_name": s.raw_name,
"via_alias": s.via_alias,
}
for s in items
]
for cat, items in sl.by_category.items()
}
}
)
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/accept", methods=["POST"]
)
async def accept_suggestion(image_id: int):
body = await request.get_json()
if not body or "tag_id" not in body:
return jsonify({"error": "tag_id required"}), 400
tag_id = body["tag_id"]
async with get_session() as session:
svc = AllowlistService(session)
newly_added = await svc.accept(image_id, tag_id)
payload = await _accept_payload(session, svc, newly_added, tag_id)
await session.commit()
if newly_added:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=tag_id)
return jsonify(payload)
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/alias", methods=["POST"]
)
async def alias_suggestion(image_id: int):
body = await request.get_json()
required = {"alias_string", "alias_category", "canonical_tag_id"}
if not body or not required.issubset(body):
return jsonify({"error": f"required: {sorted(required)}"}), 400
canonical_tag_id = body["canonical_tag_id"]
async with get_session() as session:
svc = AllowlistService(session)
newly_added = await svc.add_alias_and_accept(
image_id,
body["alias_string"],
body["alias_category"],
canonical_tag_id,
)
payload = await _accept_payload(
session, svc, newly_added, canonical_tag_id,
)
await session.commit()
if newly_added:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=canonical_tag_id)
return jsonify(payload)
@suggestions_bp.route(
"/images/<int:image_id>/suggestions/dismiss", methods=["POST"]
)
async def dismiss_suggestion(image_id: int):
body = await request.get_json()
if not body or "tag_id" not in body:
return jsonify({"error": "tag_id required"}), 400
async with get_session() as session:
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,
}
)