"""ML admin API: settings + backfill 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 = ( "video_frame_interval_seconds", "video_max_frames", "head_min_positives", "head_auto_apply_precision", "head_auto_apply_enabled", "head_auto_apply_min_positives", "ccip_match_threshold", "ccip_auto_apply_enabled", "ccip_auto_apply_threshold", "embedder_model_name", "embedder_model_version", ) # Supported embedders for the Settings dropdown — all 1152-d so a swap is a # drop-in (re-embed + retrain, no schema change). Server-authoritative so the UI # never free-types a model name. SUPPORTED_EMBEDDERS = ( { "name": "google/siglip2-so400m-patch16-512", "version": "siglip2-so400m-patch16-512", "label": "SigLIP 2 · so400m · 512px (recommended)", "dim": 1152, }, { "name": "google/siglip2-so400m-patch16-384", "version": "siglip2-so400m-patch16-384", "label": "SigLIP 2 · so400m · 384px (faster)", "dim": 1152, }, { "name": "google/siglip-so400m-patch14-384", "version": "siglip-so400m-patch14-384", "label": "SigLIP 1 · so400m · 384px (original)", "dim": 1152, }, ) @ml_admin_bp.route("/embedder-models", methods=["GET"]) async def embedder_models(): return jsonify({"models": list(SUPPORTED_EMBEDDERS)}) @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( { "video_frame_interval_seconds": s.video_frame_interval_seconds, "video_max_frames": s.video_max_frames, "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, "ccip_match_threshold": s.ccip_match_threshold, "ccip_auto_apply_enabled": s.ccip_auto_apply_enabled, "ccip_auto_apply_threshold": s.ccip_auto_apply_threshold, "embedder_model_name": s.embedder_model_name, } ) @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.""" # Video embedding (#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" # 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" if not (0.5 <= float(p["ccip_match_threshold"]) <= 0.999): return "ccip_match_threshold must be between 0.5 and 0.999" if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999): return "ccip_auto_apply_threshold must be between 0.5 and 0.999" # Embedder model swap (#1190): both must be non-empty. Changing them means a # different embedding space — the operator must re-embed + retrain after. for key in ("embedder_model_name", "embedder_model_version"): if not str(p[key]).strip(): return f"{key} must not be empty" 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