diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 83b95ea..1523f19 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -18,6 +18,7 @@ def all_blueprints() -> list[Blueprint]: from .allowlist import allowlist_bp from .gallery import gallery_bp from .import_admin import import_admin_bp + from .ml_admin import ml_admin_bp from .settings import settings_bp from .suggestions import suggestions_bp from .tags import tags_bp @@ -30,4 +31,5 @@ def all_blueprints() -> list[Blueprint]: suggestions_bp, allowlist_bp, aliases_bp, + ml_admin_bp, ] diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py new file mode 100644 index 0000000..3d7bbe5 --- /dev/null +++ b/backend/app/api/ml_admin.py @@ -0,0 +1,87 @@ +"""ML admin API: settings, backfill trigger, centroid recompute trigger.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import make_engine, make_session_factory +from ..models import MLSettings + +ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml") + +_engine = None +_Session = None + + +def _session_factory(): + global _engine, _Session + if _engine is None: + _engine = make_engine() + _Session = make_session_factory(_engine) + return _Session + + +_EDITABLE = ( + "suggestion_threshold_artist", + "suggestion_threshold_character", + "suggestion_threshold_copyright", + "suggestion_threshold_general", + "centroid_similarity_threshold", + "min_reference_images", +) + + +@ml_admin_bp.route("/settings", methods=["GET"]) +async def get_settings(): + from sqlalchemy import select + + Session = _session_factory() + async with Session() as session: + s = ( + await session.execute(select(MLSettings).where(MLSettings.id == 1)) + ).scalar_one() + return jsonify( + { + "suggestion_threshold_artist": s.suggestion_threshold_artist, + "suggestion_threshold_character": s.suggestion_threshold_character, + "suggestion_threshold_copyright": s.suggestion_threshold_copyright, + "suggestion_threshold_general": s.suggestion_threshold_general, + "centroid_similarity_threshold": s.centroid_similarity_threshold, + "min_reference_images": s.min_reference_images, + "tagger_model_version": s.tagger_model_version, + "embedder_model_version": s.embedder_model_version, + } + ) + + +@ml_admin_bp.route("/settings", methods=["PATCH"]) +async def patch_settings(): + from sqlalchemy import select + + body = await request.get_json() + if not isinstance(body, dict): + return jsonify({"error": "body must be an object"}), 400 + Session = _session_factory() + async with Session() as session: + s = ( + await session.execute(select(MLSettings).where(MLSettings.id == 1)) + ).scalar_one() + for field in _EDITABLE: + if field in body: + setattr(s, field, body[field]) + await session.commit() + return await get_settings() + + +@ml_admin_bp.route("/backfill", methods=["POST"]) +async def trigger_backfill(): + from ..tasks.ml import backfill + + r = backfill.delay() + return jsonify({"celery_task_id": r.id}), 202 + + +@ml_admin_bp.route("/recompute-centroids", methods=["POST"]) +async def trigger_recompute(): + from ..tasks.ml import recompute_centroids + + r = recompute_centroids.delay() + return jsonify({"celery_task_id": r.id}), 202 diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index d8d59e2..8498c4f 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -124,3 +124,24 @@ async def remove_tag_from_image(image_id: int, tag_id: int): await svc.remove_from_image(image_id, tag_id) await session.commit() return "", 204 + + +@tags_bp.route("/tags/", methods=["PATCH"]) +async def rename_tag(tag_id: int): + body = await request.get_json() + if not body or "name" not in body: + return jsonify({"error": "name required"}), 400 + Session = _session_factory() + async with Session() as session: + svc = TagService(session) + try: + tag = await svc.rename(tag_id, body["name"]) + except TagValidationError as exc: + # Collision → 409 so the frontend can show the FC-2c merge hint. + msg = str(exc) + status = 409 if "already exists" in msg else 400 + return jsonify({"error": msg}), status + await session.commit() + return jsonify( + {"id": tag.id, "name": tag.name, "kind": tag.kind.value} + ) diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py new file mode 100644 index 0000000..cb99630 --- /dev/null +++ b/tests/test_api_ml_admin.py @@ -0,0 +1,67 @@ +import pytest + +from backend.app import create_app +from backend.app.celery_app import celery +from backend.app.models import 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 + + +@pytest.mark.asyncio +async def test_get_and_patch_settings(client): + resp = await client.get("/api/ml/settings") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["suggestion_threshold_general"] == pytest.approx(0.95) + + resp = await client.patch( + "/api/ml/settings", json={"suggestion_threshold_general": 0.90} + ) + assert resp.status_code == 200 + assert (await resp.get_json())["suggestion_threshold_general"] == pytest.approx(0.90) + + +@pytest.mark.asyncio +async def test_backfill_and_recompute_trigger(client): + r1 = await client.post("/api/ml/backfill") + assert r1.status_code == 202 + r2 = await client.post("/api/ml/recompute-centroids") + assert r2.status_code == 202 + + +@pytest.mark.asyncio +async def test_rename_tag(client, db): + tag = await TagService(db).find_or_create("oldname", TagKind.character) + await db.commit() + resp = await client.patch(f"/api/tags/{tag.id}", json={"name": "New Name"}) + assert resp.status_code == 200 + assert (await resp.get_json())["name"] == "New Name" + + +@pytest.mark.asyncio +async def test_rename_collision_409(client, db): + svc = TagService(db) + await svc.find_or_create("Taken", TagKind.character) + other = await svc.find_or_create("Mover", TagKind.character) + await db.commit() + resp = await client.patch(f"/api/tags/{other.id}", json={"name": "Taken"}) + assert resp.status_code == 409