Files
FabledCurator/backend/app/api/ml_admin.py
T
bvandeusen 3f92669f12
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m18s
feat(ml): DB-backed tagger_store_floor (default 0.70), the ingest confidence floor
Promotes the prediction store-floor from the TAGGER_STORE_FLOOR env (default
0.05) to a DB-backed, Settings-UI-tunable ml_settings column (default 0.70).
Storing every tag down to 0.05 from a ~10k-tag tagger is what grew
image_record's TOAST to ~100 GB; the suggestion path already filters at 0.70
and the centroid/learned path covers lower-confidence preferred tags, so the
sub-0.70 tail is redundant. Foundation for plan-task #764 (backfill + reclaim
land next; this only changes the write gate for NEW imports).

- ml_settings.tagger_store_floor (migration 0044, default 0.70)
- tagger.Tagger.infer(store_floor=...); ml task passes settings.tagger_store_floor
- ML admin GET/PATCH expose it; PATCH rejects a category suggestion threshold
  below the floor (nothing below the floor is stored, so the gap surfaces
  nothing) — server backstop for the UI slider clamp
- Settings → ML: store-floor slider + caption; category sliders min-bound to it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:50:30 -04:00

105 lines
3.5 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",
)
@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,
"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
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"
)
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