From 07961dfe042de6a4ca910ca857ce6789868903c6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 15 May 2026 07:45:55 -0400 Subject: [PATCH] feat(fc2b): add /api/images//suggestions endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET (ranked, category-grouped), accept (→ apply + allowlist; enqueues retro-apply when newly added), alias (create alias + accept canonical), dismiss (per-image rejection). Thin blueprint over SuggestionService + AllowlistService. Tests marked integration, eager Celery. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 10 +++- backend/app/api/suggestions.py | 102 +++++++++++++++++++++++++++++++++ tests/test_api_suggestions.py | 89 ++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/suggestions.py create mode 100644 tests/test_api_suggestions.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index dfd66a3..91f99ab 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -17,5 +17,13 @@ def all_blueprints() -> list[Blueprint]: from .gallery import gallery_bp from .import_admin import import_admin_bp from .settings import settings_bp + from .suggestions import suggestions_bp from .tags import tags_bp - return [api_bp, gallery_bp, tags_bp, settings_bp, import_admin_bp] + return [ + api_bp, + gallery_bp, + tags_bp, + settings_bp, + import_admin_bp, + suggestions_bp, + ] diff --git a/backend/app/api/suggestions.py b/backend/app/api/suggestions.py new file mode 100644 index 0000000..9e00aea --- /dev/null +++ b/backend/app/api/suggestions.py @@ -0,0 +1,102 @@ +"""Suggestions API: per-image ranked suggestions + accept/alias/dismiss.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import make_engine, make_session_factory +from ..services.ml.allowlist import AllowlistService +from ..services.ml.suggestions import SuggestionService + +suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api") + +_engine = None +_Session = None + + +def _session_factory(): + global _engine, _Session + if _engine is None: + _engine = make_engine() + _Session = make_session_factory(_engine) + return _Session + + +@suggestions_bp.route("/images//suggestions", methods=["GET"]) +async def get_suggestions(image_id: int): + Session = _session_factory() + async with Session() as session: + sl = await SuggestionService(session).for_image(image_id) + 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, + } + for s in items + ] + for cat, items in sl.by_category.items() + } + } + ) + + +@suggestions_bp.route( + "/images//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"] + Session = _session_factory() + async with 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//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 + Session = _session_factory() + async with 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//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 + Session = _session_factory() + async with Session() as session: + await AllowlistService(session).dismiss(image_id, body["tag_id"]) + await session.commit() + return "", 204 diff --git a/tests/test_api_suggestions.py b/tests/test_api_suggestions.py new file mode 100644 index 0000000..50c7704 --- /dev/null +++ b/tests/test_api_suggestions.py @@ -0,0 +1,89 @@ +import pytest + +from backend.app import create_app +from backend.app.celery_app import celery +from backend.app.models import ImageRecord, TagKind +from backend.app.services.tag_service import TagService + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +def eager(): + celery.conf.task_always_eager = True + yield + celery.conf.task_always_eager = False + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +async def _img(db, preds): + img = ImageRecord( + path="/images/s.jpg", sha256="s" * 64, size_bytes=1, + mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + tagger_predictions=preds, + ) + db.add(img) + await db.commit() + return img + + +@pytest.mark.asyncio +async def test_get_suggestions(client, db): + img = await _img( + db, {"sword": {"category": "general", "confidence": 0.97}} + ) + resp = await client.get(f"/api/images/{img.id}/suggestions") + assert resp.status_code == 200 + body = await resp.get_json() + assert "general" in body["by_category"] + + +@pytest.mark.asyncio +async def test_accept_requires_tag_id(client, db): + img = await _img(db, {}) + resp = await client.post( + f"/api/images/{img.id}/suggestions/accept", json={} + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_accept_then_applied(client, db): + img = await _img(db, {}) + tag = await TagService(db).find_or_create("AcceptMe", TagKind.character) + await db.commit() + resp = await client.post( + f"/api/images/{img.id}/suggestions/accept", json={"tag_id": tag.id} + ) + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_dismiss(client, db): + img = await _img(db, {}) + tag = await TagService(db).find_or_create("DismissMe", TagKind.general) + await db.commit() + resp = await client.post( + f"/api/images/{img.id}/suggestions/dismiss", json={"tag_id": tag.id} + ) + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_alias_requires_fields(client, db): + img = await _img(db, {}) + resp = await client.post( + f"/api/images/{img.id}/suggestions/alias", json={"alias_string": "x"} + ) + assert resp.status_code == 400