CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 1m5s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m53s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m5s
Discord is a delivery channel, not a publisher. One message is not one post,
and today every message lands as its own `post` row, so chat lines compete
with authored work for the same surface. Rather than demote them into a
second-class feed, FC now writes the post itself: one row per DROP, its
images the drop's images, its body the messages' text in arrival order.
Synthesising a `Post` (rather than inventing a parallel entity) is the whole
point — the result is post-shaped by construction, so feed, provenance,
translation, attachments and series keep working on it unchanged.
The predicate is three axes ANDed, and the time one does the real work:
same source AND cosine distance <= threshold AND no gap > window
Similarity alone over-groups, and that is the failure that would make this
useless: any two pieces of the same character by the same artist sit close in
SigLIP space, so a cosine-only rule collapses a month of one character into a
single "post". Two details inside the predicate are load-bearing —
* distance is measured to the group's SEED, never to the previous member,
because chaining lets a group DRIFT: twenty small steps walk from one piece
to a completely different one, each hop individually within threshold;
* the window is measured between CONSECUTIVE messages, not from the first, so
an artist trickling variants out over an evening stays one drop.
Why a post-import sweep and not part of ingest. The obvious alternative was to
migrate Discord to the native post-first ingester (#1266) and group at capture
time. That cannot work: the grouping signal is `siglip_embedding`, which is
produced asynchronously AFTER import (tasks/ml.py, the GPU backfill), so at
capture time there is nothing to group on. Grouping is necessarily something
that happens once the vectors catch up — hence a re-runnable sweep that skips
what it cannot yet place, and an hourly (not daily) cadence.
The honesty rule, enforced in the schema. `post.synthesized_by` names the
grouper; `synthesis_details` records the members, the count, and the
thresholds AS THEY WERE (they are operator-tunable, so without that "why did
it group these" is unanswerable a month later). Member posts are absorbed, not
destroyed — they remain the images' true origin and the audit trail — and
`absorbed_by_post_id` is ON DELETE SET NULL, so deleting a synthetic post
releases its members back into the feed in one DELETE with no repair step.
`post_title` stays NULL deliberately: a synthesised title is the one place
this could put words in a creator's mouth.
Two guards the first draft would have failed:
* the per-run cap took the lowest post IDs, not the oldest posts — DISTINCT ON
forces its own ORDER BY, so the sort now happens outside the subquery;
* a cap landing mid-drop would have published a truncated group claiming to be
a whole drop, so the last group is left for the next run.
And one vacuous test caught before it shipped: the support vector perturbed a
single component of an all-ones vector, moving it ~1e-6, so every distance
assertion passed regardless of what the predicate did. `_vec` now builds a
unit vector at a stated angle, where distance is exactly 1 - cos(delta) —
rule 167, a guard has to be able to fail.
UI (rule 27) follows in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
192 lines
8.0 KiB
Python
192 lines
8.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",
|
|
*_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"
|
|
# 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
|