"""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") # Crop-proposer / detector config (#134). Announced to the GPU agent in the lease # → tunable here with no restart. weights = ultralytics name | URL | hf_repo::file # (empty, or enabled off, skips that proposer). _DETECTOR_FIELDS = ( "detector_person_enabled", "detector_person_weights", "detector_person_conf", "detector_anatomy_enabled", "detector_anatomy_weights", "detector_anatomy_conf", "detector_panel_enabled", "detector_panel_weights", "detector_panel_conf", "detector_max_figures", "detector_max_components", "detector_max_panels", "detector_max_regions", "detector_dedupe_iou", ) _EDITABLE = ( "cpu_embed_enabled", "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", "presentation_auto_apply_enabled", "presentation_auto_apply_threshold", "presentation_conflict_threshold", "embedder_model_name", "embedder_model_version", *_DETECTOR_FIELDS, ) # 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( { "cpu_embed_enabled": s.cpu_embed_enabled, "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, "presentation_auto_apply_enabled": s.presentation_auto_apply_enabled, "presentation_auto_apply_threshold": s.presentation_auto_apply_threshold, "presentation_conflict_threshold": s.presentation_conflict_threshold, "embedder_model_name": s.embedder_model_name, **{f: getattr(s, f) for f in _DETECTOR_FIELDS}, } ) @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" # Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is # consequential); the conflict cut is a plain probability [0,1]. if not (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999): return "presentation_auto_apply_threshold must be between 0.5 and 0.999" if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0): return "presentation_conflict_threshold must be between 0 and 1" # 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" # Crop proposers (#134). Weights may be empty (that proposer is just off); # confidences are probabilities; caps are positive counts; IoU is [0,1]. for key in ("detector_person_conf", "detector_anatomy_conf", "detector_panel_conf"): if not (0.0 <= float(p[key]) <= 1.0): return f"{key} must be between 0 and 1" for key in ( "detector_max_figures", "detector_max_components", "detector_max_panels", "detector_max_regions", ): if int(p[key]) < 1: return f"{key} must be >= 1" if not (0.0 <= float(p["detector_dedupe_iou"]) <= 1.0): return "detector_dedupe_iou must be between 0 and 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