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:
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user