22c3b54746
The eval (#1130) proved the frozen-embedding + trained-head spine; this lands its production form (the first of three slices that make heads the suggestion source, replacing Camie + centroid). - tag_head: one logistic-regression head per general/character concept with enough labelled positives. Weights (pgvector), honest CV-derived suggest threshold + earned-auto-apply point, and per-concept quality metrics. - head_training_run: persisted batch lifecycle (mirrors tag_eval_run) so the admin card shows live + historical status across navigation. - services/ml/heads.py: TRAIN (sync, ml worker, reuses tag_eval's proven data loaders + metric math so production heads match measured eval numbers) and SCORE (async, API worker — numpy via pgvector, no scikit-learn): score one image's embedding against all heads → the rail's suggestions, cached on (count, max trained_at) so a retrain invalidates without per-request loads. - tasks.ml.train_heads (ml queue, commits per head so a kill leaves progress) + recover_stalled_head_training_runs sweep + retention(20) + 5-min beat (rule 89). - api/heads.py: POST /api/heads/train (one run at a time, 409 guard) + GET /api/heads (count, graduated, last-trained, running, per-concept table, recent runs). - ml_settings: head_min_positives + head_auto_apply_precision, tunable via /api/ml/settings. Scoring isn't wired into the rail yet (slice C) and the admin UI is slice B — this slice makes training + scoring exist and CI-verifiable. 'precision' column stored as precision_cv (SQL reserved word). Migration 0058. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""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",
|
|
)
|
|
|
|
|
|
@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,
|
|
}
|
|
)
|
|
|
|
|
|
@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"
|
|
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
|