Files
FabledCurator/backend/app/api/ml_admin.py
T
bvandeusenandClaude Opus 5 1e45e2c56c
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m6s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m59s
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m7s
feat: an open grouping — a later drop joins its post (milestone 388 step E3)
A synthetic post is no longer sealed at creation. A creator who adds two more
variants the next day extends the existing post, its body grows with the new
messages, and no rival post appears. That is what makes chat capture read as
content trickling in rather than as a stream of separate arrivals.

The sweep now runs two passes per source and the ORDER is load-bearing: offer
new messages to still-open groups BEFORE founding new ones, because whichever
runs first claims a message.

E3's three named problems, each answered rather than discovered later:

**Bridging.** A candidate near two groups joins NEITHER. Nearest-wins would
silently make an arbitrary choice between two posts the operator may already
have seen; merging them is worse still, because a merge rewrites history and
anything pointing at the absorbed post dangles. Leaving it to found its own
group is the recoverable failure. AMBIGUITY_MARGIN is a module constant and
deliberately not a setting — it is not a quality dial anyone would tune toward
a better feed, and exposing it would invite turning it to zero, which is
exactly the silent arbitrary choice it prevents.

**Re-surfacing without thrashing.** A grouping has two dates, and which one
orders the feed is a real decision, so the feed orders by neither directly.
Ordering by when the drop STARTED buries a group that grows a week later under
a week of other posts — defeating the point of keeping it open. Ordering by
every growth lets a group gaining one image a day live permanently at the top,
so chat out-competes authored posts for the front page — the opposite of "post
pacing stays front and centre". Instead `resurfaced_at` moves only when growth
clears BOTH a minimum-images bar and a cooldown, so a drip-feed updates in
place and a genuine second wave resurfaces exactly once. It is NULL on every
ordinary post, so the sort key COALESCEs through it without moving anything
that is not a grouping.

**Reopening forever.** Groups close after a quiet period — artists reuse
characters for years, and a group left open indefinitely will eventually
absorb something it shouldn't. Openness is DERIVED, not stored: a group is
open if it grew (or started) within the window. Lowering the setting closes
old groups and raising it reopens them, with nothing to repair either way; a
stored closed_at would have needed a sweep to set it and a repair path to ever
change the policy.

Rule 89 is satisfied structurally rather than by a parallel mechanism:
celery_signals writes a TaskRun for every task, which already supplies
duration, the 5-minute stalled-run recovery, and retention pruning. What this
step owed on top of that was a wall-clock limit (present) and idempotence —
re-running the joiner adds nothing, asserted directly rather than left to the
unique (image, post) constraint to catch.

Two bugs fixed in the writing, one of which my own test would have hit:

* `assign_to_group` sorted bare (distance, Post) tuples, which falls through
  to comparing Posts when two distances tie — and a perfectly symmetric
  bridge, the exact case the function exists for, would have raised TypeError
  instead of declining to choose. Now keyed on the distance alone.
* The cursor was still built from `post_date or downloaded_at` while the
  ORDER BY had gained `resurfaced_at`. Two expressions that disagree at a page
  boundary don't error, they silently skip or repeat rows; both sites now go
  through one `_post_sort_value`, and a test pages through one row at a time
  to prove the walk matches the whole list.

Image linking is now one shared helper rather than written twice, because
creation and joining would otherwise be free to drift on exactly the detail
(which post owns the image) that makes a grouping reversible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
2026-09-10 11:30:17 -04:00

207 lines
9.0 KiB
Python

"""ML admin API: settings + backfill trigger."""
from quart import Blueprint, jsonify, request
from ..extensions import get_session
from ..models import MLSettings
from ..services.ml.heads import AUTO_APPLY_THRESHOLD_MAX, AUTO_APPLY_THRESHOLD_MIN
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",
"process_auto_apply_enabled",
"process_auto_apply_threshold",
"process_conflict_threshold",
"embedder_model_name",
"embedder_model_version",
# Discord drop grouping (#388 E2). Operator-facing because the quality bar
# is a judgement no test can settle: too greedy merges distinct pieces, too
# shy leaves a drop scattered.
"discord_grouping_enabled",
"discord_group_max_distance",
"discord_group_window_minutes",
# E3: how long a grouping stays open, and the anti-thrash rule that keeps
# a growing one from monopolising the feed.
"discord_group_close_after_hours",
"discord_group_resurface_min_images",
"discord_group_resurface_cooldown_hours",
*_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():
async with get_session() as session:
s = await MLSettings.load(session)
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
# can never be silently absent from GET — the split that historically dropped
# fields. _EDITABLE already includes *_DETECTOR_FIELDS.
return jsonify({f: getattr(s, f) for f in _EDITABLE})
@ml_admin_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
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 MLSettings.load(session)
# 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 (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"head_auto_apply_precision must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if int(p["head_auto_apply_min_positives"]) < 1:
return "head_auto_apply_min_positives must be >= 1"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"ccip_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
# consequential); the conflict cut is a plain probability [0,1].
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"presentation_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
return "presentation_conflict_threshold must be between 0 and 1"
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
# low-harm (excludes-from-training + a review flag), but keep the same bar.
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["process_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
return "process_conflict_threshold must be between 0 and 1"
# Discord drop grouping (#388 E2). max_distance is a cosine DISTANCE, so
# unlike the *_threshold family above it is not on the auto-apply scale:
# 0 is identical and 1 is unrelated, and both ends are legal. The upper
# bound is 1.0 rather than AUTO_APPLY_THRESHOLD_MAX for that reason.
if not (0.0 <= float(p["discord_group_max_distance"]) <= 1.0):
return "discord_group_max_distance must be between 0 and 1"
if float(p["discord_group_window_minutes"]) <= 0:
return "discord_group_window_minutes must be > 0"
# A group must stay open at least as long as the drop window it was cut
# with, or the joiner could never reach a message the grouper deferred —
# the two would fight, and the symptom (drops that never grow) would look
# like the predicate failing rather than a settings contradiction.
if float(p["discord_group_close_after_hours"]) * 60 < float(p["discord_group_window_minutes"]):
return "discord_group_close_after_hours must be at least the drop window"
if int(p["discord_group_resurface_min_images"]) < 1:
return "discord_group_resurface_min_images must be >= 1"
if float(p["discord_group_resurface_cooldown_hours"]) < 0:
return "discord_group_resurface_cooldown_hours must be >= 0"
# 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