"""ML admin API: settings, backfill trigger, centroid recompute trigger.""" from quart import Blueprint, jsonify, request from ..extensions import get_session from ..models import MLSettings ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml") _EDITABLE = ( "suggestion_threshold_character", "suggestion_threshold_general", "centroid_similarity_threshold", "min_reference_images", "tagger_store_floor", "video_frame_interval_seconds", "video_max_frames", "video_min_tag_frames", "head_min_positives", "head_auto_apply_precision", "head_auto_apply_enabled", "head_auto_apply_min_positives", ) @ml_admin_bp.route("/settings", methods=["GET"]) async def get_settings(): from sqlalchemy import select async with get_session() as session: s = ( await session.execute(select(MLSettings).where(MLSettings.id == 1)) ).scalar_one() return jsonify( { "suggestion_threshold_character": s.suggestion_threshold_character, "suggestion_threshold_general": s.suggestion_threshold_general, "centroid_similarity_threshold": s.centroid_similarity_threshold, "min_reference_images": s.min_reference_images, "tagger_store_floor": s.tagger_store_floor, "video_frame_interval_seconds": s.video_frame_interval_seconds, "video_max_frames": s.video_max_frames, "video_min_tag_frames": s.video_min_tag_frames, "tagger_model_version": s.tagger_model_version, "embedder_model_version": s.embedder_model_version, "head_min_positives": s.head_min_positives, "head_auto_apply_precision": s.head_auto_apply_precision, "head_auto_apply_enabled": s.head_auto_apply_enabled, "head_auto_apply_min_positives": s.head_auto_apply_min_positives, } ) @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 async with get_session() as session: s = ( await session.execute(select(MLSettings).where(MLSettings.id == 1)) ).scalar_one() # Merge the patch over current values, then validate the result as a # whole — the store-floor invariant couples three fields, so they # can't be checked one at a time. proposed = {f: getattr(s, f) for f in _EDITABLE} for field in _EDITABLE: if field in body: proposed[field] = body[field] err = _validate(proposed) if err is not None: return jsonify({"error": err}), 400 for field in _EDITABLE: setattr(s, field, proposed[field]) await session.commit() return await get_settings() def _validate(p: dict) -> str | None: """Returns an error string if the proposed settings are invalid, else None. Invariant (plan-task #764): the per-category suggestion thresholds can't drop below tagger_store_floor — nothing below the floor is stored, so a lower threshold would silently surface nothing in that gap. The UI clamps the sliders to the floor; this is the server-side backstop. """ floor = p["tagger_store_floor"] if not (0.0 <= floor <= 1.0): return "tagger_store_floor must be between 0 and 1" for cat in ("character", "general"): if p[f"suggestion_threshold_{cat}"] < floor: return ( f"suggestion_threshold_{cat} cannot be below tagger_store_floor " f"({floor}) — predictions below the floor are not stored" ) # Video tagging (#747). if p["video_frame_interval_seconds"] <= 0: return "video_frame_interval_seconds must be > 0" if p["video_max_frames"] < 1: return "video_max_frames must be >= 1" if p["video_min_tag_frames"] < 1: return "video_min_tag_frames must be >= 1" if p["video_min_tag_frames"] > p["video_max_frames"]: return "video_min_tag_frames cannot exceed video_max_frames" # Head training (#114). if int(p["head_min_positives"]) < 1: return "head_min_positives must be >= 1" if not (0.5 <= float(p["head_auto_apply_precision"]) <= 0.999): return "head_auto_apply_precision must be between 0.5 and 0.999" if int(p["head_auto_apply_min_positives"]) < 1: return "head_auto_apply_min_positives must be >= 1" return None @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