feat(fc2b): add tag rename (PATCH /api/tags/<id>) + /api/ml admin

Rename returns 409 on collision (frontend shows FC-2c merge hint), 400
on empty. ml_admin: GET/PATCH settings (thresholds + min_reference;
model versions read-only here), POST backfill / recompute-centroids
returning 202 + celery task id. Tests integration-marked, eager Celery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 07:54:12 -04:00
parent 46338a1f2e
commit aa4cc7c629
4 changed files with 177 additions and 0 deletions
+2
View File
@@ -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,
]
+87
View File
@@ -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
+21
View File
@@ -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/<int:tag_id>", 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}
)
+67
View File
@@ -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