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
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
207 lines
9.0 KiB
Python
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
|