5c3f8ebd70
The headline bug: aliases created from the modal NEVER resolved. Create
sent the normalized display name ('Sword', 'Uchiha Sasuke') while
resolution keys on the raw booru model key ('sword', 'uchiha_sasuke',
case-sensitive) — so the mapping was stored under a key nothing looks up,
and the prediction kept reappearing unaliased. The raw key wasn't even in
the /suggestions response, so the modal couldn't send it.
- Suggestion now carries raw_name (the model key an alias must use) and
via_alias (surfaced via an operator alias); both serialized by the API.
- Modal alias-create sends raw_name, not display_name (the fix). Aliased
suggestions show an 'alias' badge and a 'Remove alias' action; 'Treat as
alias for…' is hidden for centroid hits (no model key) and already-aliased
rows.
- Tag-side management: TagCard ⋮ → 'Aliases…' opens a dialog listing the
model keys that fold into a tag, with remove (GET /api/tags/<id>/aliases +
AliasService.list_for_tag). Creation stays in the modal suggestion flow.
Tests: full API round-trip locking the raw-key contract (raw_name exposed →
alias authored with it → resolves + via_alias on a later image);
list_for_tag (service + API); via_alias/raw_name on the existing service
suggestion tests. No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
"""Suggestions API: per-image ranked suggestions + accept/alias/dismiss."""
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from ..extensions import get_session
|
|
from ..services.ml.allowlist import AllowlistService
|
|
from ..services.ml.suggestions import SuggestionService
|
|
|
|
suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
|
|
|
|
|
@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:
|
|
newly_added = await AllowlistService(session).accept(image_id, tag_id)
|
|
await session.commit()
|
|
if newly_added:
|
|
from ..tasks.ml import apply_allowlist_tags
|
|
|
|
apply_allowlist_tags.delay(tag_id=tag_id)
|
|
return "", 204
|
|
|
|
|
|
@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
|
|
async with get_session() as session:
|
|
newly_added = await AllowlistService(session).add_alias_and_accept(
|
|
image_id,
|
|
body["alias_string"],
|
|
body["alias_category"],
|
|
body["canonical_tag_id"],
|
|
)
|
|
await session.commit()
|
|
if newly_added:
|
|
from ..tasks.ml import apply_allowlist_tags
|
|
|
|
apply_allowlist_tags.delay(tag_id=body["canonical_tag_id"])
|
|
return "", 204
|
|
|
|
|
|
@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,
|
|
}
|
|
)
|